From f4c8a80674341a3062b3176cc3fd095dd96f584e Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 17:58:50 +0800 Subject: [PATCH 1/7] fix(parser): stop double-counting turn durations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IM-1: the turn_duration special case added durationMs, then the generic durationMs branch added the same field again — every duration stat the dashboard and reports show was inflated 2x for those events. The generic read alone covers turn_duration system events. Co-Authored-By: Claude Fable 5 --- src/core/__tests__/parser.test.ts | 43 +++++++++++++++++++++++++++++++ src/core/parser.ts | 9 ++----- 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/core/__tests__/parser.test.ts diff --git a/src/core/__tests__/parser.test.ts b/src/core/__tests__/parser.test.ts new file mode 100644 index 0000000..e396814 --- /dev/null +++ b/src/core/__tests__/parser.test.ts @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { scanSession } from "../parser"; + +/** + * Regression tests for the scanSession/parseSessionFile findings of + * REVIEW-2026-06-10 (IM-1/2/4) — the parser feeds every duration, cost, and + * date stat the product reports. + */ + +function makeJsonl(lines: unknown[]): { path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "devlog-parser-")); + const path = join(dir, "session.jsonl"); + writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +test("turn durations are counted exactly once (IM-1)", async () => { + const { path, cleanup } = makeJsonl([ + { + type: "system", + subtype: "turn_duration", + durationMs: 5000, + timestamp: "2026-06-01T10:00:00.000Z", + }, + { + type: "assistant", + durationMs: 1200, + timestamp: "2026-06-01T10:00:05.000Z", + message: { role: "assistant", content: [{ type: "text", text: "done" }] }, + }, + ]); + try { + const meta = await scanSession(path); + // 5000 + 1200 — the turn_duration event previously counted double. + assert.equal(meta.totalDurationMs, 6200); + } finally { + cleanup(); + } +}); diff --git a/src/core/parser.ts b/src/core/parser.ts index e7b21d1..4929bce 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -134,13 +134,8 @@ export async function scanSession(filePath: string): Promise { meta.costByModel[m] = (meta.costByModel[m] || 0) + event.costUSD; } - // Duration from system turn_duration events - if (event.type === "system" && event.subtype === "turn_duration") { - const dur = (event as Record).durationMs; - if (typeof dur === "number") { - meta.totalDurationMs += dur; - } - } + // Duration: one generic read covers turn_duration system events too — + // a special-cased branch on top of this double-counted them (IM-1). if (event.durationMs && typeof event.durationMs === "number") { meta.totalDurationMs += event.durationMs; } From 352ef03a9c6b55e93465c675abb95b181672d62d Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 19:03:34 +0800 Subject: [PATCH 2/7] fix(parser): emit events for legacy string-content messages of both roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IM-2: the string-content branch was nested inside the Array.isArray branch (unreachable) and the fallback branch only handled the human role, so legacy assistant messages with plain string content produced zero events — entire turns missing from session views. Co-Authored-By: Claude Fable 5 --- src/core/__tests__/parser.test.ts | 38 ++++++++++++++++++++++++++++++- src/core/parser.ts | 22 +++++++----------- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/core/__tests__/parser.test.ts b/src/core/__tests__/parser.test.ts index e396814..8404b97 100644 --- a/src/core/__tests__/parser.test.ts +++ b/src/core/__tests__/parser.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { scanSession } from "../parser"; +import { parseSessionFile, scanSession } from "../parser"; /** * Regression tests for the scanSession/parseSessionFile findings of @@ -18,6 +18,42 @@ function makeJsonl(lines: unknown[]): { path: string; cleanup: () => void } { return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } +test("legacy string-content messages produce events for both roles (IM-2)", async () => { + const { path, cleanup } = makeJsonl([ + // Legacy format: top-level string content + { + type: "assistant", + content: "plain legacy assistant text", + timestamp: "2026-06-01T10:00:00.000Z", + }, + // Real format with string (non-array) message content + { + type: "assistant", + message: { role: "assistant", content: "string message content" }, + timestamp: "2026-06-01T10:00:01.000Z", + }, + { + type: "user", + content: "legacy human text", + timestamp: "2026-06-01T10:00:02.000Z", + }, + ]); + try { + const events = await parseSessionFile(path, "s1"); + assert.equal(events.length, 3); + assert.deepEqual( + events.map((e) => [e.role, e.content]), + [ + ["assistant", "plain legacy assistant text"], + ["assistant", "string message content"], + ["human", "legacy human text"], + ] + ); + } finally { + cleanup(); + } +}); + test("turn durations are counted exactly once (IM-1)", async () => { const { path, cleanup } = makeJsonl([ { diff --git a/src/core/parser.ts b/src/core/parser.ts index 4929bce..a019c07 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -280,18 +280,6 @@ function normalizeEvent( } } - if (typeof content === "string") { - events.push({ - id: baseId, - sessionId, - timestamp, - role: role, - type: "message", - content: content, - raw, - }); - } - if (events.length === 0) { events.push({ id: baseId, @@ -303,14 +291,20 @@ function normalizeEvent( raw, }); } - } else if (role === "human" && typeof content === "string") { + } else if (role && typeof content === "string") { + // Legacy string-content lines (both roles) — this branch was previously + // nested inside the array case and human-only, so assistant messages in + // this shape produced zero events (IM-2). events.push({ id: baseId, sessionId, timestamp, - role: "human", + role: role, type: "message", content: content, + costUSD: raw.costUSD, + durationMs: raw.durationMs, + model, raw, }); } else if (raw.type === "summary") { From 6898c5782ebbd342a027499a606f3806b608b9be Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 19:11:09 +0800 Subject: [PATCH 3/7] fix(parser): make the firstActivity birthtime fallback reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IM-4: firstActivity was initialized to new Date() (scan time), so discovery's 'getTime() > 0' check was always true and the file birthtime fallback never ran — sessions without timestamps got a creation date that changed on every scan. A max-date sentinel now tracks the minimum and normalizes to epoch when nothing was seen. Co-Authored-By: Claude Fable 5 --- src/core/__tests__/parser.test.ts | 30 ++++++++++++++++++++++++++++++ src/core/parser.ts | 10 +++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/core/__tests__/parser.test.ts b/src/core/__tests__/parser.test.ts index 8404b97..dd73999 100644 --- a/src/core/__tests__/parser.test.ts +++ b/src/core/__tests__/parser.test.ts @@ -18,6 +18,36 @@ function makeJsonl(lines: unknown[]): { path: string; cleanup: () => void } { return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } +test("sessions without timestamps report epoch so callers can fall back (IM-4)", async () => { + const { path, cleanup } = makeJsonl([ + { type: "assistant", content: "no timestamp here" }, + ]); + try { + const meta = await scanSession(path); + // firstActivity was initialized to new Date() (scan time), so the + // birthtime fallback in discovery (getTime() > 0) never ran and the + // session's creation date changed on every scan. + assert.equal(meta.firstActivity.getTime(), 0); + assert.equal(meta.lastActivity.getTime(), 0); + } finally { + cleanup(); + } +}); + +test("firstActivity tracks the earliest timestamp seen", async () => { + const { path, cleanup } = makeJsonl([ + { type: "assistant", content: "b", timestamp: "2026-06-02T10:00:00.000Z" }, + { type: "user", content: "a", timestamp: "2026-06-01T09:00:00.000Z" }, + ]); + try { + const meta = await scanSession(path); + assert.equal(meta.firstActivity.toISOString(), "2026-06-01T09:00:00.000Z"); + assert.equal(meta.lastActivity.toISOString(), "2026-06-02T10:00:00.000Z"); + } finally { + cleanup(); + } +}); + test("legacy string-content messages produce events for both roles (IM-2)", async () => { const { path, cleanup } = makeJsonl([ // Legacy format: top-level string content diff --git a/src/core/parser.ts b/src/core/parser.ts index a019c07..3092d67 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -67,7 +67,11 @@ export async function scanSession(filePath: string): Promise { models: [], firstUserMessage: "", lastActivity: new Date(0), - firstActivity: new Date(), + // Max-date sentinel for min-tracking; normalized to epoch after the scan + // so discovery's `getTime() > 0` birthtime fallback can fire (IM-4 — a + // `new Date()` init made it always truthy and creation dates drifted to + // scan time). + firstActivity: new Date(8640000000000000), errorCount: 0, costByModel: {}, }; @@ -179,6 +183,10 @@ export async function scanSession(filePath: string): Promise { meta.filesReferenced = [...fileSet]; meta.models = [...modelSet]; + if (meta.firstActivity.getTime() === 8640000000000000) { + meta.firstActivity = new Date(0); + } + return meta; } From 82961e63bd462321f78842cfac4da8c4ca86f26f Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 19:34:55 +0800 Subject: [PATCH 4/7] fix(reports): bucket report days by one zone end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IM-3: stored timestamps are SQLite UTC strings, but the day key was sliced from the UTC text while range boundaries used local date components — in Asia/Shanghai anything done 00:00-08:00 local landed in the previous day's report, and rendered timestamps showed 8h early (JS parses bare datetime strings as local, compounding the mismatch). Date handling is now single-sourced in report-dates.ts: bare DB timestamps parse as UTC, day keys and range boundaries both use the local calendar, and the HTML renderer formats true local time. Adds the TZ-boundary tests the CI TZ=Asia/Shanghai pin was meant for. Co-Authored-By: Claude Fable 5 --- src/core/__tests__/report-dates.test.ts | 68 +++++++++++++++++++++++ src/core/__tests__/report-summary.test.ts | 4 +- src/core/report-dates.ts | 38 +++++++++++++ src/core/report-evidence.ts | 18 ++---- src/core/report-summary.ts | 33 ++++------- 5 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 src/core/__tests__/report-dates.test.ts create mode 100644 src/core/report-dates.ts diff --git a/src/core/__tests__/report-dates.test.ts b/src/core/__tests__/report-dates.test.ts new file mode 100644 index 0000000..1dd0969 --- /dev/null +++ b/src/core/__tests__/report-dates.test.ts @@ -0,0 +1,68 @@ +// Pin the zone before any Date work — these are the TZ-boundary tests the +// CI TZ=Asia/Shanghai pin existed for (IM-3, REVIEW-2026-06-10). +process.env.TZ = "Asia/Shanghai"; + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getTodayDateKey, localDateKey, parseDbTimestamp } from "../report-dates"; +import { buildReportSummary } from "../report-summary"; +import type { Session, Task } from "../types-dashboard"; + +test("bare SQLite timestamps are parsed as UTC, not local (IM-3)", () => { + const parsed = parseDbTimestamp("2026-06-10 22:30:00"); + assert.ok(parsed); + assert.equal(parsed.toISOString(), "2026-06-10T22:30:00.000Z"); + + // Explicit zones are respected as-is. + const zoned = parseDbTimestamp("2026-06-10 22:30:00+08:00"); + assert.ok(zoned); + assert.equal(zoned.toISOString(), "2026-06-10T14:30:00.000Z"); +}); + +test("day keys bucket by the local calendar day (IM-3)", () => { + // 22:30 UTC on the 10th is 06:30 on the 11th in Asia/Shanghai — this work + // previously landed in the previous day's report. + assert.equal(localDateKey("2026-06-10 22:30:00"), "2026-06-11"); + assert.equal(localDateKey("2026-06-10 10:00:00"), "2026-06-10"); + assert.equal(localDateKey("2026-06-11"), "2026-06-11"); + assert.equal(localDateKey(null), null); + assert.equal(localDateKey("not a date"), null); + + assert.equal(getTodayDateKey(new Date("2026-06-10T22:30:00.000Z")), "2026-06-11"); +}); + +test("early-morning local work appears in that local day's report (IM-3)", () => { + // Session ran 04:00–05:00 local on June 11 → stored as June 10 UTC. + const session = { + id: "s1", + project_id: "p1", + task_id: null, + worktree_name: null, + worktree_path: null, + branch_name: null, + pid: null, + status: "completed", + claude_command: null, + claude_session_id: null, + prompt: "early bird work", + exit_code: 0, + log_path: null, + started_at: "2026-06-10 20:00:00", + ended_at: "2026-06-10 21:00:00", + } as unknown as Session; + + const base = { + projectId: "p1", + projectName: "Project", + tasks: [] as Task[], + sessions: [session], + generatedAt: "2026-06-11T08:00:00.000Z", + }; + + const reportForThe11th = buildReportSummary({ ...base, date: "2026-06-11" }); + assert.equal(reportForThe11th.metrics.sessions, 1); + assert.equal(reportForThe11th.metrics.runtimeMinutes, 60); + + const reportForThe10th = buildReportSummary({ ...base, date: "2026-06-10" }); + assert.equal(reportForThe10th.metrics.sessions, 0); +}); diff --git a/src/core/__tests__/report-summary.test.ts b/src/core/__tests__/report-summary.test.ts index b392d6a..5fb07be 100644 --- a/src/core/__tests__/report-summary.test.ts +++ b/src/core/__tests__/report-summary.test.ts @@ -137,7 +137,9 @@ test("buildReportSummary supports weekly and monthly report windows", () => { id: "sunday", title: "Sunday release check", status: "review", - updated_at: "2026-05-31 16:00:00", + // 08:00 UTC = 16:00 Asia/Shanghai — still Sunday in the local zone + // that reports bucket by (16:00 UTC would already be local Monday). + updated_at: "2026-05-31 08:00:00", }), task({ id: "month-only", diff --git a/src/core/report-dates.ts b/src/core/report-dates.ts new file mode 100644 index 0000000..58efe82 --- /dev/null +++ b/src/core/report-dates.ts @@ -0,0 +1,38 @@ +/** + * Shared date handling for reports (IM-3, REVIEW-2026-06-10). + * + * One zone end-to-end: reports bucket by the user's LOCAL calendar day. + * Stored timestamps come from SQLite's datetime('now') — bare UTC strings + * like "2026-06-10 22:30:00" — which JS would otherwise parse as local + * time. Previously the day key was sliced from the UTC string while range + * boundaries used local components, so in UTC+8 anything between 00:00 and + * 08:00 local landed in the previous day's report. + */ + +/** Local calendar Y-M-D for a Date. */ +export function getTodayDateKey(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Parses a stored timestamp. Bare strings (no zone suffix) are SQLite UTC + * and get an explicit Z; strings carrying a zone are respected as-is. + */ +export function parseDbTimestamp(value: string): Date | null { + const normalized = value.trim().replace(" ", "T"); + const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized); + const date = new Date(hasZone ? normalized : `${normalized}Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Local-day key ("YYYY-MM-DD") for a stored timestamp. */ +export function localDateKey(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed; + const parsed = parseDbTimestamp(trimmed); + return parsed ? getTodayDateKey(parsed) : null; +} diff --git a/src/core/report-evidence.ts b/src/core/report-evidence.ts index 2dbe6ae..b76d14e 100644 --- a/src/core/report-evidence.ts +++ b/src/core/report-evidence.ts @@ -4,6 +4,7 @@ import type { ReportTask, } from "./report-summary"; import type { SessionStatus, TaskStatus } from "./types-dashboard"; +import { localDateKey, parseDbTimestamp } from "./report-dates"; export type HumanReportSourceType = "task" | "session"; export type HumanReportRiskSeverity = "blocked" | "failed"; @@ -180,29 +181,18 @@ function sessionTouchedInRange(session: ReportSession, range: ReportRange): bool } function dateInRange(value: string | null | undefined, range: ReportRange): boolean { - const key = dateKey(value); + const key = localDateKey(value); return Boolean(key && key >= range.startDate && key <= range.endDate); } -function dateKey(value: string | null | undefined): string | null { - if (!value) return null; - const match = value.match(/^(\d{4}-\d{2}-\d{2})/); - return match?.[1] ?? null; -} - function sessionRuntimeMinutes(session: ReportSession): number | null { if (!session.ended_at) return null; - const started = parseDate(session.started_at); - const ended = parseDate(session.ended_at); + const started = parseDbTimestamp(session.started_at); + const ended = parseDbTimestamp(session.ended_at); if (!started || !ended) return null; return Math.max(0, Math.round((ended.getTime() - started.getTime()) / 60_000)); } -function parseDate(value: string): Date | null { - const date = new Date(value.includes("T") ? value : value.replace(" ", "T")); - return Number.isNaN(date.getTime()) ? null : date; -} - function previewText(value: string | null | undefined): string | null { if (!value) return null; const compact = value.replace(/\s+/g, " ").trim(); diff --git a/src/core/report-summary.ts b/src/core/report-summary.ts index c89243c..e8a6782 100644 --- a/src/core/report-summary.ts +++ b/src/core/report-summary.ts @@ -11,6 +11,7 @@ import { type HumanReportRisk, } from "./report-evidence"; import { HUMAN_REPORT_SKILL } from "./report-skill"; +import { getTodayDateKey, localDateKey, parseDbTimestamp } from "./report-dates"; export type ReportTask = Task; export type ReportSession = Session; @@ -137,12 +138,7 @@ const ACTIVE_SESSION_STATUSES = new Set([ "paused", ]); -export function getTodayDateKey(date = new Date()): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} +export { getTodayDateKey } from "./report-dates"; export function normalizeReportDate(value: string | null): string | null { if (value == null || value.trim() === "") return getTodayDateKey(); @@ -150,7 +146,7 @@ export function normalizeReportDate(value: string | null): string | null { const date = value.trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; - const parsed = parseDate(`${date} 00:00:00`); + const parsed = parseCalendarDate(`${date} 00:00:00`); if (!parsed) return null; return getTodayDateKey(parsed) === date ? date : null; } @@ -167,7 +163,7 @@ export function buildReportRange( ): ReportRange | null { const normalizedPeriod = normalizeReportPeriod(period); if (!normalizedPeriod) return null; - const parsed = parseDate(`${date} 00:00:00`); + const parsed = parseCalendarDate(`${date} 00:00:00`); if (!parsed) return null; if (normalizedPeriod === "daily") { @@ -500,7 +496,7 @@ function sessionTouchedInRange(session: ReportSession, range: ReportRange): bool } function dateInRange(value: string | null | undefined, range: ReportRange): boolean { - const key = dateKey(value); + const key = localDateKey(value); return Boolean(key && key >= range.startDate && key <= range.endDate); } @@ -582,26 +578,21 @@ function toSessionItem( function sessionRuntimeMinutes(session: ReportSession): number | null { if (!session.ended_at) return null; - const started = parseDate(session.started_at); - const ended = parseDate(session.ended_at); + const started = parseDbTimestamp(session.started_at); + const ended = parseDbTimestamp(session.ended_at); if (!started || !ended) return null; return Math.max(0, Math.round((ended.getTime() - started.getTime()) / 60_000)); } -function dateKey(value: string | null | undefined): string | null { - if (!value) return null; - const match = value.match(/^(\d{4}-\d{2}-\d{2})/); - return match?.[1] ?? null; -} - -function parseDate(value: string): Date | null { +/** Parses a "YYYY-MM-DD HH:MM:SS" string as a LOCAL calendar moment. */ +function parseCalendarDate(value: string): Date | null { const date = new Date(value.includes("T") ? value : value.replace(" ", "T")); return Number.isNaN(date.getTime()) ? null : date; } function compareDateStrings(a: string, b: string): number { - const left = parseDate(a)?.getTime() ?? 0; - const right = parseDate(b)?.getTime() ?? 0; + const left = parseDbTimestamp(a)?.getTime() ?? 0; + const right = parseDbTimestamp(b)?.getTime() ?? 0; return left - right; } @@ -732,7 +723,7 @@ function formatMinutes(minutes: number): string { } function formatDateTime(value: string): string { - const date = parseDate(value); + const date = parseDbTimestamp(value); if (!date) return value; return date.toLocaleString("en", { year: "numeric", From fa2ad6ef2261af58b83ade354902eb3031d8a94b Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 19:53:06 +0800 Subject: [PATCH 5/7] fix(cost): attribute Claude spend to the day each message ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IM-24: Claude cost was session-granular — one record per session booked at last-activity time with empty token totals and a hardcoded model, so a week-long session dumped its entire cost on one day and 'today's cost' could inflate by orders of magnitude. scanSession now aggregates cost and tokens per (local day, model) into a compact dailyUsage list, and the cost report emits one record per bucket with real token counts. Sessions scanned before this change keep the legacy whole-session fallback. Co-Authored-By: Claude Fable 5 --- src/core/__tests__/cost-attribution.test.ts | 141 ++++++++++++++++++++ src/core/cost-tracker.ts | 33 ++++- src/core/parser.ts | 43 ++++++ src/core/types.ts | 16 +++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/core/__tests__/cost-attribution.test.ts diff --git a/src/core/__tests__/cost-attribution.test.ts b/src/core/__tests__/cost-attribution.test.ts new file mode 100644 index 0000000..239c4b5 --- /dev/null +++ b/src/core/__tests__/cost-attribution.test.ts @@ -0,0 +1,141 @@ +// Pin the zone: daily attribution buckets by the local calendar day. +process.env.TZ = "Asia/Shanghai"; + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { scanSession } from "../parser"; +import { aggregateCostReport, claudeSessionToRecords } from "../cost-tracker"; +import type { Session, SessionMeta } from "../types"; + +/** + * Regression tests for IM-24 (REVIEW-2026-06-10): a week-long Claude + * session's entire cost was booked as a single record on its last-activity + * day with empty token totals — "today's cost" could inflate by orders of + * magnitude. + */ + +function makeJsonl(lines: unknown[]): { path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "devlog-cost-attr-")); + const path = join(dir, "session.jsonl"); + writeFileSync(path, lines.map((l) => JSON.stringify(l)).join("\n") + "\n"); + return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +function assistantMessage(id: string, timestamp: string, tokens: { in: number; out: number }) { + return { + type: "assistant", + timestamp, + message: { + id, + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "work" }], + usage: { input_tokens: tokens.in, output_tokens: tokens.out }, + }, + }; +} + +test("scanSession buckets cost and tokens per local day and model (IM-24)", async () => { + const { path, cleanup } = makeJsonl([ + assistantMessage("m1", "2026-06-09T10:00:00.000Z", { in: 1000, out: 500 }), + // 22:30 UTC on the 10th = 06:30 on the 11th in Asia/Shanghai + assistantMessage("m2", "2026-06-10T22:30:00.000Z", { in: 2000, out: 1000 }), + ]); + try { + const meta = await scanSession(path); + const daily = meta.dailyUsage ?? []; + assert.deepEqual( + daily.map((b) => b.date), + ["2026-06-09", "2026-06-11"] + ); + assert.equal(daily[0].inputTokens, 1000); + assert.equal(daily[0].outputTokens, 500); + assert.equal(daily[1].inputTokens, 2000); + // Bucketed costs add up to the session total. + const sum = daily.reduce((acc, b) => acc + b.costUSD, 0); + assert.ok(Math.abs(sum - meta.totalCostUSD) < 1e-9); + assert.ok(meta.totalCostUSD > 0); + } finally { + cleanup(); + } +}); + +function makeSession(meta: Partial): Session { + return { + id: "s1", + projectPath: "/p", + projectName: "proj", + filePath: "/p/s1.jsonl", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-11T05:00:00.000Z"), + meta: { + messageCount: 2, + humanTurns: 1, + assistantTurns: 1, + toolCalls: 0, + uniqueTools: [], + filesReferenced: [], + totalCostUSD: 3, + totalDurationMs: 0, + models: ["claude-sonnet-4-6"], + firstUserMessage: "", + lastActivity: new Date("2026-06-11T05:00:00.000Z"), + firstActivity: new Date("2026-06-01T00:00:00.000Z"), + errorCount: 0, + costByModel: { "claude-sonnet-4-6": 3 }, + ...meta, + }, + }; +} + +test("claude cost lands on the day each message ran, not the session's last day (IM-24)", () => { + const session = makeSession({ + dailyUsage: [ + { + date: "2026-06-01", + model: "claude-sonnet-4-6", + costUSD: 2, + lastTimestamp: "2026-06-01T05:00:00.000Z", + inputTokens: 1000, + cachedInputTokens: 0, + outputTokens: 200, + }, + { + date: "2026-06-11", + model: "claude-sonnet-4-6", + costUSD: 1, + lastTimestamp: "2026-06-11T05:00:00.000Z", + inputTokens: 500, + cachedInputTokens: 100, + outputTokens: 50, + }, + ], + }); + + const records = claudeSessionToRecords(session); + assert.equal(records.length, 2); + assert.equal(records[1].tokens.inputTokens, 500); + assert.equal(records[1].tokens.cachedInputTokens, 100); + + const report = aggregateCostReport(records, [], { + now: new Date("2026-06-11T12:00:00+08:00"), + claudeProjectsDir: "/claude", + codexSessionsDir: "/codex", + }); + + // Only the June 11 bucket is today's cost — previously the whole $3 + // landed on the last-activity day. + assert.equal(report.totals.today.apiCostUSD, 1); + assert.equal(report.totals.allTime.apiCostUSD, 3); +}); + +test("sessions without daily buckets keep the legacy single-record fallback", () => { + const session = makeSession({ dailyUsage: undefined }); + const records = claudeSessionToRecords(session); + assert.equal(records.length, 1); + assert.equal(records[0].apiCostUSD, 3); + assert.equal(records[0].timestamp.toISOString(), "2026-06-11T05:00:00.000Z"); +}); diff --git a/src/core/cost-tracker.ts b/src/core/cost-tracker.ts index 027491a..2a899ce 100644 --- a/src/core/cost-tracker.ts +++ b/src/core/cost-tracker.ts @@ -176,7 +176,7 @@ export async function buildCostReport(options: { const claudeProjects = await discoverProjects(claudeProjectsDir); for (const project of claudeProjects) { for (const session of project.sessions) { - records.push(claudeSessionToRecord(session)); + records.push(...claudeSessionToRecords(session)); } } @@ -343,6 +343,37 @@ async function listCodexSessionFiles(root: string, includeArchived: boolean): Pr return out; } +/** + * One usage record per (local day, model) bucket, so spend is attributed to + * the day each message actually ran (IM-24). Sessions scanned by older code + * (no dailyUsage) fall back to the legacy whole-session record. + */ +export function claudeSessionToRecords(session: Session): UsageRecord[] { + const daily = session.meta.dailyUsage; + if (!daily || daily.length === 0) { + return [claudeSessionToRecord(session)]; + } + return daily.map((bucket) => ({ + provider: "claude_code" as const, + billingMode: "api" as const, + timestamp: new Date(bucket.lastTimestamp), + sessionId: session.id, + projectName: session.projectName, + projectPath: session.projectPath, + model: bucket.model, + tokens: { + inputTokens: bucket.inputTokens, + cachedInputTokens: bucket.cachedInputTokens, + outputTokens: bucket.outputTokens, + reasoningOutputTokens: 0, + totalTokens: + bucket.inputTokens + bucket.cachedInputTokens + bucket.outputTokens, + }, + apiCostUSD: bucket.costUSD, + subscriptionCredits: 0, + })); +} + function claudeSessionToRecord(session: Session): UsageRecord { return { provider: "claude_code", diff --git a/src/core/parser.ts b/src/core/parser.ts index 3092d67..c126bdf 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -7,9 +7,12 @@ import type { TextBlock, ToolUseBlock, ToolResultBlock, + SessionDailyUsage, SessionMeta, + TokenUsage, } from "./types"; import { computeCost } from "./pricing"; +import { getTodayDateKey } from "./report-dates"; // Types to skip entirely const SKIP_TYPES = new Set([ @@ -81,6 +84,40 @@ export async function scanSession(filePath: string): Promise { const modelSet = new Set(); const costedMessageIds = new Set(); + // Per-(local day, model) cost/token buckets (IM-24) — bounded by days × + // models, so safe to keep on the meta even for long sessions. + const dailyUsage = new Map(); + const addDailyUsage = ( + ts: Date | null, + model: string, + cost: number, + usage?: TokenUsage, + ) => { + const when = + ts ?? (meta.lastActivity.getTime() > 0 ? meta.lastActivity : new Date(0)); + const date = getTodayDateKey(when); + const key = `${date}\0${model}`; + const bucket = dailyUsage.get(key) ?? { + date, + model, + costUSD: 0, + lastTimestamp: when.toISOString(), + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + bucket.costUSD += cost; + const iso = when.toISOString(); + if (iso > bucket.lastTimestamp) bucket.lastTimestamp = iso; + if (usage) { + bucket.inputTokens += + (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); + bucket.cachedInputTokens += usage.cache_read_input_tokens ?? 0; + bucket.outputTokens += usage.output_tokens ?? 0; + } + dailyUsage.set(key, bucket); + }; + const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf-8" }), crlfDelay: Infinity, @@ -131,11 +168,13 @@ export async function scanSession(filePath: string): Promise { const cost = computeCost(model, usage); meta.totalCostUSD += cost; meta.costByModel[model] = (meta.costByModel[model] || 0) + cost; + addDailyUsage(ts, model, cost, usage); } else if (event.costUSD && typeof event.costUSD === "number") { // Legacy fallback meta.totalCostUSD += event.costUSD; const m = model || "unknown"; meta.costByModel[m] = (meta.costByModel[m] || 0) + event.costUSD; + addDailyUsage(ts, m, event.costUSD); } // Duration: one generic read covers turn_duration system events too — @@ -187,6 +226,10 @@ export async function scanSession(filePath: string): Promise { meta.firstActivity = new Date(0); } + meta.dailyUsage = [...dailyUsage.values()].sort( + (a, b) => a.date.localeCompare(b.date) || a.model.localeCompare(b.model), + ); + return meta; } diff --git a/src/core/types.ts b/src/core/types.ts index abd602d..6c7d1aa 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -99,6 +99,22 @@ export interface SessionMeta { firstActivity: Date; errorCount: number; costByModel: Record; + dailyUsage?: SessionDailyUsage[]; +} + +/** + * Cost and token usage aggregated per (local day, model) — lets cost + * reporting attribute spend to the day each message ran instead of booking + * a whole session on its last-activity day (IM-24). + */ +export interface SessionDailyUsage { + date: string; + model: string; + costUSD: number; + lastTimestamp: string; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; } export interface Session { From e1bfa64b24a7b339de5bd9d06211f38b74106500 Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 20:04:37 +0800 Subject: [PATCH 6/7] fix(cli): restore 'devlog ' and 'devlog help' fallthrough under commander 14 IM-15: commander 14 hard-errors with 'too many arguments' for words it doesn't recognize, so 'devlog abc123' (open a session) and 'devlog help' were both broken in the published CLI. The default command now declares an optional [ref] argument that routes to 'show', and 'help' prints program help. Verified against the bundled dist/cli.js. Co-Authored-By: Claude Fable 5 --- src/cli/cli.ts | 33 +++++++++++++++++------- src/core/__tests__/quality-gates.test.ts | 17 ++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 1ef3d27..6927c9e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -66,6 +66,7 @@ const KNOWN_COMMANDS = [ "statusline", "setup-statusline", "setup-tmux", + "help", ]; function getGlobalOpts(): GlobalOptions { @@ -104,15 +105,29 @@ program.hook("preAction", () => { initOutput({ json: !!opts.json, quiet: !!opts.quiet }); }); -// ── Default: `devlog` with no args → dashboard ────────── -program.action(async () => { - const globalOpts = getGlobalOpts(); - try { - await dashboardCommand(globalOpts); - } catch (err) { - handleError(err, globalOpts); - } -}); +// ── Default: `devlog` → dashboard, `devlog ` → show ─ +// The optional [ref] argument must be declared: commander 14 rejects +// undeclared extra words with a hard "too many arguments" error (IM-15). +program + .argument( + "[ref]", + "Session number (1, 2, 3) or ID prefix — shorthand for `devlog show `" + ) + .action(async (ref: string | undefined) => { + if (ref === "help") { + program.help(); + } + const globalOpts = getGlobalOpts(); + try { + if (ref) { + await showCommand(ref, { limit: "50" }, globalOpts); + } else { + await dashboardCommand(globalOpts); + } + } catch (err) { + handleError(err, globalOpts); + } + }); // ── devlog serve ───────────────────────────────────────── program diff --git a/src/core/__tests__/quality-gates.test.ts b/src/core/__tests__/quality-gates.test.ts index 1fad26e..da8ee1d 100644 --- a/src/core/__tests__/quality-gates.test.ts +++ b/src/core/__tests__/quality-gates.test.ts @@ -242,3 +242,20 @@ test("task and session lists refresh on control-plane stream events", () => { ); } }); + +test("the default CLI command declares the optional session ref (IM-15)", () => { + const source = readRepoFile("src/cli/cli.ts"); + + // commander 14 hard-errors with "too many arguments" for undeclared extra + // words, so `devlog ` must be a declared default argument. + assert.match( + source, + /\.argument\(\s*"\[ref\]"/, + "default command should declare an optional [ref] argument", + ); + assert.match( + source, + /KNOWN_COMMANDS = \[[\s\S]*"help"[\s\S]*\]/, + "'help' must be a known command so the did-you-mean interceptor skips it", + ); +}); From 8d5c423ca48c7508b3e74e25aa035bc0481b1ff6 Mon Sep 17 00:00:00 2001 From: moose-lab Date: Thu, 11 Jun 2026 20:07:29 +0800 Subject: [PATCH 7/7] fix(cli): report the real package version instead of a stale constant The hardcoded VERSION was two releases behind package.json (0.4.0 vs 0.5.0). The CLI now reads the version from the resolved package root, so --version and the help banner always match the installed package. Co-Authored-By: Claude Fable 5 --- src/cli/cli.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 6927c9e..6c880e5 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -12,11 +12,31 @@ import { statuslineCommand } from "./commands/statusline"; import { setupStatuslineCommand } from "./commands/setup-statusline"; import { setupTmuxCommand } from "./commands/setup-tmux"; import { serveCommand } from "./commands/serve"; +import { readFileSync } from "fs"; +import { join } from "path"; import { initOutput, outputJson } from "./utils/output"; import { levenshtein } from "./utils/format"; +import { resolvePackageRoot } from "./lib/package-root"; import type { GlobalOptions } from "../core/types"; -const VERSION = "0.4.0"; +// Version comes from package.json — a hardcoded constant drifted two +// releases behind the published package. +const VERSION = readPackageVersion(); + +function readPackageVersion(): string { + try { + const root = resolvePackageRoot(import.meta.dirname); + if (root) { + const pkg = JSON.parse( + readFileSync(join(root, "package.json"), "utf-8"), + ) as { version?: string }; + if (pkg.version) return pkg.version; + } + } catch { + // fall through + } + return "0.0.0"; +} const HELP_TEXT = ` ${chalk.bold.cyan(" ▌")} ${chalk.bold.white("DevLog")} ${chalk.dim(`v${VERSION}`)}