Skip to content
55 changes: 45 additions & 10 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)}
Expand Down Expand Up @@ -66,6 +86,7 @@ const KNOWN_COMMANDS = [
"statusline",
"setup-statusline",
"setup-tmux",
"help",
];

function getGlobalOpts(): GlobalOptions {
Expand Down Expand Up @@ -104,15 +125,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 <ref>` → 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 <ref>`"
)
.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
Expand Down
141 changes: 141 additions & 0 deletions src/core/__tests__/cost-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -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<SessionMeta>): 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");
});
109 changes: 109 additions & 0 deletions src/core/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 { parseSessionFile, 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("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
{
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([
{
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();
}
});
17 changes: 17 additions & 0 deletions src/core/__tests__/quality-gates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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",
);
});
Loading
Loading