p-backlog 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -42,7 +42,7 @@ function formatLocalIso(date) {
42
42
  }
43
43
 
44
44
  // src/core/store/runs.ts
45
- import { appendFile, mkdir, open } from "node:fs/promises";
45
+ import { mkdir } from "node:fs/promises";
46
46
  import { join as join3 } from "node:path";
47
47
  import { z } from "zod";
48
48
 
@@ -201,7 +201,7 @@ import { setTimeout as sleep2 } from "node:timers/promises";
201
201
 
202
202
  // src/core/store/fs-utils.ts
203
203
  import { createHash, randomUUID } from "node:crypto";
204
- import { chmod, link, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
204
+ import { chmod, link, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
205
205
  import { basename, dirname, join } from "node:path";
206
206
  import { setTimeout as sleep } from "node:timers/promises";
207
207
  function contentVersion(text) {
@@ -215,16 +215,56 @@ async function readTextOrNull(path) {
215
215
  throw error;
216
216
  }
217
217
  }
218
- async function readJsonLines(path, schema) {
219
- const text = await readTextOrNull(path) ?? "";
218
+ function parseJsonLines(text, schema) {
220
219
  const parsed = text.split("\n").filter((line) => line.trim() !== "").map((line) => parseJson(line, schema));
221
220
  const values = parsed.filter((value) => value !== null);
222
221
  return { values, invalidLines: parsed.length - values.length };
223
222
  }
223
+ async function readJsonLines(path, schema) {
224
+ const text = await readTextOrNull(path) ?? "";
225
+ return parseJsonLines(text, schema);
226
+ }
224
227
  function toJsonLines(values) {
225
228
  return values.map((value) => `${JSON.stringify(value)}
226
229
  `).join("");
227
230
  }
231
+ var NEWLINE = 10;
232
+ async function appendJsonLines(path, values) {
233
+ await withFile(
234
+ path,
235
+ async (handle) => {
236
+ const { size } = await handle.stat();
237
+ const endsMidLine = size > 0 && (await readAt(handle, size - 1, 1))[0] !== NEWLINE;
238
+ await handle.appendFile((endsMidLine ? "\n" : "") + toJsonLines(values), "utf8");
239
+ },
240
+ "a+"
241
+ );
242
+ }
243
+ async function withFile(path, use, flags = "r") {
244
+ return closingAfter(await open(path, flags), use);
245
+ }
246
+ async function withExistingFile(path, use) {
247
+ const handle = await open(path, "r").catch((error) => {
248
+ if (hasErrorCode(error, "ENOENT")) return null;
249
+ throw error;
250
+ });
251
+ return handle === null ? null : closingAfter(handle, use);
252
+ }
253
+ async function closingAfter(handle, use) {
254
+ try {
255
+ return await use(handle);
256
+ } finally {
257
+ await handle.close();
258
+ }
259
+ }
260
+ async function readAt(handle, position, length) {
261
+ const buffer = Buffer.alloc(length);
262
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
263
+ return buffer.subarray(0, bytesRead);
264
+ }
265
+ function readFileAt(path, position, length) {
266
+ return withFile(path, (handle) => readAt(handle, position, length));
267
+ }
228
268
  function parseJson(text, schema) {
229
269
  try {
230
270
  const parsed = schema.safeParse(JSON.parse(text));
@@ -400,10 +440,7 @@ var cliRunSchema = z.object({
400
440
  async function appendRun(root, run) {
401
441
  await mkdir(root, { recursive: true });
402
442
  const path = join3(root, RUNS_FILE);
403
- await withFileLock(path, () => appendFile(path, toJsonLines([run]), "utf8"));
404
- }
405
- async function readRuns(root) {
406
- return (await readJsonLines(join3(root, RUNS_FILE), cliRunSchema)).values;
443
+ await withFileLock(path, () => appendJsonLines(path, [run]));
407
444
  }
408
445
  async function trimRuns(root, now) {
409
446
  const path = join3(root, RUNS_FILE);
@@ -423,20 +460,13 @@ async function trimRunsWhenStale(root, now) {
423
460
  return needsTrim ? trimRuns(root, now) : 0;
424
461
  }
425
462
  async function firstRun(path) {
426
- const file = await open(path, "r").catch((error) => {
427
- if (hasErrorCode(error, "ENOENT")) return null;
428
- throw error;
429
- });
430
- if (file === null) return "none";
431
- try {
432
- const { buffer, bytesRead } = await file.read(Buffer.alloc(FIRST_LINE_BYTES), 0, FIRST_LINE_BYTES, 0);
433
- const [firstLine = ""] = buffer.toString("utf8", 0, bytesRead).split("\n");
434
- if (firstLine.trim() === "") return "none";
435
- const run = parseJson(firstLine, cliRunSchema);
436
- return run === null ? "unparsable" : Date.parse(run.at);
437
- } finally {
438
- await file.close();
439
- }
463
+ return await withExistingFile(path, firstRunIn) ?? "none";
464
+ }
465
+ async function firstRunIn(handle) {
466
+ const [firstLine = ""] = (await readAt(handle, 0, FIRST_LINE_BYTES)).toString("utf8").split("\n");
467
+ if (firstLine.trim() === "") return "none";
468
+ const run = parseJson(firstLine, cliRunSchema);
469
+ return run === null ? "unparsable" : Date.parse(run.at);
440
470
  }
441
471
 
442
472
  // src/core/store/paths.ts
@@ -598,7 +628,7 @@ var cliEn = {
598
628
  taskNotFound: (id) => `Task ${id} not found`,
599
629
  fileConflict: (id) => `Task file ${id} changed while writing, retry the command`,
600
630
  statsTitle: (scopeName) => `${scopeName} \xB7 stats`,
601
- statsOpenLine: ({ open: open3, weight, net, created, closed }) => `Open: ${open3} (weight ${weight}) \xB7 this week: ${net} (created ${created}, closed ${closed})`,
631
+ statsOpenLine: ({ open: open2, weight, net, created, closed }) => `Open: ${open2} (weight ${weight}) \xB7 this week: ${net} (created ${created}, closed ${closed})`,
602
632
  statsAgeLine: (ageMedian, leadMedian, tail) => `Age, median: ${ageMedian} \xB7 time to close, median: ${leadMedian}${tail}`,
603
633
  statsP90Tail: (p903) => ` (90% \u2014 ${p903})`,
604
634
  statsForecastLine: (forecast3, tail) => `Forecast: ${forecast3} (${tail})`,
@@ -652,7 +682,7 @@ var cliEn = {
652
682
  "delete <id> --confirm <id> (deletes the project directory with all its tasks)"
653
683
  ],
654
684
  noProjects: "No projects",
655
- projectListLine: ({ id, name, prefix, statusWord: statusWord2, open: open3 }) => `${id} \xB7 ${name} \xB7 ${prefix} \xB7 ${statusWord2} \xB7 open ${open3}`,
685
+ projectListLine: ({ id, name, prefix, statusWord: statusWord2, open: open2 }) => `${id} \xB7 ${name} \xB7 ${prefix} \xB7 ${statusWord2} \xB7 open ${open2}`,
656
686
  confirmProjectDelete: (id) => `Confirm the deletion: backlog project delete ${id} --confirm ${id}`,
657
687
  projectDeleted: (id, taskCount) => `${id} deleted: tasks ${taskCount}`,
658
688
  projectActive: "active",
@@ -775,7 +805,7 @@ var cliRu = {
775
805
  taskNotFound: (id) => `\u0417\u0430\u0434\u0430\u0447\u0430 ${id} \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430`,
776
806
  fileConflict: (id) => `\u0424\u0430\u0439\u043B \u0437\u0430\u0434\u0430\u0447\u0438 ${id} \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u0432\u043E \u0432\u0440\u0435\u043C\u044F \u0437\u0430\u043F\u0438\u0441\u0438, \u043F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043A\u043E\u043C\u0430\u043D\u0434\u0443`,
777
807
  statsTitle: (scopeName) => `${scopeName} \xB7 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043A\u0430`,
778
- statsOpenLine: ({ open: open3, weight, net, created, closed }) => `\u041E\u0442\u043A\u0440\u044B\u0442\u043E: ${open3} (\u0432\u0435\u0441 ${weight}) \xB7 \u0437\u0430 \u043D\u0435\u0434\u0435\u043B\u044E: ${net} (\u0441\u043E\u0437\u0434\u0430\u043D\u043E ${created}, \u0437\u0430\u043A\u0440\u044B\u0442\u043E ${closed})`,
808
+ statsOpenLine: ({ open: open2, weight, net, created, closed }) => `\u041E\u0442\u043A\u0440\u044B\u0442\u043E: ${open2} (\u0432\u0435\u0441 ${weight}) \xB7 \u0437\u0430 \u043D\u0435\u0434\u0435\u043B\u044E: ${net} (\u0441\u043E\u0437\u0434\u0430\u043D\u043E ${created}, \u0437\u0430\u043A\u0440\u044B\u0442\u043E ${closed})`,
779
809
  statsAgeLine: (ageMedian, leadMedian, tail) => `\u0412\u043E\u0437\u0440\u0430\u0441\u0442, \u043C\u0435\u0434\u0438\u0430\u043D\u0430: ${ageMedian} \xB7 \u0434\u043E \u0437\u0430\u043A\u0440\u044B\u0442\u0438\u044F, \u043C\u0435\u0434\u0438\u0430\u043D\u0430: ${leadMedian}${tail}`,
780
810
  statsP90Tail: (p903) => ` (90% \u2014 ${p903})`,
781
811
  statsForecastLine: (forecast3, tail) => `\u041F\u0440\u043E\u0433\u043D\u043E\u0437: ${forecast3} (${tail})`,
@@ -840,8 +870,8 @@ var cliRu = {
840
870
  name,
841
871
  prefix,
842
872
  statusWord: statusWord2,
843
- open: open3
844
- }) => `${id} \xB7 ${name} \xB7 ${prefix} \xB7 ${statusWord2} \xB7 \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 ${open3}`,
873
+ open: open2
874
+ }) => `${id} \xB7 ${name} \xB7 ${prefix} \xB7 ${statusWord2} \xB7 \u043E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 ${open2}`,
845
875
  confirmProjectDelete: (id) => `\u041F\u043E\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u0435 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u0435: backlog project delete ${id} --confirm ${id}`,
846
876
  projectDeleted: (id, taskCount) => `${id} \u0443\u0434\u0430\u043B\u0451\u043D: \u0437\u0430\u0434\u0430\u0447 ${taskCount}`,
847
877
  projectActive: "\u0430\u043A\u0442\u0438\u0432\u0435\u043D",
@@ -1477,8 +1507,8 @@ function p90(value) {
1477
1507
  if (value === null) return "\u2014";
1478
1508
  return value < 1 ? "within a day" : `within ${days(value)}`;
1479
1509
  }
1480
- function forecast({ open: open3, weeklyNet, weeks, until }) {
1481
- if (open3 === 0) return "No open tasks";
1510
+ function forecast({ open: open2, weeklyNet, weeks, until }) {
1511
+ if (open2 === 0) return "No open tasks";
1482
1512
  if (weeks !== null && until !== null) return `Debt clears in about ${weeks} wk. (by ${formatDayMonth("en", new Date(until))})`;
1483
1513
  if (weeklyNet === 0) return "Debt is not shrinking";
1484
1514
  const growth = roundToTenth(-weeklyNet);
@@ -1710,8 +1740,8 @@ function p902(value) {
1710
1740
  if (value === null) return "\u2014";
1711
1741
  return value < 1 ? "\u0431\u044B\u0441\u0442\u0440\u0435\u0435 \u0441\u0443\u0442\u043E\u043A" : `\u0437\u0430 ${days2(value)}`;
1712
1742
  }
1713
- function forecast2({ open: open3, weeklyNet, weeks, until }) {
1714
- if (open3 === 0) return "\u041E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0437\u0430\u0434\u0430\u0447 \u043D\u0435\u0442";
1743
+ function forecast2({ open: open2, weeklyNet, weeks, until }) {
1744
+ if (open2 === 0) return "\u041E\u0442\u043A\u0440\u044B\u0442\u044B\u0445 \u0437\u0430\u0434\u0430\u0447 \u043D\u0435\u0442";
1715
1745
  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))})`;
1716
1746
  if (weeklyNet === 0) return "\u0414\u043E\u043B\u0433 \u043D\u0435 \u0443\u043C\u0435\u043D\u044C\u0448\u0430\u0435\u0442\u0441\u044F";
1717
1747
  const growth = roundToTenth(-weeklyNet);
@@ -2149,20 +2179,21 @@ function findBlockerCycle(start, resolve7) {
2149
2179
  }
2150
2180
 
2151
2181
  // src/core/store/journal.ts
2152
- import { appendFile as appendFile2 } from "node:fs/promises";
2153
2182
  import { join as join7 } from "node:path";
2154
2183
  var JOURNAL_FILE = "journal.jsonl";
2155
2184
  async function appendJournal(projectDir2, events, onError = reportToStderr) {
2156
2185
  if (events.length === 0) return;
2157
2186
  const path = join7(projectDir2, JOURNAL_FILE);
2158
2187
  try {
2159
- await appendFile2(path, toJsonLines(events), "utf8");
2188
+ await appendJsonLines(path, events);
2160
2189
  } catch (error) {
2161
2190
  onError(path, error);
2162
2191
  }
2163
2192
  }
2164
2193
  async function readJournal(projectDir2, projectId) {
2165
- const { values, invalidLines } = await readJsonLines(join7(projectDir2, JOURNAL_FILE), journalEventSchema);
2194
+ return projectJournal(projectId, await readJsonLines(join7(projectDir2, JOURNAL_FILE), journalEventSchema));
2195
+ }
2196
+ function projectJournal(projectId, { values, invalidLines }) {
2166
2197
  return { projectId, events: values, invalidLines };
2167
2198
  }
2168
2199
  async function readJournals(root, projectIds) {
@@ -2940,11 +2971,11 @@ function anchorState(task, facts) {
2940
2971
  return moved === null || movedAnchor === null ? { kind: "changed" } : { kind: "moved", source: moved, anchor: movedAnchor };
2941
2972
  }
2942
2973
  function findSimilarTask(draft, tasks) {
2943
- const open3 = tasks.filter((task) => task.type === "task" && !isClosed(task.status));
2974
+ const open2 = tasks.filter((task) => task.type === "task" && !isClosed(task.status));
2944
2975
  const { source } = draft;
2945
- const bySource = source === void 0 ? void 0 : open3.find((task) => task.source !== void 0 && samePlace(task.source, source));
2976
+ const bySource = source === void 0 ? void 0 : open2.find((task) => task.source !== void 0 && samePlace(task.source, source));
2946
2977
  if (bySource !== void 0) return { task: taskRef(bySource), match: "source" };
2947
- const byTitle = open3.find((task) => similarTitles(task.title, draft.title));
2978
+ const byTitle = open2.find((task) => similarTitles(task.title, draft.title));
2948
2979
  return byTitle === void 0 ? null : { task: taskRef(byTitle), match: "title" };
2949
2980
  }
2950
2981
  function duplicateCandidates(tasks, symbolOf = () => null) {
@@ -4640,12 +4671,12 @@ async function rememberShown(projectDir2, shown, io) {
4640
4671
 
4641
4672
  // src/cli/describe.ts
4642
4673
  function describeTask(task, index) {
4643
- const open3 = openBlockers(task, index);
4674
+ const open2 = openBlockers(task, index);
4644
4675
  return {
4645
4676
  task,
4646
4677
  progress: taskProgress(task, index),
4647
- openBlockers: open3,
4648
- inactiveBlockerIds: task.blockedBy.filter((id) => !open3.some((blocker) => blocker.id === id)),
4678
+ openBlockers: open2,
4679
+ inactiveBlockerIds: task.blockedBy.filter((id) => !open2.some((blocker) => blocker.id === id)),
4649
4680
  blocks: dependentTasks(task, index),
4650
4681
  related: relatedTasks(task, index),
4651
4682
  epic: task.epic === void 0 ? void 0 : index.byId.get(task.epic),
@@ -4830,8 +4861,8 @@ async function listProjects(io) {
4830
4861
  }
4831
4862
  const index = buildIndex(loaded.tasks);
4832
4863
  for (const project of loaded.projects) {
4833
- const open3 = filterTasks(loaded.tasks, { projectId: project.id, statuses: OPEN_STATUSES }, index).length;
4834
- io.print(cli.projectListLine({ id: project.id, name: project.name, prefix: project.prefix, statusWord: statusWord(cli, project.active), open: open3 }));
4864
+ const open2 = filterTasks(loaded.tasks, { projectId: project.id, statuses: OPEN_STATUSES }, index).length;
4865
+ io.print(cli.projectListLine({ id: project.id, name: project.name, prefix: project.prefix, statusWord: statusWord(cli, project.active), open: open2 }));
4835
4866
  }
4836
4867
  return EXIT.ok;
4837
4868
  }
@@ -5012,7 +5043,7 @@ function listenFailure(error, port, messages) {
5012
5043
  // src/server/start.ts
5013
5044
  import { serve } from "@hono/node-server";
5014
5045
  import { mkdir as mkdir6, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
5015
- import { join as join27 } from "node:path";
5046
+ import { join as join28 } from "node:path";
5016
5047
 
5017
5048
  // src/core/store/sweep.ts
5018
5049
  import { dirname as dirname7, join as join22 } from "node:path";
@@ -5162,7 +5193,7 @@ async function reserveNumbers(projects, expired) {
5162
5193
  import { serveStatic } from "@hono/node-server/serve-static";
5163
5194
  import { Hono as Hono3 } from "hono";
5164
5195
  import { secureHeaders } from "hono/secure-headers";
5165
- import { join as join24 } from "node:path";
5196
+ import { join as join25 } from "node:path";
5166
5197
 
5167
5198
  // src/server/api.ts
5168
5199
  import { Hono as Hono2 } from "hono";
@@ -5292,17 +5323,22 @@ function previousOf(task) {
5292
5323
  function createReportCache({ ttlMs, now }) {
5293
5324
  let entries = /* @__PURE__ */ new Map();
5294
5325
  return {
5295
- get: (key, compute) => {
5326
+ get: (key, compute, tags = []) => {
5296
5327
  const cached = entries.get(key);
5297
5328
  if (cached !== void 0 && now() - cached.at <= ttlMs) return cached.value;
5298
- const generation = entries;
5299
5329
  const value = compute();
5300
- generation.set(key, { at: now(), value });
5301
- value.catch(() => generation.delete(key));
5330
+ entries.set(key, { at: now(), value, tags });
5331
+ value.catch(() => {
5332
+ if (entries.get(key)?.value === value) entries.delete(key);
5333
+ });
5302
5334
  return value;
5303
5335
  },
5304
5336
  clear: () => {
5305
5337
  entries = /* @__PURE__ */ new Map();
5338
+ },
5339
+ clearTagged: (tags) => {
5340
+ const tagged = new Set(tags);
5341
+ for (const [key, entry] of entries) if (entry.tags.some((tag) => tagged.has(tag))) entries.delete(key);
5306
5342
  }
5307
5343
  };
5308
5344
  }
@@ -5347,32 +5383,36 @@ async function markOf(path) {
5347
5383
 
5348
5384
  // src/server/stats-api.ts
5349
5385
  import { Hono } from "hono";
5386
+ import { relative as relative3, sep as sep4 } from "node:path";
5350
5387
 
5351
5388
  // src/core/code/code-cache.ts
5352
5389
  import { join as join23 } from "node:path";
5353
5390
  import { z as z15 } from "zod";
5354
5391
  var CODE_CACHE_FILE = ".code-cache.json";
5355
- var CODE_CACHE_VERSION = 1;
5356
- var repoCodeSchema = z15.object({
5357
- commits: z15.array(z15.array(z15.string())),
5358
- lines: z15.array(z15.object({ path: z15.string(), lines: z15.number() })),
5359
- units: z15.array(z15.object({ date: z15.string(), lines: z15.number() }))
5392
+ var CODE_CACHE_VERSION = 2;
5393
+ var repoScanSchema = z15.object({
5394
+ head: z15.string(),
5395
+ main: z15.string().nullable(),
5396
+ commits: z15.array(z15.object({ date: z15.string(), paths: z15.array(z15.string()) })),
5397
+ units: z15.array(z15.object({ date: z15.string(), lines: z15.number() })),
5398
+ lines: z15.array(z15.object({ path: z15.string(), lines: z15.number() }))
5360
5399
  });
5361
5400
  var fixCommitSchema = z15.object({ date: z15.string(), landedAt: z15.string().optional(), byAgent: z15.boolean(), lines: z15.number(), testLines: z15.number() });
5362
5401
  var snapshotSchema = z15.object({
5363
5402
  version: z15.literal(CODE_CACHE_VERSION),
5364
- repos: z15.record(z15.string(), z15.object({ key: z15.string(), code: repoCodeSchema })),
5365
- fixes: z15.record(z15.string(), fixCommitSchema)
5403
+ repos: z15.record(z15.string(), repoScanSchema),
5404
+ fixes: z15.record(z15.string(), fixCommitSchema),
5405
+ unsettled: z15.record(z15.string(), z15.string().nullable())
5366
5406
  });
5367
5407
  function emptyCodeCache() {
5368
- return { repos: {}, fixes: {} };
5408
+ return { repos: {}, fixes: {}, unsettled: {} };
5369
5409
  }
5370
5410
  function createCodeCacheFile(root) {
5371
5411
  const path = join23(root, CODE_CACHE_FILE);
5372
5412
  return {
5373
5413
  read: async () => {
5374
5414
  const snapshot = await readJsonFile(path, snapshotSchema);
5375
- return snapshot === null ? emptyCodeCache() : { repos: snapshot.repos, fixes: snapshot.fixes };
5415
+ return snapshot === null ? emptyCodeCache() : { repos: snapshot.repos, fixes: snapshot.fixes, unsettled: snapshot.unsettled };
5376
5416
  },
5377
5417
  write: (snapshot) => writeFileAtomic(path, JSON.stringify({ version: CODE_CACHE_VERSION, ...snapshot }))
5378
5418
  };
@@ -5380,6 +5420,9 @@ function createCodeCacheFile(root) {
5380
5420
 
5381
5421
  // src/core/code/code-window.ts
5382
5422
  var CHURN_DAYS = 90;
5423
+ function churnWindowStart(now) {
5424
+ return new Date(now.getTime() - CHURN_DAYS * DAY_MS);
5425
+ }
5383
5426
 
5384
5427
  // src/core/code/test-paths.ts
5385
5428
  var TEST_DIRECTORIES = /* @__PURE__ */ new Set(["test", "tests", "__tests__", "e2e", "spec"]);
@@ -5401,7 +5444,6 @@ var CHURN_EXCLUDES = [...LOCK_EXCLUDES, ...NON_CODE_EXTENSIONS.map((ext) => `:!*
5401
5444
  var HEAD = "HEAD";
5402
5445
  var MAIN_REFS = ["origin/HEAD", "main", "master"];
5403
5446
  var AGENT_TRAILER = /^claude/i;
5404
- var GREP_PREFIX = "HEAD:";
5405
5447
  var RENAME_ARROW = " => ";
5406
5448
  async function readRefs(git, repo) {
5407
5449
  const commits = await resolveCommits(git, repo, [HEAD, ...MAIN_REFS]) ?? /* @__PURE__ */ new Map();
@@ -5409,14 +5451,68 @@ async function readRefs(git, repo) {
5409
5451
  const main = MAIN_REFS.map((ref) => commits.get(ref)).find((commit) => commit !== void 0);
5410
5452
  return { head, main: main ?? head };
5411
5453
  }
5412
- async function readRepoCode(git, repo, since, mainCommit) {
5413
- const [log2, grep, units] = await Promise.all([
5414
- git(repo, ["log", `--since=${since.toISOString()}`, `--format=tformat:${RECORD}`, "--name-only", "-M", "--relative", "-z", "--", "."]),
5415
- git(repo, ["grep", "-I", "-c", "-z", "", "HEAD", "--", ".", ...LOCK_EXCLUDES]),
5416
- readUnits(git, repo, since, mainCommit)
5454
+ async function readCommitsSince(git, repo, since, range) {
5455
+ const output = await git(repo, [
5456
+ "log",
5457
+ revisionsOf(range),
5458
+ "--full-history",
5459
+ "--sparse",
5460
+ `--since=${since.toISOString()}`,
5461
+ `--format=tformat:${RECORD}%P${FIELD}%cI`,
5462
+ "--name-only",
5463
+ "-M",
5464
+ "--relative",
5465
+ "-z",
5466
+ "--",
5467
+ "."
5417
5468
  ]);
5418
- if (log2 === null || grep === null) return null;
5419
- return { commits: parseCommits(log2), lines: parseLines(grep), units };
5469
+ if (output === null) return null;
5470
+ const { after } = range;
5471
+ const commits = records(output).map((record) => {
5472
+ const [header = "", ...files] = record.split("\0");
5473
+ return { ...parseHistoryHeader(header), paths: files.map((file) => file.replace(/^\n/, "")).filter((file) => file !== "") };
5474
+ });
5475
+ return {
5476
+ entries: commits.filter(({ paths }) => paths.length > 0).map(({ date, paths }) => ({ date, paths })),
5477
+ reachesAfter: after === void 0 || commits.some(({ parents }) => parents.includes(after))
5478
+ };
5479
+ }
5480
+ async function readUnits(git, repo, since, range) {
5481
+ const output = await git(repo, [
5482
+ "log",
5483
+ revisionsOf(range),
5484
+ "--first-parent",
5485
+ "--sparse",
5486
+ "--diff-merges=first-parent",
5487
+ `--since=${since.toISOString()}`,
5488
+ `--format=tformat:${RECORD}%P${FIELD}%cI`,
5489
+ "--numstat",
5490
+ "--relative",
5491
+ "--",
5492
+ ".",
5493
+ ...CHURN_EXCLUDES
5494
+ ]);
5495
+ if (output === null) return null;
5496
+ const { after } = range;
5497
+ const units = records(output).map((record) => {
5498
+ const [header = "", ...rows] = record.split("\n");
5499
+ return { ...parseHistoryHeader(header), rows: rows.filter((row) => row.trim() !== "") };
5500
+ });
5501
+ return {
5502
+ entries: units.filter(({ rows }) => rows.length > 0).map(({ date, rows }) => ({ date, lines: numstatLines(rows) })),
5503
+ reachesAfter: after === void 0 || units.some(({ parents }) => parents[0] === after)
5504
+ };
5505
+ }
5506
+ async function readLines(git, repo, head) {
5507
+ const output = await git(repo, ["grep", "-I", "-c", "-z", "", head, "--", ".", ...LOCK_EXCLUDES]);
5508
+ return output === null ? null : parseLines(output, `${head}:`);
5509
+ }
5510
+ function revisionsOf({ tip, after }) {
5511
+ return after === void 0 ? tip : `${after}..${tip}`;
5512
+ }
5513
+ function parseHistoryHeader(header) {
5514
+ const [parents = "", date = ""] = header.split(FIELD);
5515
+ return { parents: parents.split(" ").filter((parent) => parent !== ""), date: date.trim() };
5420
5516
  }
5421
5517
  async function readFixCommits(git, repo, { hashes, mainCommit }) {
5422
5518
  const fullHashes = await resolveCommits(git, repo, hashes);
@@ -5480,29 +5576,6 @@ function parseFixStats(output) {
5480
5576
  function records(output) {
5481
5577
  return output.split(RECORD).filter((record) => record.trim() !== "");
5482
5578
  }
5483
- async function readUnits(git, repo, since, mainCommit) {
5484
- if (mainCommit === null) return [];
5485
- const output = await git(repo, [
5486
- "log",
5487
- mainCommit,
5488
- "--first-parent",
5489
- "--diff-merges=first-parent",
5490
- `--since=${since.toISOString()}`,
5491
- `--format=tformat:${RECORD}%cI`,
5492
- "--numstat",
5493
- "--relative",
5494
- "--",
5495
- ".",
5496
- ...CHURN_EXCLUDES
5497
- ]);
5498
- return output === null ? [] : parseUnits(output);
5499
- }
5500
- function parseUnits(output) {
5501
- return output.split(RECORD).filter((record) => record.trim() !== "").map((record) => {
5502
- const [date = "", ...rows] = record.split("\n");
5503
- return { date: date.trim(), lines: numstatLines(rows) };
5504
- });
5505
- }
5506
5579
  function numstatLines(rows) {
5507
5580
  return sum(rows.filter((row) => row.trim() !== "").map(numstatRowLines).filter((lines) => !Number.isNaN(lines)));
5508
5581
  }
@@ -5510,43 +5583,95 @@ function numstatRowLines(row) {
5510
5583
  const [added = "", deleted = ""] = row.split(" ");
5511
5584
  return Number(added) + Number(deleted);
5512
5585
  }
5513
- function parseCommits(output) {
5514
- return output.split(RECORD).map((record) => record.split("\0").map((file) => file.replace(/^\n/, "")).filter((file) => file !== "")).filter((files) => files.length > 0);
5515
- }
5516
- function parseLines(output) {
5517
- return output.split("\n").filter((record) => record.startsWith(GREP_PREFIX)).map((record) => {
5518
- const [path = "", count3 = ""] = record.slice(GREP_PREFIX.length).split("\0");
5586
+ function parseLines(output, prefix) {
5587
+ return output.split("\n").filter((record) => record.startsWith(prefix)).map((record) => {
5588
+ const [path = "", count3 = ""] = record.slice(prefix.length).split("\0");
5519
5589
  return { path, lines: Number(count3) };
5520
5590
  });
5521
5591
  }
5522
5592
 
5593
+ // src/core/code/repo-scan.ts
5594
+ async function scanRepo(git, repo, { refs, now, previous }) {
5595
+ const { head, main } = refs;
5596
+ if (head === null) return null;
5597
+ const since = churnWindowStart(now);
5598
+ const [commits, units, lines] = await Promise.all([
5599
+ extendHistory((range) => readCommitsSince(git, repo, since, range), head, historyOf2(previous?.head, previous?.commits)),
5600
+ main === null ? [] : extendHistory((range) => readUnits(git, repo, since, range), main, historyOf2(previous?.main, previous?.units)),
5601
+ previous?.head === head ? previous.lines : readLines(git, repo, head)
5602
+ ]);
5603
+ if (commits === null || units === null || lines === null) return null;
5604
+ const scan = { head, main, commits: windowOf2(commits, since), units: windowOf2(units, since), lines };
5605
+ return previous !== void 0 && isSameScan(previous, scan) ? previous : scan;
5606
+ }
5607
+ function repoCodeOf(scan) {
5608
+ return { commits: scan.commits.map(({ paths }) => paths), lines: scan.lines, units: scan.units };
5609
+ }
5610
+ function historyOf2(tip, entries) {
5611
+ return tip === void 0 || tip === null || entries === void 0 ? void 0 : { tip, entries };
5612
+ }
5613
+ async function extendHistory(read, tip, previous) {
5614
+ if (previous?.tip === tip) return previous.entries;
5615
+ if (previous !== void 0) {
5616
+ const added = await read({ tip, after: previous.tip });
5617
+ if (added?.reachesAfter === true) return [...added.entries, ...previous.entries];
5618
+ }
5619
+ return (await read({ tip }))?.entries ?? null;
5620
+ }
5621
+ function windowOf2(entries, since) {
5622
+ return entries.filter((entry) => Date.parse(entry.date) >= since.getTime()).sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
5623
+ }
5624
+ function isSameScan(previous, scan) {
5625
+ return previous.head === scan.head && previous.main === scan.main && previous.commits.length === scan.commits.length && previous.units.length === scan.units.length;
5626
+ }
5627
+
5523
5628
  // src/core/code/code-source.ts
5524
5629
  function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5525
5630
  } }) {
5526
5631
  const repoCache = /* @__PURE__ */ new Map();
5527
5632
  const fixCache = /* @__PURE__ */ new Map();
5633
+ const unsettledCheckedAt = /* @__PURE__ */ new Map();
5528
5634
  let changed = false;
5529
5635
  let restored = null;
5636
+ let retainedRepos = null;
5530
5637
  const restore = () => {
5531
5638
  restored ??= readSnapshot(store, (error) => onError("read", error)).then((snapshot) => {
5532
5639
  for (const [repo, entry] of Object.entries(snapshot.repos)) if (!repoCache.has(repo)) repoCache.set(repo, entry);
5533
5640
  for (const [key, commit] of Object.entries(snapshot.fixes)) if (!fixCache.has(key)) fixCache.set(key, commit);
5641
+ for (const [key, main] of Object.entries(snapshot.unsettled)) if (!unsettledCheckedAt.has(key)) unsettledCheckedAt.set(key, main);
5534
5642
  });
5535
5643
  return restored;
5536
5644
  };
5537
5645
  const dropStaleFixes = (now, requested) => {
5538
- const oldest = now.getTime() - CHURN_DAYS * DAY_MS;
5646
+ const oldest = churnWindowStart(now).getTime();
5539
5647
  for (const [key, commit] of fixCache) {
5540
5648
  if (requested.has(key) || Date.parse(commit.date) >= oldest) continue;
5541
5649
  fixCache.delete(key);
5650
+ unsettledCheckedAt.delete(key);
5651
+ changed = true;
5652
+ }
5653
+ };
5654
+ const dropUnretainedRepos = () => {
5655
+ if (retainedRepos === null) return;
5656
+ const kept = retainedRepos;
5657
+ for (const repo of repoCache.keys()) {
5658
+ if (kept.has(repo)) continue;
5659
+ repoCache.delete(repo);
5660
+ changed = true;
5661
+ }
5662
+ for (const key of /* @__PURE__ */ new Set([...fixCache.keys(), ...unsettledCheckedAt.keys()])) {
5663
+ if (kept.has(repoOfFixCacheKey(key))) continue;
5664
+ fixCache.delete(key);
5665
+ unsettledCheckedAt.delete(key);
5542
5666
  changed = true;
5543
5667
  }
5544
5668
  };
5545
5669
  let writing = Promise.resolve();
5546
5670
  const persist = () => {
5671
+ dropUnretainedRepos();
5547
5672
  if (store === void 0 || !changed) return writing;
5548
5673
  changed = false;
5549
- const snapshot = { repos: Object.fromEntries(repoCache), fixes: Object.fromEntries(fixCache) };
5674
+ const snapshot = { repos: Object.fromEntries(repoCache), fixes: Object.fromEntries(fixCache), unsettled: Object.fromEntries(unsettledCheckedAt) };
5550
5675
  writing = writing.then(
5551
5676
  () => store.write(snapshot).catch((error) => {
5552
5677
  changed = true;
@@ -5564,35 +5689,39 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5564
5689
  return started;
5565
5690
  };
5566
5691
  const readRepoOnce = async (repo, now) => {
5567
- const refs = await readRefs(git, repo);
5568
- if (refs.head === null) return null;
5569
- const key = repoKey(refs, now);
5570
- const cached = repoCache.get(repo);
5571
- if (cached?.key === key) return { key, code: cached.code };
5572
- const code = await readRepoCode(git, repo, new Date(now.getTime() - CHURN_DAYS * DAY_MS), refs.main);
5573
- if (code === null) return null;
5574
- repoCache.set(repo, { key, code });
5692
+ const previous = repoCache.get(repo);
5693
+ const scan = await scanRepo(git, repo, { refs: await readRefs(git, repo), now, previous });
5694
+ if (scan === null) return null;
5695
+ if (scan !== previous) {
5696
+ repoCache.set(repo, scan);
5697
+ changed = true;
5698
+ }
5699
+ return repoCodeOf(scan);
5700
+ };
5701
+ const rememberUnsettled = (key, main, commit) => {
5702
+ const checkedAt = commit.landedAt === void 0 ? main : void 0;
5703
+ if (unsettledCheckedAt.get(key) === checkedAt) return;
5704
+ if (checkedAt === void 0) unsettledCheckedAt.delete(key);
5705
+ else unsettledCheckedAt.set(key, checkedAt);
5575
5706
  changed = true;
5576
- return { key, code };
5577
5707
  };
5578
- const unsettledCheckedAt = /* @__PURE__ */ new Map();
5579
5708
  const needsReading = (key, main) => {
5580
5709
  const cached = fixCache.get(key);
5581
5710
  return cached === void 0 || cached.landedAt === void 0 && unsettledCheckedAt.get(key) !== main;
5582
5711
  };
5583
5712
  const fixCommitsOf = async (repo, main, hashes) => {
5584
- const unsettled = hashes.filter((hash) => needsReading(`${repo} ${hash}`, main));
5713
+ const unsettled = hashes.filter((hash) => needsReading(fixCacheKey(repo, hash), main));
5585
5714
  if (unsettled.length === 0) return;
5586
5715
  const found = await readFixCommits(git, repo, { hashes: unsettled, mainCommit: main });
5587
5716
  for (const [hash, commit] of found ?? []) {
5588
- const key = `${repo} ${hash}`;
5589
- if (commit.landedAt === void 0) unsettledCheckedAt.set(key, main);
5717
+ const key = fixCacheKey(repo, hash);
5718
+ rememberUnsettled(key, main, commit);
5590
5719
  if (fixCache.has(key) && commit.landedAt === void 0) continue;
5591
5720
  fixCache.set(key, commit);
5592
5721
  changed = true;
5593
5722
  }
5594
5723
  };
5595
- const fixReposOf = async (projects, now) => {
5724
+ const fixReposOf = async (projects) => {
5596
5725
  const refsOf = /* @__PURE__ */ new Map();
5597
5726
  const refsOnce = (repo) => remembered(refsOf, repo, () => readRefs(git, repo));
5598
5727
  const entries = await Promise.all(
@@ -5601,7 +5730,7 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5601
5730
  project.repos.map(async (repo) => {
5602
5731
  const expanded = expandHome(repo, home2);
5603
5732
  const refs = await refsOnce(expanded);
5604
- return refs.head === null ? [] : [{ repo: expanded, key: repoKey(refs, now), main: refs.main }];
5733
+ return refs.head === null ? [] : [{ repo: expanded, main: refs.main }];
5605
5734
  })
5606
5735
  );
5607
5736
  return [project.id, repos.flat()];
@@ -5610,6 +5739,9 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5610
5739
  return new Map(entries);
5611
5740
  };
5612
5741
  return {
5742
+ retain: (backlogProjects) => {
5743
+ retainedRepos = new Set(backlogProjects.flatMap((project) => project.repos.map((repo) => expandHome(repo, home2))));
5744
+ },
5613
5745
  stateKey: async (projects) => {
5614
5746
  const repos = [...new Set(projects.flatMap((project) => project.repos.map((repo) => expandHome(repo, home2))))];
5615
5747
  const states = await Promise.all(repos.map(async (repo) => `${repo}@${refsKey(await readRefs(git, repo))}`));
@@ -5630,7 +5762,7 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5630
5762
  const readable = [];
5631
5763
  for (const { repo, read } of repos) {
5632
5764
  if (read !== null) {
5633
- readable.push(read.code);
5765
+ readable.push(read);
5634
5766
  } else if (!seenUnavailable.has(repo)) {
5635
5767
  seenUnavailable.add(repo);
5636
5768
  unavailableRepos.push(repo);
@@ -5643,13 +5775,13 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5643
5775
  },
5644
5776
  fixCommits: async (projects, requests, now) => {
5645
5777
  await restore();
5646
- const reposOf = await fixReposOf(projects, now);
5778
+ const reposOf = await fixReposOf(projects);
5647
5779
  await Promise.all(requests.flatMap(({ projectId, hashes }) => (reposOf.get(projectId) ?? []).map(({ repo, main }) => fixCommitsOf(repo, main, hashes))));
5648
5780
  const found = /* @__PURE__ */ new Map();
5649
5781
  const requested = /* @__PURE__ */ new Set();
5650
5782
  for (const { projectId, hashes } of requests) {
5651
5783
  for (const hash of hashes) {
5652
- const keys = (reposOf.get(projectId) ?? []).map(({ repo }) => `${repo} ${hash}`);
5784
+ const keys = (reposOf.get(projectId) ?? []).map(({ repo }) => fixCacheKey(repo, hash));
5653
5785
  for (const key of keys) requested.add(key);
5654
5786
  const commit = keys.map((key) => fixCache.get(key)).find((cached) => cached !== void 0);
5655
5787
  if (commit !== void 0) found.set(fixKey(projectId, hash), commit);
@@ -5661,12 +5793,15 @@ function createCodeSource({ home: home2, git = runGit, store, onError = () => {
5661
5793
  }
5662
5794
  };
5663
5795
  }
5796
+ function fixCacheKey(repo, hash) {
5797
+ return `${repo} ${hash}`;
5798
+ }
5799
+ function repoOfFixCacheKey(key) {
5800
+ return key.slice(0, key.lastIndexOf(" "));
5801
+ }
5664
5802
  function refsKey(refs) {
5665
5803
  return `${refs.head ?? ""} ${refs.main ?? ""}`;
5666
5804
  }
5667
- function repoKey(refs, now) {
5668
- return `${refsKey(refs)} ${formatLocalDay(now)}`;
5669
- }
5670
5805
  async function readSnapshot(store, onError) {
5671
5806
  try {
5672
5807
  return await store?.read() ?? emptyCodeCache();
@@ -5735,14 +5870,14 @@ function folderRows(project, openTasks) {
5735
5870
  (file) => file.lines
5736
5871
  );
5737
5872
  const ownSources = openTasks.flatMap((task) => task.projectId === project.projectId && task.source !== void 0 ? [task.source] : []);
5738
- const open3 = countBy(ownSources, folderOf);
5739
- return [...open3.entries()].flatMap(([label, count3]) => {
5873
+ const open2 = countBy(ownSources, folderOf);
5874
+ return [...open2.entries()].flatMap(([label, count3]) => {
5740
5875
  const folderLines = lines.get(label) ?? 0;
5741
5876
  return folderLines >= MIN_FOLDER_LINES ? [{ label, ...densityRow(folderLines, count3) }] : [];
5742
5877
  }).sort((a, b) => byDensity(a, b) || a.label.localeCompare(b.label)).slice(0, FOLDER_LIMIT);
5743
5878
  }
5744
- function densityRow(lines, open3) {
5745
- return { lines, open: open3, perKloc: lines === 0 ? null : open3 * LINES_PER_UNIT / lines };
5879
+ function densityRow(lines, open2) {
5880
+ return { lines, open: open2, perKloc: lines === 0 ? null : open2 * LINES_PER_UNIT / lines };
5746
5881
  }
5747
5882
  function byDensity(a, b) {
5748
5883
  return (b.perKloc ?? -1) - (a.perKloc ?? -1);
@@ -6040,8 +6175,8 @@ function sizeOf(samples) {
6040
6175
  }
6041
6176
  function totalsOf2(deferred, units, estimate) {
6042
6177
  const fixed = deferred.flatMap((item) => item.fixedLines === null ? [] : [item.fixedLines]);
6043
- const open3 = deferred.filter((item) => item.fixedLines === null);
6044
- const estimated = estimatedSizeOf(open3, estimate);
6178
+ const open2 = deferred.filter((item) => item.fixedLines === null);
6179
+ const estimated = estimatedSizeOf(open2, estimate);
6045
6180
  const estimatedLines = estimated?.lines ?? null;
6046
6181
  const realLines = sum(units.map((unit) => unit.lines));
6047
6182
  const fixedLines = sum(fixed);
@@ -6051,15 +6186,15 @@ function totalsOf2(deferred, units, estimate) {
6051
6186
  realLines,
6052
6187
  fixedTasks: fixed.length,
6053
6188
  fixedLines,
6054
- openTasks: open3.length,
6189
+ openTasks: open2.length,
6055
6190
  estimatedLines,
6056
6191
  deferredLines,
6057
6192
  deferredTestLines: sum(deferred.map((item) => item.fixedTestLines)) + (estimated?.testLines ?? 0),
6058
- noiseShare: realLines === 0 || estimatedLines === null && open3.length > 0 ? null : Math.min(1, deferredLines / denominator)
6193
+ noiseShare: realLines === 0 || estimatedLines === null && open2.length > 0 ? null : Math.min(1, deferredLines / denominator)
6059
6194
  };
6060
6195
  }
6061
- function estimatedSizeOf(open3, estimate) {
6062
- const estimates = open3.map((item) => estimate(item.history.category));
6196
+ function estimatedSizeOf(open2, estimate) {
6197
+ const estimates = open2.map((item) => estimate(item.history.category));
6063
6198
  if (estimates.some((size) => size === null)) return null;
6064
6199
  const sizes = estimates.filter((size) => size !== null);
6065
6200
  return { lines: sum(sizes.map((size) => size.lines)), testLines: sum(sizes.map((size) => size.testLines)) };
@@ -6234,9 +6369,150 @@ function previousTotals(histories, nowMs, journalStart) {
6234
6369
  };
6235
6370
  }
6236
6371
 
6372
+ // src/server/stats-sources.ts
6373
+ import { join as join24 } from "node:path";
6374
+
6375
+ // src/core/store/jsonl-tail.ts
6376
+ import { createHash as createHash5 } from "node:crypto";
6377
+ var HEAD_FINGERPRINT_BYTES = 4096;
6378
+ var EMPTY_FINGERPRINT = createHash5("sha1").digest("hex");
6379
+ function createJsonlTail(path, schema) {
6380
+ let state = emptyState();
6381
+ let generation = 0;
6382
+ let queue = Promise.resolve();
6383
+ function read() {
6384
+ const result = queue.then(doRead);
6385
+ queue = result.catch(() => void 0);
6386
+ return result;
6387
+ }
6388
+ async function doRead() {
6389
+ const pending = await withExistingFile(path, consumeNew);
6390
+ if (pending === null) restart();
6391
+ const { values, invalidLines, bytes } = pending ?? nothingPending();
6392
+ return { values: values.length === 0 ? state.values : [...state.values, ...values], invalidLines: state.invalidLines + invalidLines, length: state.offset + bytes, generation };
6393
+ }
6394
+ async function consumeNew(handle) {
6395
+ const { size } = await handle.stat();
6396
+ if (size < state.offset || !await headMatches(handle)) restart();
6397
+ return size > state.offset ? consumeUpTo(handle, size) : nothingPending();
6398
+ }
6399
+ function restart() {
6400
+ if (state.offset === 0) return;
6401
+ generation += 1;
6402
+ state = emptyState();
6403
+ }
6404
+ async function headMatches(handle) {
6405
+ if (state.offset === 0) return true;
6406
+ return await headFingerprintAt(handle, state.offset) === state.headFingerprint;
6407
+ }
6408
+ async function consumeUpTo(handle, size) {
6409
+ const chunk = await readAt(handle, state.offset, size - state.offset);
6410
+ const completeLength = chunk.lastIndexOf(NEWLINE) + 1;
6411
+ if (completeLength > 0) {
6412
+ const parsed = parseJsonLines(chunk.subarray(0, completeLength).toString("utf8"), schema);
6413
+ const offset = state.offset + completeLength;
6414
+ state = { offset, headFingerprint: await headFingerprintAt(handle, offset), values: [...state.values, ...parsed.values], invalidLines: state.invalidLines + parsed.invalidLines };
6415
+ }
6416
+ const pending = chunk.subarray(completeLength);
6417
+ return { ...parseJsonLines(pending.toString("utf8"), schema), bytes: pending.length };
6418
+ }
6419
+ return { read };
6420
+ }
6421
+ function emptyState() {
6422
+ return { offset: 0, headFingerprint: EMPTY_FINGERPRINT, values: [], invalidLines: 0 };
6423
+ }
6424
+ function nothingPending() {
6425
+ return { values: [], invalidLines: 0, bytes: 0 };
6426
+ }
6427
+ async function headFingerprintAt(handle, offset) {
6428
+ const length = Math.min(offset, HEAD_FINGERPRINT_BYTES);
6429
+ if (length === 0) return EMPTY_FINGERPRINT;
6430
+ return createHash5("sha1").update(await readAt(handle, 0, length)).digest("hex");
6431
+ }
6432
+
6433
+ // src/server/stats-sources.ts
6434
+ var ALL_PROJECTS_SLOT = "*";
6435
+ function createStatsSources(root, computeCost) {
6436
+ const tails = /* @__PURE__ */ new Map();
6437
+ const bases = /* @__PURE__ */ new Map();
6438
+ const runsTail = createJsonlTail(join24(root, RUNS_FILE), cliRunSchema);
6439
+ const costMemos = /* @__PURE__ */ new Map();
6440
+ const snapshotIds = /* @__PURE__ */ new WeakMap();
6441
+ let lastSnapshotId = 0;
6442
+ const snapshotIdOf = (snapshot) => {
6443
+ const known = snapshotIds.get(snapshot);
6444
+ if (known !== void 0) return known;
6445
+ lastSnapshotId += 1;
6446
+ snapshotIds.set(snapshot, lastSnapshotId);
6447
+ return lastSnapshotId;
6448
+ };
6449
+ const tailOf = (projectId) => {
6450
+ const known = tails.get(projectId);
6451
+ if (known !== void 0) return known;
6452
+ const created = createJsonlTail(join24(root, projectId, JOURNAL_FILE), journalEventSchema);
6453
+ tails.set(projectId, created);
6454
+ return created;
6455
+ };
6456
+ const readTailed = async (projectId) => {
6457
+ const { length, generation, ...lines } = await tailOf(projectId).read();
6458
+ return { journal: projectJournal(projectId, lines), position: `${projectId}=${generation}:${length}` };
6459
+ };
6460
+ const rememberedBase = (snapshot, tailed) => (slot, input) => {
6461
+ const slotKey = `${slot}|${input.projectId ?? "*"}`;
6462
+ const positions = tailed.filter(({ journal }) => input.projectId === void 0 || journal.projectId === input.projectId).map(({ position }) => position);
6463
+ const key = `${snapshotIdOf(snapshot)}|${slotKey}|${positions.join(",")}`;
6464
+ const remembered2 = bases.get(slotKey);
6465
+ if (remembered2?.key === key) return remembered2.base;
6466
+ const base = reportBase(input);
6467
+ bases.set(slotKey, { projectId: input.projectId, key, base });
6468
+ return base;
6469
+ };
6470
+ return {
6471
+ read: async (snapshot, projectIds) => {
6472
+ const tailed = await Promise.all(projectIds.map(readTailed));
6473
+ return { journals: tailed.map(({ journal }) => journal), baseOf: rememberedBase(snapshot, tailed) };
6474
+ },
6475
+ retain: (projectIds) => {
6476
+ const kept = new Set(projectIds);
6477
+ pruneUnlessKept(tails, kept, (projectId) => projectId);
6478
+ pruneUnlessKept(bases, kept, (_, { projectId }) => projectId);
6479
+ pruneUnlessKept(costMemos, kept, (slot) => slot === ALL_PROJECTS_SLOT ? void 0 : slot);
6480
+ },
6481
+ costReport: async (request) => {
6482
+ const { values: runs, length, generation } = await runsTail.read();
6483
+ const inputs = { ...request, runs };
6484
+ const slot = inputs.scope.projectId ?? ALL_PROJECTS_SLOT;
6485
+ const keyParts = {
6486
+ usage: `${inputs.usage.revision}`,
6487
+ runs: `${generation}:${length}`,
6488
+ scope: `${snapshotIdOf(inputs.scope.snapshot)}/${slot}`,
6489
+ now: formatLocalDay(inputs.now)
6490
+ };
6491
+ const key = Object.values(keyParts).join("|");
6492
+ const remembered2 = costMemos.get(slot);
6493
+ if (remembered2?.key === key) return remembered2.report;
6494
+ const report = computeCost(inputs);
6495
+ costMemos.set(slot, { key, report });
6496
+ report.catch(() => {
6497
+ if (costMemos.get(slot)?.report === report) costMemos.delete(slot);
6498
+ });
6499
+ return report;
6500
+ }
6501
+ };
6502
+ }
6503
+ function pruneUnlessKept(map, kept, projectIdOf) {
6504
+ for (const [key, value] of map) {
6505
+ const projectId = projectIdOf(key, value);
6506
+ if (projectId !== void 0 && !kept.has(projectId)) map.delete(key);
6507
+ }
6508
+ }
6509
+
6237
6510
  // src/server/stats-api.ts
6238
6511
  var REPORT_TTL_MS = 5 * 60 * 1e3;
6239
- function createStatsApi({ root, readLanguage: readLanguage2, now, home: home2, usage, memory, warn: warn2, backlog }) {
6512
+ var ALL_PROJECTS_TAG = "project:*";
6513
+ var WHOLE_BACKLOG_TAG = "whole-backlog";
6514
+ var projectTag = (projectId) => `project:${projectId}`;
6515
+ function createStatsApi({ root, readLanguage: readLanguage2, now, home: home2, usage, memory, warn: warn2, backlog, graphHealth: graphHealth2 }) {
6240
6516
  const routes = new Hono();
6241
6517
  const reports = createReportCache({ ttlMs: REPORT_TTL_MS, now: () => now().getTime() });
6242
6518
  const onCodeSourceError = (kind, error) => void readLanguage2().then((language) => {
@@ -6246,68 +6522,95 @@ function createStatsApi({ root, readLanguage: readLanguage2, now, home: home2, u
6246
6522
  });
6247
6523
  const codeSource = createCodeSource({ home: home2, store: createCodeCacheFile(root), onError: onCodeSourceError });
6248
6524
  const lookupRepoRoot = cachedRepoRoots();
6525
+ const sources = createStatsSources(root, (inputs) => costOf2(inputs, home2, lookupRepoRoot));
6526
+ let knownProjectIds = [];
6527
+ let forgetCount = 0;
6249
6528
  const statsScopeOf = async (c, { wholeBacklog }) => {
6250
6529
  const projectId = c.req.query("project") || void 0;
6251
- const { projects, tasks, errors } = await backlog();
6530
+ const snapshot = await backlog();
6531
+ const { projects, tasks, errors } = snapshot;
6532
+ knownProjectIds = projects.map((project) => project.id);
6533
+ sources.retain(knownProjectIds);
6534
+ codeSource.retain(projects);
6252
6535
  if (projectId !== void 0 && !projects.some((project) => project.id === projectId)) {
6253
6536
  return c.json({ errors: [serverMessages(await readLanguage2()).projectNotFound(projectId)] }, 404);
6254
6537
  }
6255
- const included = (project) => project.id === projectId || (projectId === void 0 || wholeBacklog) && project.active;
6256
- const scoped = projects.filter(included);
6538
+ const scoped = projectsInScope(projects, projectId, { wholeBacklog });
6257
6539
  const scopedIds = new Set(scoped.map((project) => project.id));
6258
6540
  const inScope = (task) => scopedIds.has(task.projectId);
6259
- return { projectId, projects: scoped, tasks: tasks.filter(inScope), unparsedTasks: unparsedTasks(errors).filter(inScope) };
6541
+ return { projectId, projects: scoped, tasks: tasks.filter(inScope), unparsedTasks: unparsedTasks(errors).filter(inScope), snapshot };
6260
6542
  };
6261
6543
  const scopedStats = (name, report, { sourceKey, wholeBacklog = false } = {}) => async (c) => {
6544
+ const forgetCountAtRead = forgetCount;
6262
6545
  const scope = await statsScopeOf(c, { wholeBacklog });
6263
6546
  if (scope instanceof Response) return scope;
6264
6547
  const moment = now();
6265
6548
  const key = [name, scope.projectId ?? "*", formatLocalDay(moment), sourceKey === void 0 ? "" : await sourceKey(scope.projects)].join("|");
6266
- const result = await reports.get(key, async () => {
6267
- const journals = await readJournals(root, scope.projects.map((project) => project.id));
6549
+ const tags = wholeBacklog ? [WHOLE_BACKLOG_TAG] : [scope.projectId === void 0 ? ALL_PROJECTS_TAG : projectTag(scope.projectId)];
6550
+ const compute = async () => {
6551
+ const { journals, baseOf } = await sources.read(scope.snapshot, scope.projects.map((project) => project.id));
6268
6552
  const input = { tasks: scope.tasks, journals, now: moment, projectId: scope.projectId, unparsedTasks: scope.unparsedTasks };
6269
- return report(input, reportBase(input), scope.projects);
6270
- });
6271
- return c.json(result);
6553
+ const wholeBacklogBase = () => baseOf("backlog", { ...input, projectId: void 0 });
6554
+ return report({ input, base: baseOf("scoped", input), projects: scope.projects, snapshot: scope.snapshot, wholeBacklogBase });
6555
+ };
6556
+ const scopeOutdated = forgetCount !== forgetCountAtRead;
6557
+ return c.json(await (scopeOutdated ? compute() : reports.get(key, compute, tags)));
6272
6558
  };
6273
- const statsOfCode = async (input, base, projects) => codeReport({ ...input, code: await codeSource.collect(projects, input.now) }, base);
6274
- const statsOfEffect = async (input, base, projects) => {
6275
- const backlogBase = input.projectId === void 0 ? base : reportBase({ ...input, projectId: void 0 });
6559
+ const statsOfCode = async ({ input, base, projects }) => codeReport({ ...input, code: await codeSource.collect(projects, input.now) }, base);
6560
+ const statsOfEffect = async ({ input, base, projects, wholeBacklogBase }) => {
6561
+ const backlogBase = input.projectId === void 0 ? base : wholeBacklogBase();
6276
6562
  const scoped = projects.filter((project) => input.projectId === void 0 || project.id === input.projectId);
6277
6563
  const code = await codeSource.collect(scoped, input.now);
6278
6564
  const fixCommits = await codeSource.fixCommits(projects, codeFixRequests({ ...input, projectId: void 0 }, backlogBase), input.now);
6279
6565
  return effectReport({ ...input, code: { ...code, fixCommits } }, base, backlogBase);
6280
6566
  };
6281
- const statsOfQuality = async (input, base, projects) => qualityReport(input, base, await projectGraphs(projects, input.tasks, home2));
6282
- const statsOfCost = async (projectId, projects) => {
6283
- usage.ensureStarted();
6284
- const { cache, scan } = usage.snapshot();
6285
- const buckets = bucketsOf2(cache);
6286
- const runs = await readRuns(root);
6287
- const repoRoots = projectId === void 0 ? /* @__PURE__ */ new Map() : await resolveRepoRoots(lookupRepoRoot, [...buckets, ...runs].map((entry) => entry.cwd));
6288
- const projectOf2 = (cwd) => {
6289
- const roots = repoRoots.get(cwd) ?? null;
6290
- return roots === null ? null : findProjectForRoots(projects, roots, home2)?.id ?? null;
6291
- };
6292
- return costReport({ buckets, runs, projectOf: projectOf2, projectId, now: now(), scan });
6567
+ const statsOfQuality = async ({ input, base, projects, snapshot }) => {
6568
+ const graphs = await Promise.all(projects.map(async (project) => ({ projectId: project.id, name: project.name, ...await graphHealth2(snapshot, project) })));
6569
+ return qualityReport(input, base, graphs);
6293
6570
  };
6294
6571
  const codeState = { sourceKey: (projects) => codeSource.stateKey(projects) };
6295
- routes.get("/stats", scopedStats("stats", (input, base) => statsReport(input, base)));
6572
+ routes.get("/stats", scopedStats("stats", ({ input, base }) => statsReport(input, base)));
6296
6573
  routes.get("/stats/code", scopedStats("code", statsOfCode, codeState));
6297
6574
  routes.get("/stats/effect", scopedStats("effect", statsOfEffect, { ...codeState, wholeBacklog: true }));
6298
6575
  routes.get("/stats/quality", scopedStats("quality", statsOfQuality));
6299
- routes.get("/stats/signals", scopedStats("signals", (input, base) => ({ signals: statsSignals(input, base) })));
6576
+ routes.get("/stats/signals", scopedStats("signals", ({ input, base }) => ({ signals: statsSignals(input, base) })));
6300
6577
  routes.get("/stats/cost", async (c) => {
6301
6578
  const scope = await statsScopeOf(c, { wholeBacklog: true });
6302
- return scope instanceof Response ? scope : c.json(await statsOfCost(scope.projectId, scope.projects));
6579
+ if (scope instanceof Response) return scope;
6580
+ usage.ensureStarted();
6581
+ const { snapshot, projectId } = scope;
6582
+ return c.json(await sources.costReport({ usage: usage.snapshot(), scope: { snapshot, projectId }, now: now() }));
6303
6583
  });
6304
6584
  routes.get("/stats/memory", (c) => c.json({ samples: memory.samples() }));
6305
- return { routes, forget: () => reports.clear() };
6306
- }
6307
- async function projectGraphs(projects, tasks, home2) {
6308
- return Promise.all(
6309
- projects.map(async (project) => ({ projectId: project.id, name: project.name, ...await projectGraphHealth(project, tasks, home2) }))
6310
- );
6585
+ const projectIdOfPath = (path) => {
6586
+ const [first] = relative3(root, path).split(sep4);
6587
+ return knownProjectIds.find((id) => id === first);
6588
+ };
6589
+ const forget = (paths) => {
6590
+ forgetCount += 1;
6591
+ if (paths === void 0 || paths.length === 0) return void reports.clear();
6592
+ const changedProjectIds = /* @__PURE__ */ new Set();
6593
+ for (const path of paths) {
6594
+ const projectId = projectIdOfPath(path);
6595
+ if (projectId === void 0) return void reports.clear();
6596
+ changedProjectIds.add(projectId);
6597
+ }
6598
+ reports.clearTagged([ALL_PROJECTS_TAG, WHOLE_BACKLOG_TAG, ...[...changedProjectIds].map(projectTag)]);
6599
+ };
6600
+ return { routes, forget };
6601
+ }
6602
+ function projectsInScope(projects, projectId, { wholeBacklog }) {
6603
+ return projects.filter((project) => project.id === projectId || (projectId === void 0 || wholeBacklog) && project.active);
6604
+ }
6605
+ async function costOf2({ usage: { cache, scan }, runs, scope: { snapshot, projectId }, now }, home2, lookupRepoRoot) {
6606
+ const projects = projectsInScope(snapshot.projects, projectId, { wholeBacklog: true });
6607
+ const buckets = bucketsOf2(cache);
6608
+ const repoRoots = projectId === void 0 ? /* @__PURE__ */ new Map() : await resolveRepoRoots(lookupRepoRoot, [...buckets, ...runs].map((entry) => entry.cwd));
6609
+ const projectOf2 = (cwd) => {
6610
+ const roots = repoRoots.get(cwd) ?? null;
6611
+ return roots === null ? null : findProjectForRoots(projects, roots, home2)?.id ?? null;
6612
+ };
6613
+ return costReport({ buckets, runs, projectOf: projectOf2, projectId, now, scan });
6311
6614
  }
6312
6615
  function bucketsOf2(cache) {
6313
6616
  return Object.values(cache.files).flatMap((entry) => entry.buckets);
@@ -6330,30 +6633,35 @@ function createApi({ root, readLanguage: readLanguage2, changes, now, home: home
6330
6633
  });
6331
6634
  return snapshot;
6332
6635
  };
6333
- const stats = createStatsApi({ root, readLanguage: readLanguage2, now, home: home2, usage, memory, warn: warn2, backlog });
6334
- const graphStates = createReportCache({ ttlMs: GRAPH_STATE_TTL_MS, now: () => now().getTime() });
6335
- const forgetBacklog = () => {
6636
+ const graphHealthsBySnapshot = /* @__PURE__ */ new WeakMap();
6637
+ const graphHealth2 = (backlogSnapshot, project) => {
6638
+ let healths = graphHealthsBySnapshot.get(backlogSnapshot);
6639
+ if (healths === void 0) {
6640
+ healths = createReportCache({ ttlMs: GRAPH_STATE_TTL_MS, now: () => now().getTime() });
6641
+ graphHealthsBySnapshot.set(backlogSnapshot, healths);
6642
+ }
6643
+ return healths.get(project.id, () => projectGraphHealth(project, backlogSnapshot.tasks, home2));
6644
+ };
6645
+ const stats = createStatsApi({ root, readLanguage: readLanguage2, now, home: home2, usage, memory, warn: warn2, backlog, graphHealth: graphHealth2 });
6646
+ const forgetBacklog = (paths) => {
6336
6647
  snapshot = null;
6337
- stats.forget();
6338
- graphStates.clear();
6648
+ stats.forget(paths);
6339
6649
  };
6340
6650
  const recordOwnWrites = async (writes) => {
6341
6651
  if (writes.length > 0) await revisions.recordOwnWrites(writes);
6342
- forgetBacklog();
6652
+ forgetBacklog(writes.length > 0 ? writes.map((write) => write.path) : void 0);
6343
6653
  };
6344
6654
  const streams = /* @__PURE__ */ new Set();
6345
6655
  changes.subscribe(async (paths) => {
6346
- if (await revisions.settle(paths) === "foreign") forgetBacklog();
6347
- else stats.forget();
6656
+ if (await revisions.settle(paths) === "foreign") forgetBacklog(paths);
6657
+ else stats.forget(paths);
6348
6658
  const revision = revisions.current();
6349
6659
  for (const send of streams) send(revision);
6350
6660
  });
6351
6661
  api.get("/projects", async (c) => {
6352
- const { projects, tasks, revision } = await backlog();
6353
- const withGraph = async (project) => {
6354
- const codeGraph2 = await graphStates.get(project.id, async () => (await projectGraphHealth(project, tasks, home2)).state);
6355
- return { ...project, codeGraph: codeGraph2 };
6356
- };
6662
+ const loaded = await backlog();
6663
+ const { projects, revision } = loaded;
6664
+ const withGraph = async (project) => ({ ...project, codeGraph: (await graphHealth2(loaded, project)).state });
6357
6665
  return c.json({ projects: await Promise.all(projects.map(withGraph)), revision });
6358
6666
  });
6359
6667
  api.get("/tasks", async (c) => {
@@ -6507,13 +6815,13 @@ function createApp({ root, readLanguage: readLanguage2, changes, allowedHosts, h
6507
6815
  });
6508
6816
  if (staticDir !== void 0) {
6509
6817
  app.use("*", serveStatic({ root: staticDir }));
6510
- app.get("*", serveStatic({ path: join24(staticDir, "index.html") }));
6818
+ app.get("*", serveStatic({ path: join25(staticDir, "index.html") }));
6511
6819
  }
6512
6820
  return app;
6513
6821
  }
6514
6822
 
6515
6823
  // src/server/change-feed.ts
6516
- import { relative as relative3, sep as sep4 } from "node:path";
6824
+ import { relative as relative4, sep as sep5 } from "node:path";
6517
6825
  import { watch } from "chokidar";
6518
6826
  var CHANGE_DEBOUNCE_MS = 100;
6519
6827
  function createDebouncer(delayMs, run) {
@@ -6527,7 +6835,7 @@ function createDebouncer(delayMs, run) {
6527
6835
  };
6528
6836
  }
6529
6837
  function isHiddenPath(root, path) {
6530
- return relative3(root, path).split(sep4).some((segment) => segment.startsWith("."));
6838
+ return relative4(root, path).split(sep5).some((segment) => segment.startsWith("."));
6531
6839
  }
6532
6840
  function createChangeFeed({ root, debounceMs, messages, warn: warn2 }) {
6533
6841
  const listeners = /* @__PURE__ */ new Set();
@@ -6618,10 +6926,13 @@ function logReport({ closedEpics, reopenedEpics, blockingFiles, deleted, conflic
6618
6926
  }
6619
6927
  }
6620
6928
 
6929
+ // src/server/usage-scanner.ts
6930
+ import { isDeepStrictEqual } from "node:util";
6931
+
6621
6932
  // src/core/usage/transcripts.ts
6622
- import { createHash as createHash5 } from "node:crypto";
6623
- import { open as open2, stat as stat6 } from "node:fs/promises";
6624
- import { basename as basename10, join as join26 } from "node:path";
6933
+ import { createHash as createHash6 } from "node:crypto";
6934
+ import { stat as stat6 } from "node:fs/promises";
6935
+ import { basename as basename10, join as join27 } from "node:path";
6625
6936
 
6626
6937
  // src/core/stats/cost/attribute.ts
6627
6938
  import { z as z18 } from "zod";
@@ -6955,7 +7266,7 @@ function slotOf(timestamp) {
6955
7266
 
6956
7267
  // src/core/usage/usage-cache.ts
6957
7268
  import { mkdir as mkdir5 } from "node:fs/promises";
6958
- import { join as join25 } from "node:path";
7269
+ import { join as join26 } from "node:path";
6959
7270
  import { z as z19 } from "zod";
6960
7271
  var USAGE_CACHE_FILE = ".usage-cache.json";
6961
7272
  var USAGE_CACHE_VERSION = 6;
@@ -6975,33 +7286,32 @@ function emptyUsageCache() {
6975
7286
  return { version: USAGE_CACHE_VERSION, files: {} };
6976
7287
  }
6977
7288
  async function readUsageCache(root) {
6978
- return await readJsonFile(join25(root, USAGE_CACHE_FILE), usageCacheSchema) ?? emptyUsageCache();
7289
+ return await readJsonFile(join26(root, USAGE_CACHE_FILE), usageCacheSchema) ?? emptyUsageCache();
6979
7290
  }
6980
7291
  async function writeUsageCache(root, cache) {
6981
7292
  await mkdir5(root, { recursive: true });
6982
- await writeFileAtomic(join25(root, USAGE_CACHE_FILE), JSON.stringify(cache));
7293
+ await writeFileAtomic(join26(root, USAGE_CACHE_FILE), JSON.stringify(cache));
6983
7294
  }
6984
7295
 
6985
7296
  // src/core/usage/transcripts.ts
6986
- var NEWLINE = 10;
6987
7297
  var FINGERPRINT_BYTES = 256;
6988
7298
  var ABANDONED_LINE_MS = 10 * 60 * 1e3;
6989
- var EMPTY_FINGERPRINT = createHash5("sha1").digest("hex");
7299
+ var EMPTY_FINGERPRINT2 = createHash6("sha1").digest("hex");
6990
7300
  var COUNTED_LINE_MARKERS = ['"type":"assistant"', '"type":"user"'];
6991
7301
  async function listTranscripts(claudeProjectsDir2) {
6992
7302
  const files = [];
6993
7303
  for (const projectEntry of await listDir(claudeProjectsDir2)) {
6994
7304
  if (!projectEntry.isDirectory()) continue;
6995
- const projectDir2 = join26(claudeProjectsDir2, projectEntry.name);
7305
+ const projectDir2 = join27(claudeProjectsDir2, projectEntry.name);
6996
7306
  for (const entry of await listDir(projectDir2)) {
6997
- const entryPath = join26(projectDir2, entry.name);
7307
+ const entryPath = join27(projectDir2, entry.name);
6998
7308
  if (entry.isFile() && entry.name.endsWith(".jsonl")) {
6999
7309
  files.push(...await listedFile(entryPath));
7000
7310
  continue;
7001
7311
  }
7002
7312
  if (!entry.isDirectory()) continue;
7003
- for (const subagentEntry of await listDir(join26(entryPath, "subagents"), { recursive: true })) {
7004
- if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await listedFile(join26(subagentEntry.parentPath, subagentEntry.name)));
7313
+ for (const subagentEntry of await listDir(join27(entryPath, "subagents"), { recursive: true })) {
7314
+ if (subagentEntry.isFile() && subagentEntry.name.endsWith(".jsonl")) files.push(...await listedFile(join27(subagentEntry.parentPath, subagentEntry.name)));
7005
7315
  }
7006
7316
  }
7007
7317
  }
@@ -7045,7 +7355,7 @@ function deletedStillReported(cache, listedFiles, now) {
7045
7355
  }
7046
7356
  async function scanChunk(file, start, chunkSize, { longestReadableLine, now }) {
7047
7357
  if (chunkSize <= 0) return { size: file.size, offset: start.offset, state: start.state, buckets: start.buckets };
7048
- const chunk = await readChunk(file.path, start.offset, chunkSize);
7358
+ const chunk = await readFileAt(file.path, start.offset, chunkSize);
7049
7359
  const tailAbandoned = start.offset + chunk.length === file.size && now.getTime() - file.mtimeMs >= ABANDONED_LINE_MS;
7050
7360
  const readableLength = tailAbandoned ? chunk.length : chunk.lastIndexOf(NEWLINE) + 1;
7051
7361
  if (readableLength === 0) {
@@ -7069,30 +7379,14 @@ async function continues(file, previous) {
7069
7379
  return await fingerprintOf(file.path, previous.offset) === previous.fingerprint;
7070
7380
  }
7071
7381
  async function fingerprintOf(path, offset) {
7072
- if (offset === 0) return EMPTY_FINGERPRINT;
7382
+ if (offset === 0) return EMPTY_FINGERPRINT2;
7073
7383
  const edge = Math.min(FINGERPRINT_BYTES, offset);
7074
7384
  return withFile(path, async (handle) => {
7075
7385
  const head = await readAt(handle, 0, edge);
7076
7386
  const tail = await readAt(handle, offset - edge, edge);
7077
- return createHash5("sha1").update(head).update(tail).digest("hex");
7387
+ return createHash6("sha1").update(head).update(tail).digest("hex");
7078
7388
  });
7079
7389
  }
7080
- function readChunk(path, position, length) {
7081
- return withFile(path, (handle) => readAt(handle, position, length));
7082
- }
7083
- async function withFile(path, use) {
7084
- const handle = await open2(path, "r");
7085
- try {
7086
- return await use(handle);
7087
- } finally {
7088
- await handle.close();
7089
- }
7090
- }
7091
- async function readAt(handle, position, length) {
7092
- const buffer = Buffer.alloc(length);
7093
- const { bytesRead } = await handle.read(buffer, 0, length, position);
7094
- return buffer.subarray(0, bytesRead);
7095
- }
7096
7390
  function parseLineOrNull(line) {
7097
7391
  try {
7098
7392
  return JSON.parse(line);
@@ -7131,17 +7425,24 @@ function createUsageScanner({
7131
7425
  }) {
7132
7426
  let cache = null;
7133
7427
  let scan = NOT_LISTED;
7428
+ let revision = 0;
7134
7429
  let inFlight = null;
7135
7430
  let timer = null;
7136
7431
  let running = false;
7137
7432
  let budgetExhausted = false;
7433
+ const setScan = (next) => {
7434
+ if (!scanEquals(scan, next)) revision += 1;
7435
+ scan = next;
7436
+ };
7138
7437
  const runPass = async () => {
7438
+ const published = cache ?? emptyUsageCache();
7139
7439
  const current = cache ?? await readUsageCache(root);
7140
7440
  const files = await listTranscripts(claudeProjectsDir2);
7141
- scan = progressBefore(files, current);
7441
+ setScan(progressBefore(files, current));
7142
7442
  const result = await scanTranscripts({ files, cache: current, byteBudget, now: now() });
7443
+ if (cacheChanged(published, result.cache)) revision += 1;
7143
7444
  cache = result.cache;
7144
- scan = { listed: true, filesTotal: files.length, filesDone: result.filesDone, bytesLeft: result.bytesLeft };
7445
+ setScan({ listed: true, filesTotal: files.length, filesDone: result.filesDone, bytesLeft: result.bytesLeft });
7145
7446
  budgetExhausted = result.bytesRead >= byteBudget;
7146
7447
  if (result.bytesRead > 0) await writeUsageCache(root, result.cache);
7147
7448
  };
@@ -7173,9 +7474,21 @@ function createUsageScanner({
7173
7474
  ensureStarted: () => {
7174
7475
  if (!scan.listed && inFlight === null) void scanOnce();
7175
7476
  },
7176
- snapshot: () => ({ cache: cache ?? emptyUsageCache(), scan })
7477
+ snapshot: () => ({ cache: cache ?? emptyUsageCache(), scan, revision })
7177
7478
  };
7178
7479
  }
7480
+ function scanEquals(a, b) {
7481
+ return a.listed === b.listed && a.filesTotal === b.filesTotal && a.filesDone === b.filesDone && a.bytesLeft === b.bytesLeft;
7482
+ }
7483
+ function cacheChanged(previous, next) {
7484
+ const previousPaths = Object.keys(previous.files);
7485
+ if (previousPaths.length !== Object.keys(next.files).length) return true;
7486
+ return previousPaths.some((path) => entryChanged(previous.files[path], next.files[path]));
7487
+ }
7488
+ function entryChanged(previous, next) {
7489
+ if (previous === void 0 || next === void 0) return previous !== next;
7490
+ return previous.size !== next.size || previous.offset !== next.offset || previous.fingerprint !== next.fingerprint || !isDeepStrictEqual(previous.buckets, next.buckets);
7491
+ }
7179
7492
  function progressBefore(files, cache) {
7180
7493
  const offsetOf = (file) => {
7181
7494
  const cached = cache.files[file.path];
@@ -7192,7 +7505,7 @@ function progressBefore(files, cache) {
7192
7505
  // src/server/start.ts
7193
7506
  var SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
7194
7507
  var STOP_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
7195
- var BUNDLED_WEB_DIR = join27(import.meta.dirname, "web");
7508
+ var BUNDLED_WEB_DIR = join28(import.meta.dirname, "web");
7196
7509
  var log = (line) => void process.stdout.write(`${line}
7197
7510
  `);
7198
7511
  var warn = (line) => void process.stderr.write(`${line}
@@ -7293,7 +7606,7 @@ async function runServe(args, io) {
7293
7606
 
7294
7607
  // src/cli/service/launchd.ts
7295
7608
  import { mkdir as mkdir7, rm as rm5, writeFile as writeFile4 } from "node:fs/promises";
7296
- import { dirname as dirname8, join as join28, posix } from "node:path";
7609
+ import { dirname as dirname8, join as join29, posix } from "node:path";
7297
7610
  import { setTimeout as sleep3 } from "node:timers/promises";
7298
7611
 
7299
7612
  // src/cli/service/service.ts
@@ -7366,7 +7679,7 @@ ${entries}
7366
7679
  `;
7367
7680
  }
7368
7681
  function launchdManager(context, delay = sleep3) {
7369
- const file = join28(context.home, "Library/LaunchAgents", `${LABEL}.plist`);
7682
+ const file = join29(context.home, "Library/LaunchAgents", `${LABEL}.plist`);
7370
7683
  const domain = `gui/${context.uid}`;
7371
7684
  const bootout = () => context.exec("launchctl", ["bootout", `${domain}/${LABEL}`]);
7372
7685
  const bootstrap = () => context.exec("launchctl", ["bootstrap", domain, file]);
@@ -7401,7 +7714,7 @@ function launchdManager(context, delay = sleep3) {
7401
7714
 
7402
7715
  // src/cli/service/startup-folder.ts
7403
7716
  import { mkdir as mkdir8, rm as rm6, writeFile as writeFile5 } from "node:fs/promises";
7404
- import { dirname as dirname9, join as join29, win32 } from "node:path";
7717
+ import { dirname as dirname9, join as join30, win32 } from "node:path";
7405
7718
  var SCRIPT_NAME = "p-backlog.vbs";
7406
7719
  var COMMAND_LINE_ARGUMENT = /"[^"]*"|\S+/g;
7407
7720
  var PROCESS_QUERY_TIMEOUT_MS = 15e3;
@@ -7409,16 +7722,16 @@ function vbsString(value) {
7409
7722
  return `"${value.replaceAll('"', '""')}"`;
7410
7723
  }
7411
7724
  function appDataDir(context) {
7412
- return join29(context.env.LOCALAPPDATA ?? join29(context.home, "AppData", "Local"), "p-backlog");
7725
+ return join30(context.env.LOCALAPPDATA ?? join30(context.home, "AppData", "Local"), "p-backlog");
7413
7726
  }
7414
7727
  function startupFolder(context) {
7415
- return join29(context.env.APPDATA ?? join29(context.home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
7728
+ return join30(context.env.APPDATA ?? join30(context.home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
7416
7729
  }
7417
7730
  function logPath2(context) {
7418
- return join29(appDataDir(context), "p-backlog.log");
7731
+ return join30(appDataDir(context), "p-backlog.log");
7419
7732
  }
7420
7733
  function pidFilePath(context) {
7421
- return join29(appDataDir(context), "server.pid");
7734
+ return join30(appDataDir(context), "server.pid");
7422
7735
  }
7423
7736
  var NODE_PATH_ENV = "P_BACKLOG_NODE";
7424
7737
  var CLI_PATH_ENV = "P_BACKLOG_CLI";
@@ -7469,7 +7782,7 @@ function sameWindowsPath(left, right) {
7469
7782
  return win32.normalize(left).toLowerCase() === win32.normalize(right).toLowerCase();
7470
7783
  }
7471
7784
  function startupFolderManager(context) {
7472
- const file = join29(startupFolder(context), SCRIPT_NAME);
7785
+ const file = join30(startupFolder(context), SCRIPT_NAME);
7473
7786
  return {
7474
7787
  file,
7475
7788
  logs: logPath2(context),
@@ -7496,7 +7809,7 @@ function startupFolderManager(context) {
7496
7809
 
7497
7810
  // src/cli/service/systemd.ts
7498
7811
  import { mkdir as mkdir9, rm as rm7, writeFile as writeFile6 } from "node:fs/promises";
7499
- import { dirname as dirname10, join as join30 } from "node:path";
7812
+ import { dirname as dirname10, join as join31 } from "node:path";
7500
7813
  var UNIT = "p-backlog.service";
7501
7814
  function quoted(value) {
7502
7815
  return `"${value.replace(/[\\"]/g, "\\$&").replaceAll("%", "%%")}"`;
@@ -7530,7 +7843,7 @@ async function systemctlSteps(exec, steps) {
7530
7843
  return "done";
7531
7844
  }
7532
7845
  function systemdManager(context) {
7533
- const file = join30(context.home, ".config/systemd/user", UNIT);
7846
+ const file = join31(context.home, ".config/systemd/user", UNIT);
7534
7847
  return {
7535
7848
  file,
7536
7849
  logs: "journalctl --user -u p-backlog",
@@ -7648,10 +7961,10 @@ function reportFailure({ failed, code, output }, io) {
7648
7961
  }
7649
7962
 
7650
7963
  // src/cli/commands/setup.ts
7651
- import { join as join32 } from "node:path";
7964
+ import { join as join33 } from "node:path";
7652
7965
 
7653
7966
  // src/cli/agents/agent-hooks.ts
7654
- import { join as join31 } from "node:path";
7967
+ import { join as join32 } from "node:path";
7655
7968
 
7656
7969
  // src/cli/agents/grouped-stop-hooks.ts
7657
7970
  import { z as z20 } from "zod";
@@ -7799,7 +8112,7 @@ async function removeCursorStopHook(path, isOurs) {
7799
8112
  var CODEX_HOOK_TIMEOUT_SECONDS = 30;
7800
8113
  var AGENT_HOOKS_FILE = "hooks.json";
7801
8114
  function agentHookConfigPath(agent, places) {
7802
- return agent === "claude" ? claudeSettingsPath(places.env, places.home) : join31(agentHomeDir(agent, places), AGENT_HOOKS_FILE);
8115
+ return agent === "claude" ? claudeSettingsPath(places.env, places.home) : join32(agentHomeDir(agent, places), AGENT_HOOKS_FILE);
7803
8116
  }
7804
8117
  function installAgentHook(agent, site) {
7805
8118
  const path = agentHookConfigPath(agent, site);
@@ -7895,7 +8208,7 @@ async function setUpAgent(agent, io) {
7895
8208
  async function linkAgentSkill(agent, io, voice) {
7896
8209
  const messages = cliMessages(io.language);
7897
8210
  const skillsDir = agentSkillsDir(agent, io);
7898
- const target = join32(skillsDir, "backlog");
8211
+ const target = join33(skillsDir, "backlog");
7899
8212
  const source = skillSourceDir(io.packageRoot, io.language);
7900
8213
  let link2;
7901
8214
  try {
@@ -7914,7 +8227,7 @@ async function linkAgentSkill(agent, io, voice) {
7914
8227
  }
7915
8228
  async function removeLegacySkillLinks(agent, io, voice) {
7916
8229
  for (const legacyDir of legacySkillsDirs(agent, io)) {
7917
- if (await unlinkOurSkill(legacyDir) === "removed") voice.print(cliMessages(io.language).manualSkillRemoval.removed(join32(legacyDir, "backlog")));
8230
+ if (await unlinkOurSkill(legacyDir) === "removed") voice.print(cliMessages(io.language).manualSkillRemoval.removed(join33(legacyDir, "backlog")));
7918
8231
  }
7919
8232
  }
7920
8233
  function reportHook(result, configPath, io, voice) {
@@ -7935,7 +8248,7 @@ async function removeManualSetup(agent, removing, io) {
7935
8248
  const voice = agentVoice(agent, io);
7936
8249
  const messages = cliMessages(io.language);
7937
8250
  const skillsDir = agentSkillsDir(agent, io);
7938
- const target = join32(skillsDir, "backlog");
8251
+ const target = join33(skillsDir, "backlog");
7939
8252
  const sharer = await remainingSkillDirUser(agent, removing, io);
7940
8253
  voice.print(sharer === null ? messages.manualSkillRemoval[await unlinkOurSkill(skillsDir)](target) : messages.manualSkillShared(target, AGENT_LABELS[sharer]));
7941
8254
  await removeLegacySkillLinks(agent, io, voice);
@@ -7990,7 +8303,7 @@ function backlogAgeWeeks(histories, now) {
7990
8303
  return firstCreated === null ? null : (now.getTime() - firstCreated) / WEEK_MS;
7991
8304
  }
7992
8305
  var clampedWeeks = (weeks) => Math.min(FORECAST_WINDOW_WEEKS, Math.max(MIN_WINDOW_WEEKS, weeks));
7993
- function flowForecast(histories, open3, now) {
8306
+ function flowForecast(histories, open2, now) {
7994
8307
  const ageWeeks = backlogAgeWeeks(histories, now);
7995
8308
  const windowWeeks = ageWeeks === null ? FORECAST_WINDOW_WEEKS : clampedWeeks(Math.floor(ageWeeks) + 1);
7996
8309
  const observedWeeks = ageWeeks === null ? FORECAST_WINDOW_WEEKS : clampedWeeks(ageWeeks);
@@ -7999,9 +8312,9 @@ function flowForecast(histories, open3, now) {
7999
8312
  const closed = histories.flatMap(closingsOf).filter((closing) => inWindow(closing.at)).length;
8000
8313
  const created = histories.filter((history) => inWindow(history.createdAt)).length;
8001
8314
  const weeklyNet = (closed - created) / observedWeeks;
8002
- const weeks = open3 > 0 && weeklyNet > 0 ? Math.ceil(open3 / weeklyNet) : null;
8315
+ const weeks = open2 > 0 && weeklyNet > 0 ? Math.ceil(open2 / weeklyNet) : null;
8003
8316
  const windowDays = Math.round(observedWeeks * DAYS_PER_WEEK);
8004
- return { closed, created, open: open3, weeklyNet, weeks, until: weeks === null ? null : formatLocalIso(weeksLater(now, weeks)), windowDays };
8317
+ return { closed, created, open: open2, weeklyNet, weeks, until: weeks === null ? null : formatLocalIso(weeksLater(now, weeks)), windowDays };
8005
8318
  }
8006
8319
  function weeksLater(now, weeks) {
8007
8320
  return new Date(now.getFullYear(), now.getMonth(), now.getDate() + DAYS_PER_WEEK * weeks, now.getHours(), now.getMinutes(), now.getSeconds());
@@ -8086,7 +8399,7 @@ var statusCommand = taskFieldCommand({
8086
8399
 
8087
8400
  // src/cli/commands/take.ts
8088
8401
  import { realpathSync as realpathSync3 } from "node:fs";
8089
- import { relative as relative4, resolve as resolve5, sep as sep5 } from "node:path";
8402
+ import { relative as relative5, resolve as resolve5, sep as sep6 } from "node:path";
8090
8403
  var takeCommand = {
8091
8404
  name: "take",
8092
8405
  usage: (language) => cliMessages(language).takeUsage(),
@@ -8161,7 +8474,7 @@ function isInside(file, path) {
8161
8474
  function repoRelativePath(io, project, path) {
8162
8475
  const roots = findGitRoots(io.cwd);
8163
8476
  if (roots === null || findProjectForDir([project], io.cwd, io.home) === void 0) return sourcePath(path);
8164
- const fromRoot = relative4(roots.worktree, resolve5(realpathSync3(io.cwd), sourcePath(path))).split(sep5).join("/");
8477
+ const fromRoot = relative5(roots.worktree, resolve5(realpathSync3(io.cwd), sourcePath(path))).split(sep6).join("/");
8165
8478
  const outsideRepo = fromRoot === ".." || fromRoot.startsWith("../");
8166
8479
  return outsideRepo ? sourcePath(path) : fromRoot;
8167
8480
  }