kankaku-claude 1.0.0 → 1.0.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kankaku",
3
3
  "description": "Records how long Claude Code works on each of your prompts: wall time, waiting time, work time and cost, per prompt, in kankaku's worklog format.",
4
- "version": "1.0.0",
4
+ "version": "1.0.2",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## 1.0.2 — 2026-09-29
8
+
9
+ ### Fixed
10
+
11
+ - **`/kankaku:status` promised a recovery that would not happen.** The
12
+ line compared the session total with the sum of ALL the session's
13
+ records and, when idle, called the whole difference "unrecorded, goes
14
+ to the next prompt". For a session that spent money before it had a
15
+ chained baseline (upgraded mid-way, or resumed without its state) that
16
+ was false: the next prompt only carries what was spent since the last
17
+ settle. The line now separates `this prompt so far`, `pending, goes to
18
+ the next prompt` and `never recorded`. Attribution itself was correct
19
+ in 1.0.1 and is unchanged.
20
+
21
+ ## 1.0.1 — 2026-09-29
22
+
23
+ ### Fixed
24
+
25
+ - Spend that landed between two prompts was in no record: on a real session
26
+ the statusline total was 461.06 USD while its 134 records summed to
27
+ 299.80 USD, so 35 % of the spend was missing. A prompt's cost was measured
28
+ from the statusline snapshot at submit, which left background subagent
29
+ work and late statusline refreshes after a `Stop` unattributed. The
30
+ session state now keeps `costBaseline`, the total at the last settle, and
31
+ each prompt is measured from it (Stop, SessionEnd and crash recovery), so
32
+ the records of a session add up to its total. A new session starts at 0; a
33
+ counter reset records the new total. `/kankaku:status` shows `recorded $X
34
+ of $Y` and flags an unrecorded gap. Headless runs still have no cost.
35
+
7
36
  ## 1.0.0 — 2026-09-29
8
37
 
9
38
  ### Changed
package/README.md CHANGED
@@ -140,8 +140,16 @@ anything behind in whatever project happens to be open.
140
140
  `node dist/cli.js report`).
141
141
  - `/kankaku:status` — the sessions kankaku-claude currently has state for:
142
142
  session id, whether its process is still alive, whether a prompt is open,
143
- and the last cost the statusline reported, preceded by the resolved work
144
- target (wraps `node dist/cli.js status`).
143
+ and the last cost the statusline reported, followed by `recorded $X of
144
+ $Y` (the sum of this session's record costs in this project's worklog
145
+ against the session total). When they differ by more than a cent the line says what the
146
+ difference is: `(this prompt so far $R)` for the open prompt's running
147
+ spend, `(pending $P, goes to the next prompt)` for what was spent since
148
+ the last settle of a session that has a chained baseline, and
149
+ `(never recorded $Z)` for spend from before the session had one (a
150
+ session upgraded mid-way, or resumed without its state) — no later
151
+ prompt carries that part.
152
+ The resolved work target comes first (wraps `node dist/cli.js status`).
145
153
  - `/kankaku:task` — links this session to a hub task (wraps
146
154
  `node dist/cli.js task`); see "Linking a task" below.
147
155
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
@@ -257,6 +265,37 @@ from Claude Code is not supported yet. Assignment is create-only
257
265
  on the hub: a row already uploaded as unassigned stays that way until it is
258
266
  reassigned in the web app; a later sync does not move it.
259
267
 
