kankaku 0.1.0 → 0.4.5

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/README.md CHANGED
@@ -55,6 +55,8 @@ Each line in `worklog.jsonl` is one JSON object:
55
55
  "sessionFile": "…",
56
56
  "mode": "tui",
57
57
  "model": "anthropic/claude-opus",
58
+ "client": "acme",
59
+ "sessionName": "billing sprint",
58
60
  "prompt": "first 200 chars of the first prompt",
59
61
  "startedAt": "2026-09-10T16:00:00.000Z",
60
62
  "settledAt": "2026-09-10T16:04:10.000Z",
@@ -65,6 +67,7 @@ Each line in `worklog.jsonl` is one JSON object:
65
67
  "turns": 9,
66
68
  "tools": { "bash": 4, "read": 3, "subagent_run": 1, "ask_user_question": 1 },
67
69
  "subagents": [{ "toolCallId": "…", "agent": "sdd-explore", "mode": "task", "taskId": "t1", "ms": 90000 }],
70
+ "segments": { "review": 62000 },
68
71
  "usage": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "cost": 0 },
69
72
  "status": "completed"
70
73
  }
@@ -122,22 +125,136 @@ Arguments are whitespace-separated and order-insensitive:
122
125
  session are shown instead.
123
126
  - `/kankaku sessions` — one line per session (id, time range, union
124
127
  wall/work, cost, task count) for today. Add `all` for every day.
128
+ - `/kankaku client <name>` — set the billing client for the current pi
129
+ session. `/kankaku client` alone shows the effective client and which
130
+ source it came from; `/kankaku client --clear` removes the session-level
131
+ override. See "Billing labels" below.
132
+ - `/kankaku clients` — one line per client (work/waiting/wall time, cost,
133
+ task count) for today. Add `all` for every day. Tasks with no resolved
134
+ client are grouped under `(none)`.
125
135
 
126
136
  Cost figures are the sum of `usage.cost` as priced by pi's model table
127
137
  (per-million-token rates in `models.json`, adjustable with `modelOverrides`).
128
138
  For subscription-based providers this is an estimate at API list prices, not
129
139
  an invoice.
130
140
 
131
- While an agent is running, pi's status bar shows a `⏱ mm:ss` indicator with
141
+ While an agent is running, pi's status bar shows a `🕒 mm:ss · <client>` indicator (the client part appears only when one resolves); while idle it shows `💼 <client>`, or nothing when no client resolves. The entry is keyed `zz-kankaku` so it sorts last among extension statuses. The running indicator carries
132
142
  the elapsed time for the current run.
133
143
 
