p-backlog 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +201 -0
  3. package/README.md +218 -0
  4. package/README.ru.md +213 -0
  5. package/dist/cli.js +6712 -0
  6. package/dist/server.js +3887 -0
  7. package/dist/web/assets/ChartTooltip-BYCfK3J2.js +55 -0
  8. package/dist/web/assets/ChartTooltip-tQC8-pw2.css +1 -0
  9. package/dist/web/assets/CodeTab-CfG0h41u.css +1 -0
  10. package/dist/web/assets/CodeTab-LYNoYtUt.js +1 -0
  11. package/dist/web/assets/CostTab-BNukfYQ_.js +1 -0
  12. package/dist/web/assets/CostTab-CtmH2WbT.css +1 -0
  13. package/dist/web/assets/EffectTab-Bk8BtfaR.js +1 -0
  14. package/dist/web/assets/EffectTab-DCTwmWXb.css +1 -0
  15. package/dist/web/assets/Figure-BcMtR7CN.js +1 -0
  16. package/dist/web/assets/OverviewTab-Bv0rKc6p.js +1 -0
  17. package/dist/web/assets/OverviewTab-C-NPX9JB.css +1 -0
  18. package/dist/web/assets/QualityTab-N81paNFO.js +1 -0
  19. package/dist/web/assets/StatsPage-D4P63bqv.js +1 -0
  20. package/dist/web/assets/StatsPage-gITQDSrz.css +1 -0
  21. package/dist/web/assets/StatsPage.module-D8dvB8Va.js +1 -0
  22. package/dist/web/assets/StatsTabState-B6PD1sC0.css +1 -0
  23. package/dist/web/assets/StatsTabState-BcFrx-N3.js +1 -0
  24. package/dist/web/assets/StatsTable-C8h-1NJ7.js +1 -0
  25. package/dist/web/assets/StatsTable-Z9x-Xaha.css +1 -0
  26. package/dist/web/assets/UnavailableRepos-COXy5-Dn.js +1 -0
  27. package/dist/web/assets/i18n-BO0kwLac.js +58 -0
  28. package/dist/web/assets/i18n-BlPq0Oql.css +1 -0
  29. package/dist/web/assets/index-C-8JzRpY.js +38 -0
  30. package/dist/web/assets/index-DyGLN1aL.css +1 -0
  31. package/dist/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/web/assets/value-dot-X354sZLx.js +1 -0
  33. package/dist/web/index.html +16 -0
  34. package/package.json +90 -0
  35. package/skill/backlog/SKILL.md +182 -0
  36. package/skill/backlog-en/SKILL.md +194 -0