268
+ ## How cost is derived
269
+
270
+ Claude Code hooks carry no cost. The only source is the statusline, whose
271
+ `cost.total_cost_usd` is the running total of the session; the statusline
272
+ command stores the latest value under your home directory (see "Where the
273
+ files live").
274
+
275
+ - **Per-prompt difference.** A prompt's cost is the session total when the
276
+ prompt settles minus the total the prompt is measured from, rounded to
277
+ micro-dollars and never negative.
278
+ - **Chained baseline.** The session state keeps `costBaseline`, the session
279
+ total at the last settle. The next prompt is measured from that baseline,
280
+ not from the snapshot at submit, so the records of a session add up to its
281
+ total. A session Claude Code reports as newly started (`SessionStart`
282
+ source `startup`) begins with baseline 0. A resumed session keeps the
283
+ baseline of its state file; with no state file its first prompt is
284
+ measured from the snapshot at submit.
285
+ - **Spend between prompts belongs to the next record.** `worklog.jsonl` is
286
+ append-only and the previous record is already written, so anything spent
287
+ after a settle and before the next prompt (background subagents that keep
288
+ running, a statusline refresh that arrives after `Stop`) is added to the
289
+ next record of the same session.
290
+ - **Counter reset.** If the total is lower than the value the prompt is
291
+ measured from, the counter was reset: the prompt's cost is the new total
292
+ and the baseline restarts from it.
293
+ - **Headless runs have no cost.** `claude -p` renders no statusline, so
294
+ there is no total: the record stays without cost (`costObserved` unset)
295
+ and the baseline does not move.
296
+ - `/kankaku:status` shows `recorded $X of $Y` per live session so a gap is
297
+ visible.
298
+
260
299
  ## Where the files live
261
300
 
262
301
  - `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
@@ -304,9 +343,11 @@ still open is closed at that same instant.
304
343
  only the statusline's aggregate `total_cost_usd`; per-record `usage` token
305
344
  fields stay at zero, cost is the only populated figure.
306
345
  - **Cost is a per-prompt delta of the session total, from the statusline,
307
- and needs the manual setup step.** A tiny race is possible: the statusline
308
- can render after `Stop` has already computed the delta, in which case a
309
- sliver of one prompt's cost is attributed to the next prompt instead.
346
+ and needs the manual setup step.** See "How cost is derived". Spend that
347
+ lands between two prompts (a late statusline refresh, a background
348
+ subagent still running) is attributed to the next record of the session,
349
+ not lost. Headless `claude -p` runs have no statusline, so their records
350
+ carry no cost.
310
351
  - **A permission wait ends at the next hook event, not when you actually
311
352
  click.** There is no documented hook that fires the moment you answer a
312
353
  permission dialog, so the waiting span closes at whatever hook fires next
package/dist/cli-core.js CHANGED
@@ -52,12 +52,21 @@ function runStatus(deps) {
52
52
  if (files.length === 0) {
53
53
  return { stdout: `${targetLine}\nNo active sessions.\n`, exitCode: 0 };
54
54
  }
55
+ const recordedBySession = new Map();
56
+ for (const record of new JsonlWorkLog(kankakuDir).readAll()) {
57
+ if (record.sessionId === undefined)
58
+ continue;
59
+ recordedBySession.set(record.sessionId, (recordedBySession.get(record.sessionId) ?? 0) + record.usage.cost);
60
+ }
55
61
  const lines = files
56
62
  .sort()
57
- .map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
63
+ .map((file) => {
64
+ const sessionId = sessionIdFromStateFile(file);
65
+ return formatStatusLine(sessionId, file, deps.isAlive, deps.env, recordedBySession.get(sessionId) ?? 0);
66
+ });
58
67
  return { stdout: [targetLine, ...lines].join("\n") + "\n", exitCode: 0 };
59
68
  }
60
- function formatStatusLine(sessionId, stateFile, isAlive, env) {
69
+ function formatStatusLine(sessionId, stateFile, isAlive, env, recordedUsd) {
61
70
  const state = readState(stateFile);
62
71
  if (!state)
63
72
  return `${sessionId} (unreadable state)`;
@@ -67,7 +76,41 @@ function formatStatusLine(sessionId, stateFile, isAlive, env) {
67
76
  : "idle";
68
77
  const cost = readCost(env, sessionId);
69
78
  const costWord = cost ? `$${cost.totalUsd.toFixed(2)}` : "-";
70
- return `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
79
+ const base = `${sessionId} pid ${state.pid} (${aliveWord}) ${promptWord} cost ${costWord}`;
80
+ if (!cost)
81
+ return base;
82
+ return `${base} recorded $${recordedUsd.toFixed(2)} of $${cost.totalUsd.toFixed(2)}${describeGap(state, cost.totalUsd, recordedUsd)}`;
83
+ }
84
+ /** Amounts of one cent or less are rounding noise, never reported. */
85
+ const REPORTABLE_USD = 0.01;
86
+ /**
87
+ * Splits the difference between the session's total and what its records
88
+ * hold into what it actually is, so the line never promises a recovery
89
+ * that will not happen:
90
+ *
91
+ * - `this prompt so far`: the open prompt's running spend (total − its
92
+ * `costAtStart`); it lands in that prompt's own record at settle.
93
+ * - `pending, goes to the next prompt`: only when idle AND the session has
94
+ * a `costBaseline` — the next prompt starts there, so it picks this up.
95
+ * Without a baseline the next prompt starts at the snapshot and picks up
96
+ * nothing, so nothing is promised.
97
+ * - `never recorded`: whatever is left — spent before this session had a
98
+ * chained baseline (a session upgraded mid-way, or resumed without its
99
+ * state). It is in no record and no later prompt will carry it.
100
+ */
101
+ function describeGap(state, totalUsd, recordedUsd) {
102
+ const start = state.promptOpen?.costAtStart;
103
+ const running = state.promptOpen && typeof start === "number" ? Math.max(0, totalUsd - start) : 0;
104
+ const pending = !state.promptOpen && typeof state.costBaseline === "number" ? Math.max(0, totalUsd - state.costBaseline) : 0;
105
+ const neverRecorded = totalUsd - recordedUsd - running - pending;
106
+ const parts = [];
107
+ if (running > REPORTABLE_USD)
108
+ parts.push(` (this prompt so far $${running.toFixed(2)})`);
109
+ if (pending > REPORTABLE_USD)
110
+ parts.push(` (pending $${pending.toFixed(2)}, goes to the next prompt)`);
111
+ if (neverRecorded > REPORTABLE_USD)
112
+ parts.push(` (never recorded $${neverRecorded.toFixed(2)})`);
113
+ return parts.join("");
71
114
  }