144
+ ## Billing labels
145
+
146
+ Every `WorkRecord` can carry a `client` — who the work is billed to — so
147
+ reports and exports can be grouped by client. The effective client is
148
+ resolved from three sources, in decreasing precedence:
149
+
150
+ 1. **Session** — set with `/kankaku client <name>` (see above), persisted as
151
+ a `kankaku-client` custom session entry and restored on session reload.
152
+ 2. **`KANKAKU_CLIENT`** — the environment variable, a per-process default.
153
+ 3. **Project** — `client` in `<KANKAKU_DIR>/config.json` (e.g.
154
+ `{"client": "acme"}`), the project's own default.
155
+
156
+ A client name must match `/^[A-Za-z0-9._-]{1,64}$/`; anything else (empty,
157
+ too long, containing spaces or other characters) is ignored and resolution
158
+ falls through to the next source.
159
+
160
+ A `subagent_run` child process does not resolve its own client — a
161
+ subagent's own `WorkRecord` never carries `client`. Instead, the **task**
162
+ view (see "Task and session views") exposes the client from its
163
+ orchestrator record only, so `/kankaku tasks`, `/kankaku clients`, and the
164
+ export all see subagent work grouped under the task's (i.e. the
165
+ orchestrator's) client.
166
+
167
+ `sessionName` is also attached to every record from `pi.getSessionName()`,
168
+ so reports can show which named session produced a task.
169
+
170
+ ## Tagged segments
171
+
172
+ While a run is open, kankaku can also time tool executions that match a
173
+ configured rule and tag the resulting span with a name — for example,
174
+ knowing how much of a task went to gentle-ai's review-with-receipts step,
175
+ which runs as `gentle-ai review ...` commands through the `bash` tool inside
176
+ the prompt's run.
177
+
178
+ The default rule tags `review`: tool `bash` running a command matching
179
+ `/\bgentle-ai review\b/`. Configure rules with `KANKAKU_SEGMENTS`, a
180
+ `;`-separated list of `tag=tool:regex` entries, e.g.:
181
+
182
+ ```
183
+ KANKAKU_SEGMENTS="review=bash:gentle-ai review;commit=bash:git commit"
184
+ ```
185
+
186
+ Setting `KANKAKU_SEGMENTS` replaces the default rule entirely; malformed
187
+ entries (missing tag, tool or regex, or an invalid regex) are skipped.
188
+ When several rules could match the same tool call, only the first one
189
+ applies. A `WorkRecord`'s `segments` field is the **union** of milliseconds
190
+ per tag within that one record, so overlapping matching calls are not
191
+ double-counted. `TaskView.segments` and `SessionView.segments` are instead
192
+ the **sum** of `segments` across the orchestrator and its children (or
193
+ across a session's tasks): segment spans are not persisted to
194
+ `worklog.jsonl`, so once a record settles there is nothing left to union
195
+ across records, only per-record totals to add up.
196
+
197
+ Note that the reviewer's own token cost is not observable here: gentle-pi
198
+ runs it with `--no-extensions`, so kankaku never sees the reviewer's own
199
+ prompt/tool events, only the `bash` call the orchestrator makes to invoke
200
+ it.
201
+
202
+ ## Crash recovery
203
+
204
+ While a run is open, each pi process periodically writes a checkpoint of
205
+ its current record to `<KANKAKU_DIR>/inflight/<pid>.json` (after every
206
+ `turn_end` and `tool_execution_end`), and removes it on a normal
207
+ `agent_settled`/`session_shutdown`. If the process is killed outright
208
+ (`kill -9`, power loss) before it can settle, the checkpoint file survives
209
+ it. On the next pi start, `session_start` scans `inflight/` for checkpoints
210
+ whose owning pid is no longer alive, appends each one to `worklog.jsonl` as
211
+ `interrupted`, deletes the checkpoint file, and shows a
212
+ `kankaku: recovered N interrupted record(s)` notice. `settledAt` on a
213
+ recovered record is the time of its last checkpoint, not the actual crash
214
+ time, so `wallMs`/`workMs` are a **lower bound** on the real duration.
215
+
216
+ ## Export
217
+
218
+ `/kankaku export [csv|json] [all]` writes one flat row per task (today's
219
+ tasks by default, or every task with `all`) to
220
+ `<KANKAKU_DIR>/export/tasks-<YYYY-MM-DD or all>.<csv|json>`, and confirms
221
+ with the file's path and row count via the durable report card. Format
222
+ defaults to `csv`; each subagent's own time is folded into its task's row
223
+ rather than exported separately (see "Task and session views").
224
+
225
+ Columns (in this order for CSV; the same fields for JSON):
226
+
227
+ | Column | Meaning |
228
+ | --- | --- |
229
+ | `id` | Task id (the orchestrator record's `id`). |
230
+ | `day` | Local calendar day (`YYYY-MM-DD`) the task started on. |
231
+ | `startedAt` / `endedAt` | ISO timestamps of the task's span. |
232
+ | `client` | Billing client, or empty when unresolved. |
233
+ | `sessionName` | pi session display name, or empty. |
234
+ | `sessionId` | pi session id, or empty. |
235
+ | `project` | Project cwd. |
236
+ | `status` | `completed`, `aborted`, or `interrupted`. |
237
+ | `prompt` | First 200 chars of the prompt, newlines collapsed to spaces. |
238
+ | `wallMs` / `waitingMs` / `workMs` | Union-based task timings (see "Task and session views"). |
239
+ | `cost` | Estimated USD cost, orchestrator plus subagents. |
240
+ | `tokensIn` / `tokensOut` / `cacheRead` | Token usage totals. |
241
+ | `subagentCount` | Number of subagent records matched to the task. |
242
+ | `segments` | JSON-encoded per-tag segment totals (see "Tagged segments"). |
243
+ | `model` | The orchestrator record's model, or empty. |
244
+
134
245
  ## Environment variables
135
246
 
136
- - `KANKAKU_DIR`: directory for the work log, relative to the project cwd
137
- unless given as an absolute path. Defaults to `.kankaku`.
247
+ - `KANKAKU_DIR`: directory for the work log (`worklog.jsonl`) and the
248
+ crash-recovery checkpoints (`inflight/`, see above), relative to the
249
+ project cwd unless given as an absolute path. Defaults to `.kankaku`.
138
250
  - `KANKAKU_INTERACTIVE_TOOLS`: comma-separated list of tool names whose
139
251
  execution span counts as waiting time. Defaults to
140
252
  `ask_user_question,ask_user_choice`.
253
+ - `KANKAKU_SEGMENTS`: `;`-separated `tag=tool:regex` rules for tagged
254
+ segments (see above). Defaults to the single `review` rule.
255
+ - `KANKAKU_CLIENT`: default billing client for this project (see "Billing
256
+ labels" above). Lower precedence than the session-level
257
+ `/kankaku client` override, higher than `<KANKAKU_DIR>/config.json`.
141
258
 
142
259
  ## Limitations
143
260
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.1.0",
3
+ "version": "0.4.5",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -0,0 +1,33 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
4
+
5
+ const EXPORT_SUBDIR = "export";
6
+
7
+ /**
8
+ * Write `content` to `<dir>/export/<name>` (creating the `export`
9
+ * subdirectory as needed, overwriting any existing file with the same
10
+ * name) and return the absolute path it was written to.
11
+ */
12
+ export function writeExport(dir: string, name: string, content: string): string {
13
+ const exportDir = join(dir, EXPORT_SUBDIR);
14
+ mkdirSync(exportDir, { recursive: true });
15
+ const filePath = resolve(join(exportDir, name));
16
+ writeFileSync(filePath, content);
17
+ return filePath;
18
+ }
19
+
20
+ /** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
21
+ export class LazyExportWriter {
22
+ private readonly dirOrRelative: string;
23
+ private readonly fallbackCwd: () => string;
24
+
25
+ constructor(dirOrRelative: string, fallbackCwd: () => string = () => process.cwd()) {
26
+ this.dirOrRelative = dirOrRelative;
27
+ this.fallbackCwd = fallbackCwd;
28
+ }
29
+
30
+ write(name: string, content: string): string {
31
+ return writeExport(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), name, content);
32
+ }
33
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isWorkRecord } from "../domain/work-record.ts";
4
+ import type { WorkRecord } from "../domain/work-record.ts";
5
+ import type { InflightStore } from "../ports/inflight-store.ts";
6
+
7
+ const INFLIGHT_DIR_NAME = "inflight";
8
+ const JSON_EXT = ".json";
9
+
10
+ function safeUnlink(filePath: string): void {
11
+ try {
12
+ unlinkSync(filePath);
13
+ } catch {
14
+ // Best effort: another process may have already removed it.
15
+ }
16
+ }
17
+
18
+ /**
19
+ * {@link InflightStore} backed by one checkpoint file per process,
20
+ * `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
21
+ * it into place so a reader never observes a partially written checkpoint.
22
+ */
23
+ export class FileInflightStore implements InflightStore {
24
+ private readonly dir: string;
25
+ private readonly pid: number;
26
+
27
+ constructor(dir: string, pid: number) {
28
+ this.dir = dir;
29
+ this.pid = pid;
30
+ }
31
+
32
+ private get inflightDir(): string {
33
+ return join(this.dir, INFLIGHT_DIR_NAME);
34
+ }
35
+
36
+ private filePathFor(pid: number): string {
37
+ return join(this.inflightDir, `${pid}${JSON_EXT}`);
38
+ }
39
+
40
+ save(record: WorkRecord): void {
41
+ mkdirSync(this.inflightDir, { recursive: true });
42
+ const target = this.filePathFor(this.pid);
43
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify(record));
45
+ renameSync(tmp, target);
46
+ }
47
+
48
+ clear(): void {
49
+ safeUnlink(this.filePathFor(this.pid));
50
+ }
51
+
52
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
53
+ if (!existsSync(this.inflightDir)) return [];
54
+
55
+ const recovered: WorkRecord[] = [];
56
+ const ownFileName = `${this.pid}${JSON_EXT}`;
57
+
58
+ for (const entry of readdirSync(this.inflightDir)) {
59
+ if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
60
+
61
+ const filePath = join(this.inflightDir, entry);
62
+ let parsed: unknown;
63
+ try {
64
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
65
+ } catch {
66
+ safeUnlink(filePath);
67
+ continue;
68
+ }
69
+
70
+ if (!isWorkRecord(parsed)) {
71
+ safeUnlink(filePath);
72
+ continue;
73
+ }
74
+
75
+ if (isAlive(parsed.pid)) continue;
76
+
77
+ recovered.push({ ...parsed, status: "interrupted" });
78
+ safeUnlink(filePath);
79
+ }
80
+
81
+ return recovered;
82
+ }
83
+ }
@@ -2,6 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { WorkLog } from "../ports/work-log.ts";
4
4
  import type { WorkRecord } from "../domain/work-record.ts";
5
+ import { isWorkRecord } from "../domain/work-record.ts";
5
6
 
6
7
  const LOG_FILE_NAME = "worklog.jsonl";
7
8
 
@@ -34,7 +35,12 @@ export class JsonlWorkLog implements WorkLog {
34
35
  const trimmed = line.trim();
35
36
  if (!trimmed) continue;
36
37
  try {
37
- records.push(JSON.parse(trimmed) as WorkRecord);
38
+ const parsed: unknown = JSON.parse(trimmed);
39
+ if (isWorkRecord(parsed)) {
40
+ records.push(parsed);
41
+ }
42
+ // Tolerate a structurally invalid record (e.g. an incompatible
43
+ // schema or a torn write that still parses as JSON); skip it.
38
44
  } catch {
39
45
  // Tolerate malformed lines (e.g. a torn write); skip them.
40
46
  }
@@ -0,0 +1,10 @@
1
+ import { isAbsolute, join } from "node:path";
2
+
3
+ /**
4
+ * Resolve the kankaku data directory: an absolute `dirOrRelative` is used
5
+ * as-is, a relative one is joined against `cwd`. Shared by every lazy
6
+ * adapter so the rule lives in exactly one place.
7
+ */
8
+ export function resolveKankakuDir(dirOrRelative: string, cwd: string): string {
9
+ return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
10
+ }
@@ -0,0 +1,43 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { InflightStore } from "../ports/inflight-store.ts";
3
+ import { FileInflightStore } from "./file-inflight-store.ts";
4
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
5
+
6
+ /**
7
+ * {@link InflightStore} that resolves a relative checkpoint directory
8
+ * lazily, mirroring {@link LazyJsonlWorkLog}: against the project of the
9
+ * first saved record, or against `fallbackCwd()` when `clear` or
10
+ * `recoverStale` happens before any record was saved in this process
11
+ * (e.g. `session_start`, which runs before `before_agent_start`).
12
+ */
13
+ export class LazyFileInflightStore implements InflightStore {
14
+ private readonly dirOrRelative: string;
15
+ private readonly pid: number;
16
+ private readonly fallbackCwd: () => string;
17
+ private resolved: FileInflightStore | undefined;
18
+
19
+ constructor(dirOrRelative: string, pid: number, fallbackCwd: () => string = () => process.cwd()) {
20
+ this.dirOrRelative = dirOrRelative;
21
+ this.pid = pid;
22
+ this.fallbackCwd = fallbackCwd;
23
+ }
24
+
25
+ private resolveFor(cwd: string): FileInflightStore {
26
+ if (!this.resolved) {
27
+ this.resolved = new FileInflightStore(resolveKankakuDir(this.dirOrRelative, cwd), this.pid);
28
+ }
29
+ return this.resolved;
30
+ }
31
+
32
+ save(record: WorkRecord): void {
33
+ this.resolveFor(record.project).save(record);
34
+ }
35
+
36
+ clear(): void {
37
+ this.resolveFor(this.fallbackCwd()).clear();
38
+ }
39
+
40
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
41
+ return this.resolveFor(this.fallbackCwd()).recoverStale(isAlive);
42
+ }
43
+ }
@@ -1,7 +1,7 @@
1
- import { isAbsolute, join } from "node:path";
2
1
  import type { WorkRecord } from "../domain/work-record.ts";
3
2
  import type { WorkLog } from "../ports/work-log.ts";
4
3
  import { JsonlWorkLog } from "./jsonl-work-log.ts";
4
+ import { resolveKankakuDir } from "./kankaku-dir.ts";
5
5
 
6
6
  /**
7
7
  * {@link WorkLog} that resolves a relative log directory lazily: against the
@@ -20,8 +20,7 @@ export class LazyJsonlWorkLog implements WorkLog {
20
20
 
21
21
  private resolveFor(cwd: string): JsonlWorkLog {
22
22
  if (!this.resolved) {
23
- const dir = isAbsolute(this.dirOrRelative) ? this.dirOrRelative : join(cwd, this.dirOrRelative);
24
- this.resolved = new JsonlWorkLog(dir);
23
+ this.resolved = new JsonlWorkLog(resolveKankakuDir(this.dirOrRelative, cwd));
25
24
  }
26
25
  return this.resolved;
27
26
  }