package/dist/server.js ADDED
@@ -0,0 +1,3887 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/errors.ts
4
+ function errorText(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+
8
+ // src/server/main.ts
9
+ import { homedir } from "node:os";
10
+
11
+ // src/core/store/paths.ts
12
+ import { basename, join } from "node:path";
13
+ var PROJECT_FILE = "project.md";
14
+ function expandHome(path, home2) {
15
+ if (path === "~") return home2;
16
+ return path.startsWith("~/") ? join(home2, path.slice(2)) : path;
17
+ }
18
+ function resolveBacklogRoot(env, home2) {
19
+ const configured = env.BACKLOG_DIR;
20
+ return configured ? expandHome(configured, home2) : join(home2, "backlog");
21
+ }
22
+ function projectDir(root, id) {
23
+ const isPlainName = id !== "" && id !== "." && id !== ".." && basename(id) === id && !id.includes("\\");
24
+ return isPlainName ? join(root, id) : null;
25
+ }
26
+
27
+ // src/core/sqlite-warning.ts
28
+ function suppressSqliteExperimentalWarning() {
29
+ const previousListeners = [...process.listeners("warning")];
30
+ process.removeAllListeners("warning");
31
+ process.on("warning", (warning) => {
32
+ if (isSqliteExperimentalWarning(warning)) return;
33
+ for (const listener of previousListeners) listener.call(process, warning);
34
+ });
35
+ }
36
+ function isSqliteExperimentalWarning(warning) {
37
+ return warning.name === "ExperimentalWarning" && warning.message.includes("SQLite");
38
+ }
39
+
40
+ // src/server/messages.ru.ts
41
+ var serverRu = {
42
+ taskNotFound: (id) => `\u0417\u0430\u0434\u0430\u0447\u0430 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`,
43
+ taskChangedOnDisk: "\u0417\u0430\u0434\u0430\u0447\u0430 \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0430\u0441\u044C \u043D\u0430 \u0434\u0438\u0441\u043A\u0435",
44
+ projectNotFound: (id) => `\u041F\u0440\u043E\u0435\u043A\u0442 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`,
45
+ 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",
46
+ 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",
47
+ unknownRoute: (path) => `\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 API: ${path}`,
48
+ 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`,
49
+ jsonContentTypeExpected: "\u041E\u0436\u0438\u0434\u0430\u0435\u0442\u0441\u044F Content-Type: application/json",
50
+ runsTrimFailed: (detail) => `\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: ${detail}`,
51
+ serverStarted: (port, root) => `p-backlog: http://localhost:${port}
52
+ \u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u0431\u0435\u043A\u043B\u043E\u0433\u0430: ${root}
53
+ `,
54
+ 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.`,
55
+ 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}`,
56
+ 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}`,
57
+ 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}`,
58
+ deletedClosedTasks: (ids) => `\u0423\u0434\u0430\u043B\u0435\u043D\u044B \u0437\u0430\u043A\u0440\u044B\u0442\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438: ${ids}`,
59
+ 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}`,
60
+ invalidAfterSweep: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0437\u0430\u0434\u0430\u0447\u0438, \u0438\u0441\u043F\u0440\u0430\u0432\u044C\u0442\u0435 \u0444\u0430\u0439\u043B\u044B: ${detail}`,
61
+ transcriptsScanFailed: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043E\u0432\u043A\u0438 Claude Code: ${detail}`,
62
+ watcherError: (root, detail) => `\u041D\u0430\u0431\u043B\u044E\u0434\u0430\u0442\u0435\u043B\u044C \u0437\u0430 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u043E\u043C ${root}: ${detail}`,
63
+ listenFailed: (reason) => `p-backlog \u043D\u0435 \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u043B\u0441\u044F: ${reason}`,
64
+ portBusy: (port) => `\u043F\u043E\u0440\u0442 ${port} \u0443\u0436\u0435 \u0437\u0430\u043D\u044F\u0442`,
65
+ codeCacheReadFailed: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0447\u0438\u0442\u0430\u0442\u044C \u043A\u044D\u0448 git: ${detail}`,
66
+ codeCacheWriteFailed: (detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u044C \u043A\u044D\u0448 git: ${detail}`
67
+ };
68
+
69
+ // src/server/port.ts
70
+ var DEFAULT_PORT = 4317;
71
+ function readPort(value) {
72
+ const port = Number(value);
73
+ return value !== void 0 && Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_PORT;
74
+ }
75
+ function listenFailure(error, port, messages = serverRu) {
76
+ const reason = error.code === "EADDRINUSE" ? messages.portBusy(port) : error.message;
77
+ return messages.listenFailed(reason);
78
+ }
79
+
80
+ // src/server/start.ts
81
+ import { serve } from "@hono/node-server";
82
+ import { mkdir as mkdir3, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
83
+ import { join as join17 } from "node:path";
84
+
85
+ // src/core/claude-dir.ts
86
+ import { join as join2 } from "node:path";
87
+ function claudeDir(env, home2) {
88
+ return env.CLAUDE_CONFIG_DIR || join2(home2, ".claude");
89
+ }
90
+ function claudeProjectsDir(env, home2) {
91
+ return join2(claudeDir(env, home2), "projects");
92
+ }
93
+
94
+ // src/core/messages/en.ts
95
+ import { z } from "zod";
96
+
97
+ // src/core/check/source-lines.ts
98
+ var SOURCE_LINES = /:(\d+)(?:-(\d+))?$/;
99
+ function lineSuffix(source) {
100
+ return SOURCE_LINES.exec(source)?.[0] ?? "";
101
+ }
102
+
103
+ // src/core/numbers.ts
104
+ function roundToTenth(value) {
105
+ return Math.round(value * 10) / 10;
106
+ }
107
+
108
+ // src/core/i18n/language.ts
109
+ var LANGUAGES = ["ru", "en"];
110
+ function languageFromLocale(locale) {
111
+ return locale?.toLowerCase().startsWith("ru") ? "ru" : "en";
112
+ }
113
+ function localeOf(language) {
114
+ return language === "ru" ? "ru-RU" : "en-US";
115
+ }
116
+
117
+ // src/core/i18n/format.ts
118
+ function formatDayMonth(language, date) {
119
+ return date.toLocaleDateString(localeOf(language), { day: "2-digit", month: "2-digit" });
120
+ }
121
+ function formatDecimal(language, value) {
122
+ const rounded = String(roundToTenth(value));
123
+ return language === "ru" ? rounded.replace(".", ",") : rounded;
124
+ }
125
+
126
+ // src/core/i18n/plural.ts
127
+ function pluralRu(n, one, few, many) {
128
+ if (!Number.isInteger(n)) return few;
129
+ const abs = Math.abs(n);
130
+ const lastTwo = abs % 100;
131
+ if (lastTwo >= 11 && lastTwo <= 14) return many;
132
+ const last = abs % 10;
133
+ if (last === 1) return one;
134
+ return last >= 2 && last <= 4 ? few : many;
135
+ }
136
+ function pluralEn(n, one, other) {
137
+ return n === 1 ? one : other;
138
+ }
139
+ var NBSP = "\xA0";
140
+ function countRu(n, one, few, many) {
141
+ return `${n.toLocaleString("ru-RU")}${NBSP}${pluralRu(n, one, few, many)}`;
142
+ }
143
+ function countEn(n, one, other) {
144
+ return `${n.toLocaleString("en-US")}${NBSP}${pluralEn(n, one, other)}`;
145
+ }
146
+
147
+ // src/core/messages/zod.ts
148
+ var TYPE_WITNESSES = {
149
+ number: 0,
150
+ nan: Number.NaN,
151
+ string: "",
152
+ boolean: false,
153
+ bigint: 0n,
154
+ symbol: /* @__PURE__ */ Symbol(),
155
+ function: () => void 0,
156
+ undefined: void 0,
157
+ null: null,
158
+ array: [],
159
+ object: {}
160
+ };
161
+ function zodIssueText(localeError, { issue, receivedType }) {
162
+ const input = receivedType === void 0 ? void 0 : witnessOf(receivedType);
163
+ const text = localeError({ ...issue, input });
164
+ return typeof text === "string" ? text : text?.message ?? issue.message;
165
+ }
166
+ function witnessOf(type) {
167
+ return type in TYPE_WITNESSES ? TYPE_WITNESSES[type] : /* @__PURE__ */ Object.create({ constructor: { name: type } });
168
+ }
169
+
170
+ // src/core/messages/en.ts
171
+ var zodEn = z.locales.en().localeError;
172
+ var CATEGORY_LABELS = {
173
+ bloaters: "Bloaters",
174
+ "change-preventers": "Change preventers",
175
+ couplers: "Couplers",
176
+ "data-dealers": "Data dealers",
177
+ dispensables: "Dispensables",
178
+ "functional-abusers": "Functional abusers",
179
+ "lexical-abusers": "Lexical abusers",
180
+ "oo-abusers": "OO abusers",
181
+ obfuscators: "Obfuscators",
182
+ bug: "Bug"
183
+ };
184
+ var STATUS_LABELS = {
185
+ backlog: "backlog",
186
+ "in-progress": "in progress",
187
+ blocked: "blocked",
188
+ done: "done",
189
+ cancelled: "cancelled"
190
+ };
191
+ var PRIORITY_LABELS = { low: "low", medium: "medium", high: "high", critical: "critical" };
192
+ var RESOLUTION_LABELS = { fixed: "fixed", obsolete: "code gone", duplicate: "duplicate", "epic-done": "epic done" };
193
+ var EVIDENCE_LABELS = {
194
+ "source-changed": "code changed",
195
+ "source-missing": "file missing",
196
+ duplicate: "duplicate",
197
+ "no-source": "no source",
198
+ total: "Total"
199
+ };
200
+ var CHECK_METHOD_LABELS = { symbol: "by symbol", anchor: "by source lines", file: "by file" };
201
+ var DUPLICATE_MATCH_LABELS = { source: "by code location", title: "by title", symbol: "by symbol" };
202
+ var GRAPH_STATE_LABELS = { none: "none", unreadable: "unreadable", stale: "stale", fresh: "fresh" };
203
+ var COUNT_FORMS = {
204
+ task: ["task", "tasks"],
205
+ line: ["line", "lines"],
206
+ project: ["project", "projects"],
207
+ day: ["day", "days"],
208
+ week: ["week", "weeks"],
209
+ session: ["session", "sessions"]
210
+ };
211
+ function evidenceLabel(evidence) {
212
+ return EVIDENCE_LABELS[evidence];
213
+ }
214
+ function checkMethodLabel(method) {
215
+ return CHECK_METHOD_LABELS[method];
216
+ }
217
+ function duplicateMatchLabel(match) {
218
+ return DUPLICATE_MATCH_LABELS[match];
219
+ }
220
+ function graphStateLabel(state) {
221
+ return GRAPH_STATE_LABELS[state];
222
+ }
223
+ function count(n, unit) {
224
+ const [one, other] = COUNT_FORMS[unit];
225
+ return countEn(n, one, other);
226
+ }
227
+ function days(value) {
228
+ if (value === null) return "\u2014";
229
+ if (value < 1) return "less than a day";
230
+ const rounded = Math.round(value);
231
+ return `${rounded}${NBSP}${pluralEn(rounded, "day", "days")}`;
232
+ }
233
+ function p90(value) {
234
+ if (value === null) return "\u2014";
235
+ return value < 1 ? "within a day" : `within ${days(value)}`;
236
+ }
237
+ function forecast({ open: open2, weeklyNet, weeks, until }) {
238
+ if (open2 === 0) return "No open tasks";
239
+ if (weeks !== null && until !== null) return `Debt clears in about ${weeks} wk. (by ${formatDayMonth("en", new Date(until))})`;
240
+ if (weeklyNet === 0) return "Debt is not shrinking";
241
+ const growth = roundToTenth(-weeklyNet);
242
+ return `Debt grows by ${growth} ${pluralEn(growth, "task", "tasks")} a week`;
243
+ }
244
+ function forecastTail({ windowWeeks, closed, created }) {
245
+ return `over ${countEn(windowWeeks, "week", "weeks")}: closed ${closed}, created ${created}`;
246
+ }
247
+ function signal(s) {
248
+ switch (s.kind) {
249
+ case "debt-growing":
250
+ return `Debt has grown for ${countEn(s.params.weeks, "week", "weeks")} straight: created ${s.params.created}, closed ${s.params.closed}`;
251
+ case "urgent-stale":
252
+ return `Urgent tasks have been waiting more than ${countEn(s.params.days, "day", "days")}: ${s.params.count}`;
253
+ case "stuck":
254
+ return `Stuck in progress: ${s.params.count}, longest ${s.params.id} \u2014 ${days(s.params.days)}`;
255
+ case "noisy-check": {
256
+ const { evidence, method, percent, decided, windowDays } = s.params;
257
+ const name = method === null ? `"${evidenceLabel(evidence)}"` : `"${evidenceLabel(evidence)}" ${checkMethodLabel(method)}`;
258
+ return `Check ${name} is almost always wrong: precision ${percent}% on ${decided} decided over ${countEn(windowDays, "day", "days")}`;
259
+ }
260
+ case "low-changed":
261
+ return `Code changed for ${countEn(s.params.count, "task", "tasks")} with low priority \u2014 re-check when convenient ("clean up the backlog")`;
262
+ case "stale-low":
263
+ return `Tasks with low priority older than ${countEn(s.params.days, "day", "days")}: ${s.params.count} \u2014 clean them up (backlog prune)`;
264
+ }
265
+ }
266
+ function schemaIssue(issue) {
267
+ switch (issue.kind) {
268
+ case "bad-id":
269
+ return "invalid ID";
270
+ case "empty-title":
271
+ return "empty title";
272
+ case "empty-name":
273
+ return "empty project name";
274
+ case "bad-prefix":
275
+ return "invalid prefix";
276
+ case "zod":
277
+ return zodIssueText(zodEn, issue);
278
+ }
279
+ }
280
+ function problem(p) {
281
+ switch (p.code) {
282
+ case "self-block":
283
+ return "a task cannot block itself";
284
+ case "self-related":
285
+ return "a task cannot be related to itself";
286
+ case "referenced-as-epic":
287
+ return `other tasks refer to this task as their epic: ${p.children.join(", ")}`;
288
+ case "blocker-cycle":
289
+ return `blocker cycle: ${p.cycle.join(" \u2192 ")}`;
290
+ case "resolution-needs-status":
291
+ return `resolution ${p.resolution} requires status ${p.status}`;
292
+ case "reason-without-resolution":
293
+ return "reason can only be set together with resolution";
294
+ case "epic-self":
295
+ return "a task cannot be its own epic";
296
+ case "epic-missing":
297
+ return `epic ${p.epic} not found`;
298
+ case "epic-not-epic":
299
+ return `${p.epic} is not an epic`;
300
+ case "epic-in-epic":
301
+ return "an epic cannot belong to another epic";
302
+ case "reference-missing":
303
+ return `${p.id} not found`;
304
+ case "no-frontmatter":
305
+ return "file does not start with frontmatter (---)";
306
+ case "frontmatter-unclosed":
307
+ return "frontmatter is not closed with a --- line";
308
+ case "yaml":
309
+ return `YAML error: ${p.detail}`;
310
+ case "schema":
311
+ return p.path === "" ? schemaIssue(p.issue) : `${p.path}: ${schemaIssue(p.issue)}`;
312
+ case "id-mismatch":
313
+ return `id ${p.id} does not match the file name ${p.file}.md`;
314
+ case "prefix-mismatch":
315
+ return `prefix of ${p.file} does not match the project prefix ${p.prefix}`;
316
+ case "id-exhausted":
317
+ return `could not allocate an ID in ${p.attempts} attempts`;
318
+ }
319
+ }
320
+ function problems(list) {
321
+ return [...new Set(list.map(problem))].join("; ");
322
+ }
323
+ function epicDoneReason(ids) {
324
+ return `all tasks of the epic are closed: ${ids.join(", ")}`;
325
+ }
326
+ function checkFix(fix) {
327
+ switch (fix.kind) {
328
+ case "references-removed":
329
+ return `${fix.taskId}: removed references to missing tasks: ${fix.ids.join(", ")}`;
330
+ case "epic-closed":
331
+ return `${fix.taskId}: epic closed \u2014 ${epicDoneReason(fix.childIds)}`;
332
+ case "source-moved":
333
+ return `${fix.taskId}: source moved ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
334
+ }
335
+ }
336
+ function checkProblem(p) {
337
+ switch (p.kind) {
338
+ case "fix-failed":
339
+ return `${p.taskId}: could not fix \u2014 ${fixFailureCause(p)}`;
340
+ case "task-invalid":
341
+ return `${p.taskId}: ${problem(p.problem)}`;
342
+ case "file-not-parsed":
343
+ return `File ${p.path} could not be parsed: ${problems(p.problems)}`;
344
+ case "epics-wait-for-files":
345
+ return `Epics ${p.epicIds.join(", ")} are complete but will not close until the unparsed files are fixed`;
346
+ case "project-without-repos":
347
+ return `Project ${p.projectId}: repos has no paths \u2014 its tasks' code cannot be checked`;
348
+ case "project-repos-missing":
349
+ return `Project ${p.projectId}: none of the repos paths exist (${p.repos.join(", ")}) \u2014 its tasks' code cannot be checked`;
350
+ }
351
+ }
352
+ function fixFailureCause(p) {
353
+ switch (p.cause) {
354
+ case "invalid":
355
+ return problems(p.problems);
356
+ case "changed-during-check":
357
+ return "the file changed during the check";
358
+ case "gone-during-check":
359
+ return "the file disappeared during the check";
360
+ }
361
+ }
362
+ var coreEn = {
363
+ hookMark: "Backlog",
364
+ languageName: "English",
365
+ problem,
366
+ problems,
367
+ schemaIssue,
368
+ categoryLabel: (category) => category === void 0 ? "not set" : CATEGORY_LABELS[category],
369
+ statusLabel: (status) => STATUS_LABELS[status],
370
+ priorityLabel: (priority) => PRIORITY_LABELS[priority],
371
+ resolutionLabel: (resolution) => RESOLUTION_LABELS[resolution],
372
+ evidenceLabel,
373
+ checkMethodLabel,
374
+ duplicateMatchLabel,
375
+ graphStateLabel,
376
+ count,
377
+ days,
378
+ p90,
379
+ forecast,
380
+ forecastTail,
381
+ signal,
382
+ epicDoneReason,
383
+ fileBusy: (path, lock, seconds) => `${path} has been locked by another process for more than ${seconds} s (${lock})`,
384
+ checkFix,
385
+ checkProblem,
386
+ candidatesRecordFailed: (projectId, detail) => `Could not record candidates to the ${projectId} journal: ${detail}`
387
+ };
388
+
389
+ // src/core/messages/ru.ts
390
+ import { z as z2 } from "zod";
391
+ var zodRu = z2.locales.ru().localeError;
392
+ var CATEGORY_LABELS2 = {
393
+ bloaters: "\u0420\u0430\u0437\u0431\u0443\u0445\u0448\u0435\u0435",
394
+ "change-preventers": "\u041C\u0435\u0448\u0430\u0435\u0442 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u044F\u043C",
395
+ couplers: "\u0421\u0432\u044F\u0437\u0430\u043D\u043D\u043E\u0441\u0442\u044C",
396
+ "data-dealers": "\u0420\u0430\u0431\u043E\u0442\u0430 \u0441 \u0434\u0430\u043D\u043D\u044B\u043C\u0438",
397
+ dispensables: "\u041B\u0438\u0448\u043D\u0435\u0435",
398
+ "functional-abusers": "\u0418\u043C\u043F\u0435\u0440\u0430\u0442\u0438\u0432\u043D\u043E\u0441\u0442\u044C",
399
+ "lexical-abusers": "\u0418\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u0438\u0435",
400
+ "oo-abusers": "\u041E\u041E\u041F",
401
+ obfuscators: "\u0417\u0430\u043F\u0443\u0442\u0430\u043D\u043D\u043E\u0441\u0442\u044C",
402
+ bug: "\u041E\u0448\u0438\u0431\u043A\u0430"
403
+ };
404
+ var STATUS_LABELS2 = {
405
+ backlog: "\u0432 \u0431\u0435\u043A\u043B\u043E\u0433\u0435",
406
+ "in-progress": "\u0432 \u0440\u0430\u0431\u043E\u0442\u0435",
407
+ blocked: "\u0437\u0430\u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u043D\u0430",
408
+ done: "\u0441\u0434\u0435\u043B\u0430\u043D\u0430",
409
+ cancelled: "\u043E\u0442\u043C\u0435\u043D\u0435\u043D\u0430"
410
+ };
411
+ var PRIORITY_LABELS2 = { low: "\u043D\u0438\u0437\u043A\u0438\u0439", medium: "\u0441\u0440\u0435\u0434\u043D\u0438\u0439", high: "\u0432\u044B\u0441\u043E\u043A\u0438\u0439", critical: "\u043A\u0440\u0438\u0442\u0438\u0447\u043D\u044B\u0439" };
412
+ var RESOLUTION_LABELS2 = { fixed: "\u0438\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E", obsolete: "\u043A\u043E\u0434\u0430 \u043D\u0435\u0442", duplicate: "\u0434\u0443\u0431\u043B\u044C", "epic-done": "\u044D\u043F\u0438\u043A \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D" };
413
+ var EVIDENCE_LABELS2 = {
414
+ "source-changed": "\u043A\u043E\u0434 \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0441\u044F",
415
+ "source-missing": "\u0444\u0430\u0439\u043B \u043F\u0440\u043E\u043F\u0430\u043B",
416
+ duplicate: "\u0434\u0443\u0431\u043B\u044C",
417
+ "no-source": "\u043D\u0435\u0442 source",
418
+ total: "\u0412\u0441\u0435\u0433\u043E"
419
+ };
420
+ var CHECK_METHOD_LABELS2 = { symbol: "\u043F\u043E \u0441\u0438\u043C\u0432\u043E\u043B\u0443", anchor: "\u043F\u043E \u0441\u0442\u0440\u043E\u043A\u0430\u043C source", file: "\u043F\u043E \u0444\u0430\u0439\u043B\u0443" };
421
+ var DUPLICATE_MATCH_LABELS2 = { source: "\u043F\u043E \u043C\u0435\u0441\u0442\u0443 \u0432 \u043A\u043E\u0434\u0435", title: "\u043F\u043E \u0437\u0430\u0433\u043E\u043B\u043E\u0432\u043A\u0443", symbol: "\u043F\u043E \u0441\u0438\u043C\u0432\u043E\u043B\u0443" };
422
+ var GRAPH_STATE_LABELS2 = { none: "\u043D\u0435\u0442", unreadable: "\u043D\u0435 \u0447\u0438\u0442\u0430\u0435\u0442\u0441\u044F", stale: "\u0443\u0441\u0442\u0430\u0440\u0435\u043B", fresh: "\u0441\u0432\u0435\u0436\u0438\u0439" };
423
+ var COUNT_FORMS2 = {
424
+ task: ["\u0437\u0430\u0434\u0430\u0447\u0430", "\u0437\u0430\u0434\u0430\u0447\u0438", "\u0437\u0430\u0434\u0430\u0447"],
425
+ line: ["\u0441\u0442\u0440\u043E\u043A\u0430", "\u0441\u0442\u0440\u043E\u043A\u0438", "\u0441\u0442\u0440\u043E\u043A"],
426
+ project: ["\u043F\u0440\u043E\u0435\u043A\u0442", "\u043F\u0440\u043E\u0435\u043A\u0442\u0430", "\u043F\u0440\u043E\u0435\u043A\u0442\u043E\u0432"],
427
+ day: ["\u0434\u0435\u043D\u044C", "\u0434\u043D\u044F", "\u0434\u043D\u0435\u0439"],
428
+ week: ["\u043D\u0435\u0434\u0435\u043B\u044F", "\u043D\u0435\u0434\u0435\u043B\u0438", "\u043D\u0435\u0434\u0435\u043B\u044C"],
429
+ session: ["\u0441\u0435\u0441\u0441\u0438\u044F", "\u0441\u0435\u0441\u0441\u0438\u0438", "\u0441\u0435\u0441\u0441\u0438\u0439"]
430
+ };
431
+ function evidenceLabel2(evidence) {
432
+ return EVIDENCE_LABELS2[evidence];
433
+ }
434
+ function checkMethodLabel2(method) {
435
+ return CHECK_METHOD_LABELS2[method];
436
+ }
437
+ function duplicateMatchLabel2(match) {
438
+ return DUPLICATE_MATCH_LABELS2[match];
439
+ }
440
+ function graphStateLabel2(state) {
441
+ return GRAPH_STATE_LABELS2[state];
442
+ }
443
+ function count2(n, unit) {
444
+ const [one, few, many] = COUNT_FORMS2[unit];
445
+ return countRu(n, one, few, many);
446
+ }
447
+ function genitiveDays(days3) {
448
+ return `${days3} ${pluralRu(days3, "\u0434\u043D\u044F", "\u0434\u043D\u0435\u0439", "\u0434\u043D\u0435\u0439")}`;
449
+ }
450
+ function days2(value) {
451
+ if (value === null) return "\u2014";
452
+ if (value < 1) return "\u043C\u0435\u043D\u044C\u0448\u0435 \u0434\u043D\u044F";
453
+ return `${Math.round(value)}${NBSP}\u0434\u043D.`;
454
+ }
455
+ function p902(value) {
456
+ if (value === null) return "\u2014";
457
+ return value < 1 ? "\u0431\u044B\u0441\u0442\u0440\u0435\u0435 \u0441\u0443\u0442\u043E\u043A" : `\u0437\u0430 ${days2(value)}`;
458
+ }
459
+ function forecast2({ open: open2, weeklyNet, weeks, until }) {
460
+ if (open2 === 0) return "\u041E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0437\u0430\u0434\u0430\u0447 \u043D\u0435\u0442";
461
+ if (weeks !== null && until !== null) return `\u0414\u043E\u043B\u0433 \u0440\u0430\u0437\u0431\u0435\u0440\u0451\u0442\u0441\u044F \u043F\u0440\u0438\u043C\u0435\u0440\u043D\u043E \u0437\u0430 ${weeks}${NBSP}\u043D\u0435\u0434. (\u043A ${formatDayMonth("ru", new Date(until))})`;
462
+ if (weeklyNet === 0) return "\u0414\u043E\u043B\u0433 \u043D\u0435 \u0443\u043C\u0435\u043D\u044C\u0448\u0430\u0435\u0442\u0441\u044F";
463
+ const growth = roundToTenth(-weeklyNet);
464
+ return `\u0414\u043E\u043B\u0433 \u0440\u0430\u0441\u0442\u0451\u0442 \u043D\u0430 ${formatDecimal("ru", growth)}${NBSP}${pluralRu(growth, "\u0437\u0430\u0434\u0430\u0447\u0430", "\u0437\u0430\u0434\u0430\u0447\u0438", "\u0437\u0430\u0434\u0430\u0447")} \u0432 \u043D\u0435\u0434\u0435\u043B\u044E`;
465
+ }
466
+ function forecastTail2({ windowWeeks, closed, created }) {
467
+ return `\u0437\u0430 ${countRu(windowWeeks, "\u043D\u0435\u0434\u0435\u043B\u044E", "\u043D\u0435\u0434\u0435\u043B\u0438", "\u043D\u0435\u0434\u0435\u043B\u044C")}: \u0437\u0430\u043A\u0440\u044B\u0442\u043E ${closed}, \u0441\u043E\u0437\u0434\u0430\u043D\u043E ${created}`;
468
+ }
469
+ function signal2(s) {
470
+ switch (s.kind) {
471
+ case "debt-growing":
472
+ return `\u0414\u043E\u043B\u0433 \u0440\u0430\u0441\u0442\u0451\u0442 ${countRu(s.params.weeks, "\u043D\u0435\u0434\u0435\u043B\u044E", "\u043D\u0435\u0434\u0435\u043B\u0438", "\u043D\u0435\u0434\u0435\u043B\u044C")} \u043F\u043E\u0434\u0440\u044F\u0434: \u0441\u043E\u0437\u0434\u0430\u043D\u043E ${s.params.created}, \u0437\u0430\u043A\u0440\u044B\u0442\u043E ${s.params.closed}`;
473
+ case "urgent-stale":
474
+ return `\u0421\u0440\u043E\u0447\u043D\u044B\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0436\u0434\u0443\u0442 \u0434\u043E\u043B\u044C\u0448\u0435 ${genitiveDays(s.params.days)}: ${s.params.count}`;
475
+ case "stuck":
476
+ return `\u0417\u0430\u0441\u0442\u0440\u044F\u043B\u0438 \u0432 \u0440\u0430\u0431\u043E\u0442\u0435: ${s.params.count}, \u0434\u043E\u043B\u044C\u0448\u0435 \u0432\u0441\u0435\u0445 ${s.params.id} \u2014 ${days2(s.params.days)}`;
477
+ case "noisy-check": {
478
+ const { evidence, method, percent, decided, windowDays } = s.params;
479
+ const name = method === null ? `\xAB${evidenceLabel2(evidence)}\xBB` : `\xAB${evidenceLabel2(evidence)}\xBB ${checkMethodLabel2(method)}`;
480
+ return `\u041F\u0440\u043E\u0432\u0435\u0440\u043A\u0430 ${name} \u043F\u043E\u0447\u0442\u0438 \u0432\u0441\u0435\u0433\u0434\u0430 \u043E\u0448\u0438\u0431\u0430\u0435\u0442\u0441\u044F: \u0442\u043E\u0447\u043D\u043E\u0441\u0442\u044C ${percent}% \u043D\u0430 ${decided} \u0440\u0435\u0448\u0451\u043D\u043D\u044B\u0445 \u0437\u0430 ${countRu(windowDays, "\u0434\u0435\u043D\u044C", "\u0434\u043D\u044F", "\u0434\u043D\u0435\u0439")}`;
481
+ }
482
+ case "low-changed":
483
+ return `\u041A\u043E\u0434 \u043C\u0435\u043D\u044F\u043B\u0441\u044F \u0443 ${countRu(s.params.count, "\u0437\u0430\u0434\u0430\u0447\u0438", "\u0437\u0430\u0434\u0430\u0447", "\u0437\u0430\u0434\u0430\u0447")} \u0441 \u043D\u0438\u0437\u043A\u0438\u043C \u043F\u0440\u0438\u043E\u0440\u0438\u0442\u0435\u0442\u043E\u043C \u2014 \u043F\u0435\u0440\u0435\u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 \u043F\u0440\u0438 \u0441\u043B\u0443\u0447\u0430\u0435 (\xAB\u043F\u043E\u0447\u0438\u0441\u0442\u0438 \u0431\u0435\u043A\u043B\u043E\u0433\xBB)`;
484
+ case "stale-low":
485
+ return `\u0417\u0430\u0434\u0430\u0447 \u0441 \u043D\u0438\u0437\u043A\u0438\u043C \u043F\u0440\u0438\u043E\u0440\u0438\u0442\u0435\u0442\u043E\u043C \u0441\u0442\u0430\u0440\u0448\u0435 ${genitiveDays(s.params.days)}: ${s.params.count} \u2014 \u0440\u0430\u0437\u0431\u0435\u0440\u0438\u0442\u0435 (backlog prune)`;
486
+ }
487
+ }
488
+ function schemaIssue2(issue) {
489
+ switch (issue.kind) {
490
+ case "bad-id":
491
+ return "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 ID";
492
+ case "empty-title":
493
+ return "\u043F\u0443\u0441\u0442\u043E\u0439 \u0437\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A";
494
+ case "empty-name":
495
+ return "\u043F\u0443\u0441\u0442\u043E\u0435 \u0438\u043C\u044F \u043F\u0440\u043E\u0435\u043A\u0442\u0430";
496
+ case "bad-prefix":
497
+ return "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 \u043F\u0440\u0435\u0444\u0438\u043A\u0441";
498
+ case "zod":
499
+ return zodIssueText(zodRu, issue);
500
+ }
501
+ }
502
+ function problem2(p) {
503
+ switch (p.code) {
504
+ case "self-block":
505
+ return "\u0437\u0430\u0434\u0430\u0447\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u0441\u0430\u043C\u0443 \u0441\u0435\u0431\u044F";
506
+ case "self-related":
507
+ return "\u0437\u0430\u0434\u0430\u0447\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u0441\u0432\u044F\u0437\u0430\u043D\u0430 \u0441\u0430\u043C\u0430 \u0441 \u0441\u043E\u0431\u043E\u0439";
508
+ case "referenced-as-epic":
509
+ return `\u043D\u0430 \u0437\u0430\u0434\u0430\u0447\u0443 \u0441\u0441\u044B\u043B\u0430\u044E\u0442\u0441\u044F \u043A\u0430\u043A \u043D\u0430 \u044D\u043F\u0438\u043A: ${p.children.join(", ")}`;
510
+ case "blocker-cycle":
511
+ return `\u0446\u0438\u043A\u043B \u0431\u043B\u043E\u043A\u0435\u0440\u043E\u0432: ${p.cycle.join(" \u2192 ")}`;
512
+ case "resolution-needs-status":
513
+ return `resolution ${p.resolution} \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 ${p.status}`;
514
+ case "reason-without-resolution":
515
+ return "reason \u0437\u0430\u0434\u0430\u0451\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E \u0432\u043C\u0435\u0441\u0442\u0435 \u0441 resolution";
516
+ case "epic-self":
517
+ return "\u0437\u0430\u0434\u0430\u0447\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u0441\u0432\u043E\u0438\u043C \u044D\u043F\u0438\u043A\u043E\u043C";
518
+ case "epic-missing":
519
+ return `\u044D\u043F\u0438\u043A ${p.epic} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D`;
520
+ case "epic-not-epic":
521
+ return `${p.epic} \u043D\u0435 \u044F\u0432\u043B\u044F\u0435\u0442\u0441\u044F \u044D\u043F\u0438\u043A\u043E\u043C`;
522
+ case "epic-in-epic":
523
+ 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";
524
+ case "reference-missing":
525
+ return `${p.id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`;
526
+ case "no-frontmatter":
527
+ return "\u0444\u0430\u0439\u043B \u043D\u0435 \u043D\u0430\u0447\u0438\u043D\u0430\u0435\u0442\u0441\u044F \u0441 frontmatter (---)";
528
+ case "frontmatter-unclosed":
529
+ return "frontmatter \u043D\u0435 \u0437\u0430\u043A\u0440\u044B\u0442 \u0441\u0442\u0440\u043E\u043A\u043E\u0439 ---";
530
+ case "yaml":
531
+ return `\u043E\u0448\u0438\u0431\u043A\u0430 YAML: ${p.detail}`;
532
+ case "schema":
533
+ return p.path === "" ? schemaIssue2(p.issue) : `${p.path}: ${schemaIssue2(p.issue)}`;
534
+ case "id-mismatch":
535
+ return `id ${p.id} \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 \u0438\u043C\u0435\u043D\u0435\u043C \u0444\u0430\u0439\u043B\u0430 ${p.file}.md`;
536
+ case "prefix-mismatch":
537
+ return `\u043F\u0440\u0435\u0444\u0438\u043A\u0441 ${p.file} \u043D\u0435 \u0441\u043E\u0432\u043F\u0430\u0434\u0430\u0435\u0442 \u0441 \u043F\u0440\u0435\u0444\u0438\u043A\u0441\u043E\u043C \u043F\u0440\u043E\u0435\u043A\u0442\u0430 ${p.prefix}`;
538
+ case "id-exhausted":
539
+ return `\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0432\u044B\u0434\u0435\u043B\u0438\u0442\u044C ID \u0437\u0430 ${p.attempts} \u043F\u043E\u043F\u044B\u0442\u043E\u043A`;
540
+ }
541
+ }
542
+ function problems2(list) {
543
+ return [...new Set(list.map(problem2))].join("; ");
544
+ }
545
+ function epicDoneReason2(ids) {
546
+ return `\u0432\u0441\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u044D\u043F\u0438\u043A\u0430 \u0437\u0430\u043A\u0440\u044B\u0442\u044B: ${ids.join(", ")}`;
547
+ }
548
+ function checkFix2(fix) {
549
+ switch (fix.kind) {
550
+ case "references-removed":
551
+ 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(", ")}`;
552
+ case "epic-closed":
553
+ return `${fix.taskId}: \u044D\u043F\u0438\u043A \u0437\u0430\u043A\u0440\u044B\u0442 \u2014 ${epicDoneReason2(fix.childIds)}`;
554
+ case "source-moved":
555
+ return `${fix.taskId}: source \u0441\u0434\u0432\u0438\u043D\u0443\u043B\u0441\u044F ${lineSuffix(fix.from)} \u2192 ${lineSuffix(fix.to)}`;
556
+ }
557
+ }
558
+ function checkProblem2(p) {
559
+ switch (p.kind) {
560
+ case "fix-failed":
561
+ return `${p.taskId}: \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0438\u0441\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u2014 ${fixFailureCause2(p)}`;
562
+ case "task-invalid":
563
+ return `${p.taskId}: ${problem2(p.problem)}`;
564
+ case "file-not-parsed":
565
+ return `\u0424\u0430\u0439\u043B ${p.path} \u043D\u0435 \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D: ${problems2(p.problems)}`;
566
+ case "epics-wait-for-files":
567
+ return `\u042D\u043F\u0438\u043A\u0438 ${p.epicIds.join(", ")} \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u044B, \u043D\u043E \u043D\u0435 \u0437\u0430\u043A\u0440\u043E\u044E\u0442\u0441\u044F, \u043F\u043E\u043A\u0430 \u043D\u0435 \u0438\u0441\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u044B \u043D\u0435\u0440\u0430\u0437\u043E\u0431\u0440\u0430\u043D\u043D\u044B\u0435 \u0444\u0430\u0439\u043B\u044B`;
568
+ case "project-without-repos":
569
+ return `\u041F\u0440\u043E\u0435\u043A\u0442 ${p.projectId}: \u0432 repos \u043D\u0435\u0442 \u043F\u0443\u0442\u0435\u0439 \u2014 \u043A\u043E\u0434 \u0435\u0433\u043E \u0437\u0430\u0434\u0430\u0447 \u043D\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C`;
570
+ case "project-repos-missing":
571
+ return `\u041F\u0440\u043E\u0435\u043A\u0442 ${p.projectId}: \u043D\u0438 \u043E\u0434\u0438\u043D \u043F\u0443\u0442\u044C \u0438\u0437 repos \u043D\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 (${p.repos.join(", ")}) \u2014 \u043A\u043E\u0434 \u0435\u0433\u043E \u0437\u0430\u0434\u0430\u0447 \u043D\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C`;
572
+ }
573
+ }
574
+ function fixFailureCause2(p) {
575
+ switch (p.cause) {
576
+ case "invalid":
577
+ return problems2(p.problems);
578
+ case "changed-during-check":
579
+ return "\u0444\u0430\u0439\u043B \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u0432\u043E \u0432\u0440\u0435\u043C\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438";
580
+ case "gone-during-check":
581
+ return "\u0444\u0430\u0439\u043B \u0438\u0441\u0447\u0435\u0437 \u0432\u043E \u0432\u0440\u0435\u043C\u044F \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438";
582
+ }
583
+ }
584
+ var coreRu = {
585
+ hookMark: "\u0411\u0435\u043A\u043B\u043E\u0433",
586
+ languageName: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439",
587
+ problem: problem2,
588
+ problems: problems2,
589
+ schemaIssue: schemaIssue2,
590
+ categoryLabel: (category) => category === void 0 ? "\u043D\u0435 \u0443\u043A\u0430\u0437\u0430\u043D\u0430" : CATEGORY_LABELS2[category],
591
+ statusLabel: (status) => STATUS_LABELS2[status],
592
+ priorityLabel: (priority) => PRIORITY_LABELS2[priority],
593
+ resolutionLabel: (resolution) => RESOLUTION_LABELS2[resolution],
594
+ evidenceLabel: evidenceLabel2,
595
+ checkMethodLabel: checkMethodLabel2,
596
+ duplicateMatchLabel: duplicateMatchLabel2,
597
+ graphStateLabel: graphStateLabel2,
598
+ count: count2,
599
+ days: days2,
600
+ p90: p902,
601
+ forecast: forecast2,
602
+ forecastTail: forecastTail2,
603
+ signal: signal2,
604
+ epicDoneReason: epicDoneReason2,
605
+ fileBusy: (path, lock, seconds) => `${path} \u0437\u0430\u043D\u044F\u0442 \u0434\u0440\u0443\u0433\u0438\u043C \u043F\u0440\u043E\u0446\u0435\u0441\u0441\u043E\u043C \u0434\u043E\u043B\u044C\u0448\u0435 ${seconds} \u0441 (${lock})`,
606
+ checkFix: checkFix2,
607
+ checkProblem: checkProblem2,
608
+ candidatesRecordFailed: (projectId, detail) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0438\u0441\u0430\u0442\u044C \u043A\u0430\u043D\u0434\u0438\u0434\u0430\u0442\u043E\u0432 \u0432 \u0436\u0443\u0440\u043D\u0430\u043B ${projectId}: ${detail}`
609
+ };
610
+
611
+ // src/core/messages/index.ts
612
+ function coreMessages(language) {
613
+ return language === "ru" ? coreRu : coreEn;
614
+ }
615
+
616
+ // src/core/store/runs.ts
617
+ import { join as join5 } from "node:path";
618
+ import { z as z3 } from "zod";
619
+
620
+ // src/core/model/dates.ts
621
+ function formatLocalDay(date) {
622
+ return formatLocalIso(date).slice(0, 10);
623
+ }
624
+ function formatLocalIso(date) {
625
+ const pad = (value) => String(Math.abs(value)).padStart(2, "0");
626
+ const offsetMinutes = -date.getTimezoneOffset();
627
+ const sign = offsetMinutes >= 0 ? "+" : "-";
628
+ const offset = `${sign}${pad(Math.trunc(offsetMinutes / 60))}:${pad(offsetMinutes % 60)}`;
629
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
630
+ const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
631
+ return `${day}T${time}${offset}`;
632
+ }
633
+
634
+ // src/core/model/graph.ts
635
+ var CLOSED_STATUSES = /* @__PURE__ */ new Set(["done", "cancelled"]);
636
+ function isClosed(status) {
637
+ return CLOSED_STATUSES.has(status);
638
+ }
639
+ function buildIndex(tasks) {
640
+ const byId = /* @__PURE__ */ new Map();
641
+ const dependentsOf = /* @__PURE__ */ new Map();
642
+ const relatedFrom = /* @__PURE__ */ new Map();
643
+ const childrenOf = /* @__PURE__ */ new Map();
644
+ for (const task of tasks) {
645
+ byId.set(task.id, task);
646
+ for (const blockerId of task.blockedBy) appendTo(dependentsOf, blockerId, task);
647
+ for (const relatedId of task.related) appendTo(relatedFrom, relatedId, task);
648
+ if (task.epic !== void 0) appendTo(childrenOf, task.epic, task);
649
+ }
650
+ return { byId, dependentsOf, relatedFrom, childrenOf };
651
+ }
652
+ function epicChildren(epic, index) {
653
+ return index.childrenOf.get(epic.id) ?? [];
654
+ }
655
+ function appendTo(map, key, task) {
656
+ const list = map.get(key);
657
+ if (list) list.push(task);
658
+ else map.set(key, [task]);
659
+ }
660
+
661
+ // src/core/model/lifecycle.ts
662
+ var RETENTION_DAYS = 7;
663
+ var DAY_MS = 24 * 60 * 60 * 1e3;
664
+ var RESOLUTION_STATUS = {
665
+ fixed: "done",
666
+ obsolete: "cancelled",
667
+ duplicate: "cancelled",
668
+ "epic-done": "done"
669
+ };
670
+ function changeStatus(task, status, now, closure) {
671
+ if (status === task.status) return task;
672
+ return { ...task, status, closed: closedAt(task, status, now), resolution: closure?.resolution, reason: closure?.reason };
673
+ }
674
+ function settleLifecycle(task, now) {
675
+ if (!isClosed(task.status)) return { ...task, closed: void 0, resolution: void 0, reason: void 0 };
676
+ return task.closed === void 0 ? { ...task, closed: formatLocalIso(now) } : task;
677
+ }
678
+ function deletionDate(task) {
679
+ if (!isClosed(task.status) || task.closed === void 0) return void 0;
680
+ return new Date(Date.parse(task.closed) + RETENTION_DAYS * DAY_MS);
681
+ }
682
+ function isExpired(task, now) {
683
+ const deletesAt = deletionDate(task);
684
+ return deletesAt !== void 0 && deletesAt.getTime() <= now.getTime();
685
+ }
686
+ function planEpicClosing(tasks, parseErrors) {
687
+ const unreadableProjects = new Set(parseErrors.map((error) => error.projectId));
688
+ const completed = completedEpics(tasks);
689
+ return {
690
+ close: completed.filter(({ epic }) => !unreadableProjects.has(epic.projectId)),
691
+ waiting: completed.filter(({ epic }) => unreadableProjects.has(epic.projectId))
692
+ };
693
+ }
694
+ function completedEpics(tasks) {
695
+ const index = buildIndex(tasks);
696
+ return tasks.flatMap((epic) => {
697
+ const childIds = completedEpicChildren(epic, index);
698
+ return childIds === null ? [] : [{ epic, childIds }];
699
+ });
700
+ }
701
+ function completedEpicChildren(task, index) {
702
+ if (task.type !== "epic" || isClosed(task.status)) return null;
703
+ const children = epicChildren(task, index);
704
+ const complete = children.length > 0 && children.every((child) => isClosed(child.status));
705
+ return complete ? children.map((child) => child.id) : null;
706
+ }
707
+ function epicDoneClosure(childIds, messages) {
708
+ return { resolution: "epic-done", reason: messages.epicDoneReason(childIds) };
709
+ }
710
+ function closedAt(task, status, now) {
711
+ if (!isClosed(status)) return void 0;
712
+ return isClosed(task.status) ? task.closed : formatLocalIso(now);
713
+ }
714
+
715
+ // src/core/store/file-lock.ts
716
+ import { rm as rm2, stat, writeFile as writeFile2 } from "node:fs/promises";
717
+ import { basename as basename3, dirname as dirname2, join as join4 } from "node:path";
718
+ import { setTimeout as sleep2 } from "node:timers/promises";
719
+
720
+ // src/core/store/fs-utils.ts
721
+ import { createHash, randomUUID } from "node:crypto";
722
+ import { link, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
723
+ import { basename as basename2, dirname, join as join3 } from "node:path";
724
+ import { setTimeout as sleep } from "node:timers/promises";
725
+ function contentVersion(text) {
726
+ return createHash("sha1").update(text).digest("hex");
727
+ }
728
+ function hasErrorCode(error, code) {
729
+ return error instanceof Error && "code" in error && error.code === code;
730
+ }
731
+ async function readTextOrNull(path) {
732
+ try {
733
+ return await readFile(path, "utf8");
734
+ } catch (error) {
735
+ if (hasErrorCode(error, "ENOENT")) return null;
736
+ throw error;
737
+ }
738
+ }
739
+ async function readJsonLines(path, schema) {
740
+ const text = await readTextOrNull(path) ?? "";
741
+ const parsed = text.split("\n").filter((line) => line.trim() !== "").map((line) => parseJson(line, schema));
742
+ const values = parsed.filter((value) => value !== null);
743
+ return { values, invalidLines: parsed.length - values.length };
744
+ }
745
+ function toJsonLines(values) {
746
+ return values.map((value) => `${JSON.stringify(value)}
747
+ `).join("");
748
+ }
749
+ function parseJson(text, schema) {
750
+ try {
751
+ const parsed = schema.safeParse(JSON.parse(text));
752
+ return parsed.success ? parsed.data : null;
753
+ } catch {
754
+ return null;
755
+ }
756
+ }
757
+ async function readJsonFile(path, schema) {
758
+ const text = await readTextOrNull(path);
759
+ return text === null ? null : parseJson(text, schema);
760
+ }
761
+ async function writeJsonFile(path, value) {
762
+ await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}
763
+ `);
764
+ }
765
+ async function removeIfUnchanged(path, version) {
766
+ const text = await readTextOrNull(path);
767
+ if (text !== null && contentVersion(text) !== version) return false;
768
+ await rm(path, { force: true });
769
+ return true;
770
+ }
771
+ async function listDir(path, { recursive = false } = {}) {
772
+ try {
773
+ return await readdir(path, { withFileTypes: true, recursive });
774
+ } catch (error) {
775
+ if (hasErrorCode(error, "ENOENT")) return [];
776
+ throw error;
777
+ }
778
+ }
779
+ var REPLACE_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 320, 640];
780
+ var REPLACE_BLOCKED_CODES = ["EPERM", "EACCES", "EBUSY"];
781
+ async function writeFileAtomic(path, content) {
782
+ await viaTemporaryFile(path, content, (temporary) => replaceFile(temporary, path));
783
+ }
784
+ async function replaceFile(temporary, path) {
785
+ if (process.platform !== "win32") return rename(temporary, path);
786
+ for (const delayMs of REPLACE_RETRY_DELAYS_MS) {
787
+ try {
788
+ return await rename(temporary, path);
789
+ } catch (error) {
790
+ if (!REPLACE_BLOCKED_CODES.some((code) => hasErrorCode(error, code))) throw error;
791
+ await sleep(delayMs);
792
+ }
793
+ }
794
+ await rename(temporary, path);
795
+ }
796
+ async function viaTemporaryFile(path, content, publish) {
797
+ const temporary = join3(dirname(path), `.${basename2(path)}.${randomUUID()}.tmp`);
798
+ try {
799
+ await writeFile(temporary, content, "utf8");
800
+ await publish(temporary);
801
+ } finally {
802
+ await rm(temporary, { force: true });
803
+ }
804
+ }
805
+
806
+ // src/core/store/file-lock.ts
807
+ var RETRY_MS = 10;
808
+ var WAIT_LIMIT_MS = 5e3;
809
+ var ABANDONED_AFTER_MS = 3e4;
810
+ var FileBusyError = class extends Error {
811
+ constructor(path, lock, seconds) {
812
+ super(`${path} is locked: ${lock}`);
813
+ this.path = path;
814
+ this.lock = lock;
815
+ this.seconds = seconds;
816
+ this.name = "FileBusyError";
817
+ }
818
+ path;
819
+ lock;
820
+ seconds;
821
+ };
822
+ async function withFileLock(path, action) {
823
+ const lock = join4(dirname2(path), `.${basename3(path)}.lock`);
824
+ await acquire(lock, path);
825
+ try {
826
+ return await action();
827
+ } finally {
828
+ await rm2(lock, { force: true });
829
+ }
830
+ }
831
+ async function acquire(lock, path) {
832
+ const giveUpAt = Date.now() + WAIT_LIMIT_MS;
833
+ for (; ; ) {
834
+ if (await tryCreate(lock)) return;
835
+ if (await isAbandoned(lock)) {
836
+ await rm2(lock, { force: true });
837
+ continue;
838
+ }
839
+ if (Date.now() > giveUpAt) throw new FileBusyError(path, lock, WAIT_LIMIT_MS / 1e3);
840
+ await sleep2(RETRY_MS);
841
+ }
842
+ }
843
+ async function tryCreate(lock) {
844
+ try {
845
+ await writeFile2(lock, String(process.pid), { flag: "wx" });
846
+ return true;
847
+ } catch (error) {
848
+ if (hasErrorCode(error, "EEXIST")) return false;
849
+ throw error;
850
+ }
851
+ }
852
+ async function isAbandoned(lock) {
853
+ try {
854
+ return Date.now() - (await stat(lock)).mtimeMs > ABANDONED_AFTER_MS;
855
+ } catch (error) {
856
+ if (hasErrorCode(error, "ENOENT")) return false;
857
+ throw error;
858
+ }
859
+ }
860
+
861
+ // src/core/store/runs.ts
862
+ var RUNS_FILE = ".runs.jsonl";
863
+ var RUNS_KEPT_DAYS = 30;
864
+ var cliRunSchema = z3.object({
865
+ at: z3.iso.datetime({ offset: true }),
866
+ command: z3.string().min(1),
867
+ cwd: z3.string().min(1),
868
+ ms: z3.number(),
869
+ rssMb: z3.number(),
870
+ exitCode: z3.number()
871
+ });
872
+ async function readRuns(root) {
873
+ return (await readJsonLines(join5(root, RUNS_FILE), cliRunSchema)).values;
874
+ }
875
+ async function trimRuns(root, now) {
876
+ const path = join5(root, RUNS_FILE);
877
+ return withFileLock(path, async () => {
878
+ const runs = await readRuns(root);
879
+ const cutoff = now.getTime() - RUNS_KEPT_DAYS * DAY_MS;
880
+ const kept = runs.filter((run) => Date.parse(run.at) >= cutoff);
881
+ const removed = runs.length - kept.length;
882
+ if (removed > 0) await writeFileAtomic(path, toJsonLines(kept));
883
+ return removed;
884
+ });
885
+ }
886
+
887
+ // src/core/store/settings.ts
888
+ import { mkdir } from "node:fs/promises";
889
+ import { join as join6 } from "node:path";
890
+ import { z as z4 } from "zod";
891
+ var SETTINGS_FILE = ".settings.json";
892
+ var settingsSchema = z4.object({ language: z4.enum(LANGUAGES) });
893
+ function settingsFilePath(root) {
894
+ return join6(root, SETTINGS_FILE);
895
+ }
896
+ async function readSettingsFile(root) {
897
+ const text = await readTextOrNull(settingsFilePath(root));
898
+ if (text === null) return { found: false };
899
+ const settings = parseJson(text, settingsSchema);
900
+ return settings === null ? { found: true, valid: false } : { found: true, valid: true, settings };
901
+ }
902
+ async function writeSettings(root, settings) {
903
+ await mkdir(root, { recursive: true });
904
+ await writeJsonFile(settingsFilePath(root), settings);
905
+ }
906
+ async function settledLanguage(root, env) {
907
+ const file = await readSettingsFile(root);
908
+ if (file.found && file.valid) return { language: file.settings.language, invalidSettingsFile: false };
909
+ const locale = () => languageFromLocale(env.LC_ALL || env.LANG || Intl.DateTimeFormat().resolvedOptions().locale);
910
+ if (!file.found) {
911
+ const language = await hasProjects(root) ? "ru" : locale();
912
+ await writeSettings(root, { language }).catch(() => {
913
+ });
914
+ return { language, invalidSettingsFile: false };
915
+ }
916
+ return { language: locale(), invalidSettingsFile: true };
917
+ }
918
+ async function hasProjects(root) {
919
+ return (await listDir(root)).some((entry) => entry.isDirectory() && !entry.name.startsWith("."));
920
+ }
921
+
922
+ // src/core/store/sweep.ts
923
+ import { dirname as dirname4 } from "node:path";
924
+
925
+ // src/core/model/ids.ts
926
+ var ID_PATTERN = /^[A-Z][A-Z0-9]{0,9}-[1-9]\d*$/;
927
+ var PREFIX_PATTERN = /^[A-Z][A-Z0-9]{0,9}$/;
928
+ function parseId(id) {
929
+ if (!ID_PATTERN.test(id)) return null;
930
+ const separator = id.lastIndexOf("-");
931
+ return { prefix: id.slice(0, separator), number: Number(id.slice(separator + 1)) };
932
+ }
933
+ function compareIds(a, b) {
934
+ const left = parseId(a);
935
+ const right = parseId(b);
936
+ if (!left || !right) return a.localeCompare(b);
937
+ return left.prefix === right.prefix ? left.number - right.number : left.prefix.localeCompare(right.prefix);
938
+ }
939
+
940
+ // src/core/journal/events.ts
941
+ import { z as z6 } from "zod";
942
+
943
+ // src/core/model/types.ts
944
+ import { z as z5 } from "zod";
945
+
946
+ // src/core/model/problems.ts
947
+ var CODED_SCHEMA_ISSUES = ["bad-id", "empty-title", "empty-name", "bad-prefix"];
948
+ function schemaCode(kind) {
949
+ return { error: () => kind };
950
+ }
951
+
952
+ // src/core/model/types.ts
953
+ var TASK_TYPES = ["task", "epic"];
954
+ var TASK_STATUSES = ["backlog", "in-progress", "blocked", "done", "cancelled"];
955
+ var PRIORITIES = ["low", "medium", "high", "critical"];
956
+ var TASK_CATEGORIES = [
957
+ "bloaters",
958
+ "change-preventers",
959
+ "couplers",
960
+ "data-dealers",
961
+ "dispensables",
962
+ "functional-abusers",
963
+ "lexical-abusers",
964
+ "oo-abusers",
965
+ "obfuscators",
966
+ "bug"
967
+ ];
968
+ var RESOLUTIONS = ["fixed", "obsolete", "duplicate", "epic-done"];
969
+ function normalizeTag(tag) {
970
+ return tag.trim().toLowerCase();
971
+ }
972
+ var taskIdSchema = z5.string().regex(ID_PATTERN, schemaCode("bad-id"));
973
+ var taskIdList = z5.array(taskIdSchema).default([]).transform((ids) => [...new Set(ids)]);
974
+ var taskFrontmatterSchema = z5.object({
975
+ id: taskIdSchema,
976
+ title: z5.string().trim().min(1, schemaCode("empty-title")),
977
+ type: z5.enum(TASK_TYPES).default("task"),
978
+ status: z5.enum(TASK_STATUSES).default("backlog"),
979
+ priority: z5.enum(PRIORITIES).default("medium"),
980
+ category: z5.enum(TASK_CATEGORIES).optional(),
981
+ tags: z5.array(z5.string()).default([]).transform((tags) => [...new Set(tags.map(normalizeTag).filter(Boolean))]),
982
+ epic: taskIdSchema.optional(),
983
+ blockedBy: taskIdList,
984
+ related: taskIdList,
985
+ created: z5.iso.datetime({ offset: true }),
986
+ source: z5.string().optional(),
987
+ anchor: z5.string().optional(),
988
+ closed: z5.iso.datetime({ offset: true }).optional(),
989
+ resolution: z5.enum(RESOLUTIONS).optional(),
990
+ reason: z5.string().optional(),
991
+ verified: z5.iso.datetime({ offset: true }).optional()
992
+ });
993
+ var projectFrontmatterSchema = z5.object({
994
+ name: z5.string().trim().min(1, schemaCode("empty-name")),
995
+ prefix: z5.string().regex(PREFIX_PATTERN, schemaCode("bad-prefix")),
996
+ repos: z5.array(z5.string()).default([]),
997
+ issuedUpTo: z5.number().int().nonnegative().optional(),
998
+ active: z5.boolean().default(true)
999
+ });
1000
+
1001
+ // src/core/journal/events.ts
1002
+ var CHANGE_SOURCES = ["cli", "web", "check", "sweep"];
1003
+ var FOUND_HOW = ["review", "incidental"];
1004
+ var CANDIDATE_EVIDENCE = ["source-changed", "source-missing", "duplicate", "no-source"];
1005
+ var CHECK_MODES = ["full", "changed"];
1006
+ var CHECK_METHODS = ["symbol", "anchor", "file"];
1007
+ var RECORDED_METHODS = [...CHECK_METHODS, "unknown"];
1008
+ var DUPLICATE_MATCHES = ["source", "title", "symbol"];
1009
+ var RECORDED_MATCHES = [...DUPLICATE_MATCHES, "unknown"];
1010
+ function checkMethodOf({ bySymbol, byAnchor }) {
1011
+ if (bySymbol === true) return "symbol";
1012
+ return byAnchor === true ? "anchor" : "file";
1013
+ }
1014
+ function recordedMethodOf({ method, bySymbol, byAnchor }) {
1015
+ if (method !== void 0) return method;
1016
+ return bySymbol === true || byAnchor === true ? checkMethodOf({ bySymbol, byAnchor }) : "unknown";
1017
+ }
1018
+ var eventBase = { at: z6.iso.datetime({ offset: true }), task: z6.string().min(1), via: z6.enum(CHANGE_SOURCES) };
1019
+ var journalEventSchema = z6.discriminatedUnion("kind", [
1020
+ z6.object({
1021
+ ...eventBase,
1022
+ kind: z6.literal("created"),
1023
+ type: z6.enum(TASK_TYPES),
1024
+ priority: z6.enum(PRIORITIES),
1025
+ tags: z6.array(z6.string()),
1026
+ source: z6.string().optional(),
1027
+ epic: z6.string().optional(),
1028
+ category: z6.enum(TASK_CATEGORIES).optional(),
1029
+ found: z6.enum(FOUND_HOW).optional(),
1030
+ origin: z6.object({ branch: z6.string().optional(), commit: z6.string().min(1) }).optional()
1031
+ }),
1032
+ z6.object({ ...eventBase, kind: z6.literal("status"), from: z6.enum(TASK_STATUSES), to: z6.enum(TASK_STATUSES), resolution: z6.enum(RESOLUTIONS).optional() }),
1033
+ z6.object({ ...eventBase, kind: z6.literal("priority"), from: z6.enum(PRIORITIES), to: z6.enum(PRIORITIES) }),
1034
+ z6.object({ ...eventBase, kind: z6.literal("deleted"), snapshot: taskFrontmatterSchema }),
1035
+ z6.object({ ...eventBase, kind: z6.literal("category"), from: z6.enum(TASK_CATEGORIES).optional(), to: z6.enum(TASK_CATEGORIES).optional() }),
1036
+ z6.object({ ...eventBase, kind: z6.literal("verified"), source: z6.string().optional() }),
1037
+ z6.object({
1038
+ ...eventBase,
1039
+ kind: z6.literal("candidate"),
1040
+ evidence: z6.enum(CANDIDATE_EVIDENCE),
1041
+ mode: z6.enum(CHECK_MODES),
1042
+ method: z6.enum(CHECK_METHODS).optional(),
1043
+ bySymbol: z6.boolean().optional(),
1044
+ byAnchor: z6.boolean().optional(),
1045
+ match: z6.enum(DUPLICATE_MATCHES).optional()
1046
+ }),
1047
+ z6.object({ ...eventBase, kind: z6.literal("candidate-gone"), evidence: z6.enum(CANDIDATE_EVIDENCE) }),
1048
+ z6.object({ ...eventBase, kind: z6.literal("candidate-filtered"), symbol: z6.string() })
1049
+ ]);
1050
+ var SNAPSHOT_FIELDS = Object.keys(taskFrontmatterSchema.shape);
1051
+ function changeEvents(before, after, now, via) {
1052
+ const at = formatLocalIso(now);
1053
+ const events = [];
1054
+ if (before.status !== after.status) {
1055
+ const resolution = isClosed(after.status) ? after.resolution : void 0;
1056
+ events.push({ at, task: after.id, via, kind: "status", from: before.status, to: after.status, resolution });
1057
+ }
1058
+ if (before.priority !== after.priority) events.push({ at, task: after.id, via, kind: "priority", from: before.priority, to: after.priority });
1059
+ if (before.category !== after.category) events.push({ at, task: after.id, via, kind: "category", from: before.category, to: after.category });
1060
+ if (before.verified !== after.verified) {
1061
+ events.push({ at, task: after.id, via, kind: "verified", source: before.source === after.source ? void 0 : after.source });
1062
+ }
1063
+ return events;
1064
+ }
1065
+ function deletedEvent(task, now, via) {
1066
+ return { at: formatLocalIso(now), task: task.id, via, kind: "deleted", snapshot: snapshotOf(task) };
1067
+ }
1068
+ function snapshotOf(task) {
1069
+ return Object.fromEntries(SNAPSHOT_FIELDS.map((field) => [field, task[field]]));
1070
+ }
1071
+
1072
+ // src/core/store/journal.ts
1073
+ import { appendFile } from "node:fs/promises";
1074
+ import { join as join7 } from "node:path";
1075
+ var JOURNAL_FILE = "journal.jsonl";
1076
+ async function appendJournal(projectDir2, events, onError = reportToStderr) {
1077
+ if (events.length === 0) return;
1078
+ const path = join7(projectDir2, JOURNAL_FILE);
1079
+ try {
1080
+ await appendFile(path, toJsonLines(events), "utf8");
1081
+ } catch (error) {
1082
+ onError(path, error);
1083
+ }
1084
+ }
1085
+ async function readJournal(projectDir2, projectId) {
1086
+ const { values, invalidLines } = await readJsonLines(join7(projectDir2, JOURNAL_FILE), journalEventSchema);
1087
+ return { projectId, events: values, invalidLines };
1088
+ }
1089
+ async function readJournals(root, projectIds) {
1090
+ return Promise.all(projectIds.map((projectId) => readJournal(join7(root, projectId), projectId)));
1091
+ }
1092
+ function reportToStderr(path, error) {
1093
+ console.error(`${path}: ${errorText(error)}`);
1094
+ }
1095
+
1096
+ // src/core/store/load.ts
1097
+ import { basename as basename4, join as join8 } from "node:path";
1098
+
1099
+ // src/core/model/frontmatter.ts
1100
+ import { Document, parse, visit } from "yaml";
1101
+
1102
+ // src/core/model/zod-issues.ts
1103
+ import { z as z7 } from "zod";
1104
+ function parseSchema(schema, value) {
1105
+ const parsed = schema.safeParse(value, { reportInput: true });
1106
+ return parsed.success ? { ok: true, value: parsed.data } : { ok: false, problems: parsed.error.issues.map(schemaProblem) };
1107
+ }
1108
+ function parseWithLocale(schema, value, language) {
1109
+ const parsed = parseSchema(schema, value);
1110
+ if (parsed.ok) return parsed;
1111
+ const messages = coreMessages(language);
1112
+ return { ok: false, errors: [...new Set(parsed.problems.map((problem3) => messages.schemaIssue(problem3.issue)))] };
1113
+ }
1114
+ function schemaProblem(issue) {
1115
+ return { code: "schema", path: issue.path.join("."), issue: schemaIssue3(issue) };
1116
+ }
1117
+ function schemaIssue3({ input, ...issue }) {
1118
+ const coded = CODED_SCHEMA_ISSUES.find((kind) => kind === issue.message);
1119
+ if (coded !== void 0) return { kind: coded };
1120
+ return issue.code === "invalid_type" ? { kind: "zod", issue, receivedType: z7.core.util.parsedType(input) } : { kind: "zod", issue };
1121
+ }
1122
+
1123
+ // src/core/model/frontmatter.ts
1124
+ var DELIMITER = "---";
1125
+ function parseFrontmatter(text, schema) {
1126
+ const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
1127
+ if (lines[0] !== DELIMITER) return { ok: false, problems: [{ code: "no-frontmatter" }] };
1128
+ const closing = lines.indexOf(DELIMITER, 1);
1129
+ if (closing === -1) return { ok: false, problems: [{ code: "frontmatter-unclosed" }] };
1130
+ let raw;
1131
+ try {
1132
+ raw = parse(lines.slice(1, closing).join("\n"));
1133
+ } catch (error) {
1134
+ return { ok: false, problems: [{ code: "yaml", detail: errorText(error) }] };
1135
+ }
1136
+ const parsed = parseSchema(schema, raw);
1137
+ if (!parsed.ok) return parsed;
1138
+ const knownFields = Object.keys(schema.shape);
1139
+ const extra = Object.fromEntries(Object.entries(raw).filter(([key]) => !knownFields.includes(key)));
1140
+ const body = lines.slice(closing + 1).join("\n").replace(/^\n+/, "");
1141
+ return { ok: true, value: { data: parsed.value, extra, body } };
1142
+ }
1143
+ function stringifyFrontmatter(data, body) {
1144
+ const document = new Document(data);
1145
+ visit(document, {
1146
+ Seq(_, node) {
1147
+ node.flow = true;
1148
+ }
1149
+ });
1150
+ const yamlText = document.toString({ lineWidth: 0, flowCollectionPadding: false });
1151
+ const content = body.replace(/^\n+/, "").trimEnd();
1152
+ return content === "" ? `${DELIMITER}
1153
+ ${yamlText}${DELIMITER}
1154
+ ` : `${DELIMITER}
1155
+ ${yamlText}${DELIMITER}
1156
+
1157
+ ${content}
1158
+ `;
1159
+ }
1160
+
1161
+ // src/core/model/project-file.ts
1162
+ function parseProjectFile(text, location) {
1163
+ const parsed = parseFrontmatter(text, projectFrontmatterSchema);
1164
+ if (!parsed.ok) return parsed;
1165
+ const { data, extra, body } = parsed.value;
1166
+ return { ok: true, value: { ...data, extra, body, ...location } };
1167
+ }
1168
+ function serializeProject(project) {
1169
+ const { name, prefix, repos, issuedUpTo, active } = project;
1170
+ return stringifyFrontmatter({ name, prefix, repos, issuedUpTo, active: active ? void 0 : false, ...project.extra }, project.body);
1171
+ }
1172
+
1173
+ // src/core/model/task-file.ts
1174
+ var TASK_FIELDS = Object.keys(taskFrontmatterSchema.shape);
1175
+ function parseTaskFile(text, location) {
1176
+ const parsed = parseFrontmatter(text, taskFrontmatterSchema);
1177
+ if (!parsed.ok) return parsed;
1178
+ const { data, extra, body } = parsed.value;
1179
+ return { ok: true, value: { ...data, extra, body, ...location } };
1180
+ }
1181
+ function serializeTask(task) {
1182
+ const known = Object.fromEntries(TASK_FIELDS.map((field) => [field, task[field]]));
1183
+ return stringifyFrontmatter({ ...known, ...task.extra }, task.body);
1184
+ }
1185
+
1186
+ // src/core/store/load.ts
1187
+ async function loadBacklog(root) {
1188
+ const projectDirs = (await listDir(root)).filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
1189
+ const parts = await Promise.all(projectDirs.map((entry) => loadProjectDir(join8(root, entry.name), entry.name)));
1190
+ return {
1191
+ projects: parts.flatMap((part) => part.projects).sort((a, b) => a.id.localeCompare(b.id)),
1192
+ tasks: parts.flatMap((part) => part.tasks).sort((a, b) => compareIds(a.id, b.id)),
1193
+ errors: parts.flatMap((part) => part.errors).sort((a, b) => a.path.localeCompare(b.path))
1194
+ };
1195
+ }
1196
+ async function loadProjectDir(dir, projectId) {
1197
+ const projectPath = join8(dir, PROJECT_FILE);
1198
+ const projectText = await readTextOrNull(projectPath);
1199
+ if (projectText === null) return { projects: [], tasks: [], errors: [] };
1200
+ const project = parseProjectFile(projectText, { id: projectId, path: projectPath });
1201
+ if (!project.ok) return { projects: [], tasks: [], errors: [{ path: projectPath, projectId, problems: project.problems }] };
1202
+ const taskPaths = (await listDir(dir)).filter((entry) => entry.isFile() && isTaskFileName(entry.name)).map((entry) => join8(dir, entry.name));
1203
+ const loaded = await Promise.all(taskPaths.map(async (path) => ({ path, result: await loadTaskFile(path, project.value) })));
1204
+ return {
1205
+ projects: [project.value],
1206
+ tasks: loaded.flatMap(({ result }) => result?.ok ? [result.value] : []),
1207
+ errors: loaded.flatMap(({ path, result }) => result && !result.ok ? [{ path, projectId, problems: result.problems }] : [])
1208
+ };
1209
+ }
1210
+ async function loadTaskFile(path, project) {
1211
+ const text = await readTextOrNull(path);
1212
+ if (text === null) return null;
1213
+ const parsed = parseTaskFile(text, { projectId: project.id, path, version: contentVersion(text) });
1214
+ if (!parsed.ok) return parsed;
1215
+ const stem = basename4(path, ".md");
1216
+ if (parsed.value.id !== stem) return { ok: false, problems: [{ code: "id-mismatch", id: parsed.value.id, file: stem }] };
1217
+ if (parseId(stem)?.prefix !== project.prefix) {
1218
+ return { ok: false, problems: [{ code: "prefix-mismatch", file: stem, prefix: project.prefix }] };
1219
+ }
1220
+ return parsed;
1221
+ }
1222
+ function isTaskFileName(name) {
1223
+ return name.endsWith(".md") && ID_PATTERN.test(name.slice(0, -".md".length));
1224
+ }
1225
+
1226
+ // src/core/store/projects.ts
1227
+ import { rm as rm3 } from "node:fs/promises";
1228
+ import { join as join9 } from "node:path";
1229
+ var NOT_FOUND = { ok: false, reason: "not-found" };
1230
+ async function setProjectActive(root, id, active) {
1231
+ const dir = projectDir(root, id);
1232
+ if (dir === null) return NOT_FOUND;
1233
+ return editProjectFile({ id, path: join9(dir, PROJECT_FILE) }, (project) => ({ ...project, active }));
1234
+ }
1235
+ async function reserveIssuedUpTo(project, number) {
1236
+ const edited = await editProjectFile(project, (current) => {
1237
+ const issuedUpTo = Math.max(current.issuedUpTo ?? 0, number);
1238
+ return issuedUpTo === current.issuedUpTo ? current : { ...current, issuedUpTo };
1239
+ });
1240
+ return edited.ok;
1241
+ }
1242
+ async function editProjectFile({ id, path }, edit) {
1243
+ const text = await readTextOrNull(path);
1244
+ if (text === null) return NOT_FOUND;
1245
+ const parsed = parseProjectFile(text, { id, path });
1246
+ if (!parsed.ok) return { ok: false, reason: "invalid", problems: parsed.problems };
1247
+ const project = edit(parsed.value);
1248
+ if (project !== parsed.value) await writeFileAtomic(path, serializeProject(project));
1249
+ return { ok: true, project };
1250
+ }
1251
+ async function deleteProject(root, id) {
1252
+ const dir = projectDir(root, id);
1253
+ if (dir === null || await readTextOrNull(join9(dir, PROJECT_FILE)) === null) return NOT_FOUND;
1254
+ await rm3(dir, { recursive: true, force: true });
1255
+ return { ok: true };
1256
+ }
1257
+
1258
+ // src/core/store/references.ts
1259
+ function referenceCleanup(task, isGone) {
1260
+ const blockedBy = task.blockedBy.filter((id) => !isGone(id));
1261
+ const related = task.related.filter((id) => !isGone(id));
1262
+ const epicGone = task.epic !== void 0 && isGone(task.epic);
1263
+ const untouched = blockedBy.length === task.blockedBy.length && related.length === task.related.length && !epicGone;
1264
+ if (untouched) return null;
1265
+ return epicGone ? { blockedBy, related, epic: null } : { blockedBy, related };
1266
+ }
1267
+
1268
+ // src/core/store/update.ts
1269
+ import { dirname as dirname3 } from "node:path";
1270
+
1271
+ // src/core/model/integrity.ts
1272
+ function integrityErrors(candidate, index) {
1273
+ const resolve = (id) => id === candidate.id ? candidate : index.byId.get(id);
1274
+ const errors = [];
1275
+ if (candidate.blockedBy.includes(candidate.id)) errors.push({ code: "self-block" });
1276
+ if (candidate.related.includes(candidate.id)) errors.push({ code: "self-related" });
1277
+ errors.push(...epicProblems(candidate, resolve));
1278
+ const children = (index.childrenOf.get(candidate.id) ?? []).filter((task) => task.id !== candidate.id);
1279
+ if (candidate.type === "task" && children.length > 0) {
1280
+ errors.push({ code: "referenced-as-epic", children: children.map((task) => task.id) });
1281
+ }
1282
+ const cycle = findBlockerCycle(candidate, resolve);
1283
+ if (cycle) errors.push({ code: "blocker-cycle", cycle });
1284
+ if (candidate.resolution !== void 0 && candidate.status !== RESOLUTION_STATUS[candidate.resolution]) {
1285
+ errors.push({ code: "resolution-needs-status", resolution: candidate.resolution, status: RESOLUTION_STATUS[candidate.resolution] });
1286
+ }
1287
+ if (candidate.reason !== void 0 && candidate.resolution === void 0) errors.push({ code: "reason-without-resolution" });
1288
+ return errors;
1289
+ }
1290
+ function epicProblems(candidate, resolve) {
1291
+ if (candidate.epic === void 0) return [];
1292
+ const problems3 = [];
1293
+ if (candidate.epic === candidate.id) {
1294
+ problems3.push({ code: "epic-self" });
1295
+ } else {
1296
+ const epic = resolve(candidate.epic);
1297
+ if (!epic) problems3.push({ code: "epic-missing", epic: candidate.epic });
1298
+ else if (epic.type !== "epic") problems3.push({ code: "epic-not-epic", epic: candidate.epic });
1299
+ }
1300
+ if (candidate.type === "epic") problems3.push({ code: "epic-in-epic" });
1301
+ return problems3;
1302
+ }
1303
+ function findBlockerCycle(start, resolve) {
1304
+ const visited = /* @__PURE__ */ new Set();
1305
+ const walk = (task, path) => {
1306
+ for (const blockerId of task.blockedBy) {
1307
+ if (blockerId === start.id) return [...path, blockerId];
1308
+ if (visited.has(blockerId)) continue;
1309
+ visited.add(blockerId);
1310
+ const blocker = resolve(blockerId);
1311
+ const cycle = blocker ? walk(blocker, [...path, blockerId]) : null;
1312
+ if (cycle) return cycle;
1313
+ }
1314
+ return null;
1315
+ };
1316
+ const withoutSelfBlock = { ...start, blockedBy: start.blockedBy.filter((id) => id !== start.id) };
1317
+ return walk(withoutSelfBlock, [start.id]);
1318
+ }
1319
+
1320
+ // src/core/store/task-text.ts
1321
+ function taskText(draft) {
1322
+ const parsed = parseTaskFile(serializeTask(draft), { projectId: draft.projectId, path: draft.path, version: "" });
1323
+ if (!parsed.ok) return parsed;
1324
+ const text = serializeTask(parsed.value);
1325
+ return { ok: true, value: { text, task: { ...parsed.value, version: contentVersion(text) } } };
1326
+ }
1327
+
1328
+ // src/core/store/write-result.ts
1329
+ function invalid(errors) {
1330
+ return { ok: false, reason: "invalid", errors };
1331
+ }
1332
+
1333
+ // src/core/store/update.ts
1334
+ var CHANGE_FIELDS = ["title", "type", "priority", "tags", "blockedBy", "related", "body", "source", "verified"];
1335
+ async function updateTaskInIndex(index, { id, changes, expectedVersion, now, closure, via }) {
1336
+ const current = index.byId.get(id);
1337
+ if (!current) return { ok: false, reason: "not-found" };
1338
+ if (expectedVersion !== void 0 && expectedVersion !== current.version) return { ok: false, reason: "conflict", current };
1339
+ const normalized = taskText(applyChanges(current, changes, now, closure));
1340
+ if (!normalized.ok) return invalid(normalized.problems);
1341
+ const { text, task } = normalized.value;
1342
+ const errors = integrityErrors(task, index);
1343
+ if (errors.length > 0) return invalid(errors);
1344
+ return withFileLock(current.path, async () => {
1345
+ const changedOnDisk = expectedVersion === void 0 ? null : await diskChange(current, expectedVersion);
1346
+ if (changedOnDisk !== null) return changedOnDisk;
1347
+ await writeFileAtomic(current.path, text);
1348
+ await appendJournal(dirname3(current.path), changeEvents(current, task, now, via));
1349
+ return { ok: true, task };
1350
+ });
1351
+ }
1352
+ async function diskChange(snapshot, expectedVersion) {
1353
+ const text = await readTextOrNull(snapshot.path);
1354
+ if (text === null) return { ok: false, reason: "not-found" };
1355
+ const version = contentVersion(text);
1356
+ if (version === expectedVersion) return null;
1357
+ const fresh = parseTaskFile(text, { projectId: snapshot.projectId, path: snapshot.path, version });
1358
+ return { ok: false, reason: "conflict", current: fresh.ok ? fresh.value : snapshot };
1359
+ }
1360
+ function applyChanges(task, changes, now, closure) {
1361
+ const edited = {
1362
+ ...task,
1363
+ ...pickDefined(changes, CHANGE_FIELDS),
1364
+ epic: nextOptional(task.epic, changes.epic),
1365
+ category: nextOptional(task.category, changes.category),
1366
+ anchor: nextAnchor(task, changes)
1367
+ };
1368
+ const moved = changes.status === void 0 ? edited : changeStatus(edited, changes.status, now, closure);
1369
+ return settleLifecycle(moved, now);
1370
+ }
1371
+ function nextAnchor(task, changes) {
1372
+ if (changes.anchor !== void 0) return changes.anchor ?? void 0;
1373
+ const sourceMoved = changes.source !== void 0 && changes.source !== task.source;
1374
+ return sourceMoved ? void 0 : task.anchor;
1375
+ }
1376
+ function pickDefined(source, keys) {
1377
+ const picked = {};
1378
+ for (const key of keys) {
1379
+ const value = source[key];
1380
+ if (value !== void 0) picked[key] = value;
1381
+ }
1382
+ return picked;
1383
+ }
1384
+ function nextOptional(current, change) {
1385
+ if (change === null) return void 0;
1386
+ return change ?? current;
1387
+ }
1388
+
1389
+ // src/core/store/sweep.ts
1390
+ async function sweepClosed(root, now, messages) {
1391
+ const initial = await loadBacklog(root);
1392
+ const epics = await closeCompletedEpics(initial, now, messages);
1393
+ const { projects, tasks } = epics.closed.length > 0 ? await loadBacklog(root) : initial;
1394
+ const waitsForEpic = (task) => task.epic !== void 0 && epics.leftOpen.has(task.epic);
1395
+ const expired = tasks.filter((task) => isExpired(task, now) && !waitsForEpic(task));
1396
+ const reserved = await reserveNumbers(projects, expired);
1397
+ const removable = expired.filter((task) => reserved.has(task.projectId));
1398
+ const updates = await repairRemainingTasks(tasks, removable, now);
1399
+ const removal = await removeExpired(removable.filter((task) => !updates.stillReferenced.has(task.id)), now);
1400
+ return {
1401
+ closedEpics: epics.closed,
1402
+ blockingFiles: epics.blockingFiles,
1403
+ deleted: removal.deleted,
1404
+ ...failureLists([...epics.failures, ...updates.failures, ...removal.failures], messages)
1405
+ };
1406
+ }
1407
+ async function closeCompletedEpics(loaded, now, messages) {
1408
+ const plan = planEpicClosing(loaded.tasks, loaded.errors);
1409
+ const index = buildIndex(loaded.tasks);
1410
+ const closed = [];
1411
+ const failures = [];
1412
+ for (const { epic, childIds } of plan.close) {
1413
+ const closure = epicDoneClosure(childIds, messages);
1414
+ const result = await updateTaskInIndex(index, { id: epic.id, changes: { status: "done" }, expectedVersion: epic.version, now, closure, via: "sweep" });
1415
+ if (result.ok) closed.push(epic.id);
1416
+ else failures.push(sweepFailure(epic.id, result));
1417
+ }
1418
+ const leftOpen = /* @__PURE__ */ new Set([...plan.waiting.map(({ epic }) => epic.id), ...failures.map(({ id }) => id)]);
1419
+ const waitingProjects = new Set(plan.waiting.map(({ epic }) => epic.projectId));
1420
+ const blockingFiles = loaded.errors.filter((error) => waitingProjects.has(error.projectId)).map((error) => error.path);
1421
+ return { closed, failures, leftOpen, blockingFiles };
1422
+ }
1423
+ async function repairRemainingTasks(tasks, expired, now) {
1424
+ const index = buildIndex(tasks);
1425
+ const expiredIds = new Set(expired.map((task) => task.id));
1426
+ const failures = [];
1427
+ const stillReferenced = /* @__PURE__ */ new Set();
1428
+ for (const task of tasks.filter((candidate) => !expiredIds.has(candidate.id))) {
1429
+ const cleanup = referenceCleanup(task, (id) => expiredIds.has(id));
1430
+ if (cleanup === null && !lacksClosedDate(task)) continue;
1431
+ const result = await updateTaskInIndex(index, { id: task.id, changes: cleanup ?? {}, expectedVersion: task.version, now, via: "sweep" });
1432
+ if (result.ok) continue;
1433
+ failures.push(sweepFailure(task.id, result));
1434
+ for (const id of referencedIds(task)) if (expiredIds.has(id)) stillReferenced.add(id);
1435
+ }
1436
+ return { failures, stillReferenced };
1437
+ }
1438
+ function referencedIds(task) {
1439
+ return [...task.epic === void 0 ? [] : [task.epic], ...task.blockedBy, ...task.related];
1440
+ }
1441
+ async function removeExpired(expired, now) {
1442
+ const deleted = [];
1443
+ const failures = [];
1444
+ for (const task of expired) {
1445
+ if (await withFileLock(task.path, () => removeIfUnchanged(task.path, task.version))) {
1446
+ deleted.push(task.id);
1447
+ await appendJournal(dirname4(task.path), [deletedEvent(task, now, "sweep")]);
1448
+ } else failures.push({ id: task.id, reason: "conflict" });
1449
+ }
1450
+ return { deleted, failures };
1451
+ }
1452
+ function sweepFailure(id, failure) {
1453
+ switch (failure.reason) {
1454
+ case "invalid":
1455
+ return { id, reason: "invalid", errors: failure.errors };
1456
+ case "conflict":
1457
+ case "not-found":
1458
+ return { id, reason: "conflict" };
1459
+ }
1460
+ }
1461
+ function failureLists(failures, messages) {
1462
+ const firstPerTask = failures.filter((failure, position) => failures.findIndex(({ id }) => id === failure.id) === position);
1463
+ return {
1464
+ conflicts: firstPerTask.filter((failure) => failure.reason === "conflict").map(({ id }) => id),
1465
+ invalid: firstPerTask.flatMap((failure) => failure.reason === "invalid" ? [{ id: failure.id, errors: failure.errors.map(messages.problem) }] : [])
1466
+ };
1467
+ }
1468
+ function lacksClosedDate(task) {
1469
+ return isClosed(task.status) && task.closed === void 0;
1470
+ }
1471
+ async function reserveNumbers(projects, expired) {
1472
+ const reserved = /* @__PURE__ */ new Set();
1473
+ for (const project of projects) {
1474
+ const numbers = expired.filter((task) => task.projectId === project.id).flatMap((task) => parseId(task.id)?.number ?? []);
1475
+ if (numbers.length === 0 || await reserveIssuedUpTo(project, Math.max(...numbers))) reserved.add(project.id);
1476
+ }
1477
+ return reserved;
1478
+ }
1479
+
1480
+ // src/server/app.ts
1481
+ import { serveStatic } from "@hono/node-server/serve-static";
1482
+ import { Hono as Hono3 } from "hono";
1483
+ import { join as join14 } from "node:path";
1484
+
1485
+ // src/server/api.ts
1486
+ import { Hono as Hono2 } from "hono";
1487
+ import { streamSSE } from "hono/streaming";
1488
+
1489
+ // src/core/api/contract.ts
1490
+ import { z as z8 } from "zod";
1491
+ var idList = z8.array(taskIdSchema);
1492
+ var taskChangesSchema = z8.strictObject({
1493
+ title: z8.string().optional(),
1494
+ type: z8.enum(TASK_TYPES).optional(),
1495
+ status: z8.enum(TASK_STATUSES).optional(),
1496
+ priority: z8.enum(PRIORITIES).optional(),
1497
+ tags: z8.array(z8.string()).optional(),
1498
+ epic: taskIdSchema.nullable().optional(),
1499
+ blockedBy: idList.optional(),
1500
+ related: idList.optional(),
1501
+ body: z8.string().optional(),
1502
+ category: z8.enum(TASK_CATEGORIES).nullable().optional()
1503
+ });
1504
+ var projectActiveSchema = z8.strictObject({ active: z8.boolean() });
1505
+ var projectDeleteSchema = z8.strictObject({ confirm: z8.string() });
1506
+ var settingsSchema2 = z8.strictObject({ language: z8.enum(LANGUAGES) });
1507
+ var updateTaskRequestSchema = z8.strictObject({
1508
+ version: z8.string().min(1),
1509
+ changes: taskChangesSchema
1510
+ });
1511
+
1512
+ // src/core/check/graph-health.ts
1513
+ import { join as join12 } from "node:path";
1514
+
1515
+ // src/core/graph/code-graph.ts
1516
+ import { existsSync, realpathSync } from "node:fs";
1517
+ import { join as join10 } from "node:path";
1518
+ var GRAPH_FILE = join10(".code-review-graph", "graph.db");
1519
+ function hasCodeGraph(repo) {
1520
+ return existsSync(join10(repo, GRAPH_FILE));
1521
+ }
1522
+ var SUPPORTED_SCHEMA = "13";
1523
+ function openCodeGraph(repo) {
1524
+ let db = null;
1525
+ try {
1526
+ const { DatabaseSync: SqliteDatabase } = process.getBuiltinModule("node:sqlite");
1527
+ db = new SqliteDatabase(join10(repo, GRAPH_FILE), { readOnly: true });
1528
+ const meta = readMetadata(db);
1529
+ const root = meta.get("repo_root");
1530
+ if (meta.get("schema_version") !== SUPPORTED_SCHEMA || root === void 0 || realpathSync(root) !== realpathSync(repo)) {
1531
+ db.close();
1532
+ return null;
1533
+ }
1534
+ return codeGraph(db, root);
1535
+ } catch {
1536
+ closeQuietly(db);
1537
+ return null;
1538
+ }
1539
+ }
1540
+ function readMetadata(db) {
1541
+ const rows = db.prepare("select key, value from metadata").all();
1542
+ return new Map(rows.map((row) => [row.key, row.value]));
1543
+ }
1544
+ function codeGraph(db, repo) {
1545
+ const fileNode = db.prepare("select file_hash from nodes where kind = 'File' and file_path = ?");
1546
+ const enclosing = db.prepare(
1547
+ "select qualified_name, line_start, line_end from nodes where file_path = ? and kind != 'File' and line_start <= ? and line_end >= ? order by line_end - line_start asc limit 1"
1548
+ );
1549
+ const fileState = (path, fileHash2) => {
1550
+ const row = ask(fileNode, (statement) => statement.get(join10(repo, path)), void 0);
1551
+ if (row === void 0) return "absent";
1552
+ return row.file_hash === fileHash2 ? "fresh" : "changed";
1553
+ };
1554
+ return {
1555
+ fileState,
1556
+ symbolAt(path, line, fileHash2) {
1557
+ if (fileState(path, fileHash2) !== "fresh") return null;
1558
+ const file = join10(repo, path);
1559
+ const row = ask(enclosing, (statement) => statement.get(file, line, line), void 0);
1560
+ return row === void 0 ? null : { qualifiedName: row.qualified_name, from: row.line_start, to: row.line_end };
1561
+ },
1562
+ close() {
1563
+ closeQuietly(db);
1564
+ }
1565
+ };
1566
+ }
1567
+ function ask(statement, run, fallback) {
1568
+ try {
1569
+ return run(statement);
1570
+ } catch {
1571
+ return fallback;
1572
+ }
1573
+ }
1574
+ function closeQuietly(db) {
1575
+ try {
1576
+ db?.close();
1577
+ } catch {
1578
+ return;
1579
+ }
1580
+ }
1581
+
1582
+ // src/core/check/anchor.ts
1583
+ var LINE_CONTEXT = 2;
1584
+ function sourceRange(source) {
1585
+ const span = source === void 0 ? null : sourceSpan(source);
1586
+ return span === null ? null : { from: span.first, to: span.last };
1587
+ }
1588
+ function sourceSpan(source) {
1589
+ const match = SOURCE_LINES.exec(source);
1590
+ if (match === null) return null;
1591
+ const first = Number(match[1]);
1592
+ return match[2] === void 0 ? { first, last: first, context: LINE_CONTEXT } : { first, last: Number(match[2]), context: 0 };
1593
+ }
1594
+
1595
+ // src/core/model/query.ts
1596
+ var STALE_LOW_DAYS = 30;
1597
+ function staleLowTasks(tasks, now) {
1598
+ const cutoff = now.getTime() - STALE_LOW_DAYS * DAY_MS;
1599
+ return tasks.filter((task) => task.type === "task" && task.status === "backlog" && task.priority === "low" && Date.parse(task.created) < cutoff).sort((a, b) => Date.parse(a.created) - Date.parse(b.created));
1600
+ }
1601
+
1602
+ // src/core/check/candidates.ts
1603
+ function isReviewable(task) {
1604
+ return task.type === "task" && (task.status === "backlog" || task.status === "blocked");
1605
+ }
1606
+ function sourcePath(source) {
1607
+ return source.replace(SOURCE_LINES, "").replace(/^\.\//, "").replace(/\/+$/, "");
1608
+ }
1609
+
1610
+ // src/core/check/project-repo.ts
1611
+ import { access, readFile as readFile2 } from "node:fs/promises";
1612
+
1613
+ // src/core/git/run.ts
1614
+ import { execFile, execFileSync } from "node:child_process";
1615
+ import { promisify } from "node:util";
1616
+
1617
+ // src/core/git/limit.ts
1618
+ function createLimiter(maxRunning) {
1619
+ let running = 0;
1620
+ const waiting = [];
1621
+ const release = () => {
1622
+ const next = waiting.shift();
1623
+ if (next === void 0) running--;
1624
+ else next();
1625
+ };
1626
+ const acquire2 = () => {
1627
+ if (running < maxRunning) {
1628
+ running++;
1629
+ return Promise.resolve();
1630
+ }
1631
+ return new Promise((resolve) => waiting.push(resolve));
1632
+ };
1633
+ return async (task) => {
1634
+ await acquire2();
1635
+ try {
1636
+ return await task();
1637
+ } finally {
1638
+ release();
1639
+ }
1640
+ };
1641
+ }
1642
+
1643
+ // src/core/git/run.ts
1644
+ var RECORD = "";
1645
+ var FIELD = "";
1646
+ var runFile = promisify(execFile);
1647
+ var GIT_OUTPUT_LIMIT = 64 * 1024 * 1024;
1648
+ var GREP_NO_MATCH_EXIT = 1;
1649
+ var GIT_PROCESS_LIMIT = 8;
1650
+ var gitProcessSlot = createLimiter(GIT_PROCESS_LIMIT);
1651
+ var runGit = (repo, args, input) => gitProcessSlot(async () => {
1652
+ try {
1653
+ const running = runFile("git", gitArgs(repo, args), { maxBuffer: GIT_OUTPUT_LIMIT });
1654
+ running.child.stdin?.on("error", ignoreStdinOfExitedGit).end(input);
1655
+ const { stdout } = await running;
1656
+ return stdout;
1657
+ } catch (error) {
1658
+ return args[0] === "grep" && exitCodeOf(error) === GREP_NO_MATCH_EXIT ? "" : null;
1659
+ }
1660
+ });
1661
+ function gitArgs(repo, args) {
1662
+ return ["-C", repo, "--no-optional-locks", "-c", "core.quotePath=false", ...args];
1663
+ }
1664
+ async function resolveCommits(git, repo, revisions) {
1665
+ if (revisions.length === 0) return /* @__PURE__ */ new Map();
1666
+ const output = await git(repo, ["cat-file", "--batch-check"], revisions.map((revision) => `${revision}^{commit}
1667
+ `).join(""));
1668
+ if (output === null) return null;
1669
+ const lines = output.split("\n");
1670
+ return new Map(
1671
+ revisions.flatMap((revision, index) => {
1672
+ const [objectName = "", objectType] = lines[index]?.split(" ") ?? [];
1673
+ return objectType === "commit" ? [[revision, objectName]] : [];
1674
+ })
1675
+ );
1676
+ }
1677
+ function ignoreStdinOfExitedGit() {
1678
+ return void 0;
1679
+ }
1680
+ function exitCodeOf(error) {
1681
+ return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
1682
+ }
1683
+
1684
+ // src/core/check/project-repo.ts
1685
+ async function findRepo(project, home2) {
1686
+ for (const repo of project.repos.map((path) => expandHome(path, home2))) {
1687
+ if (await access(repo).then(() => true, () => false)) return repo;
1688
+ }
1689
+ return void 0;
1690
+ }
1691
+
1692
+ // src/core/check/symbol-filter.ts
1693
+ import { createHash as createHash2 } from "node:crypto";
1694
+ import { readFileSync } from "node:fs";
1695
+ import { join as join11 } from "node:path";
1696
+ function symbolLookup(repo, graph) {
1697
+ if (graph === null) return () => null;
1698
+ const known = /* @__PURE__ */ new Map();
1699
+ return (task) => {
1700
+ const cached = known.get(task.id);
1701
+ if (cached !== void 0) return cached;
1702
+ const symbol = lookUp(repo, graph, task);
1703
+ known.set(task.id, symbol);
1704
+ return symbol;
1705
+ };
1706
+ }
1707
+ function lookUp(repo, graph, task) {
1708
+ const range = sourceRange(task.source);
1709
+ if (task.source === void 0 || range === null) return null;
1710
+ const path = sourcePath(task.source);
1711
+ const hash = fileHash(join11(repo, path));
1712
+ return hash === null ? null : graph.symbolAt(path, range.from, hash);
1713
+ }
1714
+ function fileHash(path) {
1715
+ try {
1716
+ return createHash2("sha256").update(readFileSync(path)).digest("hex");
1717
+ } catch {
1718
+ return null;
1719
+ }
1720
+ }
1721
+
1722
+ // src/core/check/graph-health.ts
1723
+ async function projectGraphHealth(project, tasks, home2) {
1724
+ return graphHealth(await findRepo(project, home2), tasks.filter((task) => task.projectId === project.id));
1725
+ }
1726
+ function graphHealth(repo, tasks) {
1727
+ const pinned = tasks.filter((task) => isReviewable(task) && sourceRange(task.source) !== null);
1728
+ const unusable = (state) => ({ state, pinned: pinned.length, resolved: 0 });
1729
+ if (repo === void 0 || !hasCodeGraph(repo)) return unusable("none");
1730
+ const graph = openCodeGraph(repo);
1731
+ if (graph === null) return unusable("unreadable");
1732
+ try {
1733
+ const symbolOf = symbolLookup(repo, graph);
1734
+ const resolved = pinned.filter((task) => symbolOf(task) !== null).length;
1735
+ return { state: isStale(repo, graph, pinned) ? "stale" : "fresh", pinned: pinned.length, resolved };
1736
+ } finally {
1737
+ graph.close();
1738
+ }
1739
+ }
1740
+ function isStale(repo, graph, pinned) {
1741
+ const paths = [...new Set(pinned.flatMap((task) => task.source === void 0 ? [] : [sourcePath(task.source)]))];
1742
+ const states = paths.map((path) => {
1743
+ const hash = fileHash(join12(repo, path));
1744
+ return hash === null ? "absent" : graph.fileState(path, hash);
1745
+ });
1746
+ const count3 = (state) => states.filter((candidate) => candidate === state).length;
1747
+ return count3("changed") > count3("fresh");
1748
+ }
1749
+
1750
+ // src/server/messages.en.ts
1751
+ var serverEn = {
1752
+ taskNotFound: (id) => `Task ${id} not found`,
1753
+ taskChangedOnDisk: "The task changed on disk",
1754
+ projectNotFound: (id) => `Project ${id} not found`,
1755
+ confirmMismatch: "Confirmation does not match the project id",
1756
+ bodyNotParsed: "Request body could not be parsed: JSON expected",
1757
+ unknownRoute: (path) => `Unknown API route: ${path}`,
1758
+ hostRejected: (host) => `Requests from host ${host} are not accepted`,
1759
+ jsonContentTypeExpected: "Content-Type: application/json is expected",
1760
+ runsTrimFailed: (detail) => `Could not trim the run log: ${detail}`,
1761
+ serverStarted: (port, root) => `p-backlog: http://localhost:${port}
1762
+ Backlog directory: ${root}
1763
+ `,
1764
+ settingsFileInvalid: (path) => `${path} could not be parsed, the language for this run was detected automatically. The file was not changed \u2014 fix it manually.`,
1765
+ sweepFailed: (detail) => `Could not delete closed tasks: ${detail}`,
1766
+ closedEpics: (ids) => `Closed completed epics: ${ids}`,
1767
+ epicsBlockedByFiles: (paths) => `Epics will not close until these files are fixed: ${paths}`,
1768
+ deletedClosedTasks: (ids) => `Deleted closed tasks: ${ids}`,
1769
+ conflictedDuringSweep: (ids) => `Tasks changed during the sweep, will retry next time: ${ids}`,
1770
+ invalidAfterSweep: (detail) => `Could not update tasks, fix the files: ${detail}`,
1771
+ transcriptsScanFailed: (detail) => `Could not read Claude Code transcripts: ${detail}`,
1772
+ watcherError: (root, detail) => `Watcher for directory ${root}: ${detail}`,
1773
+ listenFailed: (reason) => `p-backlog failed to start: ${reason}`,
1774
+ portBusy: (port) => `port ${port} is already in use`,
1775
+ codeCacheReadFailed: (detail) => `Could not read the git cache: ${detail}`,
1776
+ codeCacheWriteFailed: (detail) => `Could not save the git cache: ${detail}`
1777
+ };
1778
+
1779
+ // src/server/messages.ts
1780
+ function serverMessages(language) {
1781
+ return language === "ru" ? serverRu : serverEn;
1782
+ }
1783
+ function serverLanguage(root) {
1784
+ return settledLanguage(root, process.env).then((settled) => settled.language);
1785
+ }
1786
+
1787
+ // src/server/report-cache.ts
1788
+ function createReportCache({ ttlMs, now }) {
1789
+ let entries = /* @__PURE__ */ new Map();
1790
+ return {
1791
+ get: (key, compute) => {
1792
+ const cached = entries.get(key);
1793
+ if (cached !== void 0 && now() - cached.at <= ttlMs) return cached.value;
1794
+ const generation = entries;
1795
+ const value = compute();
1796
+ generation.set(key, { at: now(), value });
1797
+ value.catch(() => generation.delete(key));
1798
+ return value;
1799
+ },
1800
+ clear: () => {
1801
+ entries = /* @__PURE__ */ new Map();
1802
+ }
1803
+ };
1804
+ }
1805
+
1806
+ // src/server/stats-api.ts
1807
+ import { Hono } from "hono";
1808
+
1809
+ // src/core/code/code-cache.ts
1810
+ import { join as join13 } from "node:path";
1811
+ import { z as z9 } from "zod";
1812
+ var CODE_CACHE_FILE = ".code-cache.json";
1813
+ var CODE_CACHE_VERSION = 1;
1814
+ var repoCodeSchema = z9.object({
1815
+ commits: z9.array(z9.array(z9.string())),
1816
+ lines: z9.array(z9.object({ path: z9.string(), lines: z9.number() })),
1817
+ units: z9.array(z9.object({ date: z9.string(), lines: z9.number() }))
1818
+ });
1819
+ var fixCommitSchema = z9.object({ date: z9.string(), byAgent: z9.boolean(), lines: z9.number(), testLines: z9.number() });
1820
+ var snapshotSchema = z9.object({
1821
+ version: z9.literal(CODE_CACHE_VERSION),
1822
+ repos: z9.record(z9.string(), z9.object({ key: z9.string(), code: repoCodeSchema })),
1823
+ fixes: z9.record(z9.string(), fixCommitSchema)
1824
+ });
1825
+ function emptyCodeCache() {
1826
+ return { repos: {}, fixes: {} };
1827
+ }
1828
+ function createCodeCacheFile(root) {
1829
+ const path = join13(root, CODE_CACHE_FILE);
1830
+ return {
1831
+ read: async () => {
1832
+ const snapshot = await readJsonFile(path, snapshotSchema);
1833
+ return snapshot === null ? emptyCodeCache() : { repos: snapshot.repos, fixes: snapshot.fixes };
1834
+ },
1835
+ write: (snapshot) => writeFileAtomic(path, JSON.stringify({ version: CODE_CACHE_VERSION, ...snapshot }))
1836
+ };
1837
+ }
1838
+
1839
+ // src/core/stats/history.ts
1840
+ function taskHistories(tasks, journals) {
1841
+ const known = /* @__PURE__ */ new Map();
1842
+ const entry = (id, projectId) => {
1843
+ const existing = known.get(id);
1844
+ if (existing) return existing;
1845
+ const created = { projectId, categoryEvents: [], transitions: [], candidates: [], verifications: [], filtered: [] };
1846
+ known.set(id, created);
1847
+ return created;
1848
+ };
1849
+ for (const task of tasks) entry(task.id, task.projectId).final = task;
1850
+ for (const { projectId, events } of journals) {
1851
+ for (const event of events) {
1852
+ const item = entry(event.task, projectId);
1853
+ if (event.kind === "created") item.created = event;
1854
+ if (event.kind === "deleted") item.final ??= event.snapshot;
1855
+ if (event.kind === "category") item.categoryEvents.push({ at: Date.parse(event.at), to: event.to });
1856
+ if (event.kind === "status") {
1857
+ item.transitions.push({ at: Date.parse(event.at), from: event.from, to: event.to, resolution: event.resolution, via: event.via });
1858
+ }
1859
+ if (event.kind === "candidate") item.candidates.push({ at: Date.parse(event.at), evidence: event.evidence, method: recordedMethodOf(event), match: event.match ?? "unknown" });
1860
+ if (event.kind === "candidate-filtered") item.filtered.push(Date.parse(event.at));
1861
+ if (event.kind === "verified") item.verifications.push(Date.parse(event.at));
1862
+ }
1863
+ }
1864
+ return [...known.entries()].flatMap(([id, item]) => historyOf(id, item));
1865
+ }
1866
+ function isOpenAt(history, moment) {
1867
+ if (history.createdAt > moment) return false;
1868
+ const last = history.transitions.filter((transition) => transition.at <= moment).at(-1);
1869
+ if (last !== void 0) return !isClosed(last.to);
1870
+ const first = history.transitions[0];
1871
+ return first?.from === void 0 || !isClosed(first.from);
1872
+ }
1873
+ function isClosing(transition) {
1874
+ return isClosed(transition.to) && (transition.from === void 0 || !isClosed(transition.from));
1875
+ }
1876
+ function closingsOf(history) {
1877
+ return history.transitions.filter(isClosing);
1878
+ }
1879
+ function isFixedNow(history) {
1880
+ return isClosed(history.finalStatus) && closingsOf(history).at(-1)?.resolution === "fixed";
1881
+ }
1882
+ function reopeningsOf(history) {
1883
+ return history.transitions.filter((transition) => transition.from !== void 0 && isClosed(transition.from) && !isClosed(transition.to));
1884
+ }
1885
+ function historyOf(id, { projectId, final, created, categoryEvents, transitions, candidates, verifications, filtered }) {
1886
+ const createdIso = final?.created ?? created?.at;
1887
+ const type = final?.type ?? created?.type;
1888
+ if (createdIso === void 0 || type === void 0) return [];
1889
+ const ordered = [...transitions].sort((a, b) => a.at - b.at);
1890
+ return [
1891
+ {
1892
+ id,
1893
+ projectId,
1894
+ type,
1895
+ createdAt: Date.parse(createdIso),
1896
+ source: final?.source ?? created?.source,
1897
+ reason: final?.reason,
1898
+ finalStatus: final?.status ?? "cancelled",
1899
+ transitions: [...ordered, ...restoredTransitions(final, ordered, Date.parse(createdIso))],
1900
+ category: categoryOf(final, created, categoryEvents),
1901
+ found: created?.found,
1902
+ branch: created?.origin?.branch,
1903
+ candidates: [...candidates].sort((a, b) => a.at - b.at),
1904
+ verifications: [...verifications].sort((a, b) => a - b),
1905
+ filtered: [...filtered].sort((a, b) => a - b)
1906
+ }
1907
+ ];
1908
+ }
1909
+ function categoryOf(final, created, categoryEvents) {
1910
+ if (final !== void 0) return final.category;
1911
+ const lastCategoryEvent = [...categoryEvents].sort((a, b) => a.at - b.at).at(-1);
1912
+ return lastCategoryEvent !== void 0 ? lastCategoryEvent.to : created?.category;
1913
+ }
1914
+ function restoredTransitions(final, ordered, createdAt) {
1915
+ const last = ordered.at(-1);
1916
+ const lastClosed = last !== void 0 && isClosed(last.to);
1917
+ if (final === void 0) return lastClosed ? [] : [{ at: last?.at ?? createdAt, to: "cancelled", via: "unknown" }];
1918
+ if (isClosed(final.status)) {
1919
+ if (lastClosed) return [];
1920
+ const at = final.closed === void 0 ? last?.at ?? createdAt : Date.parse(final.closed);
1921
+ return [{ at, to: final.status, resolution: final.resolution, via: "unknown" }];
1922
+ }
1923
+ if (!lastClosed || last === void 0) return [];
1924
+ return [{ at: last.at + 1, from: last.to, to: final.status, via: "unknown" }];
1925
+ }
1926
+
1927
+ // src/core/stats/code/fixes.ts
1928
+ var HASH_PATTERN = /(?<![\p{L}\p{N}])[0-9a-f]{7,40}(?![\p{L}\p{N}])/gu;
1929
+ function reasonHashes(reason) {
1930
+ return reason === void 0 ? [] : [...reason.matchAll(HASH_PATTERN)].map((match) => match[0]);
1931
+ }
1932
+ function fixKey(projectId, hash) {
1933
+ return `${projectId} ${hash}`;
1934
+ }
1935
+ function fixRequests(histories, period2) {
1936
+ const byProject = /* @__PURE__ */ new Map();
1937
+ for (const history of fixedWithin(histories, period2)) {
1938
+ const hashes = byProject.get(history.projectId) ?? /* @__PURE__ */ new Set();
1939
+ for (const hash of reasonHashes(history.reason)) hashes.add(hash);
1940
+ byProject.set(history.projectId, hashes);
1941
+ }
1942
+ return [...byProject.entries()].filter(([, hashes]) => hashes.size > 0).map(([projectId, hashes]) => ({ projectId, hashes: [...hashes] }));
1943
+ }
1944
+ function fixCommitEntry(history, commits) {
1945
+ return reasonHashes(history.reason).map((hash) => ({ key: fixKey(history.projectId, hash), commit: commits.get(fixKey(history.projectId, hash)) })).flatMap(({ key, commit }) => commit === void 0 ? [] : [{ key, commit }]).at(0);
1946
+ }
1947
+ function fixedWithin(histories, period2) {
1948
+ return histories.filter((history) => {
1949
+ const closing = closingsOf(history).at(-1);
1950
+ return isFixedNow(history) && closing !== void 0 && period2.contains(closing.at);
1951
+ });
1952
+ }
1953
+
1954
+ // src/core/code/code-window.ts
1955
+ var CHURN_DAYS = 90;
1956
+
1957
+ // src/core/stats/numbers.ts
1958
+ var TAIL_FRACTION = 0.9;
1959
+ function median(values) {
1960
+ if (values.length === 0) return null;
1961
+ const sorted = [...values].sort((a, b) => a - b);
1962
+ const middle = Math.floor(sorted.length / 2);
1963
+ if (sorted.length % 2 === 1) return sorted[middle] ?? null;
1964
+ return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2;
1965
+ }
1966
+ function nearestRank(values, fraction) {
1967
+ if (values.length === 0) return null;
1968
+ const sorted = [...values].sort((a, b) => a - b);
1969
+ return sorted[Math.ceil(fraction * sorted.length) - 1] ?? null;
1970
+ }
1971
+ function sum(values) {
1972
+ return values.reduce((total, value) => total + value, 0);
1973
+ }
1974
+ function smallest(values) {
1975
+ return values.reduce((least, value) => least === null || value < least ? value : least, null);
1976
+ }
1977
+ function daysBetween(from, to) {
1978
+ return (to - from) / DAY_MS;
1979
+ }
1980
+ function groupBy(items, keyOf) {
1981
+ const groups = /* @__PURE__ */ new Map();
1982
+ for (const item of items) {
1983
+ const key = keyOf(item);
1984
+ const group = groups.get(key);
1985
+ if (group === void 0) groups.set(key, [item]);
1986
+ else group.push(item);
1987
+ }
1988
+ return groups;
1989
+ }
1990
+ function countBy(items, keyOf, amountOf = () => 1) {
1991
+ const totals2 = /* @__PURE__ */ new Map();
1992
+ for (const item of items) {
1993
+ const key = keyOf(item);
1994
+ totals2.set(key, (totals2.get(key) ?? 0) + amountOf(item));
1995
+ }
1996
+ return totals2;
1997
+ }
1998
+
1999
+ // src/core/code/test-paths.ts
2000
+ var TEST_DIRECTORIES = /* @__PURE__ */ new Set(["test", "tests", "__tests__", "e2e", "spec"]);
2001
+ var TEST_FILE_NAMES = [/\.(test|spec)\.[^.]+$/, /_test\.[^.]+$/, /^test_.+\.py$/];
2002
+ function isTestPath(numstatPath) {
2003
+ const segments = currentPath(numstatPath).split("/");
2004
+ const fileName = segments.at(-1) ?? "";
2005
+ return segments.slice(0, -1).some((segment) => TEST_DIRECTORIES.has(segment)) || TEST_FILE_NAMES.some((pattern) => pattern.test(fileName));
2006
+ }
2007
+ function currentPath(numstatPath) {
2008
+ return numstatPath.replace(/\{[^}]* => ([^}]*)\}/g, "$1").replace(/^.* => /, "").replace(/\/\//g, "/");
2009
+ }
2010
+
2011
+ // src/core/code/git-code.ts
2012
+ var LOCK_FILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
2013
+ var LOCK_EXCLUDES = LOCK_FILES.map((name) => `:!*${name}`);
2014
+ var NON_CODE_EXTENSIONS = [".md", ".mdx", ".svg"];
2015
+ var CHURN_EXCLUDES = [...LOCK_EXCLUDES, ...NON_CODE_EXTENSIONS.map((ext) => `:!*${ext}`)];
2016
+ var HEAD = "HEAD";
2017
+ var MAIN_REFS = ["origin/HEAD", "main", "master"];
2018
+ var AGENT_TRAILER = /^claude/i;
2019
+ var GREP_PREFIX = "HEAD:";
2020
+ async function readRefs(git, repo) {
2021
+ const commits = await resolveCommits(git, repo, [HEAD, ...MAIN_REFS]) ?? /* @__PURE__ */ new Map();
2022
+ const head = commits.get(HEAD) ?? null;
2023
+ const main = MAIN_REFS.map((ref) => commits.get(ref)).find((commit) => commit !== void 0);
2024
+ return { head, main: main ?? head };
2025
+ }
2026
+ async function readRepoCode(git, repo, since, mainCommit) {
2027
+ const [log, grep, units] = await Promise.all([
2028
+ git(repo, ["log", `--since=${since.toISOString()}`, `--format=tformat:${RECORD}`, "--name-only", "-M", "--relative", "-z", "--", "."]),
2029
+ git(repo, ["grep", "-I", "-c", "-z", "", "HEAD", "--", ".", ...LOCK_EXCLUDES]),
2030
+ readUnits(git, repo, since, mainCommit)
2031
+ ]);
2032
+ if (log === null || grep === null) return null;
2033
+ return { commits: parseCommits(log), lines: parseLines(grep), units };
2034
+ }
2035
+ async function readFixCommits(git, repo, hashes) {
2036
+ const fullHashes = await resolveCommits(git, repo, hashes);
2037
+ if (fullHashes === null) return null;
2038
+ return fullHashes.size === 0 ? /* @__PURE__ */ new Map() : readFixBatch(git, repo, fullHashes);
2039
+ }
2040
+ async function readFixBatch(git, repo, fullHashes) {
2041
+ const commits = [...new Set(fullHashes.values())];
2042
+ const [headers, stats] = await Promise.all([
2043
+ git(repo, ["log", "--no-walk=unsorted", ...commits, `--format=tformat:${RECORD}%H${FIELD}%cI${FIELD}%(trailers:key=Co-authored-by,valueonly,separator=${FIELD})`, "--"]),
2044
+ git(repo, ["log", "--no-walk=unsorted", ...commits, `--format=tformat:${RECORD}%H`, "--numstat", "--diff-merges=first-parent", "--relative", "--", ".", ...CHURN_EXCLUDES])
2045
+ ]);
2046
+ if (headers === null || stats === null) return null;
2047
+ const changedLines = new Map(parseFixStats(stats));
2048
+ const byFullHash = new Map(parseFixHeaders(headers, changedLines));
2049
+ return new Map(
2050
+ [...fullHashes].flatMap(([hash, full]) => {
2051
+ const commit = byFullHash.get(full);
2052
+ return commit === void 0 ? [] : [[hash, commit]];
2053
+ })
2054
+ );
2055
+ }
2056
+ function parseFixHeaders(output, changedLines) {
2057
+ return records(output).map((record) => {
2058
+ const [full = "", date = "", ...trailers] = record.split("\n")[0]?.split(FIELD) ?? [];
2059
+ const changed = changedLines.get(full) ?? { lines: 0, testLines: 0 };
2060
+ return [full, { date, byAgent: trailers.some((trailer) => AGENT_TRAILER.test(trailer.trim())), ...changed }];
2061
+ });
2062
+ }
2063
+ function parseFixStats(output) {
2064
+ return records(output).map((record) => {
2065
+ const [full = "", ...rows] = record.split("\n");
2066
+ return [full.trim(), { lines: numstatLines(rows), testLines: numstatLines(rows.filter((row) => isTestPath(row.split(" ")[2] ?? ""))) }];
2067
+ });
2068
+ }
2069
+ function records(output) {
2070
+ return output.split(RECORD).filter((record) => record.trim() !== "");
2071
+ }
2072
+ async function readUnits(git, repo, since, mainCommit) {
2073
+ if (mainCommit === null) return [];
2074
+ const output = await git(repo, [
2075
+ "log",
2076
+ mainCommit,
2077
+ "--first-parent",
2078
+ "--diff-merges=first-parent",
2079
+ `--since=${since.toISOString()}`,
2080
+ `--format=tformat:${RECORD}%cI`,
2081
+ "--numstat",
2082
+ "--relative",
2083
+ "--",
2084
+ ".",
2085
+ ...CHURN_EXCLUDES
2086
+ ]);
2087
+ return output === null ? [] : parseUnits(output);
2088
+ }
2089
+ function parseUnits(output) {
2090
+ return output.split(RECORD).filter((record) => record.trim() !== "").map((record) => {
2091
+ const [date = "", ...rows] = record.split("\n");
2092
+ return { date: date.trim(), lines: numstatLines(rows) };
2093
+ });
2094
+ }
2095
+ function numstatLines(rows) {
2096
+ return sum(rows.filter((row) => row.trim() !== "").map(numstatRowLines).filter((lines) => !Number.isNaN(lines)));
2097
+ }
2098
+ function numstatRowLines(row) {
2099
+ const [added = "", deleted = ""] = row.split(" ");
2100
+ return Number(added) + Number(deleted);
2101
+ }
2102
+ function parseCommits(output) {
2103
+ return output.split(RECORD).map((record) => record.split("\0").map((file) => file.replace(/^\n/, "")).filter((file) => file !== "")).filter((files) => files.length > 0);
2104
+ }
2105
+ function parseLines(output) {
2106
+ return output.split("\n").filter((record) => record.startsWith(GREP_PREFIX)).map((record) => {
2107
+ const [path = "", count3 = ""] = record.slice(GREP_PREFIX.length).split("\0");
2108
+ return { path, lines: Number(count3) };
2109
+ });
2110
+ }
2111
+
2112
+ // src/core/code/code-source.ts
2113
+ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
2114
+ } }) {
2115
+ const repoCache = /* @__PURE__ */ new Map();
2116
+ const fixCache = /* @__PURE__ */ new Map();
2117
+ let changed = false;
2118
+ let restored = null;
2119
+ const restore = () => {
2120
+ restored ??= readSnapshot(store, (error) => onError("read", error)).then((snapshot) => {
2121
+ for (const [repo, entry] of Object.entries(snapshot.repos)) if (!repoCache.has(repo)) repoCache.set(repo, entry);
2122
+ for (const [key, commit] of Object.entries(snapshot.fixes)) if (!fixCache.has(key)) fixCache.set(key, commit);
2123
+ });
2124
+ return restored;
2125
+ };
2126
+ const dropStaleFixes = (now, requested) => {
2127
+ const oldest = now.getTime() - CHURN_DAYS * DAY_MS;
2128
+ for (const [key, commit] of fixCache) {
2129
+ if (requested.has(key) || Date.parse(commit.date) >= oldest) continue;
2130
+ fixCache.delete(key);
2131
+ changed = true;
2132
+ }
2133
+ };
2134
+ const persist = async () => {
2135
+ if (store === void 0 || !changed) return;
2136
+ changed = false;
2137
+ await store.write({ repos: Object.fromEntries(repoCache), fixes: Object.fromEntries(fixCache) }).catch((error) => onError("write", error));
2138
+ };
2139
+ const inFlight = /* @__PURE__ */ new Map();
2140
+ const repoCode = (repo, now) => {
2141
+ const running = inFlight.get(repo);
2142
+ if (running !== void 0) return running;
2143
+ const started = readRepoOnce(repo, now).finally(() => inFlight.delete(repo));
2144
+ inFlight.set(repo, started);
2145
+ return started;
2146
+ };
2147
+ const readRepoOnce = async (repo, now) => {
2148
+ const refs = await readRefs(git, repo);
2149
+ if (refs.head === null) return null;
2150
+ const key = repoKey(refs, now);
2151
+ const cached = repoCache.get(repo);
2152
+ if (cached?.key === key) return { key, code: cached.code };
2153
+ const code = await readRepoCode(git, repo, new Date(now.getTime() - CHURN_DAYS * DAY_MS), refs.main);
2154
+ if (code === null) return null;
2155
+ repoCache.set(repo, { key, code });
2156
+ changed = true;
2157
+ return { key, code };
2158
+ };
2159
+ const fixCommitsOf = async (repo, hashes) => {
2160
+ const unknown = hashes.filter((hash) => !fixCache.has(`${repo} ${hash}`));
2161
+ if (unknown.length === 0) return;
2162
+ const found = await readFixCommits(git, repo, unknown);
2163
+ for (const [hash, commit] of found ?? []) {
2164
+ fixCache.set(`${repo} ${hash}`, commit);
2165
+ changed = true;
2166
+ }
2167
+ };
2168
+ const fixReposOf = async (projects, now) => {
2169
+ const refsOf = /* @__PURE__ */ new Map();
2170
+ const refsOnce = (repo) => {
2171
+ const known = refsOf.get(repo) ?? readRefs(git, repo);
2172
+ refsOf.set(repo, known);
2173
+ return known;
2174
+ };
2175
+ const entries = await Promise.all(
2176
+ projects.map(async (project) => {
2177
+ const repos = await Promise.all(
2178
+ project.repos.map(async (repo) => {
2179
+ const expanded = expandHome(repo, home2);
2180
+ const refs = await refsOnce(expanded);
2181
+ return refs.head === null ? [] : [{ repo: expanded, key: repoKey(refs, now) }];
2182
+ })
2183
+ );
2184
+ return [project.id, repos.flat()];
2185
+ })
2186
+ );
2187
+ return new Map(entries);
2188
+ };
2189
+ return {
2190
+ stateKey: async (projects) => {
2191
+ const repos = [...new Set(projects.flatMap((project) => project.repos.map((repo) => expandHome(repo, home2))))];
2192
+ const states = await Promise.all(repos.map(async (repo) => `${repo}@${refsKey(await readRefs(git, repo))}`));
2193
+ return states.join(" ");
2194
+ },
2195
+ collect: async (projects, now) => {
2196
+ await restore();
2197
+ const unavailableRepos = [];
2198
+ const projectCodes = [];
2199
+ const seenUnavailable = /* @__PURE__ */ new Set();
2200
+ const scanned = await Promise.all(
2201
+ projects.map(async (project) => ({
2202
+ project,
2203
+ repos: await Promise.all(project.repos.map(async (repo) => ({ repo, read: await repoCode(expandHome(repo, home2), now) })))
2204
+ }))
2205
+ );
2206
+ for (const { project, repos } of scanned) {
2207
+ const readable = [];
2208
+ for (const { repo, read } of repos) {
2209
+ if (read !== null) {
2210
+ readable.push(read.code);
2211
+ } else if (!seenUnavailable.has(repo)) {
2212
+ seenUnavailable.add(repo);
2213
+ unavailableRepos.push(repo);
2214
+ }
2215
+ }
2216
+ projectCodes.push({ projectId: project.id, name: project.name, repos: readable });
2217
+ }
2218
+ await persist();
2219
+ return { projects: projectCodes, unavailableRepos };
2220
+ },
2221
+ fixCommits: async (projects, requests, now) => {
2222
+ await restore();
2223
+ const reposOf = await fixReposOf(projects, now);
2224
+ await Promise.all(requests.flatMap(({ projectId, hashes }) => (reposOf.get(projectId) ?? []).map(({ repo }) => fixCommitsOf(repo, hashes))));
2225
+ const found = /* @__PURE__ */ new Map();
2226
+ const requested = /* @__PURE__ */ new Set();
2227
+ for (const { projectId, hashes } of requests) {
2228
+ for (const hash of hashes) {
2229
+ const keys = (reposOf.get(projectId) ?? []).map(({ repo }) => `${repo} ${hash}`);
2230
+ for (const key of keys) requested.add(key);
2231
+ const commit = keys.map((key) => fixCache.get(key)).find((cached) => cached !== void 0);
2232
+ if (commit !== void 0) found.set(fixKey(projectId, hash), commit);
2233
+ }
2234
+ }
2235
+ dropStaleFixes(now, requested);
2236
+ await persist();
2237
+ return found;
2238
+ }
2239
+ };
2240
+ }
2241
+ function refsKey(refs) {
2242
+ return `${refs.head ?? ""} ${refs.main ?? ""}`;
2243
+ }
2244
+ function repoKey(refs, now) {
2245
+ return `${refsKey(refs)} ${formatLocalDay(now)}`;
2246
+ }
2247
+ async function readSnapshot(store, onError) {
2248
+ try {
2249
+ return await store?.read() ?? emptyCodeCache();
2250
+ } catch (error) {
2251
+ onError(error);
2252
+ return emptyCodeCache();
2253
+ }
2254
+ }
2255
+
2256
+ // src/core/stats/scope.ts
2257
+ function statsScope({ tasks, journals, projectId }) {
2258
+ const inScope = (candidate) => projectId === void 0 || candidate === projectId;
2259
+ const scopedJournals = journals.filter((journal) => inScope(journal.projectId));
2260
+ const scopedTasks = tasks.filter((task) => inScope(task.projectId));
2261
+ const journalStart = smallest(scopedJournals.flatMap((journal) => journal.events.map((event) => Date.parse(event.at))));
2262
+ return {
2263
+ tasks: scopedTasks,
2264
+ histories: taskHistories(scopedTasks, scopedJournals).filter((history) => inScope(history.projectId)),
2265
+ journalStart,
2266
+ journalSince: journalStart === null ? null : formatLocalIso(new Date(journalStart)),
2267
+ invalidJournalLines: sum(scopedJournals.map((journal) => journal.invalidLines))
2268
+ };
2269
+ }
2270
+ function reportBase(input) {
2271
+ const scope = statsScope(input);
2272
+ const histories = scope.histories.filter((history) => history.type === "task");
2273
+ const tasks = scope.tasks.filter((task) => task.type === "task");
2274
+ return {
2275
+ scope,
2276
+ histories,
2277
+ tasks,
2278
+ openTasks: tasks.filter((task) => !isClosed(task.status)),
2279
+ head: { taskCount: histories.length, journalSince: scope.journalSince, invalidJournalLines: scope.invalidJournalLines }
2280
+ };
2281
+ }
2282
+
2283
+ // src/core/stats/period.ts
2284
+ function period(from, to) {
2285
+ return { from, to, contains: (moment) => moment >= from && moment <= to };
2286
+ }
2287
+ function consecutivePeriods(starts, lastTo) {
2288
+ return starts.map((start, index) => period(start.getTime(), (starts[index + 1]?.getTime() ?? lastTo + 1) - 1));
2289
+ }
2290
+
2291
+ // src/core/stats/weeks.ts
2292
+ var STATS_WEEKS = 12;
2293
+ var DAYS_PER_WEEK = 7;
2294
+ var WEEK_MS = DAYS_PER_WEEK * DAY_MS;
2295
+ var MONDAY = 1;
2296
+ function weekStarts(now, count3) {
2297
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (now.getDay() + DAYS_PER_WEEK - MONDAY) % DAYS_PER_WEEK);
2298
+ return Array.from({ length: count3 }, (_, index) => new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() - DAYS_PER_WEEK * (count3 - 1 - index)));
2299
+ }
2300
+ function statsPeriod(now) {
2301
+ return period(weekStarts(now, STATS_WEEKS)[0]?.getTime() ?? now.getTime(), now.getTime());
2302
+ }
2303
+ function weekWindows(now, count3 = STATS_WEEKS) {
2304
+ return consecutivePeriods(weekStarts(now, count3), now.getTime());
2305
+ }
2306
+ function weeklyFlow(histories, now) {
2307
+ const closings = histories.flatMap(closingsOf);
2308
+ return weekWindows(now).map((week) => ({
2309
+ start: formatLocalIso(new Date(week.from)),
2310
+ created: histories.filter((history) => week.contains(history.createdAt)).length,
2311
+ closed: closings.filter((closing) => week.contains(closing.at)).length,
2312
+ openAtEnd: histories.filter((history) => isOpenAt(history, week.to)).length
2313
+ }));
2314
+ }
2315
+
2316
+ // src/core/stats/format.ts
2317
+ function projectLabel(projectId, label, withProject) {
2318
+ return withProject ? `${projectId} \xB7 ${label}` : label;
2319
+ }
2320
+
2321
+ // src/core/stats/breakdowns.ts
2322
+ var HOTSPOT_LIMIT = 8;
2323
+ var STALE_URGENT_DAYS = 7;
2324
+ var AGE_LIMITS = [
2325
+ { bucket: "week", belowDays: DAYS_PER_WEEK },
2326
+ { bucket: "month", belowDays: 30 },
2327
+ { bucket: "quarter", belowDays: 90 },
2328
+ { bucket: "older", belowDays: Number.POSITIVE_INFINITY }
2329
+ ];
2330
+ function folderOf(source) {
2331
+ const path = source.replace(/(:\d+)+$/, "");
2332
+ const slash = path.lastIndexOf("/");
2333
+ return slash === -1 ? path : path.slice(0, slash);
2334
+ }
2335
+ function hotspots(openTasks, withProject) {
2336
+ const folders = openTasks.flatMap((task) => {
2337
+ if (task.source === void 0) return [];
2338
+ const folder = folderOf(task.source);
2339
+ return [projectLabel(task.projectId, folder, withProject)];
2340
+ });
2341
+ return {
2342
+ folders: topCounts(folders).map(([label, count3]) => ({ label, count: count3 })),
2343
+ tags: topCounts(openTasks.flatMap((task) => task.tags)).map(([tag, count3]) => ({ tag, count: count3 }))
2344
+ };
2345
+ }
2346
+ function ageBreakdown(openTasks, now) {
2347
+ const ageOf = (task) => daysBetween(Date.parse(task.created), now.getTime());
2348
+ const bucketOf = (task) => AGE_LIMITS.find(({ belowDays }) => ageOf(task) < belowDays)?.bucket ?? "older";
2349
+ return {
2350
+ buckets: AGE_LIMITS.map(({ bucket }) => ({ bucket, byPriority: priorityCounts(openTasks.filter((task) => bucketOf(task) === bucket)) })),
2351
+ urgentStale: urgentStaleCount(openTasks, now)
2352
+ };
2353
+ }
2354
+ function urgentStaleCount(openTasks, now) {
2355
+ return openTasks.filter((task) => (task.priority === "critical" || task.priority === "high") && daysBetween(Date.parse(task.created), now.getTime()) >= STALE_URGENT_DAYS).length;
2356
+ }
2357
+ function closingBreakdown(histories, period2) {
2358
+ const closings = histories.flatMap(closingsOf).filter((closing) => period2.contains(closing.at));
2359
+ const created = histories.filter((history) => period2.contains(history.createdAt));
2360
+ const byReason = { done: 0, fixed: 0, obsolete: 0, duplicate: 0, cancelled: 0 };
2361
+ for (const closing of closings) byReason[closingReason(closing)] += 1;
2362
+ return {
2363
+ byReason,
2364
+ duplicateShare: closings.length === 0 ? null : byReason.duplicate / closings.length,
2365
+ withoutSourceShare: created.length === 0 ? null : created.filter((history) => history.source === void 0).length / created.length,
2366
+ reopened: histories.flatMap(reopeningsOf).filter((reopening) => period2.contains(reopening.at)).length
2367
+ };
2368
+ }
2369
+ function closingReason(closing) {
2370
+ if (closing.resolution === "fixed" || closing.resolution === "obsolete" || closing.resolution === "duplicate") return closing.resolution;
2371
+ return closing.to === "cancelled" ? "cancelled" : "done";
2372
+ }
2373
+ function priorityCounts(tasks) {
2374
+ return Object.fromEntries(PRIORITIES.map((priority) => [priority, tasks.filter((task) => task.priority === priority).length]));
2375
+ }
2376
+ function topCounts(values) {
2377
+ const counts = countBy(values, (value) => value);
2378
+ return [...counts.entries()].sort(([a, countA], [b, countB]) => countB - countA || a.localeCompare(b)).slice(0, HOTSPOT_LIMIT);
2379
+ }
2380
+
2381
+ // src/core/stats/weights.ts
2382
+ var PRIORITY_WEIGHT = { critical: 8, high: 4, medium: 2, low: 1 };
2383
+
2384
+ // src/core/stats/code/churn.ts
2385
+ var CHURN_LIMIT = 8;
2386
+ function churn(openTasks, projects, withProject) {
2387
+ return projects.flatMap((project) => {
2388
+ const commits = folderCommits(project);
2389
+ const debt = folderDebt(openTasks.filter((task) => task.projectId === project.projectId));
2390
+ return [...debt.entries()].map(([folder, { tasks, weight }]) => {
2391
+ const folderChanges = commits.get(folder) ?? 0;
2392
+ return { label: projectLabel(project.projectId, folder, withProject), commits: folderChanges, tasks, weight, score: folderChanges * weight };
2393
+ });
2394
+ }).filter((row) => row.score > 0).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)).slice(0, CHURN_LIMIT);
2395
+ }
2396
+ function folderCommits(project) {
2397
+ const touched = project.repos.flatMap((repo) => repo.commits).flatMap((files) => [...new Set(files.map(folderOf))]);
2398
+ return countBy(touched, (folder) => folder);
2399
+ }
2400
+ function folderDebt(tasks) {
2401
+ const debt = /* @__PURE__ */ new Map();
2402
+ for (const task of tasks) {
2403
+ if (task.source === void 0) continue;
2404
+ const folder = folderOf(task.source);
2405
+ const current = debt.get(folder) ?? { tasks: 0, weight: 0 };
2406
+ debt.set(folder, { tasks: current.tasks + 1, weight: current.weight + PRIORITY_WEIGHT[task.priority] });
2407
+ }
2408
+ return debt;
2409
+ }
2410
+
2411
+ // src/core/stats/code/density.ts
2412
+ var FOLDER_LIMIT = 8;
2413
+ var MIN_FOLDER_LINES = 500;
2414
+ var LINES_PER_UNIT = 1e3;
2415
+ function density(openTasks, projects, projectId) {
2416
+ const projectRows = projects.filter((project) => project.repos.length > 0).map((project) => {
2417
+ const lines = sum(project.repos.flatMap((repo) => repo.lines).map((file) => file.lines));
2418
+ return { projectId: project.projectId, name: project.name, ...densityRow(lines, openTasks.filter((task) => task.projectId === project.projectId).length) };
2419
+ }).sort((a, b) => byDensity(a, b) || a.projectId.localeCompare(b.projectId));
2420
+ const scoped = projectId === void 0 ? void 0 : projects.find((project) => project.projectId === projectId);
2421
+ return { projects: projectRows, folders: scoped === void 0 ? [] : folderRows(scoped, openTasks) };
2422
+ }
2423
+ function folderRows(project, openTasks) {
2424
+ const lines = countBy(
2425
+ project.repos.flatMap((repo) => repo.lines),
2426
+ (file) => folderOf(file.path),
2427
+ (file) => file.lines
2428
+ );
2429
+ const ownSources = openTasks.flatMap((task) => task.projectId === project.projectId && task.source !== void 0 ? [task.source] : []);
2430
+ const open2 = countBy(ownSources, folderOf);
2431
+ return [...open2.entries()].flatMap(([label, count3]) => {
2432
+ const folderLines = lines.get(label) ?? 0;
2433
+ return folderLines >= MIN_FOLDER_LINES ? [{ label, ...densityRow(folderLines, count3) }] : [];
2434
+ }).sort((a, b) => byDensity(a, b) || a.label.localeCompare(b.label)).slice(0, FOLDER_LIMIT);
2435
+ }
2436
+ function densityRow(lines, open2) {
2437
+ return { lines, open: open2, perKloc: lines === 0 ? null : open2 * LINES_PER_UNIT / lines };
2438
+ }
2439
+ function byDensity(a, b) {
2440
+ return (b.perKloc ?? -1) - (a.perKloc ?? -1);
2441
+ }
2442
+
2443
+ // src/core/stats/code/code-report.ts
2444
+ function codeReport({ code, ...input }, base = reportBase(input)) {
2445
+ const { projectId } = input;
2446
+ const { openTasks } = base;
2447
+ const projects = code.projects.filter((project) => projectId === void 0 || project.projectId === projectId);
2448
+ return {
2449
+ ...base.head,
2450
+ unavailableRepos: code.unavailableRepos,
2451
+ churn: churn(openTasks, projects, projectId === void 0),
2452
+ density: density(openTasks, projects, projectId)
2453
+ };
2454
+ }
2455
+ function codeFixRequests(input, base = reportBase(input)) {
2456
+ return fixRequests(base.histories, statsPeriod(input.now));
2457
+ }
2458
+
2459
+ // src/core/stats/cost/hook-signature.ts
2460
+ var HOOK_STOP_EVENT = "stop";
2461
+ var HOOK_STOP_COMMAND = `hook ${HOOK_STOP_EVENT}`;
2462
+ var STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback:";
2463
+ function isBacklogHookFeedback(text) {
2464
+ return text.startsWith(STOP_HOOK_FEEDBACK_PREFIX) && LANGUAGES.some((language) => text.includes(`${coreMessages(language).hookMark} `));
2465
+ }
2466
+
2467
+ // src/core/stats/cost/pricing.ts
2468
+ var FAST_PRICED_MODEL = "claude-opus-5";
2469
+ var FAST_MODEL_MARKER = ":fast";
2470
+ var FAST_PRICE_FACTOR = 2;
2471
+ var CACHE_WRITE_5M_FACTOR = 1.25;
2472
+ var CACHE_WRITE_1H_FACTOR = 2;
2473
+ var PRICE_TABLE = {
2474
+ "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25 },
2475
+ "claude-mythos-5-1": { input: 10, output: 50, cacheRead: 0.25 },
2476
+ "claude-fable-5": { input: 10, output: 50, cacheRead: 1 },
2477
+ "claude-mythos-5": { input: 10, output: 50, cacheRead: 1 },
2478
+ [FAST_PRICED_MODEL]: { input: 5, output: 25, cacheRead: 0.5 },
2479
+ "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5 },
2480
+ "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5 },
2481
+ "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5 },
2482
+ "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2 },
2483
+ "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3 },
2484
+ "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1 }
2485
+ };
2486
+ var PRICE_KEYS_BY_LENGTH_DESC = Object.keys(PRICE_TABLE).sort((a, b) => b.length - a.length);
2487
+ var DATED_SUFFIX = /-\d{8}$/;
2488
+ var BRACKET_SUFFIX = /\[[^\]]*\]$/;
2489
+ function baseModelId(model) {
2490
+ return model.replace(BRACKET_SUFFIX, "").replace(DATED_SUFFIX, "");
2491
+ }
2492
+ function fastModel(model) {
2493
+ return `${model}${FAST_MODEL_MARKER}`;
2494
+ }
2495
+ function splitFastModel(modelKey) {
2496
+ const fast = modelKey.endsWith(FAST_MODEL_MARKER);
2497
+ return { model: fast ? modelKey.slice(0, -FAST_MODEL_MARKER.length) : modelKey, fast };
2498
+ }
2499
+ function priceOf(modelKey) {
2500
+ const { model, fast } = splitFastModel(modelKey);
2501
+ const stripped = baseModelId(model);
2502
+ const key = PRICE_KEYS_BY_LENGTH_DESC.find((candidate) => stripped.startsWith(candidate));
2503
+ const price = key ? PRICE_TABLE[key] : void 0;
2504
+ if (!price) return null;
2505
+ if (fast && key === FAST_PRICED_MODEL) return { input: price.input * FAST_PRICE_FACTOR, output: price.output * FAST_PRICE_FACTOR, cacheRead: price.cacheRead * FAST_PRICE_FACTOR };
2506
+ return price;
2507
+ }
2508
+ function costOf(model, tokens) {
2509
+ const price = priceOf(model);
2510
+ if (!price) return null;
2511
+ const { input, cacheWrite5m, cacheWrite1h, cacheRead, output } = tokens;
2512
+ const total = input * price.input + cacheWrite5m * price.input * CACHE_WRITE_5M_FACTOR + cacheWrite1h * price.input * CACHE_WRITE_1H_FACTOR + cacheRead * price.cacheRead + output * price.output;
2513
+ return total / 1e6;
2514
+ }
2515
+
2516
+ // src/core/stats/days.ts
2517
+ var STATS_DAYS = 30;
2518
+ function dayRange(now, count3) {
2519
+ return dayStarts(now, count3).map(formatLocalDay);
2520
+ }
2521
+ function dailyIntake(histories, now) {
2522
+ const created = /* @__PURE__ */ new Map();
2523
+ for (const history of histories) {
2524
+ const day = formatLocalDay(new Date(history.createdAt));
2525
+ created.set(day, (created.get(day) ?? 0) + 1);
2526
+ }
2527
+ return dayRange(now, STATS_DAYS).map((day) => ({ day, created: created.get(day) ?? 0 }));
2528
+ }
2529
+ function dayWindows(now, count3 = STATS_DAYS) {
2530
+ return consecutivePeriods(dayStarts(now, count3), now.getTime());
2531
+ }
2532
+ function dayStarts(now, count3) {
2533
+ return Array.from({ length: count3 }, (_, index) => new Date(now.getFullYear(), now.getMonth(), now.getDate() - (count3 - 1 - index)));
2534
+ }
2535
+
2536
+ // src/core/stats/cost/cost-report.ts
2537
+ var COST_REPORT_DAYS = 30;
2538
+ var COST_TOTALS_DAYS = 7;
2539
+ function costReport({ buckets, runs, projectOf, projectId, now, scan }) {
2540
+ const projectOfCwd = memoizedByCwd(projectOf);
2541
+ const inScope = (cwd) => projectId === void 0 || projectOfCwd(cwd) === projectId;
2542
+ const scopedBuckets = buckets.filter((bucket) => inScope(bucket.cwd));
2543
+ const scopedRuns = runs.filter((run) => inScope(run.cwd));
2544
+ const days3 = dayRange(now, COST_REPORT_DAYS);
2545
+ const bucketsByDay = groupBy(scopedBuckets, (bucket) => localDay(bucket.slot));
2546
+ const runsByDay = groupBy(scopedRuns, (run) => localDay(run.at));
2547
+ const inDays = (byDay, window) => window.flatMap((day) => byDay.get(day) ?? []);
2548
+ const totalsDays = days3.slice(-COST_TOTALS_DAYS);
2549
+ return {
2550
+ scan,
2551
+ since: sinceOf(scopedBuckets),
2552
+ totals: totalsOf(inDays(bucketsByDay, totalsDays), inDays(runsByDay, totalsDays)),
2553
+ days: days3.map((day) => dayRow(day, bucketsByDay.get(day) ?? [], runsByDay.get(day) ?? [])),
2554
+ models: modelsOf(scopedBuckets),
2555
+ commands: commandsOf(inDays(runsByDay, days3))
2556
+ };
2557
+ }
2558
+ function memoizedByCwd(projectOf) {
2559
+ const known = /* @__PURE__ */ new Map();
2560
+ return (cwd) => {
2561
+ if (!known.has(cwd)) known.set(cwd, projectOf(cwd));
2562
+ return known.get(cwd) ?? null;
2563
+ };
2564
+ }
2565
+ function localDay(at) {
2566
+ const moment = Date.parse(at);
2567
+ return Number.isNaN(moment) ? "" : formatLocalDay(new Date(moment));
2568
+ }
2569
+ function tokenSum(tokens) {
2570
+ return tokens.input + tokens.cacheWrite5m + tokens.cacheWrite1h + tokens.cacheRead + tokens.output;
2571
+ }
2572
+ function tokensTotalOf(buckets) {
2573
+ return sum(buckets.map((bucket) => tokenSum(bucket.tokens)));
2574
+ }
2575
+ function costOfBuckets(buckets) {
2576
+ if (tokensTotalOf(buckets) === 0) return 0;
2577
+ const priced = buckets.flatMap((bucket) => {
2578
+ const cost = costOf(bucket.model, bucket.tokens);
2579
+ return cost === null ? [] : [{ cost, tokens: tokenSum(bucket.tokens) }];
2580
+ });
2581
+ return sum(priced.map((entry) => entry.tokens)) === 0 ? null : sum(priced.map((entry) => entry.cost));
2582
+ }
2583
+ function sinceOf(buckets) {
2584
+ const days3 = buckets.map((bucket) => localDay(bucket.slot)).filter((day) => day !== "");
2585
+ return days3.length === 0 ? null : days3.reduce((earliest, day) => day < earliest ? day : earliest);
2586
+ }
2587
+ function totalsOf(buckets, runs) {
2588
+ return {
2589
+ tokens: tokensTotalOf(buckets),
2590
+ cost: costOfBuckets(buckets),
2591
+ hasUnpricedTokens: buckets.some((bucket) => tokenSum(bucket.tokens) > 0 && costOf(bucket.model, bucket.tokens) === null),
2592
+ hookTurns: sum(buckets.map((bucket) => bucket.hookTurns)),
2593
+ cliRuns: runs.filter((run) => run.command !== HOOK_STOP_COMMAND).length,
2594
+ hookRuns: runs.filter((run) => run.command === HOOK_STOP_COMMAND).length
2595
+ };
2596
+ }
2597
+ function dayRow(day, dayBuckets, dayRuns) {
2598
+ const hookBuckets = dayBuckets.filter((bucket) => bucket.kind === "hook");
2599
+ const cliBuckets = dayBuckets.filter((bucket) => bucket.kind === "cli" || bucket.kind === "skill");
2600
+ return {
2601
+ day,
2602
+ hookTokens: tokensTotalOf(hookBuckets),
2603
+ cliTokens: tokensTotalOf(cliBuckets),
2604
+ cost: costOfBuckets(dayBuckets),
2605
+ hookTurns: sum(dayBuckets.map((bucket) => bucket.hookTurns)),
2606
+ cliRuns: dayRuns.filter((run) => run.command !== HOOK_STOP_COMMAND).length,
2607
+ hookRuns: dayRuns.filter((run) => run.command === HOOK_STOP_COMMAND).length
2608
+ };
2609
+ }
2610
+ function modelsOf(buckets) {
2611
+ return [...groupBy(buckets, (bucket) => bucket.model).entries()].map(([key, modelBuckets]) => ({ ...splitFastModel(key), tokens: tokensTotalOf(modelBuckets), cost: costOfBuckets(modelBuckets) })).filter((row) => row.tokens > 0).sort((a, b) => b.tokens - a.tokens);
2612
+ }
2613
+ function commandsOf(runs) {
2614
+ return [...groupBy(runs, (run) => run.command).entries()].map(([command, commandRuns]) => ({
2615
+ command,
2616
+ runs: commandRuns.length,
2617
+ avgMs: average(commandRuns.map((run) => run.ms)),
2618
+ avgRssMb: average(commandRuns.map((run) => run.rssMb)),
2619
+ maxRssMb: commandRuns.reduce((most, run) => Math.max(most, run.rssMb), 0)
2620
+ })).sort((a, b) => b.runs - a.runs);
2621
+ }
2622
+ function average(values) {
2623
+ return sum(values) / values.length;
2624
+ }
2625
+
2626
+ // src/core/stats/effect/effect-report.ts
2627
+ var MIN_FIXES_FOR_ESTIMATE = 5;
2628
+ function effectReport({ code, ...input }, base = reportBase(input), wholeBacklog = input.projectId === void 0 ? base : reportBase({ ...input, projectId: void 0 })) {
2629
+ const { now, projectId } = input;
2630
+ const { histories } = base;
2631
+ const reportPeriod = statsPeriod(now);
2632
+ const projects = code.projects.filter((project) => project.repos.length > 0 && (projectId === void 0 || project.projectId === projectId));
2633
+ const deferred = buildDeferred(histories.filter((history) => reportPeriod.contains(history.createdAt)), histories, code);
2634
+ const estimate = estimator(estimateSamples(wholeBacklog.histories, code));
2635
+ const adoptionStart = (id) => {
2636
+ const firstCreated = smallest(histories.filter((history) => history.projectId === id).map((history) => history.createdAt));
2637
+ return firstCreated === null ? reportPeriod.from : Math.max(reportPeriod.from, firstCreated);
2638
+ };
2639
+ const unitsOf = (project) => project.repos.flatMap((repo) => repo.units);
2640
+ const unitsForTotals = (id) => projects.filter((project) => id === void 0 || project.projectId === id).flatMap((project) => {
2641
+ const adopted = period(adoptionStart(project.projectId), reportPeriod.to);
2642
+ return unitsOf(project).filter((unit) => adopted.contains(Date.parse(unit.date)));
2643
+ });
2644
+ const periodUnits = projects.flatMap(unitsOf).filter((unit) => reportPeriod.contains(Date.parse(unit.date)));
2645
+ return {
2646
+ ...base.head,
2647
+ unavailableRepos: code.unavailableRepos,
2648
+ totals: totalsOf2(deferred, unitsForTotals(), estimate),
2649
+ weeks: bucketsOf(weekWindows(now), deferred, periodUnits, estimate),
2650
+ days: bucketsOf(dayWindows(now), deferred, periodUnits, estimate),
2651
+ projects: projects.map((project) => {
2652
+ const own = totalsOf2(deferred.filter((item) => item.history.projectId === project.projectId), unitsForTotals(project.projectId), estimate);
2653
+ return {
2654
+ projectId: project.projectId,
2655
+ name: project.name,
2656
+ realLines: own.realLines,
2657
+ deferredTasks: own.fixedTasks + own.openTasks,
2658
+ fixedLines: own.fixedLines,
2659
+ estimatedLines: own.estimatedLines,
2660
+ noiseShare: own.noiseShare
2661
+ };
2662
+ })
2663
+ };
2664
+ }
2665
+ function fixEntryOf(history, code) {
2666
+ return isFixedNow(history) ? fixCommitEntry(history, code.fixCommits) : void 0;
2667
+ }
2668
+ function buildDeferred(candidates, histories, code) {
2669
+ const sharersByCommit = countBy(
2670
+ histories.flatMap((history) => fixEntryOf(history, code)?.key ?? []),
2671
+ (key) => key
2672
+ );
2673
+ return candidates.flatMap((history) => {
2674
+ if (!isClosed(history.finalStatus)) return [{ history, fixedLines: null, fixedTestLines: 0, fixedAt: null }];
2675
+ const entry = fixEntryOf(history, code);
2676
+ if (entry === void 0) return [];
2677
+ const sharers = sharersByCommit.get(entry.key) ?? 1;
2678
+ return [{ history, fixedLines: entry.commit.lines / sharers, fixedTestLines: entry.commit.testLines / sharers, fixedAt: Date.parse(entry.commit.date) }];
2679
+ });
2680
+ }
2681
+ function estimateSamples(histories, code) {
2682
+ const seen = /* @__PURE__ */ new Map();
2683
+ for (const history of histories) {
2684
+ const entry = fixEntryOf(history, code);
2685
+ if (entry === void 0 || seen.has(entry.key)) continue;
2686
+ seen.set(entry.key, { category: history.category, lines: entry.commit.lines, testLines: entry.commit.testLines });
2687
+ }
2688
+ return [...seen.values()];
2689
+ }
2690
+ function estimator(samples) {
2691
+ const overall = sizeOf(samples);
2692
+ return (category) => {
2693
+ if (category === void 0) return overall;
2694
+ return sizeOf(samples.filter((sample) => sample.category === category)) ?? overall;
2695
+ };
2696
+ }
2697
+ function sizeOf(samples) {
2698
+ const lines = samples.length < MIN_FIXES_FOR_ESTIMATE ? null : median(samples.map((sample) => sample.lines));
2699
+ if (lines === null) return null;
2700
+ const totalLines = sum(samples.map((sample) => sample.lines));
2701
+ const testShare = totalLines === 0 ? 0 : sum(samples.map((sample) => sample.testLines)) / totalLines;
2702
+ return { lines, testLines: lines * testShare };
2703
+ }
2704
+ function totalsOf2(deferred, units, estimate) {
2705
+ const fixed = deferred.flatMap((item) => item.fixedLines === null ? [] : [item.fixedLines]);
2706
+ const open2 = deferred.filter((item) => item.fixedLines === null);
2707
+ const estimated = estimatedSizeOf(open2, estimate);
2708
+ const estimatedLines = estimated?.lines ?? null;
2709
+ const realLines = sum(units.map((unit) => unit.lines));
2710
+ const fixedLines = sum(fixed);
2711
+ const deferredLines = fixedLines + (estimatedLines ?? 0);
2712
+ const denominator = realLines + (estimatedLines ?? 0);
2713
+ return {
2714
+ realLines,
2715
+ fixedTasks: fixed.length,
2716
+ fixedLines,
2717
+ openTasks: open2.length,
2718
+ estimatedLines,
2719
+ deferredLines,
2720
+ deferredTestLines: sum(deferred.map((item) => item.fixedTestLines)) + (estimated?.testLines ?? 0),
2721
+ noiseShare: realLines === 0 || estimatedLines === null && open2.length > 0 ? null : Math.min(1, deferredLines / denominator)
2722
+ };
2723
+ }
2724
+ function estimatedSizeOf(open2, estimate) {
2725
+ const estimates = open2.map((item) => estimate(item.history.category));
2726
+ if (estimates.some((size) => size === null)) return null;
2727
+ const sizes = estimates.filter((size) => size !== null);
2728
+ return { lines: sum(sizes.map((size) => size.lines)), testLines: sum(sizes.map((size) => size.testLines)) };
2729
+ }
2730
+ function bucketsOf(windows, deferred, periodUnits, estimate) {
2731
+ return windows.map((span) => {
2732
+ const rawRealLines = sum(periodUnits.filter((unit) => span.contains(Date.parse(unit.date))).map((unit) => unit.lines));
2733
+ const fixedInWindow = deferred.filter((item) => item.fixedAt !== null && span.contains(item.fixedAt));
2734
+ const openInWindow = deferred.filter((item) => item.fixedLines === null && span.contains(item.history.createdAt));
2735
+ const fixedLinesInWindow = sum(fixedInWindow.map((item) => item.fixedLines ?? 0));
2736
+ const estimatedInWindow = estimatedSizeOf(openInWindow, estimate);
2737
+ const deferredLines = fixedLinesInWindow + (estimatedInWindow?.lines ?? 0);
2738
+ return {
2739
+ start: formatLocalIso(new Date(span.from)),
2740
+ onTopicLines: Math.max(0, rawRealLines - fixedLinesInWindow),
2741
+ deferredLines,
2742
+ deferredTestLines: sum(fixedInWindow.map((item) => item.fixedTestLines)) + (estimatedInWindow?.testLines ?? 0),
2743
+ deferredTasks: fixedInWindow.length + openInWindow.length
2744
+ };
2745
+ });
2746
+ }
2747
+
2748
+ // src/core/stats/quality/accuracy.ts
2749
+ function accuracy(histories, period2) {
2750
+ const episodes = histories.flatMap(
2751
+ (history) => history.candidates.filter((candidate) => period2.contains(candidate.at)).map((candidate) => ({ evidence: candidate.evidence, outcome: outcomeAfter(history, candidate.at) }))
2752
+ );
2753
+ if (episodes.length === 0) return [];
2754
+ const byEvidence = CANDIDATE_EVIDENCE.flatMap((evidence) => {
2755
+ const own = episodes.filter((episode) => episode.evidence === evidence);
2756
+ return own.length === 0 ? [] : [{ evidence, ...outcomeCounts(own) }];
2757
+ });
2758
+ return [...byEvidence, { evidence: "total", ...outcomeCounts(episodes) }];
2759
+ }
2760
+ function accuracyWeeks(histories, now) {
2761
+ return weekWindows(now).map((week) => {
2762
+ const decided = histories.flatMap(
2763
+ (history) => history.candidates.filter((candidate) => week.contains(candidate.at) && candidate.evidence !== "no-source").map((candidate) => outcomeAfter(history, candidate.at)).filter((outcome) => outcome !== "open")
2764
+ );
2765
+ const closed = decided.filter((outcome) => outcome === "closed").length;
2766
+ return { start: formatLocalIso(new Date(week.from)), decided: decided.length, precision: decided.length === 0 ? null : closed / decided.length };
2767
+ });
2768
+ }
2769
+ function methodAccuracy(histories, period2) {
2770
+ return splitAccuracy(histories, period2, { evidence: "source-changed", keys: RECORDED_METHODS, keyOf: (candidate) => candidate.method });
2771
+ }
2772
+ function matchAccuracy(histories, period2) {
2773
+ return splitAccuracy(histories, period2, { evidence: "duplicate", keys: RECORDED_MATCHES, keyOf: (candidate) => candidate.match });
2774
+ }
2775
+ function splitAccuracy(histories, period2, { evidence, keys, keyOf }) {
2776
+ const episodes = histories.flatMap(
2777
+ (history) => history.candidates.filter((candidate) => candidate.evidence === evidence && period2.contains(candidate.at)).map((candidate) => ({ by: keyOf(candidate), outcome: outcomeAfter(history, candidate.at) }))
2778
+ );
2779
+ return keys.flatMap((by) => {
2780
+ const own = episodes.filter((episode) => episode.by === by);
2781
+ return own.length === 0 ? [] : [{ by, ...outcomeCounts(own) }];
2782
+ });
2783
+ }
2784
+ function outcomeAfter(history, moment) {
2785
+ const closedAt2 = closingsOf(history).find((closing) => closing.at > moment)?.at;
2786
+ const verifiedAt = history.verifications.find((verification) => verification > moment);
2787
+ if (closedAt2 === void 0) return verifiedAt === void 0 ? "open" : "verified";
2788
+ return verifiedAt !== void 0 && verifiedAt < closedAt2 ? "verified" : "closed";
2789
+ }
2790
+ function outcomeCounts(episodes) {
2791
+ const count3 = (outcome) => episodes.filter((episode) => episode.outcome === outcome).length;
2792
+ const closed = count3("closed");
2793
+ const verified = count3("verified");
2794
+ return { candidates: episodes.length, closed, verified, open: count3("open"), precision: closed + verified === 0 ? null : closed / (closed + verified) };
2795
+ }
2796
+
2797
+ // src/core/stats/quality/categories.ts
2798
+ function categoryBreakdown(openTasks, histories, period2) {
2799
+ const rows = /* @__PURE__ */ new Map();
2800
+ const row = (category) => {
2801
+ const key = category ?? null;
2802
+ const existing = rows.get(key);
2803
+ if (existing !== void 0) return existing;
2804
+ const created = { category: key, open: 0, weight: 0, created: 0, closed: 0 };
2805
+ rows.set(key, created);
2806
+ return created;
2807
+ };
2808
+ for (const task of openTasks) {
2809
+ const target = row(task.category);
2810
+ target.open += 1;
2811
+ target.weight += PRIORITY_WEIGHT[task.priority];
2812
+ }
2813
+ for (const history of histories) {
2814
+ if (period2.contains(history.createdAt)) row(history.category).created += 1;
2815
+ row(history.category).closed += closingsOf(history).filter((closing) => period2.contains(closing.at)).length;
2816
+ }
2817
+ return [...rows.values()].filter((entry) => entry.open + entry.created + entry.closed > 0).sort(
2818
+ (a, b) => Number(a.category === null) - Number(b.category === null) || b.weight - a.weight || b.created - a.created || (a.category ?? "").localeCompare(b.category ?? "")
2819
+ );
2820
+ }
2821
+
2822
+ // src/core/stats/quality/graph-filter.ts
2823
+ var SILENT_CLOSINGS = /* @__PURE__ */ new Set(["fixed", "obsolete"]);
2824
+ function graphFilterEffect(histories, period2) {
2825
+ const outcomes = histories.flatMap(
2826
+ (history) => history.filtered.flatMap((at, index) => period2.contains(at) ? [outcomeAfter2(history, at, history.filtered[index + 1] ?? Number.POSITIVE_INFINITY)] : [])
2827
+ );
2828
+ const count3 = (outcome) => outcomes.filter((candidate) => candidate === outcome).length;
2829
+ return { filtered: outcomes.length, caught: count3("caught"), missed: count3("missed"), quiet: count3("quiet") };
2830
+ }
2831
+ function outcomeAfter2(history, from, until) {
2832
+ const within = (at) => at > from && at <= until;
2833
+ const candidate = history.candidates.find((seen) => seen.evidence === "source-changed" && within(seen.at));
2834
+ const closing = closingsOf(history).find((transition) => within(transition.at));
2835
+ if (candidate !== void 0 && (closing === void 0 || candidate.at <= closing.at)) return "caught";
2836
+ if (closing === void 0 || !SILENT_CLOSINGS.has(closing.resolution)) return "quiet";
2837
+ const taken = history.transitions.some((transition) => transition.to === "in-progress" && transition.at > from && transition.at < closing.at);
2838
+ return taken ? "quiet" : "missed";
2839
+ }
2840
+
2841
+ // src/core/stats/quality/origin.ts
2842
+ var BRANCH_LIMIT = 8;
2843
+ var FOUND_ORDER = [...FOUND_HOW, null];
2844
+ function foundBreakdown(histories, period2) {
2845
+ const created = createdIn(histories, period2);
2846
+ return FOUND_ORDER.map((found) => {
2847
+ const own = created.filter((history) => (history.found ?? null) === found);
2848
+ return {
2849
+ found,
2850
+ created: own.length,
2851
+ open: own.filter(isOpenNow).length,
2852
+ fixed: own.filter(isFixedNow).length
2853
+ };
2854
+ });
2855
+ }
2856
+ function branchBreakdown(histories, period2, withProject) {
2857
+ const rows = /* @__PURE__ */ new Map();
2858
+ for (const history of createdIn(histories, period2)) {
2859
+ if (history.branch === void 0) continue;
2860
+ const label = projectLabel(history.projectId, history.branch, withProject);
2861
+ const current = rows.get(label) ?? { label, created: 0, open: 0 };
2862
+ rows.set(label, { label, created: current.created + 1, open: current.open + (isOpenNow(history) ? 1 : 0) });
2863
+ }
2864
+ return [...rows.values()].sort((a, b) => b.created - a.created || a.label.localeCompare(b.label)).slice(0, BRANCH_LIMIT);
2865
+ }
2866
+ function createdIn(histories, period2) {
2867
+ return histories.filter((history) => period2.contains(history.createdAt));
2868
+ }
2869
+ function isOpenNow(history) {
2870
+ return !isClosed(history.finalStatus);
2871
+ }
2872
+
2873
+ // src/core/stats/quality/quality-report.ts
2874
+ function qualityReport(input, base = reportBase(input), graphs = []) {
2875
+ const { now, projectId } = input;
2876
+ const { histories, openTasks } = base;
2877
+ const period2 = statsPeriod(now);
2878
+ return {
2879
+ ...base.head,
2880
+ accuracy: accuracy(histories, period2),
2881
+ accuracyWeeks: accuracyWeeks(histories, now),
2882
+ methodAccuracy: methodAccuracy(histories, period2),
2883
+ matchAccuracy: matchAccuracy(histories, period2),
2884
+ graph: { projects: graphs, filter: graphFilterEffect(histories, period2) },
2885
+ categories: categoryBreakdown(openTasks, histories, period2),
2886
+ found: foundBreakdown(histories, period2),
2887
+ branches: branchBreakdown(histories, period2, projectId === void 0)
2888
+ };
2889
+ }
2890
+
2891
+ // src/core/stats/report.ts
2892
+ var STALE_DAYS = 30;
2893
+ function statsReport(input, base = reportBase(input)) {
2894
+ const { now, projectId } = input;
2895
+ const { histories, openTasks } = base;
2896
+ const period2 = statsPeriod(now);
2897
+ return {
2898
+ ...base.head,
2899
+ totals: totals(openTasks, histories, now, period2, base.scope.journalStart),
2900
+ weeks: weeklyFlow(histories, now),
2901
+ days: dailyIntake(histories, now),
2902
+ hotspots: hotspots(openTasks, projectId === void 0),
2903
+ age: ageBreakdown(openTasks, now),
2904
+ closing: closingBreakdown(histories, period2)
2905
+ };
2906
+ }
2907
+ function totals(openTasks, histories, now, period2, journalStart) {
2908
+ const nowMs = now.getTime();
2909
+ const inLastWeek = (moment) => moment > nowMs - WEEK_MS && moment <= nowMs;
2910
+ const ages = openTasks.map((task) => daysBetween(Date.parse(task.created), nowMs));
2911
+ const leadTimes = histories.flatMap(
2912
+ (history) => closingsOf(history).filter((closing) => period2.contains(closing.at)).map((closing) => daysBetween(history.createdAt, closing.at))
2913
+ );
2914
+ const today = formatLocalDay(now);
2915
+ return {
2916
+ open: openTasks.length,
2917
+ createdToday: histories.filter((history) => formatLocalDay(new Date(history.createdAt)) === today).length,
2918
+ closedToday: histories.flatMap(closingsOf).filter((closing) => formatLocalDay(new Date(closing.at)) === today).length,
2919
+ openWeight: sum(openTasks.map((task) => PRIORITY_WEIGHT[task.priority])),
2920
+ createdLastWeek: histories.filter((history) => inLastWeek(history.createdAt)).length,
2921
+ closedLastWeek: histories.flatMap(closingsOf).filter((closing) => inLastWeek(closing.at)).length,
2922
+ ageMedianDays: median(ages),
2923
+ olderThan30Days: ages.filter((age) => age >= STALE_DAYS).length,
2924
+ leadTimeMedianDays: median(leadTimes),
2925
+ leadTimeP90Days: nearestRank(leadTimes, TAIL_FRACTION),
2926
+ previous: previousTotals(histories, nowMs, journalStart)
2927
+ };
2928
+ }
2929
+ function previousTotals(histories, nowMs, journalStart) {
2930
+ const weekAgo = nowMs - WEEK_MS;
2931
+ if (journalStart === null || journalStart > weekAgo) return null;
2932
+ const inWeekBefore = (moment) => moment > weekAgo - WEEK_MS && moment <= weekAgo;
2933
+ const openThen = histories.filter((history) => isOpenAt(history, weekAgo));
2934
+ const closedThen = histories.flatMap(closingsOf).filter((closing) => inWeekBefore(closing.at));
2935
+ const leadTimes = histories.flatMap((history) => closingsOf(history).filter((closing) => inWeekBefore(closing.at)).map((closing) => daysBetween(history.createdAt, closing.at)));
2936
+ return {
2937
+ open: openThen.length,
2938
+ net: histories.filter((history) => inWeekBefore(history.createdAt)).length - closedThen.length,
2939
+ ageMedianDays: median(openThen.map((history) => daysBetween(history.createdAt, weekAgo))),
2940
+ leadTimeMedianDays: median(leadTimes)
2941
+ };
2942
+ }
2943
+
2944
+ // src/core/stats/flow/current.ts
2945
+ function inWorkTasks(tasks, histories, now) {
2946
+ const nowMs = now.getTime();
2947
+ const historyById = new Map(histories.map((history) => [history.id, history]));
2948
+ return tasks.flatMap((task) => isWorkStatus(task.status) ? [{ task, status: task.status }] : []).map(({ task, status }) => {
2949
+ const entered = historyById.get(task.id)?.transitions.filter((transition) => transition.to === status && transition.at <= nowMs).at(-1)?.at;
2950
+ return {
2951
+ id: task.id,
2952
+ projectId: task.projectId,
2953
+ title: task.title,
2954
+ status,
2955
+ days: daysBetween(entered ?? Date.parse(task.created), nowMs),
2956
+ atLeast: entered === void 0
2957
+ };
2958
+ }).sort((a, b) => b.days - a.days || compareIds(a.id, b.id));
2959
+ }
2960
+ function isWorkStatus(status) {
2961
+ return status === "in-progress" || status === "blocked";
2962
+ }
2963
+
2964
+ // src/core/stats/signals/signals.ts
2965
+ var GROWTH_WEEKS = 3;
2966
+ var STUCK_IN_PROGRESS_DAYS = 7;
2967
+ var STUCK_BLOCKED_DAYS = 14;
2968
+ var NOISY_MIN_DECIDED = 10;
2969
+ var NOISY_MAX_PERCENT = 20;
2970
+ var NOISY_WINDOW_DAYS = 14;
2971
+ function statsSignals(input, base = reportBase(input)) {
2972
+ const { now } = input;
2973
+ return [
2974
+ ...debtGrowing(weeklyFlow(base.histories, now)),
2975
+ ...urgentStale(urgentStaleCount(base.openTasks, now)),
2976
+ ...stuck(base, now),
2977
+ ...noisyChecks(base, now),
2978
+ ...staleLow(base, now)
2979
+ ];
2980
+ }
2981
+ function debtGrowing(weeks) {
2982
+ const recent = weeks.slice(-GROWTH_WEEKS);
2983
+ if (recent.length < GROWTH_WEEKS || !recent.every((week) => week.created > week.closed)) return [];
2984
+ const created = sum(recent.map((week) => week.created));
2985
+ const closed = sum(recent.map((week) => week.closed));
2986
+ return [{ kind: "debt-growing", params: { weeks: GROWTH_WEEKS, created, closed } }];
2987
+ }
2988
+ function urgentStale(count3) {
2989
+ return count3 === 0 ? [] : [{ kind: "urgent-stale", params: { days: STALE_URGENT_DAYS, count: count3 } }];
2990
+ }
2991
+ function stuck({ scope, tasks }, now) {
2992
+ const stuckTasks = inWorkTasks(tasks, scope.histories, now).filter(
2993
+ (item) => item.days > (item.status === "blocked" ? STUCK_BLOCKED_DAYS : STUCK_IN_PROGRESS_DAYS)
2994
+ );
2995
+ const longest = stuckTasks[0];
2996
+ if (longest === void 0) return [];
2997
+ return [{ kind: "stuck", params: { count: stuckTasks.length, id: longest.id, days: longest.days } }];
2998
+ }
2999
+ function staleLow({ scope }, now) {
3000
+ const stale = staleLowTasks(scope.tasks, now);
3001
+ return stale.length === 0 ? [] : [{ kind: "stale-low", params: { days: STALE_LOW_DAYS, count: stale.length } }];
3002
+ }
3003
+ function noisyChecks({ histories }, now) {
3004
+ return checkGauges(histories, period(now.getTime() - NOISY_WINDOW_DAYS * DAY_MS, now.getTime())).flatMap((gauge) => {
3005
+ const decided = gauge.closed + gauge.verified;
3006
+ if (decided < NOISY_MIN_DECIDED || gauge.precision === null) return [];
3007
+ const percent = Math.round(gauge.precision * 100);
3008
+ if (percent >= NOISY_MAX_PERCENT) return [];
3009
+ return [{ kind: "noisy-check", params: { evidence: gauge.evidence, method: gauge.method, percent, decided, windowDays: NOISY_WINDOW_DAYS } }];
3010
+ });
3011
+ }
3012
+ function checkGauges(histories, window) {
3013
+ const byEvidence = accuracy(histories, window).filter((row) => measuredByClosing(row.evidence) && row.evidence !== "source-changed").map((row) => ({ ...row, method: null }));
3014
+ const byFile = methodAccuracy(histories, window).flatMap(
3015
+ (row) => row.by === "file" ? [{ ...row, evidence: "source-changed", method: "file" }] : []
3016
+ );
3017
+ return [...byFile, ...byEvidence];
3018
+ }
3019
+ function measuredByClosing(evidence) {
3020
+ return evidence !== "total" && evidence !== "no-source";
3021
+ }
3022
+
3023
+ // src/core/store/resolve-project.ts
3024
+ import { realpathSync as realpathSync2 } from "node:fs";
3025
+ import { realpath } from "node:fs/promises";
3026
+ import { isAbsolute, relative, sep } from "node:path";
3027
+ var REPO_ROOT_TTL_MS = 6e4;
3028
+ var SHOW_TOP_LEVEL = ["rev-parse", "--show-toplevel"];
3029
+ function cachedRepoRoots({ ttlMs = REPO_ROOT_TTL_MS, now = Date.now } = {}) {
3030
+ const known = /* @__PURE__ */ new Map();
3031
+ return (dir) => {
3032
+ const cached = known.get(dir);
3033
+ if (cached !== void 0 && now() - cached.checkedAt < ttlMs) return cached.root;
3034
+ const root = repoRootOrNull(dir);
3035
+ known.set(dir, { root, checkedAt: now() });
3036
+ return root;
3037
+ };
3038
+ }
3039
+ async function repoRootOrNull(dir) {
3040
+ const topLevel = topLevelOf(await runGit(dir, SHOW_TOP_LEVEL));
3041
+ return realpath(topLevel ?? dir).catch(() => null);
3042
+ }
3043
+ function topLevelOf(output) {
3044
+ const topLevel = output?.trim() ?? "";
3045
+ return topLevel === "" ? null : topLevel;
3046
+ }
3047
+ function findProjectForRepoRoot(projects, root, home2) {
3048
+ const matches = projects.flatMap(
3049
+ (project) => project.repos.flatMap((repo) => {
3050
+ const repoPath = realpathOrNull(expandHome(repo, home2));
3051
+ return repoPath !== null && isSameOrInside(root, repoPath) ? [{ project, repoPath }] : [];
3052
+ })
3053
+ );
3054
+ return matches.sort((a, b) => b.repoPath.length - a.repoPath.length)[0]?.project;
3055
+ }
3056
+ function isSameOrInside(path, container) {
3057
+ const relation = relative(container, path);
3058
+ return relation === "" || !isAbsolute(relation) && relation.split(sep)[0] !== "..";
3059
+ }
3060
+ function realpathOrNull(path) {
3061
+ try {
3062
+ return realpathSync2(path);
3063
+ } catch {
3064
+ return null;
3065
+ }
3066
+ }
3067
+
3068
+ // src/server/stats-api.ts
3069
+ var REPORT_TTL_MS = 5 * 60 * 1e3;
3070
+ function createStatsApi({ root, now, home: home2, usage, memory, backlog }) {
3071
+ const routes = new Hono();
3072
+ const reports = createReportCache({ ttlMs: REPORT_TTL_MS, now: () => now().getTime() });
3073
+ const onCodeSourceError = (kind, error) => void serverLanguage(root).then((language) => {
3074
+ const messages = serverMessages(language);
3075
+ const text = kind === "read" ? messages.codeCacheReadFailed(errorText(error)) : messages.codeCacheWriteFailed(errorText(error));
3076
+ process.stderr.write(`${text}
3077
+ `);
3078
+ });
3079
+ const codeSource = createCodeSource({ home: home2, store: createCodeCacheFile(root), onError: onCodeSourceError });
3080
+ const lookupRepoRoot = cachedRepoRoots();
3081
+ const statsScopeOf = async (c, { wholeBacklog }) => {
3082
+ const projectId = c.req.query("project") || void 0;
3083
+ const { projects, tasks } = await backlog();
3084
+ if (projectId !== void 0 && !projects.some((project) => project.id === projectId)) {
3085
+ return c.json({ errors: [serverMessages(await serverLanguage(root)).projectNotFound(projectId)] }, 404);
3086
+ }
3087
+ const included = (project) => projectId === void 0 ? project.active : wholeBacklog || project.id === projectId;
3088
+ const scoped = projects.filter(included);
3089
+ const scopedIds = new Set(scoped.map((project) => project.id));
3090
+ return { projectId, projects: scoped, tasks: tasks.filter((task) => scopedIds.has(task.projectId)) };
3091
+ };
3092
+ const scopedStats = (name, report, { sourceKey, wholeBacklog = false } = {}) => async (c) => {
3093
+ const scope = await statsScopeOf(c, { wholeBacklog });
3094
+ if (scope instanceof Response) return scope;
3095
+ const moment = now();
3096
+ const key = [name, scope.projectId ?? "*", formatLocalDay(moment), sourceKey === void 0 ? "" : await sourceKey(scope.projects)].join("|");
3097
+ const result = await reports.get(key, async () => {
3098
+ const journals = await readJournals(root, scope.projects.map((project) => project.id));
3099
+ const input = { tasks: scope.tasks, journals, now: moment, projectId: scope.projectId };
3100
+ return report(input, reportBase(input), scope.projects);
3101
+ });
3102
+ return c.json(result);
3103
+ };
3104
+ const statsOfCode = async (input, base, projects) => codeReport({ ...input, code: await codeSource.collect(projects, input.now) }, base);
3105
+ const statsOfEffect = async (input, base, projects) => {
3106
+ const backlogBase = input.projectId === void 0 ? base : reportBase({ ...input, projectId: void 0 });
3107
+ const scoped = projects.filter((project) => input.projectId === void 0 || project.id === input.projectId);
3108
+ const code = await codeSource.collect(scoped, input.now);
3109
+ const fixCommits = await codeSource.fixCommits(projects, codeFixRequests({ ...input, projectId: void 0 }, backlogBase), input.now);
3110
+ return effectReport({ ...input, code: { ...code, fixCommits } }, base, backlogBase);
3111
+ };
3112
+ const statsOfQuality = async (input, base, projects) => qualityReport(input, base, await projectGraphs(projects, input.tasks, home2));
3113
+ const statsOfCost = async (projectId, projects) => {
3114
+ usage.ensureStarted();
3115
+ const { cache, scan } = usage.snapshot();
3116
+ const buckets = bucketsOf2(cache);
3117
+ const runs = await readRuns(root);
3118
+ const repoRoots = projectId === void 0 ? /* @__PURE__ */ new Map() : await resolveRepoRoots(lookupRepoRoot, [...buckets, ...runs].map((entry) => entry.cwd));
3119
+ const projectOf = (cwd) => {
3120
+ const repoRoot = repoRoots.get(cwd) ?? null;
3121
+ return repoRoot === null ? null : findProjectForRepoRoot(projects, repoRoot, home2)?.id ?? null;
3122
+ };
3123
+ return costReport({ buckets, runs, projectOf, projectId, now: now(), scan });
3124
+ };
3125
+ const codeState = { sourceKey: (projects) => codeSource.stateKey(projects) };
3126
+ routes.get("/stats", scopedStats("stats", (input, base) => statsReport(input, base)));
3127
+ routes.get("/stats/code", scopedStats("code", statsOfCode, codeState));
3128
+ routes.get("/stats/effect", scopedStats("effect", statsOfEffect, { ...codeState, wholeBacklog: true }));
3129
+ routes.get("/stats/quality", scopedStats("quality", statsOfQuality));
3130
+ routes.get("/stats/signals", scopedStats("signals", (input, base) => ({ signals: statsSignals(input, base) })));
3131
+ routes.get("/stats/cost", async (c) => {
3132
+ const scope = await statsScopeOf(c, { wholeBacklog: true });
3133
+ return scope instanceof Response ? scope : c.json(await statsOfCost(scope.projectId, scope.projects));
3134
+ });
3135
+ routes.get("/stats/memory", (c) => c.json({ samples: memory.samples() }));
3136
+ return { routes, forget: () => reports.clear() };
3137
+ }
3138
+ async function projectGraphs(projects, tasks, home2) {
3139
+ return Promise.all(
3140
+ projects.map(async (project) => ({ projectId: project.id, name: project.name, ...await projectGraphHealth(project, tasks, home2) }))
3141
+ );
3142
+ }
3143
+ function bucketsOf2(cache) {
3144
+ return Object.values(cache.files).flatMap((entry) => entry.buckets);
3145
+ }
3146
+ async function resolveRepoRoots(lookupRepoRoot, cwds) {
3147
+ const unique = [...new Set(cwds)];
3148
+ return new Map(await Promise.all(unique.map(async (cwd) => [cwd, await lookupRepoRoot(cwd)])));
3149
+ }
3150
+
3151
+ // src/server/api.ts
3152
+ var GRAPH_STATE_TTL_MS = 60 * 1e3;
3153
+ function createApi({ root, changes, now, home: home2, usage, memory }) {
3154
+ const api = new Hono2();
3155
+ let snapshot = null;
3156
+ const readLanguage = () => serverLanguage(root);
3157
+ const backlog = () => {
3158
+ snapshot ??= loadSnapshot(root).catch((error) => {
3159
+ snapshot = null;
3160
+ throw error;
3161
+ });
3162
+ return snapshot;
3163
+ };
3164
+ const stats = createStatsApi({ root, now, home: home2, usage, memory, backlog });
3165
+ const graphStates = createReportCache({ ttlMs: GRAPH_STATE_TTL_MS, now: () => now().getTime() });
3166
+ const forgetBacklog = () => {
3167
+ snapshot = null;
3168
+ stats.forget();
3169
+ };
3170
+ changes.subscribe(forgetBacklog);
3171
+ api.get("/projects", async (c) => {
3172
+ const { projects, tasks } = await backlog();
3173
+ const withGraph = async (project) => {
3174
+ const codeGraph2 = await graphStates.get(project.id, async () => (await projectGraphHealth(project, tasks, home2)).state);
3175
+ return { ...project, codeGraph: codeGraph2 };
3176
+ };
3177
+ return c.json(await Promise.all(projects.map(withGraph)));
3178
+ });
3179
+ api.get("/tasks", async (c) => {
3180
+ const [{ tasks, errors }, language] = await Promise.all([backlog(), readLanguage()]);
3181
+ const messages = coreMessages(language);
3182
+ return c.json({ tasks, errors: errors.map(({ problems: problems3, ...error }) => ({ ...error, message: messages.problems(problems3) })) });
3183
+ });
3184
+ api.route("/", stats.routes);
3185
+ api.get("/settings", async (c) => c.json({ language: await readLanguage() }));
3186
+ api.patch("/settings", async (c) => {
3187
+ const body = await readBody(c, settingsSchema2, readLanguage);
3188
+ if (!body.ok) return body.response;
3189
+ await writeSettings(root, body.data);
3190
+ return c.json({ language: body.data.language });
3191
+ });
3192
+ api.patch("/tasks/:id", async (c) => {
3193
+ const body = await readBody(c, updateTaskRequestSchema, readLanguage);
3194
+ if (!body.ok) return body.response;
3195
+ const id = c.req.param("id");
3196
+ const { index } = await backlog();
3197
+ const result = await updateTaskInIndex(index, { id, changes: body.data.changes, expectedVersion: body.data.version, now: now(), via: "web" });
3198
+ forgetBacklog();
3199
+ if (result.ok) return c.json(result.task);
3200
+ const messages = serverMessages(body.language);
3201
+ if (result.reason === "not-found") return c.json({ errors: [messages.taskNotFound(id)] }, 404);
3202
+ if (result.reason === "conflict") return c.json({ errors: [messages.taskChangedOnDisk], current: result.current }, 409);
3203
+ return invalidResponse(c, result, coreMessages(body.language));
3204
+ });
3205
+ api.patch("/projects/:id", async (c) => {
3206
+ const body = await readBody(c, projectActiveSchema, readLanguage);
3207
+ if (!body.ok) return body.response;
3208
+ const id = c.req.param("id");
3209
+ const result = await setProjectActive(root, id, body.data.active);
3210
+ forgetBacklog();
3211
+ if (result.ok) return c.json(result.project);
3212
+ if (result.reason === "invalid") return c.json({ errors: [coreMessages(body.language).problems(result.problems)] }, 422);
3213
+ return c.json({ errors: [serverMessages(body.language).projectNotFound(id)] }, 404);
3214
+ });
3215
+ api.delete("/projects/:id", async (c) => {
3216
+ const body = await readBody(c, projectDeleteSchema, readLanguage);
3217
+ if (!body.ok) return body.response;
3218
+ const id = c.req.param("id");
3219
+ const messages = serverMessages(body.language);
3220
+ if (body.data.confirm !== id) return c.json({ errors: [messages.confirmMismatch] }, 422);
3221
+ const result = await deleteProject(root, id);
3222
+ forgetBacklog();
3223
+ return result.ok ? c.json({ deleted: id }) : c.json({ errors: [messages.projectNotFound(id)] }, 404);
3224
+ });
3225
+ api.get(
3226
+ "/events",
3227
+ (c) => streamSSE(c, async (stream) => {
3228
+ const unsubscribe = changes.subscribe(() => void stream.writeSSE({ event: "change", data: "" }));
3229
+ await new Promise((resolve) => {
3230
+ stream.onAbort(() => {
3231
+ unsubscribe();
3232
+ resolve();
3233
+ });
3234
+ });
3235
+ })
3236
+ );
3237
+ return api;
3238
+ }
3239
+ async function loadSnapshot(root) {
3240
+ const loaded = await loadBacklog(root);
3241
+ return { ...loaded, index: buildIndex(loaded.tasks) };
3242
+ }
3243
+ function invalidResponse(c, result, messages) {
3244
+ return c.json({ errors: result.errors.map(messages.problem) }, 422);
3245
+ }
3246
+ async function readBody(c, schema, readLanguage) {
3247
+ const [body, language] = await Promise.all([readJson(c), readLanguage()]);
3248
+ if (body.ok === false) return { ok: false, response: c.json({ errors: [serverMessages(language).bodyNotParsed] }, 400) };
3249
+ const parsed = parseWithLocale(schema, body.value, language);
3250
+ return parsed.ok ? { ok: true, data: parsed.value, language } : { ok: false, response: c.json({ errors: parsed.errors }, 422) };
3251
+ }
3252
+ async function readJson(c) {
3253
+ try {
3254
+ return { ok: true, value: await c.req.json() };
3255
+ } catch {
3256
+ return { ok: false };
3257
+ }
3258
+ }
3259
+
3260
+ // src/server/guards.ts
3261
+ var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PATCH", "PUT", "DELETE"]);
3262
+ function localHosts(port) {
3263
+ return /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
3264
+ }
3265
+ function allowLocalHostsOnly(allowedHosts, root) {
3266
+ return async (c, next) => {
3267
+ const { host } = new URL(c.req.url);
3268
+ if (!allowedHosts.has(host)) {
3269
+ const messages = serverMessages(await serverLanguage(root));
3270
+ return c.json({ errors: [messages.hostRejected(host)] }, 403);
3271
+ }
3272
+ await next();
3273
+ return void 0;
3274
+ };
3275
+ }
3276
+ function requireJsonBody(root) {
3277
+ return async (c, next) => {
3278
+ if (MUTATING_METHODS.has(c.req.method) && !c.req.header("content-type")?.startsWith("application/json")) {
3279
+ const messages = serverMessages(await serverLanguage(root));
3280
+ return c.json({ errors: [messages.jsonContentTypeExpected] }, 415);
3281
+ }
3282
+ await next();
3283
+ return void 0;
3284
+ };
3285
+ }
3286
+
3287
+ // src/server/app.ts
3288
+ function createApp({ root, changes, allowedHosts, home: home2, usage, memory, staticDir, now = () => /* @__PURE__ */ new Date() }) {
3289
+ const app = new Hono3();
3290
+ app.use("*", allowLocalHostsOnly(allowedHosts, root));
3291
+ app.use("/api/*", requireJsonBody(root));
3292
+ app.route("/api", createApi({ root, changes, now, home: home2, usage, memory }));
3293
+ app.all("/api/*", async (c) => c.json({ errors: [serverMessages(await serverLanguage(root)).unknownRoute(new URL(c.req.url).pathname)] }, 404));
3294
+ app.onError(async (error, c) => {
3295
+ const text = error instanceof FileBusyError ? coreMessages(await serverLanguage(root)).fileBusy(error.path, error.lock, error.seconds) : errorText(error);
3296
+ return c.json({ errors: [text] }, 500);
3297
+ });
3298
+ if (staticDir !== void 0) {
3299
+ app.use("*", serveStatic({ root: staticDir }));
3300
+ app.get("*", serveStatic({ path: join14(staticDir, "index.html") }));
3301
+ }
3302
+ return app;
3303
+ }
3304
+
3305
+ // src/server/change-feed.ts
3306
+ import { relative as relative2, sep as sep2 } from "node:path";
3307
+ import { watch } from "chokidar";
3308
+ var CHANGE_DEBOUNCE_MS = 100;
3309
+ function createDebouncer(delayMs, run) {
3310
+ let timer;
3311
+ return {
3312
+ schedule: () => {
3313
+ clearTimeout(timer);
3314
+ timer = setTimeout(run, delayMs);
3315
+ },
3316
+ cancel: () => clearTimeout(timer)
3317
+ };
3318
+ }
3319
+ function isHiddenPath(root, path) {
3320
+ return relative2(root, path).split(sep2).some((segment) => segment.startsWith("."));
3321
+ }
3322
+ function createChangeFeed(root, debounceMs = CHANGE_DEBOUNCE_MS, messages = () => Promise.resolve(serverRu)) {
3323
+ const listeners = /* @__PURE__ */ new Set();
3324
+ const debouncer = createDebouncer(debounceMs, () => {
3325
+ for (const listener of listeners) listener();
3326
+ });
3327
+ const watcher = watch(root, { ignoreInitial: true, ignored: (path) => isHiddenPath(root, path) });
3328
+ watcher.on("all", () => debouncer.schedule());
3329
+ watcher.on("error", (error) => {
3330
+ void messages().then((texts) => process.stderr.write(`${texts.watcherError(root, errorText(error))}
3331
+ `));
3332
+ });
3333
+ return {
3334
+ subscribe: (listener) => {
3335
+ listeners.add(listener);
3336
+ return () => listeners.delete(listener);
3337
+ },
3338
+ close: async () => {
3339
+ debouncer.cancel();
3340
+ listeners.clear();
3341
+ await watcher.close();
3342
+ }
3343
+ };
3344
+ }
3345
+
3346
+ // src/core/api/memory.ts
3347
+ var MEMORY_SAMPLE_INTERVAL_MS = 5e3;
3348
+ var MEMORY_HISTORY_MS = 60 * 60 * 1e3;
3349
+ function megabytesOf(bytes) {
3350
+ return Math.round(bytes / 1024 / 1024 * 10) / 10;
3351
+ }
3352
+
3353
+ // src/server/memory-sampler.ts
3354
+ var DEFAULT_CAPACITY = MEMORY_HISTORY_MS / MEMORY_SAMPLE_INTERVAL_MS;
3355
+ function createMemorySampler({ intervalMs = MEMORY_SAMPLE_INTERVAL_MS, capacity = DEFAULT_CAPACITY, now = () => /* @__PURE__ */ new Date() } = {}) {
3356
+ let points = [];
3357
+ let timer = null;
3358
+ const sample = () => {
3359
+ const usage = process.memoryUsage();
3360
+ const point = { at: formatLocalIso(now()), rssMb: megabytesOf(usage.rss), heapUsedMb: megabytesOf(usage.heapUsed) };
3361
+ points = [...points, point].slice(-capacity);
3362
+ };
3363
+ return {
3364
+ start: () => {
3365
+ sample();
3366
+ timer = setInterval(sample, intervalMs);
3367
+ },
3368
+ stop: () => {
3369
+ if (timer !== null) clearInterval(timer);
3370
+ timer = null;
3371
+ },
3372
+ sample,
3373
+ samples: () => points
3374
+ };
3375
+ }
3376
+
3377
+ // src/server/sweeper.ts
3378
+ function startSweeper({ sweep, intervalMs, log, warn, messages = () => Promise.resolve(serverRu) }) {
3379
+ let current = Promise.resolve();
3380
+ const run = async () => {
3381
+ let texts = serverRu;
3382
+ try {
3383
+ texts = await messages();
3384
+ logReport(await sweep(), texts, log);
3385
+ } catch (error) {
3386
+ warn(texts.sweepFailed(errorText(error)));
3387
+ }
3388
+ };
3389
+ const trigger = () => {
3390
+ current = run();
3391
+ };
3392
+ trigger();
3393
+ const timer = setInterval(trigger, intervalMs);
3394
+ return () => {
3395
+ clearInterval(timer);
3396
+ return current;
3397
+ };
3398
+ }
3399
+ function logReport({ closedEpics, blockingFiles, deleted, conflicts, invalid: invalid2 }, messages, log) {
3400
+ if (closedEpics.length > 0) log(messages.closedEpics(closedEpics.join(", ")));
3401
+ if (blockingFiles.length > 0) log(messages.epicsBlockedByFiles(blockingFiles.join(", ")));
3402
+ if (deleted.length > 0) log(messages.deletedClosedTasks(deleted.join(", ")));
3403
+ if (conflicts.length > 0) log(messages.conflictedDuringSweep(conflicts.join(", ")));
3404
+ if (invalid2.length > 0) {
3405
+ log(messages.invalidAfterSweep(invalid2.map(({ id, errors }) => `${id} (${errors.join("; ")})`).join(", ")));
3406
+ }
3407
+ }
3408
+
3409
+ // src/core/usage/transcripts.ts
3410
+ import { createHash as createHash3 } from "node:crypto";
3411
+ import { open, stat as stat2 } from "node:fs/promises";
3412
+ import { join as join16 } from "node:path";
3413
+
3414
+ // src/core/stats/cost/attribute.ts
3415
+ import { z as z10 } from "zod";
3416
+ var FAST_SPEED = "fast";
3417
+ var CHARS_PER_TOKEN = 3;
3418
+ var PENDING_TOOL_LIMIT = 64;
3419
+ var ZONE_OFFSET_STEP_MS = 15 * 60 * 1e3;
3420
+ var BACKLOG_COMMAND = /(?:^|&&|\|\||;|\||\n)\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*backlog(?=\s|$)/;
3421
+ var ZERO_TOKENS = { input: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0, output: 0 };
3422
+ var usageSchema = z10.object({
3423
+ input_tokens: z10.number().optional(),
3424
+ output_tokens: z10.number().optional(),
3425
+ cache_read_input_tokens: z10.number().optional(),
3426
+ cache_creation_input_tokens: z10.number().optional(),
3427
+ speed: z10.string().optional(),
3428
+ cache_creation: z10.object({ ephemeral_5m_input_tokens: z10.number().optional(), ephemeral_1h_input_tokens: z10.number().optional() }).passthrough().optional()
3429
+ }).passthrough();
3430
+ var assistantMessageSchema = z10.object({ id: z10.string().optional(), model: z10.string().optional(), usage: usageSchema.optional(), content: z10.array(z10.unknown()).optional() }).passthrough();
3431
+ var userMessageSchema = z10.object({ content: z10.unknown().optional() }).passthrough();
3432
+ var toolUseBlockSchema = z10.object({ type: z10.literal("tool_use"), id: z10.string(), name: z10.string(), input: z10.unknown().optional() }).passthrough();
3433
+ var toolResultBlockSchema = z10.object({ type: z10.literal("tool_result"), tool_use_id: z10.string().optional(), content: z10.unknown().optional() }).passthrough();
3434
+ var textBlockSchema = z10.object({ type: z10.literal("text"), text: z10.string() }).passthrough();
3435
+ var bashInputSchema = z10.object({ command: z10.string() }).passthrough();
3436
+ var lineSchema = z10.object({ type: z10.string().optional(), timestamp: z10.string().optional(), cwd: z10.string().optional(), isMeta: z10.boolean().optional(), message: z10.unknown().optional() }).passthrough();
3437
+ function newTranscriptState() {
3438
+ return { hookOpen: false, lastModel: null, lastMessageId: null, pending: {}, pendingEstimates: [] };
3439
+ }
3440
+ function attributeLine(line, state) {
3441
+ const parsed = lineSchema.safeParse(line);
3442
+ if (!parsed.success) return [];
3443
+ const { type, timestamp, cwd, isMeta, message } = parsed.data;
3444
+ const context = { slot: typeof timestamp === "string" ? slotOf(timestamp) : "", cwd: cwd ?? "" };
3445
+ if (type === "assistant") return attributeAssistant(message, state, context);
3446
+ if (type === "user") return attributeUser(message, isMeta === true, state, context);
3447
+ return [];
3448
+ }
3449
+ function attributeAssistant(rawMessage, state, context) {
3450
+ const message = assistantMessageSchema.safeParse(rawMessage);
3451
+ if (!message.success) return [];
3452
+ const { id, model, usage, content } = message.data;
3453
+ const buckets = [];
3454
+ const repeatOfCountedMessage = id !== void 0 && id === state.lastMessageId;
3455
+ if (model && model !== "<synthetic>" && usage && !repeatOfCountedMessage) {
3456
+ const pricedModel = usage.speed === FAST_SPEED ? fastModel(model) : model;
3457
+ state.lastModel = pricedModel;
3458
+ state.lastMessageId = id ?? null;
3459
+ const tokens = tokensFrom(usage);
3460
+ if (state.hookOpen) buckets.push({ ...context, model: pricedModel, kind: "hook", tokens, hookTurns: 0 });
3461
+ buckets.push(...drainEstimates(state, pricedModel));
3462
+ }
3463
+ if (content) for (const block of content) registerToolUseBlock(block, state);
3464
+ return buckets;
3465
+ }
3466
+ function attributeUser(rawMessage, isMeta, state, context) {
3467
+ const message = userMessageSchema.safeParse(rawMessage);
3468
+ const content = message.success ? message.data.content : void 0;
3469
+ if (isToolResultOnly(content)) {
3470
+ resolveToolResults(content, state, context);
3471
+ return [];
3472
+ }
3473
+ if (isMeta) {
3474
+ const text = textOf(content);
3475
+ if (isBacklogHookFeedback(text)) {
3476
+ state.hookOpen = true;
3477
+ return [{ ...context, model: state.lastModel ?? "unknown", kind: "hook", tokens: ZERO_TOKENS, hookTurns: 1 }];
3478
+ }
3479
+ if (!state.hookOpen && text.startsWith("Base directory for this skill:") && text.includes("/skills/backlog")) {
3480
+ state.pendingEstimates.push({ kind: "skill", chars: text.length, ...context });
3481
+ }
3482
+ return [];
3483
+ }
3484
+ state.hookOpen = false;
3485
+ return [];
3486
+ }
3487
+ function resolveToolResults(blocks, state, context) {
3488
+ for (const raw of blocks) {
3489
+ const block = toolResultBlockSchema.safeParse(raw);
3490
+ if (!block.success || !block.data.tool_use_id) continue;
3491
+ const kind = state.pending[block.data.tool_use_id];
3492
+ if (!kind) continue;
3493
+ if (!state.hookOpen) state.pendingEstimates.push({ kind, chars: textOf(block.data.content).length, ...context });
3494
+ Reflect.deleteProperty(state.pending, block.data.tool_use_id);
3495
+ }
3496
+ }
3497
+ function registerToolUseBlock(raw, state) {
3498
+ const block = toolUseBlockSchema.safeParse(raw);
3499
+ if (!block.success) return;
3500
+ if (block.data.name === "Bash") {
3501
+ const input = bashInputSchema.safeParse(block.data.input);
3502
+ if (!input.success || !BACKLOG_COMMAND.test(input.data.command)) return;
3503
+ state.pending[block.data.id] = "cli";
3504
+ forgetOldestPending(state);
3505
+ }
3506
+ }
3507
+ function forgetOldestPending(state) {
3508
+ const ids = Object.keys(state.pending);
3509
+ for (const id of ids.slice(0, Math.max(0, ids.length - PENDING_TOOL_LIMIT))) Reflect.deleteProperty(state.pending, id);
3510
+ }
3511
+ function flushEstimates(state) {
3512
+ return state.lastModel === null ? [] : drainEstimates(state, state.lastModel);
3513
+ }
3514
+ function drainEstimates(state, model) {
3515
+ const buckets = state.pendingEstimates.map(
3516
+ (estimate) => ({
3517
+ slot: estimate.slot,
3518
+ cwd: estimate.cwd,
3519
+ model,
3520
+ kind: estimate.kind,
3521
+ tokens: { ...ZERO_TOKENS, cacheWrite5m: Math.ceil(estimate.chars / CHARS_PER_TOKEN) },
3522
+ hookTurns: 0
3523
+ })
3524
+ );
3525
+ state.pendingEstimates = [];
3526
+ return buckets;
3527
+ }
3528
+ function isToolResultOnly(content) {
3529
+ return Array.isArray(content) && content.length > 0 && content.every((block) => toolResultBlockSchema.safeParse(block).success);
3530
+ }
3531
+ function textOf(content) {
3532
+ if (typeof content === "string") return content;
3533
+ if (!Array.isArray(content)) return "";
3534
+ return content.map((block) => {
3535
+ const parsed = textBlockSchema.safeParse(block);
3536
+ return parsed.success ? parsed.data.text : "";
3537
+ }).join("");
3538
+ }
3539
+ function tokensFrom(usage) {
3540
+ const creation = usage.cache_creation;
3541
+ const cacheWrite5m = creation ? creation.ephemeral_5m_input_tokens ?? 0 : usage.cache_creation_input_tokens ?? 0;
3542
+ const cacheWrite1h = creation ? creation.ephemeral_1h_input_tokens ?? 0 : 0;
3543
+ return { input: usage.input_tokens ?? 0, cacheWrite5m, cacheWrite1h, cacheRead: usage.cache_read_input_tokens ?? 0, output: usage.output_tokens ?? 0 };
3544
+ }
3545
+ function slotOf(timestamp) {
3546
+ const moment = Date.parse(timestamp);
3547
+ return Number.isNaN(moment) ? "" : new Date(moment - moment % ZONE_OFFSET_STEP_MS).toISOString();
3548
+ }
3549
+
3550
+ // src/core/usage/usage-cache.ts
3551
+ import { mkdir as mkdir2 } from "node:fs/promises";
3552
+ import { join as join15 } from "node:path";
3553
+ import { z as z11 } from "zod";
3554
+ var USAGE_CACHE_FILE = ".usage-cache.json";
3555
+ var USAGE_CACHE_VERSION = 6;
3556
+ var tokenCountsSchema = z11.object({
3557
+ input: z11.number(),
3558
+ cacheWrite5m: z11.number(),
3559
+ cacheWrite1h: z11.number(),
3560
+ cacheRead: z11.number(),
3561
+ output: z11.number()
3562
+ });
3563
+ var usageBucketSchema = z11.object({
3564
+ slot: z11.string(),
3565
+ cwd: z11.string(),
3566
+ model: z11.string(),
3567
+ kind: z11.enum(["hook", "cli", "skill"]),
3568
+ tokens: tokenCountsSchema,
3569
+ hookTurns: z11.number()
3570
+ });
3571
+ var pendingEstimateSchema = z11.object({
3572
+ kind: z11.enum(["cli", "skill"]),
3573
+ chars: z11.number(),
3574
+ slot: z11.string(),
3575
+ cwd: z11.string()
3576
+ });
3577
+ var transcriptStateSchema = z11.object({
3578
+ hookOpen: z11.boolean(),
3579
+ lastModel: z11.string().nullable(),
3580
+ lastMessageId: z11.string().nullable(),
3581
+ pending: z11.record(z11.string(), z11.enum(["cli", "skill"])),
3582
+ pendingEstimates: z11.array(pendingEstimateSchema)
3583
+ });
3584
+ var usageCacheEntrySchema = z11.object({
3585
+ size: z11.number(),
3586
+ offset: z11.number(),
3587
+ fingerprint: z11.string(),
3588
+ state: transcriptStateSchema,
3589
+ buckets: z11.array(usageBucketSchema)
3590
+ });
3591
+ var usageCacheSchema = z11.object({
3592
+ version: z11.literal(USAGE_CACHE_VERSION),
3593
+ files: z11.record(z11.string(), usageCacheEntrySchema)
3594
+ });
3595
+ function emptyUsageCache() {
3596
+ return { version: USAGE_CACHE_VERSION, files: {} };
3597
+ }
3598
+ async function readUsageCache(root) {
3599
+ return await readJsonFile(join15(root, USAGE_CACHE_FILE), usageCacheSchema) ?? emptyUsageCache();
3600
+ }
3601
+ async function writeUsageCache(root, cache) {
3602
+ await mkdir2(root, { recursive: true });
3603
+ await writeFileAtomic(join15(root, USAGE_CACHE_FILE), JSON.stringify(cache));
3604
+ }
3605
+
3606
+ // src/core/usage/transcripts.ts
3607
+ var NEWLINE = 10;
3608
+ var FINGERPRINT_BYTES = 256;
3609
+ var COUNTED_LINE_MARKERS = ['"type":"assistant"', '"type":"user"'];
3610
+ async function listTranscripts(claudeProjectsDir2) {
3611
+ const files = [];
3612
+ for (const projectEntry of await listDir(claudeProjectsDir2)) {
3613
+ if (!projectEntry.isDirectory()) continue;
3614
+ const projectDir2 = join16(claudeProjectsDir2, projectEntry.name);
3615
+ for (const entry of await listDir(projectDir2)) {
3616
+ const entryPath = join16(projectDir2, entry.name);
3617
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
3618
+ files.push(...await withSize(entryPath));
3619
+ continue;
3620
+ }
3621
+ if (!entry.isDirectory()) continue;
3622
+ for (const subagentEntry of await listDir(join16(entryPath, "subagents"), { recursive: true })) {
3623
+ if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await withSize(join16(subagentEntry.parentPath, subagentEntry.name)));
3624
+ }
3625
+ }
3626
+ }
3627
+ return files;
3628
+ }
3629
+ async function withSize(path) {
3630
+ try {
3631
+ return [{ path, size: (await stat2(path)).size }];
3632
+ } catch {
3633
+ return [];
3634
+ }
3635
+ }
3636
+ async function scanTranscripts({ files, cache, byteBudget }) {
3637
+ let remainingBudget = byteBudget;
3638
+ const resultFiles = {};
3639
+ for (const file of files) {
3640
+ const previous = cache.files[file.path];
3641
+ const resumable = previous !== void 0 && file.size >= previous.size && await fingerprintOf(file.path, previous.offset) === previous.fingerprint;
3642
+ const start = resumable ? { offset: previous.offset, state: structuredClone(previous.state), buckets: previous.buckets } : { offset: 0, state: newTranscriptState(), buckets: [] };
3643
+ const chunkSize = Math.min(file.size - start.offset, remainingBudget);
3644
+ const scanned = await scanChunk(file, start, chunkSize, byteBudget);
3645
+ const unmoved = resumable && scanned.offset === previous.offset;
3646
+ resultFiles[file.path] = { ...scanned, fingerprint: unmoved ? previous.fingerprint : await fingerprintOf(file.path, scanned.offset) };
3647
+ if (chunkSize > 0) remainingBudget -= chunkSize;
3648
+ await yieldToEventLoop();
3649
+ }
3650
+ return {
3651
+ cache: { version: USAGE_CACHE_VERSION, files: resultFiles },
3652
+ bytesRead: byteBudget - remainingBudget,
3653
+ bytesLeft: sum(Object.values(resultFiles).map((entry) => entry.size - entry.offset)),
3654
+ filesDone: Object.values(resultFiles).filter((entry) => entry.offset === entry.size).length
3655
+ };
3656
+ }
3657
+ async function scanChunk(file, start, chunkSize, longestReadableLine) {
3658
+ if (chunkSize <= 0) return { size: file.size, offset: start.offset, state: start.state, buckets: start.buckets };
3659
+ const chunk = await readChunk(file.path, start.offset, chunkSize);
3660
+ const lastNewline = chunk.lastIndexOf(NEWLINE);
3661
+ if (lastNewline === -1) {
3662
+ const lineTooLong = chunk.length >= longestReadableLine;
3663
+ return { size: file.size, offset: start.offset + (lineTooLong ? chunk.length : 0), state: start.state, buckets: start.buckets };
3664
+ }
3665
+ const bucketsByKey = new Map(start.buckets.map((bucket) => [bucketKey(bucket), bucket]));
3666
+ for (const line of chunk.subarray(0, lastNewline).toString("utf8").split("\n")) {
3667
+ if (!COUNTED_LINE_MARKERS.some((marker) => line.includes(marker))) continue;
3668
+ const parsed = parseLineOrNull(line);
3669
+ if (parsed === null) continue;
3670
+ for (const addition of attributeLine(parsed, start.state)) addBucket(bucketsByKey, addition);
3671
+ }
3672
+ const offset = start.offset + lastNewline + 1;
3673
+ if (offset === file.size) for (const addition of flushEstimates(start.state)) addBucket(bucketsByKey, addition);
3674
+ return { size: file.size, offset, state: start.state, buckets: [...bucketsByKey.values()] };
3675
+ }
3676
+ async function fingerprintOf(path, offset) {
3677
+ const edge = Math.min(FINGERPRINT_BYTES, offset);
3678
+ const [head, tail] = await Promise.all([readChunk(path, 0, edge), readChunk(path, offset - edge, edge)]);
3679
+ return createHash3("sha1").update(head).update(tail).digest("hex");
3680
+ }
3681
+ async function readChunk(path, position, length) {
3682
+ const handle = await open(path, "r");
3683
+ try {
3684
+ const buffer = Buffer.alloc(length);
3685
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
3686
+ return buffer.subarray(0, bytesRead);
3687
+ } finally {
3688
+ await handle.close();
3689
+ }
3690
+ }
3691
+ function parseLineOrNull(line) {
3692
+ try {
3693
+ return JSON.parse(line);
3694
+ } catch {
3695
+ return null;
3696
+ }
3697
+ }
3698
+ function yieldToEventLoop() {
3699
+ return new Promise((resolve) => setImmediate(resolve));
3700
+ }
3701
+ function addBucket(buckets, addition) {
3702
+ const key = bucketKey(addition);
3703
+ const existing = buckets.get(key);
3704
+ buckets.set(key, existing === void 0 ? addition : combineBuckets(existing, addition));
3705
+ }
3706
+ function bucketKey(bucket) {
3707
+ return JSON.stringify([bucket.slot, bucket.cwd, bucket.model, bucket.kind]);
3708
+ }
3709
+ function combineBuckets(existing, addition) {
3710
+ return { ...existing, tokens: combineTokens(existing.tokens, addition.tokens), hookTurns: existing.hookTurns + addition.hookTurns };
3711
+ }
3712
+ function combineTokens(a, b) {
3713
+ return { input: a.input + b.input, cacheWrite5m: a.cacheWrite5m + b.cacheWrite5m, cacheWrite1h: a.cacheWrite1h + b.cacheWrite1h, cacheRead: a.cacheRead + b.cacheRead, output: a.output + b.output };
3714
+ }
3715
+
3716
+ // src/server/usage-scanner.ts
3717
+ var DEFAULT_BYTE_BUDGET = 16 * 1024 * 1024;
3718
+ var DEFAULT_INTERVAL_MS = 6e4;
3719
+ var CATCH_UP_DELAY_MS = 100;
3720
+ var NOT_LISTED = { listed: false, filesTotal: 0, filesDone: 0, bytesLeft: 0 };
3721
+ function createUsageScanner({
3722
+ root,
3723
+ claudeProjectsDir: claudeProjectsDir2,
3724
+ byteBudget = DEFAULT_BYTE_BUDGET,
3725
+ intervalMs = DEFAULT_INTERVAL_MS,
3726
+ messages = () => Promise.resolve(serverRu)
3727
+ }) {
3728
+ let cache = null;
3729
+ let scan = NOT_LISTED;
3730
+ let inFlight = null;
3731
+ let timer = null;
3732
+ let running = false;
3733
+ let budgetExhausted = false;
3734
+ const runPass = async () => {
3735
+ const current = cache ?? await readUsageCache(root);
3736
+ const files = await listTranscripts(claudeProjectsDir2);
3737
+ scan = progressBefore(files, current);
3738
+ const result = await scanTranscripts({ files, cache: current, byteBudget });
3739
+ cache = result.cache;
3740
+ scan = { listed: true, filesTotal: files.length, filesDone: result.filesDone, bytesLeft: result.bytesLeft };
3741
+ budgetExhausted = result.bytesRead >= byteBudget;
3742
+ if (result.bytesRead > 0) await writeUsageCache(root, result.cache);
3743
+ };
3744
+ const scanOnce = () => {
3745
+ inFlight ??= runPass().catch(async (error) => {
3746
+ budgetExhausted = false;
3747
+ process.stderr.write(`${(await messages()).transcriptsScanFailed(errorText(error))}
3748
+ `);
3749
+ }).finally(() => {
3750
+ inFlight = null;
3751
+ });
3752
+ return inFlight;
3753
+ };
3754
+ const tick = async () => {
3755
+ await scanOnce();
3756
+ if (running) timer = setTimeout(() => void tick(), budgetExhausted ? CATCH_UP_DELAY_MS : intervalMs);
3757
+ };
3758
+ return {
3759
+ start: () => {
3760
+ running = true;
3761
+ void tick();
3762
+ },
3763
+ stop: () => {
3764
+ running = false;
3765
+ if (timer !== null) clearTimeout(timer);
3766
+ timer = null;
3767
+ return inFlight ?? Promise.resolve();
3768
+ },
3769
+ scanOnce,
3770
+ ensureStarted: () => {
3771
+ if (!scan.listed && inFlight === null) void scanOnce();
3772
+ },
3773
+ snapshot: () => ({ cache: cache ?? emptyUsageCache(), scan })
3774
+ };
3775
+ }
3776
+ function progressBefore(files, cache) {
3777
+ const offsetOf = (file) => {
3778
+ const cached = cache.files[file.path];
3779
+ return cached !== void 0 && file.size >= cached.size ? cached.offset : 0;
3780
+ };
3781
+ return {
3782
+ listed: true,
3783
+ filesTotal: files.length,
3784
+ filesDone: files.filter((file) => offsetOf(file) === file.size).length,
3785
+ bytesLeft: sum(files.map((file) => file.size - offsetOf(file)))
3786
+ };
3787
+ }
3788
+
3789
+ // src/server/start.ts
3790
+ var SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
3791
+ async function startServer({ root, port, home: home2, env, pidFile }) {
3792
+ await mkdir3(root, { recursive: true });
3793
+ const settled = await settledLanguage(root, env);
3794
+ const startupMessages = serverMessages(settled.language);
3795
+ if (settled.invalidSettingsFile) process.stderr.write(`${startupMessages.settingsFileInvalid(settingsFilePath(root))}
3796
+ `);
3797
+ const readMessages = () => serverLanguage(root).then(serverMessages);
3798
+ const usage = createUsageScanner({ root, claudeProjectsDir: claudeProjectsDir(env, home2), messages: readMessages });
3799
+ const memory = createMemorySampler();
3800
+ const changes = createChangeFeed(root, CHANGE_DEBOUNCE_MS, readMessages);
3801
+ const hostsForActualPort = /* @__PURE__ */ new Set();
3802
+ const app = createApp({ root, changes, allowedHosts: hostsForActualPort, home: home2, usage, memory, staticDir: join17(import.meta.dirname, "web") });
3803
+ const sweepAll = async (now) => {
3804
+ const language = await serverLanguage(root);
3805
+ await trimRuns(root, now).catch((error) => process.stderr.write(`${serverMessages(language).runsTrimFailed(errorText(error))}
3806
+ `));
3807
+ return sweepClosed(root, now, coreMessages(language));
3808
+ };
3809
+ let stopSweeper = () => Promise.resolve();
3810
+ const startBackground = () => {
3811
+ usage.start();
3812
+ memory.start();
3813
+ stopSweeper = startSweeper({
3814
+ sweep: () => sweepAll(/* @__PURE__ */ new Date()),
3815
+ intervalMs: SWEEP_INTERVAL_MS,
3816
+ log: (line) => process.stdout.write(`${line}
3817
+ `),
3818
+ warn: (line) => process.stderr.write(`${line}
3819
+ `),
3820
+ messages: readMessages
3821
+ });
3822
+ };
3823
+ const stopBackground = async () => {
3824
+ await usage.stop();
3825
+ memory.stop();
3826
+ await stopSweeper();
3827
+ };
3828
+ return new Promise((resolve, reject) => {
3829
+ let listening = false;
3830
+ let decided = false;
3831
+ const closeServer = () => new Promise((resolveClose, rejectClose) => server.close((error) => error ? rejectClose(error) : resolveClose()));
3832
+ const teardown = async () => {
3833
+ await stopBackground();
3834
+ await changes.close();
3835
+ if (pidFile !== void 0) await rm4(pidFile, { force: true });
3836
+ if (listening) await closeServer();
3837
+ };
3838
+ const finish = async (actualPort) => {
3839
+ if (pidFile !== void 0) await writeFile3(pidFile, String(process.pid));
3840
+ if (decided) return;
3841
+ decided = true;
3842
+ resolve({ port: actualPort, close: teardown });
3843
+ };
3844
+ const failStartup = async (error) => {
3845
+ decided = true;
3846
+ await teardown();
3847
+ reject(error);
3848
+ };
3849
+ const reportErrorAfterStartup = (error) => {
3850
+ process.stderr.write(`${errorText(error)}
3851
+ `);
3852
+ };
3853
+ const onPidFileError = (error) => {
3854
+ if (decided) reportErrorAfterStartup(error);
3855
+ else void failStartup(error);
3856
+ };
3857
+ const onSocketError = (error) => {
3858
+ if (decided) reportErrorAfterStartup(error);
3859
+ else void failStartup(new Error(listenFailure(error, port, startupMessages)));
3860
+ };
3861
+ const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, (info) => {
3862
+ listening = true;
3863
+ for (const host of localHosts(info.port)) hostsForActualPort.add(host);
3864
+ startBackground();
3865
+ process.stdout.write(startupMessages.serverStarted(info.port, root));
3866
+ void finish(info.port).catch(onPidFileError);
3867
+ });
3868
+ server.on("error", onSocketError);
3869
+ });
3870
+ }
3871
+
3872
+ // src/server/main.ts
3873
+ suppressSqliteExperimentalWarning();
3874
+ var home = homedir();
3875
+ try {
3876
+ await startServer({
3877
+ root: resolveBacklogRoot(process.env, home),
3878
+ port: readPort(process.env.PORT),
3879
+ home,
3880
+ env: process.env,
3881
+ pidFile: process.env.P_BACKLOG_PID_FILE
3882
+ });
3883
+ } catch (error) {
3884
+ process.stderr.write(`${errorText(error)}
3885
+ `);
3886
+ process.exit(1);
3887
+ }