72
115
  function sessionIdFromStateFile(file) {
73
116
  return basename(file).replace(/\.state\.json$/, "");
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The chained per-prompt cost rule. `totalUsd` is the session total at
3
+ * settle, `start` the total the prompt is measured from (the previous
4
+ * settle's baseline, or the snapshot at submit for a session with none).
5
+ *
6
+ * - No finite total (headless, no statusline): nothing observed, baseline
7
+ * untouched.
8
+ * - Total below the start: the counter was reset, so the cost is the total.
9
+ * - Otherwise cost = total - start, in micro-dollars (no binary float noise).
10
+ *
11
+ * Node builtins only; safe on the light hook path.
12
+ */
13
+ export function settleCost(totalUsd, start) {
14
+ if (typeof totalUsd !== "number" || !Number.isFinite(totalUsd))
15
+ return {};
16
+ if (typeof start !== "number" || !Number.isFinite(start))
17
+ return { baseline: totalUsd };
18
+ const raw = totalUsd < start ? totalUsd : totalUsd - start;
19
+ return { cost: Math.max(0, Math.round(raw * 1e6) / 1e6), baseline: totalUsd };
20
+ }
@@ -5,6 +5,7 @@ import { readState, writeState, updateState } from "./session-state.js";
5
5
  import { resolveClaudePid } from "./claude-pid.js";
6
6
  import { splitPrompts } from "./prompts.js";
7
7
  import { readCost, deleteCost, sweepStaleCostFiles } from "./cost-store.js";
8
+ import { settleCost } from "./cost-chain.js";
8
9
  const STOP_COST_WAIT_POLL_MS = 100;
9
10
  const STOP_COST_WAIT_MAX_MS = 1500;
10
11
  /**
@@ -31,14 +32,16 @@ export async function handleHook(input, deps) {
31
32
  ? { ts, event: "UserPromptSubmit", prompt, promptId }
32
33
  : { ts, event: "UserPromptSubmit", prompt };
33
34
  appendEvent(paths.eventsFile, event);
34
- const costAtStart = readCost(deps.env, sessionId)?.totalUsd;
35
+ const snapshot = readCost(deps.env, sessionId)?.totalUsd;
35
36
  updateState(paths.stateFile, (state) => ({
36
37
  pid: state?.pid ?? 0,
37
38
  parentPid: state?.parentPid ?? 0,
38
39
  cwd: state?.cwd || cwd,
39
40
  startedAt: state?.startedAt ?? ts,
40
- promptOpen: { id: promptId ?? `${sessionId}:${ts}`, startedAt: ts, costAtStart },
41
+ // The chained baseline wins over the snapshot: spend since the last settle belongs to this prompt.
42
+ promptOpen: { id: promptId ?? `${sessionId}:${ts}`, startedAt: ts, costAtStart: state?.costBaseline ?? snapshot },
41
43
  permissionOpen: null,
44
+ ...(state?.costBaseline !== undefined ? { costBaseline: state.costBaseline } : {}),
42
45
  }));
43
46
  return;
44
47
  }
@@ -68,6 +71,7 @@ export async function handleHook(input, deps) {
68
71
  startedAt: state?.startedAt ?? ts,
69
72
  promptOpen: state?.promptOpen ?? null,
70
73
  permissionOpen: ts,
74
+ ...(state?.costBaseline !== undefined ? { costBaseline: state.costBaseline } : {}),
71
75
  }));
72
76
  return;
73
77
  }
@@ -95,7 +99,7 @@ export async function handleHook(input, deps) {
95
99
  case "SessionStart": {
96
100
  const source = readString(raw.source) ?? "startup";
97
101
  appendEvent(paths.eventsFile, { ts, event: "SessionStart", source });
98
- await handleSessionStart(paths, sessionId, cwd, source, ts, deps);
102
+ await handleSessionStart(paths, sessionId, cwd, source, raw.source === "startup", ts, deps);
99
103
  return;
100
104
  }
101
105
  case "SessionEnd": {
@@ -128,25 +132,27 @@ async function handleStop(paths, sessionId, deps) {
128
132
  deps.stderr(`kankaku: no session state at Stop for ${sessionId}`);
129
133
  return;
130
134
  }
131
- const costAtStart = state.promptOpen?.costAtStart;
132
- const totalUsd = cost?.totalUsd;
133
- const costDelta = typeof costAtStart === "number" && typeof totalUsd === "number"
134
- ? Math.max(0, Math.round((totalUsd - costAtStart) * 1e6) / 1e6) // micro-dollars: no binary float noise in the record
135
- : undefined;
136
- const core = replayPrompt(last, { cost: costDelta });
135
+ // A Stop with no open prompt settles nothing: no cost, no baseline move.
136
+ const settled = state.promptOpen ? settleCost(cost?.totalUsd, state.promptOpen.costAtStart) : {};
137
+ const core = replayPrompt(last, { cost: settled.cost });
137
138
  if (core) {
138
139
  const assignment = await assignmentResolver(paths, deps);
139
140
  const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd, sessionId));
140
141
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
141
142
  log.append(record);
142
143
  }
143
- writeState(paths.stateFile, { ...state, promptOpen: null, permissionOpen: null });
144
+ writeState(paths.stateFile, {
145
+ ...state,
146
+ promptOpen: null,
147
+ permissionOpen: null,
148
+ ...(settled.baseline !== undefined ? { costBaseline: settled.baseline } : {}),
149
+ });
144
150
  const keep = events.slice(0, events.length - last.events.length);
145
151
  dropSettledPrompts(paths.eventsFile, keep);
146
152
  if (core)
147
153
  await syncHeavy("agent_settled", state.cwd, deps);
148
154
  }
149
- async function handleSessionStart(paths, sessionId, cwd, source, ts, deps) {
155
+ async function handleSessionStart(paths, sessionId, cwd, source, isStartup, ts, deps) {
150
156
  if (source !== "compact") {
151
157
  const { recoverStaleSessions } = await import("./inflight-recovery.js");
152
158
  const { JsonlWorkLog } = await import("kankaku-pi/hub");
@@ -180,6 +186,8 @@ async function handleSessionStart(paths, sessionId, cwd, source, ts, deps) {
180
186
  startedAt: ts,
181
187
  promptOpen: null,
182
188
  permissionOpen: null,
189
+ // A newly started session's counter starts at zero; any other source keeps the snapshot-at-submit fallback.
190
+ ...(isStartup ? { costBaseline: 0 } : {}),
183
191
  };
184
192
  writeState(paths.stateFile, state);
185
193
  await syncHeavy("session_start", cwd, deps);
@@ -194,9 +202,11 @@ async function handleSessionEnd(paths, sessionId, cwd, ts, deps) {
194
202
  const prompts = splitPrompts(events);
195
203
  const last = prompts[prompts.length - 1];
196
204
  if (last) {
197
- const core = replayPrompt(last, { settledAt: ts });
205
+ const costNow = readCost(deps.env, sessionId);
206
+ const settled = settleCost(costNow?.totalUsd, state.promptOpen.costAtStart);
207
+ const core = replayPrompt(last, { settledAt: ts, cost: settled.cost });
198
208
  if (core) {
199
- const model = readCost(deps.env, sessionId)?.model;
209
+ const model = costNow?.model;
200
210
  const assignment = await assignmentResolver(paths, deps);
201
211
  const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd, sessionId));
202
212
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
@@ -6,6 +6,7 @@ import { readEventLog } from "./event-log.js";
6
6
  import { splitPrompts, replayPrompt } from "./replay.js";
7
7
  import { buildClaudeRecord } from "./record.js";
8
8
  import { readCost, deleteCost } from "./cost-store.js";
9
+ import { settleCost } from "./cost-chain.js";
9
10
  /**
10
11
  * Crash recovery: for every other session's state file whose pid is dead,
11
12
  * replay its still-open prompt (if any) into an `interrupted` record, then
@@ -46,9 +47,10 @@ export function recoverStaleSessions(input) {
46
47
  // `now` is only a fallback when no usable timestamp exists.
47
48
  const lastTs = last.events[last.events.length - 1]?.ts;
48
49
  const settledAt = typeof lastTs === "number" && Number.isFinite(lastTs) ? lastTs : input.now;
49
- const core = replayPrompt(last, { settledAt });
50
+ const costNow = readCost(input.env, sessionId);
51
+ const core = replayPrompt(last, { settledAt, cost: settleCost(costNow?.totalUsd, state.promptOpen.costAtStart).cost });
50
52
  if (core) {
51
- const model = readCost(input.env, sessionId)?.model;
53
+ const model = costNow?.model;
52
54
  records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd, sessionId)));
53
55
  }
54
56
  }
package/dist/replay.js CHANGED
@@ -97,5 +97,10 @@ export function replayPrompt(prompt, opts = {}) {
97
97
  if (terminalStopIndex >= 0)
98
98
  return settled;
99
99
  current = opts.settledAt ?? prompt.events[prompt.events.length - 1].ts;
100
- return tracker.onShutdown();
100
+ const interrupted = tracker.onShutdown();
101
+ if (!interrupted || typeof opts.cost !== "number" || !Number.isFinite(opts.cost))
102
+ return interrupted;
103
+ // An interrupted prompt ends no turn, so the tracker saw no cost; stamp the
104
+ // measured delta without inventing a turn.
105
+ return { ...interrupted, usage: { ...interrupted.usage, cost: interrupted.usage.cost + opts.cost }, costObserved: true };
101
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Claude Code plugin that records how long the agent works on each user prompt, in kankaku's worklog format.",
5
5
  "type": "module",
6
6
  "files": [
@@ -27,7 +27,7 @@
27
27
  "prepublishOnly": "npm run check"
28
28
  },
29
29
  "dependencies": {
30
- "kankaku-pi": "^1.0.0"
30
+ "kankaku-pi": "^1.0.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.4",