kankaku 0.1.0 → 0.4.6

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,145 @@ 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 writes a checkpoint of its current
205
+ record to `<KANKAKU_DIR>/inflight/<pid>.json` — first as soon as the run
206
+ starts (`before_agent_start`), so even a crash on the very first turn still
207
+ leaves a checkpoint, and then again after every `turn_end` and
208
+ `tool_execution_end` — and removes it on a normal
209
+ `agent_settled`/`session_shutdown`. If the process is killed outright
210
+ (`kill -9`, power loss) before it can settle, the checkpoint file survives
211
+ it. On the next pi start, `session_start` scans `inflight/` for checkpoints
212
+ whose owning pid is no longer alive, appends each one to `worklog.jsonl` as
213
+ `interrupted`, deletes the checkpoint file, and shows a
214
+ `kankaku: recovered N interrupted record(s)` notice. `settledAt` on a
215
+ recovered record is the time of its last checkpoint, not the actual crash
216
+ time, so `wallMs`/`workMs` are a **lower bound** on the real duration.
217
+
218
+ The same scan also sweeps `inflight/` for orphaned `.tmp` files: `save`
219
+ writes to a temp file before renaming it into place, and a process killed
220
+ between those two steps leaves the temp file behind. A stray `.tmp` file is
221
+ deleted once its writer pid is no longer alive (or its name cannot be
222
+ parsed); one still owned by a live writer — including this very process's
223
+ own in-progress write — is left alone.
224
+
225
+ ## Export
226
+
227
+ `/kankaku export [csv|json] [all]` writes one flat row per task (today's
228
+ tasks by default, or every task with `all`) to
229
+ `<KANKAKU_DIR>/export/tasks-<YYYY-MM-DD or all>.<csv|json>`, and confirms
230
+ with the file's path and row count via the durable report card. Format
231
+ defaults to `csv`; each subagent's own time is folded into its task's row
232
+ rather than exported separately (see "Task and session views").
233
+
234
+ Columns (in this order for CSV; the same fields for JSON):
235
+
236
+ | Column | Meaning |
237
+ | --- | --- |
238
+ | `id` | Task id (the orchestrator record's `id`). |
239
+ | `day` | Local calendar day (`YYYY-MM-DD`) the task started on. |
240
+ | `startedAt` / `endedAt` | ISO timestamps of the task's span. |
241
+ | `client` | Billing client, or empty when unresolved. |
242
+ | `sessionName` | pi session display name, or empty. |
243
+ | `sessionId` | pi session id, or empty. |
244
+ | `project` | Project cwd. |
245
+ | `status` | `completed`, `aborted`, or `interrupted`. |
246
+ | `prompt` | First 200 chars of the prompt, newlines collapsed to spaces. |
247
+ | `wallMs` / `waitingMs` / `workMs` | Union-based task timings (see "Task and session views"). |
248
+ | `cost` | Estimated USD cost, orchestrator plus subagents. |
249
+ | `tokensIn` / `tokensOut` / `cacheRead` | Token usage totals. |
250
+ | `subagentCount` | Number of subagent records matched to the task. |
251
+ | `segments` | JSON-encoded per-tag segment totals (see "Tagged segments"). |
252
+ | `model` | The orchestrator record's model, or empty. |
253
+
134
254
  ## Environment variables
135
255
 
136
- - `KANKAKU_DIR`: directory for the work log, relative to the project cwd
137
- unless given as an absolute path. Defaults to `.kankaku`.
256
+ - `KANKAKU_DIR`: directory for the work log (`worklog.jsonl`) and the
257
+ crash-recovery checkpoints (`inflight/`, see above), relative to the
258
+ project cwd unless given as an absolute path. Defaults to `.kankaku`.
138
259
  - `KANKAKU_INTERACTIVE_TOOLS`: comma-separated list of tool names whose
139
260
  execution span counts as waiting time. Defaults to
140
261
  `ask_user_question,ask_user_choice`.
262
+ - `KANKAKU_SEGMENTS`: `;`-separated `tag=tool:regex` rules for tagged
263
+ segments (see above). Defaults to the single `review` rule.
264
+ - `KANKAKU_CLIENT`: default billing client for this project (see "Billing
265
+ labels" above). Lower precedence than the session-level
266
+ `/kankaku client` override, higher than `<KANKAKU_DIR>/config.json`.
141
267
 
142
268
  ## Limitations
143
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.1.0",
3
+ "version": "0.4.6",
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,115 @@
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
+ const TMP_EXT = ".tmp";
10
+ /** Matches `save`'s tmp filename: `<ownerPid>.json.<writerPid>.<timestamp>.tmp`. */
11
+ const TMP_NAME_PATTERN = /\.json\.(\d+)\.\d+\.tmp$/;
12
+
13
+ function safeUnlink(filePath: string): void {
14
+ try {
15
+ unlinkSync(filePath);
16
+ } catch {
17
+ // Best effort: another process may have already removed it.
18
+ }
19
+ }
20
+
21
+ /** The writer pid embedded in a `save` tmp filename, or `undefined` when it cannot be parsed. */
22
+ function parseTmpWriterPid(entry: string): number | undefined {
23
+ const match = TMP_NAME_PATTERN.exec(entry);
24
+ if (!match) return undefined;
25
+ const pid = Number(match[1]);
26
+ return Number.isFinite(pid) ? pid : undefined;
27
+ }
28
+
29
+ /**
30
+ * {@link InflightStore} backed by one checkpoint file per process,
31
+ * `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
32
+ * it into place so a reader never observes a partially written checkpoint.
33
+ */
34
+ export class FileInflightStore implements InflightStore {
35
+ private readonly dir: string;
36
+ private readonly pid: number;
37
+
38
+ constructor(dir: string, pid: number) {
39
+ this.dir = dir;
40
+ this.pid = pid;
41
+ }
42
+
43
+ private get inflightDir(): string {
44
+ return join(this.dir, INFLIGHT_DIR_NAME);
45
+ }
46
+
47
+ private filePathFor(pid: number): string {
48
+ return join(this.inflightDir, `${pid}${JSON_EXT}`);
49
+ }
50
+
51
+ save(record: WorkRecord): void {
52
+ mkdirSync(this.inflightDir, { recursive: true });
53
+ const target = this.filePathFor(this.pid);
54
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
55
+ writeFileSync(tmp, JSON.stringify(record));
56
+ renameSync(tmp, target);
57
+ }
58
+
59
+ clear(): void {
60
+ safeUnlink(this.filePathFor(this.pid));
61
+ }
62
+
63
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
64
+ if (!existsSync(this.inflightDir)) return [];
65
+
66
+ const recovered: WorkRecord[] = [];
67
+ const ownFileName = `${this.pid}${JSON_EXT}`;
68
+
69
+ for (const entry of readdirSync(this.inflightDir)) {
70
+ if (entry.endsWith(TMP_EXT)) {
71
+ this.sweepTmpEntry(entry, isAlive);
72
+ continue;
73
+ }
74
+
75
+ if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
76
+
77
+ const filePath = join(this.inflightDir, entry);
78
+ let parsed: unknown;
79
+ try {
80
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
81
+ } catch {
82
+ safeUnlink(filePath);
83
+ continue;
84
+ }
85
+
86
+ if (!isWorkRecord(parsed)) {
87
+ safeUnlink(filePath);
88
+ continue;
89
+ }
90
+
91
+ if (isAlive(parsed.pid)) continue;
92
+
93
+ recovered.push({ ...parsed, status: "interrupted" });
94
+ safeUnlink(filePath);
95
+ }
96
+
97
+ return recovered;
98
+ }
99
+
100
+ /**
101
+ * Delete a stray `save()` tmp file left behind by a writer that crashed
102
+ * between the write and the rename. Deleted when the writer pid is not
103
+ * alive, or when the filename cannot be parsed at all (nothing to check
104
+ * liveness against). A tmp file written by this very process is always
105
+ * left alone regardless of what `isAlive` reports, since a concurrent
106
+ * `save()` in this process may still be renaming it into place.
107
+ */
108
+ private sweepTmpEntry(entry: string, isAlive: (pid: number) => boolean): void {
109
+ const writerPid = parseTmpWriterPid(entry);
110
+ if (writerPid === process.pid) return;
111
+ if (writerPid === undefined || !isAlive(writerPid)) {
112
+ safeUnlink(join(this.inflightDir, entry));
113
+ }
114
+ }
115
+ }
@@ -1,7 +1,8 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } 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
 
@@ -26,6 +27,20 @@ export class JsonlWorkLog implements WorkLog {
26
27
  appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
27
28
  }
28
29
 
30
+ /**
31
+ * Cheap change signal: `mtimeMs:size` of the log file, computed with a
32
+ * single `statSync` rather than reading the file. `"0:0"` when the file
33
+ * does not exist yet (before the first `append`).
34
+ */
35
+ version(): string {
36
+ try {
37
+ const stats = statSync(this.filePath);
38
+ return `${stats.mtimeMs}:${stats.size}`;
39
+ } catch {
40
+ return "0:0";
41
+ }
42
+ }
43
+
29
44
  readAll(): WorkRecord[] {
30
45
  if (!existsSync(this.filePath)) return [];
31
46
  const content = readFileSync(this.filePath, "utf8");
@@ -34,7 +49,12 @@ export class JsonlWorkLog implements WorkLog {
34
49
  const trimmed = line.trim();
35
50
  if (!trimmed) continue;
36
51
  try {
37
- records.push(JSON.parse(trimmed) as WorkRecord);
52
+ const parsed: unknown = JSON.parse(trimmed);
53
+ if (isWorkRecord(parsed)) {
54
+ records.push(parsed);
55
+ }
56
+ // Tolerate a structurally invalid record (e.g. an incompatible
57
+ // schema or a torn write that still parses as JSON); skip it.
38
58
  } catch {
39
59
  // Tolerate malformed lines (e.g. a torn write); skip them.
40
60
  }
@@ -0,0 +1,226 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ import { isValidClient } from "../domain/client-label.ts";
5
+ import { exportRows, toCsv, toJson } from "../domain/export.ts";
6
+ import { buildSessions, buildTasks } from "../domain/task-view.ts";
7
+ import type { WorkLog } from "../ports/work-log.ts";
8
+ import { formatClients, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient } from "./report.ts";
9
+ import type { SessionClient } from "./session-client.ts";
10
+
11
+ const REPORT_ENTRY_TYPE = "kankaku-report";
12
+
13
+ /** Durable report rendered inside the chat transcript; never sent to the LLM. */
14
+ export interface KankakuReportData {
15
+ title: string;
16
+ lines: string[];
17
+ }
18
+
19
+ /** Notify the user of an error through the UI, when one is available. */
20
+ export function notifyError(ctx: ExtensionContext, error: unknown): void {
21
+ if (!ctx.hasUI) return;
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ ctx.ui.notify(`kankaku: ${message}`, "error");
24
+ }
25
+
26
+ const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export"];
27
+
28
+ export interface KankakuCommandDeps {
29
+ log: WorkLog;
30
+ sessionClient: SessionClient;
31
+ /** Refresh the idle status line, e.g. after `/kankaku client` changes the session client. */
32
+ refreshIdleStatus: (ctx: ExtensionContext) => void;
33
+ /**
34
+ * Write an export file (name, content) under the kankaku dir and return
35
+ * its absolute path. `/kankaku export` notifies an error when this is not
36
+ * configured.
37
+ */
38
+ writeExportFile?: (name: string, content: string) => string;
39
+ }
40
+
41
+ export interface KankakuCommand {
42
+ /** Drop the cached client-name list so the next completion re-reads the log. */
43
+ invalidateClientNames(): void;
44
+ }
45
+
46
+ /**
47
+ * Registers the `/kankaku` command (report/tasks/sessions/clients/export/
48
+ * client), its argument completions, and the durable report entry renderer.
49
+ */
50
+ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDeps): KankakuCommand {
51
+ const { log, sessionClient } = deps;
52
+
53
+ /**
54
+ * Cached, sorted, de-duplicated client names for `/kankaku client <prefix>`
55
+ * autocomplete, so pressing a key does not re-read the whole worklog.
56
+ * Invalidated whenever this process appends a record, and — when `log`
57
+ * exposes the optional `version()` signal — whenever that signal changes,
58
+ * so a change from another process is picked up too.
59
+ */
60
+ let clientNamesCache: string[] | undefined;
61
+ let clientNamesCacheVersion: string | number | undefined;
62
+
63
+ function invalidateClientNames(): void {
64
+ clientNamesCache = undefined;
65
+ }
66
+
67
+ function clientNames(): string[] {
68
+ const currentVersion = log.version?.();
69
+ const versionUnchanged = log.version === undefined || currentVersion === clientNamesCacheVersion;
70
+ if (clientNamesCache !== undefined && versionUnchanged) {
71
+ return clientNamesCache;
72
+ }
73
+ const names = Array.from(
74
+ new Set(
75
+ log
76
+ .readAll()
77
+ .map((record) => record.client)
78
+ .filter((client): client is string => typeof client === "string"),
79
+ ),
80
+ ).sort();
81
+ clientNamesCache = names;
82
+ clientNamesCacheVersion = currentVersion;
83
+ return names;
84
+ }
85
+
86
+ pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
87
+ const data = entry.data ?? { title: "kankaku", lines: [] };
88
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
89
+ box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
90
+ for (const line of data.lines) {
91
+ box.addChild(new Text(line, 0, 0));
92
+ }
93
+ return box;
94
+ });
95
+
96
+ function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
97
+ if (ctx.hasUI) {
98
+ pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
99
+ return;
100
+ }
101
+ ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
102
+ }
103
+
104
+ /** Handle `/kankaku client [<name> | --clear]`; `rest` excludes the leading `client` token. */
105
+ function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
106
+ if (rest.length === 1 && rest[0] === "--clear") {
107
+ sessionClient.set(pi, undefined);
108
+ deps.refreshIdleStatus(ctx);
109
+ showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
110
+ return;
111
+ }
112
+
113
+ if (rest.length === 0) {
114
+ const client = sessionClient.effectiveClient();
115
+ const source = sessionClient.effectiveSource();
116
+ const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
117
+ showReport(ctx, { title: "client", lines: [line] });
118
+ return;
119
+ }
120
+
121
+ const name = rest.join(" ");
122
+ if (!isValidClient(name)) {
123
+ notifyError(ctx, new Error(`invalid client name: ${name}`));
124
+ return;
125
+ }
126
+ sessionClient.set(pi, name);
127
+ deps.refreshIdleStatus(ctx);
128
+ showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
129
+ }
130
+
131
+ /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
132
+ function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
133
+ if (!deps.writeExportFile) {
134
+ notifyError(ctx, new Error("export is not configured"));
135
+ return;
136
+ }
137
+
138
+ const all = rest.includes("all");
139
+ const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
140
+ const records = log.readAll();
141
+ const today = localDay(new Date().toISOString());
142
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
143
+ const rows = exportRows(tasks);
144
+ const content = format === "json" ? toJson(rows) : toCsv(rows);
145
+ const name = `tasks-${all ? "all" : today}.${format}`;
146
+ const path = deps.writeExportFile(name, content);
147
+ showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
148
+ }
149
+
150
+ pi.registerCommand("kankaku", {
151
+ description:
152
+ "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
153
+ "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
154
+ "'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
155
+ "'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
156
+ "'export [csv|json] [all]' to write today's (or every) task as a file.",
157
+ getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
158
+ const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
159
+ if (clientMatch) {
160
+ const prefix = clientMatch[1] ?? "";
161
+ return clientNames()
162
+ .filter((name) => name.startsWith(prefix))
163
+ .map((name) => ({ value: name, label: name }));
164
+ }
165
+ return COMMAND_TOKENS.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
166
+ },
167
+ handler: async (args, ctx) => {
168
+ try {
169
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
170
+
171
+ if (tokens[0] === "client") {
172
+ handleClientCommand(tokens.slice(1), ctx);
173
+ return;
174
+ }
175
+
176
+ if (tokens[0] === "export") {
177
+ handleExportCommand(tokens.slice(1), ctx);
178
+ return;
179
+ }
180
+
181
+ const all = tokens.includes("all");
182
+ const records = log.readAll();
183
+ const today = localDay(new Date().toISOString());
184
+
185
+ if (tokens.includes("clients")) {
186
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
187
+ showReport(ctx, {
188
+ title: all ? "clients (all days)" : "clients (today)",
189
+ lines: formatClients(summarizeByClient(tasks)).split("\n"),
190
+ });
191
+ return;
192
+ }
193
+
194
+ if (tokens.includes("tasks")) {
195
+ const sessionId = ctx.sessionManager.getSessionId();
196
+ const scoped = all || !sessionId;
197
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
198
+ showReport(ctx, {
199
+ title: scoped ? "tasks (every session)" : "tasks (this session)",
200
+ lines: formatTasks(tasks).split("\n"),
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (tokens.includes("sessions")) {
206
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
207
+ showReport(ctx, {
208
+ title: all ? "sessions (all days)" : "sessions (today)",
209
+ lines: formatSessions(buildSessions(tasks)).split("\n"),
210
+ });
211
+ return;
212
+ }
213
+
214
+ const summary = summarize(records, { all });
215
+ showReport(ctx, {
216
+ title: all ? "summary (all days)" : "summary (today)",
217
+ lines: formatReport(summary).split(" | "),
218
+ });
219
+ } catch (error) {
220
+ notifyError(ctx, error);
221
+ }
222
+ },
223
+ });
224
+
225
+ return { invalidateClientNames };
226
+ }
@@ -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
  }
@@ -33,4 +32,8 @@ export class LazyJsonlWorkLog implements WorkLog {
33
32
  readAll(): WorkRecord[] {
34
33
  return this.resolveFor(this.fallbackCwd()).readAll();
35
34
  }
35
+
36
+ version(): string {
37
+ return this.resolveFor(this.fallbackCwd()).version();
38
+ }
36
39
  }