kankaku-claude 0.10.0 → 0.11.0

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": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ 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
+ ## 0.11.0 — 2026-09-29
8
+
9
+ ### Added
10
+
11
+ - **Records resolve their client and project automatically.** Records
12
+ written at Stop, SessionEnd and crash recovery now carry `clientId`,
13
+ `clientName`, `projectId` and `projectName` (and the legacy `client`
14
+ label) when the project `config.json` ids or the cached catalog's
15
+ `repo_paths` match the session's working directory, so the hub no longer
16
+ files them under the unassigned client. The catalog is read from
17
+ `~/.kankaku/catalog.json` only: no network, no delay, and no target when
18
+ the cache is missing. Records already on disk are not rewritten, and rows
19
+ already uploaded as unassigned stay so until reassigned in the web app.
20
+ - `/kankaku:status` and `/kankaku:doctor` print the resolved target and its
21
+ source, or `target: none (<reason>)`.
22
+
23
+ ## 0.10.2 — 2026-09-29
24
+
25
+ ### Fixed
26
+
27
+ - **Records synced by another tool were attributed to the syncer.** Records
28
+ carried no `agent`/`plugin`, so when the kankaku TUI synced a worklog
29
+ written by Claude Code, the hub row was created as agent `unknown`,
30
+ plugin `kankaku-tui`. Every record (settled, settled at SessionEnd, or
31
+ recovered as `interrupted`) now carries `agent: "claude-code"`,
32
+ `plugin: "kankaku-claude"` and `pluginVersion`. Records already on disk
33
+ are not rewritten.
34
+
35
+ ## 0.10.1 — 2026-09-29
36
+
37
+ ### Fixed
38
+
39
+ - **A prompt interrupted by a hung session was recorded with the time until
40
+ the next session started.** Crash recovery settled the open prompt at
41
+ recovery time, so a session that hung and was recovered the next morning
42
+ produced a record with a 14.4 h wall time. The prompt is now closed at
43
+ the timestamp of the last event recorded for it (its own
44
+ `UserPromptSubmit` when nothing followed); a waiting span still open is
45
+ closed at the same instant, and the record stays `interrupted`.
46
+
7
47
  ## 0.10.0 — 2026-09-28
8
48
 
9
49
  ### Fixed
package/README.md CHANGED
@@ -25,6 +25,13 @@ its own `worklog.jsonl`. This means kankaku's existing report and export
25
25
  tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
26
26
  manual and best-effort automatic hub sync through kankaku's public hub adapters (below).
27
27
 
28
+ Every record carries the identity of who measured it: `agent: "claude-code"`,
29
+ `plugin: "kankaku-claude"` and `pluginVersion` (this package's version).
30
+ A worklog synced by another tool, such as the kankaku TUI, therefore keeps
31
+ the right agent on the hub. `agentVersion` is left unset because Claude Code
32
+ does not pass its version to hooks. Records written before this version carry
33
+ no identity and are labelled by whichever tool syncs them first.
34
+
28
35
  ## Requirements
29
36
 
30
37
  - Claude Code with plugin support.
@@ -58,8 +65,7 @@ README ("Claude Code") for the full behaviour, the
58
65
  `--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` override, and why you
59
66
  should drop `--plugin-dir` (below) once this has run.
60
67
 
61
- **Manual/dev: `--plugin-dir` (also required for the `/kankaku:*` slash
62
- commands).** Clone this repository, build it once, then point Claude Code
68
+ **Manual/dev: `--plugin-dir`.** Clone this repository, build it once, then point Claude Code
63
69
  at it directly:
64
70
 
65
71
  ```bash
@@ -79,10 +85,10 @@ new commits.
79
85
  If your Claude Code version does not recognize `--plugin-dir`, or plugin
80
86
  loading has changed since this was written, check your installed version's
81
87
  own plugin documentation (`claude --help`, or `/plugin` inside a session) for
82
- the current local-install flow. `--plugin-dir` is also the only way to get
83
- the `/kankaku:*` slash commands (`/kankaku:report`, `/kankaku:setup`, …),
84
- since a Claude Code plugin loaded only through settings.json hooks — the
85
- recommended path above — never registers commands.
88
+ the current local-install flow. A plugin
89
+ wired only through settings.json hooks does not register its commands, so
90
+ `kankaku setup` installs the `/kankaku:*` commands separately (see
91
+ "Commands").
86
92
 
87
93
  **Do not combine the two.** If `kankaku setup` has already configured this
88
94
  machine's `~/.claude/settings.json` hooks, loading the plugin again with
@@ -127,11 +133,15 @@ anything behind in whatever project happens to be open.
127
133
 
128
134
  ## Commands
129
135
 
136
+ `kankaku setup` installs these as user commands under
137
+ `~/.claude/commands/kankaku/`, so they work without `--plugin-dir`.
138
+
130
139
  - `/kankaku:report` — a report of recent work, grouped by day (wraps
131
140
  `node dist/cli.js report`).
132
141
  - `/kankaku:status` — the sessions kankaku-claude currently has state for:
133
142
  session id, whether its process is still alive, whether a prompt is open,
134
- and the last cost the statusline reported (wraps `node dist/cli.js status`).
143
+ and the last cost the statusline reported, preceded by the resolved work
144
+ target (wraps `node dist/cli.js status`).
135
145
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
136
146
  `node dist/cli.js setup`).
137
147
  - `/kankaku:sync` — manually syncs recent local work records to the hub
@@ -186,6 +196,36 @@ window, and record settings above apply to both manual and automatic sync.
186
196
  Automatic runs use kankaku's change detection and per-prompt throttle; session
187
197
  boundaries are not throttled. Set `KANKAKU_SYNC_AUTO=0` to opt out.
188
198
 
199
+ ### Client and project assignment
200
+
201
+ Each record is stamped with a hub client and project when one resolves, so
202
+ the hub files the task under the right client instead of "Sin determinar".
203
+ Sources, in order:
204
+
205
+ 1. the project's `<KANKAKU_DIR>/config.json` ids (`clientId`, optional
206
+ `projectId`);
207
+ 2. the cached catalog's `repo_paths`: the active project whose path equals
208
+ the session's working directory, or contains it.
209
+
210
+ Inactive clients and projects, and the "unassigned" client, are never used.
211
+ The catalog is read from the cache file `~/.kankaku/catalog.json` only; a
212
+ hook never fetches it and never waits on the network. Every real sync
213
+ refreshes that cache, and `kankaku catalog refresh` refreshes it on demand.
214
+ Without a readable cache (or without hub credentials, which name the hub the
215
+ cache belongs to) no target resolves and records stay unassigned, exactly as
216
+ before. The legacy `client` label is the client's code when it is a valid
217
+ label; without a hub target it comes from `KANKAKU_CLIENT`, then the
218
+ `client` in `config.json`.
219
+
220
+ The target is resolved when a record is written (`Stop`, `SessionEnd`,
221
+ crash recovery), never on the per-tool-call hooks. `/kankaku:status` and
222
+ `/kankaku:doctor` print `target: <client> · <project> (source: ...)`, or
223
+ `target: none (<reason>)`.
224
+
225
+ There is nothing to pick inside Claude Code yet. Assignment is create-only
226
+ on the hub: a row already uploaded as unassigned stays that way until it is
227
+ reassigned in the web app; a later sync does not move it.
228
+
189
229
  ## Where the files live
190
230
 
191
231
  - `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
@@ -218,6 +258,12 @@ and appended as one `status: "interrupted"` record before its files are
218
258
  deleted; a dead session with no open prompt just has its files deleted. This
219
259
  also runs for the current session's own leftover state at `SessionEnd`.
220
260
 
261
+ An interrupted prompt is closed at its last recorded activity — the
262
+ timestamp of the last event logged for it, or its own start when nothing
263
+ followed — never at the moment of recovery, so a session that hung and was
264
+ recovered hours later does not report those hours as work. A waiting span
265
+ still open is closed at that same instant.
266
+
221
267
  ## Limitations
222
268
 
223
269
  - **`turns` is always 1 per run.** Claude Code hooks give no way to observe
package/dist/cli-core.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { homedir } from "node:os";
1
2
  import { basename, join } from "node:path";
2
3
  import { JsonlWorkLog } from "kankaku/hub";
3
4
  import { listStateFiles, resolveKankakuDir } from "./paths.js";
@@ -6,6 +7,7 @@ import { readCost } from "./cost-store.js";
6
7
  import { formatReport } from "./report.js";
7
8
  import { runSyncCli } from "./sync-cli.js";
8
9
  import { runDoctor } from "./doctor.js";
10
+ import { formatTargetLine, resolveClaudeWorkTarget } from "./work-target.js";
9
11
  const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor> [--days N]\n";
10
12
  /** CLI commands, resolved from `deps.cwd`. */
11
13
  export async function runCli(argv, deps) {
@@ -43,13 +45,14 @@ function runStatus(deps) {
43
45
  const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
44
46
  const claudeDir = join(kankakuDir, "claude");
45
47
  const files = listStateFiles(claudeDir);
48
+ const targetLine = formatTargetLine(resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir: deps.env.HOME || homedir(), env: deps.env }));
46
49
  if (files.length === 0) {
47
- return { stdout: "No active sessions.\n", exitCode: 0 };
50
+ return { stdout: `${targetLine}\nNo active sessions.\n`, exitCode: 0 };
48
51
  }
49
52
  const lines = files
50
53
  .sort()
51
54
  .map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
52
- return { stdout: lines.join("\n") + "\n", exitCode: 0 };
55
+ return { stdout: [targetLine, ...lines].join("\n") + "\n", exitCode: 0 };
53
56
  }
54
57
  function formatStatusLine(sessionId, stateFile, isAlive, env) {
55
58
  const state = readState(stateFile);
package/dist/doctor.js CHANGED
@@ -5,6 +5,7 @@ import { JsonlWorkLog, SyncStateStore, computeSyncStatus, resolveHubCredentials
5
5
  import { costDir } from "./cost-store.js";
6
6
  import { listStateFiles, resolveKankakuDir } from "./paths.js";
7
7
  import { readState } from "./session-state.js";
8
+ import { formatTargetLine, resolveClaudeWorkTarget } from "./work-target.js";
8
9
  function metadata(file) {
9
10
  try {
10
11
  const value = JSON.parse(readFileSync(file, "utf8"));
@@ -88,6 +89,8 @@ export function runDoctor(deps) {
88
89
  `active sessions: ${states.length}`,
89
90
  `alive: ${alive}; dead: ${dead}; open prompts: ${open}; unreadable: ${unreadable}`,
90
91
  `cost files visible: ${costsVisible ? "yes" : "no"} (current HOME)`,
92
+ "", "## Work target",
93
+ formatTargetLine(resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir: dir, homeDir: deps.env.HOME || homedir(), env: deps.env })),
91
94
  "", "## Hub / sync",
92
95
  `hub: ${hubState}`,
93
96
  `pending: ${pending}`,
@@ -135,7 +135,8 @@ async function handleStop(paths, sessionId, deps) {
135
135
  : undefined;
136
136
  const core = replayPrompt(last, { cost: costDelta });
137
137
  if (core) {
138
- const record = buildClaudeRecord(core, state, sessionId, cost?.model);
138
+ const assignment = await assignmentResolver(paths, deps);
139
+ const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd));
139
140
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
140
141
  log.append(record);
141
142
  }
@@ -155,6 +156,7 @@ async function handleSessionStart(paths, sessionId, cwd, source, ts, deps) {
155
156
  isAlive: deps.isAlive,
156
157
  now: deps.now(),
157
158
  env: deps.env,
159
+ resolveAssignment: await assignmentResolver(paths, deps),
158
160
  });
159
161
  if (recovered.length > 0) {
160
162
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
@@ -195,7 +197,8 @@ async function handleSessionEnd(paths, sessionId, cwd, ts, deps) {
195
197
  const core = replayPrompt(last, { settledAt: ts });
196
198
  if (core) {
197
199
  const model = readCost(deps.env, sessionId)?.model;
198
- const record = buildClaudeRecord(core, state, sessionId, model);
200
+ const assignment = await assignmentResolver(paths, deps);
201
+ const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd));
199
202
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
200
203
  log.append(record);
201
204
  }
@@ -209,6 +212,39 @@ async function handleSessionEnd(paths, sessionId, cwd, ts, deps) {
209
212
  deleteCost(deps.env, sessionId);
210
213
  }
211
214
  }
215
+ /**
216
+ * Loads the work-target resolver (a heavy module) and returns a per-cwd
217
+ * lookup that never throws: any failure means "no assignment", exactly the
218
+ * unassigned behaviour. Heavy hooks only.
219
+ */
220
+ async function assignmentResolver(paths, deps) {
221
+ try {
222
+ const { resolveClaudeWorkTarget } = await import("./work-target.js");
223
+ const { homedir } = await import("node:os");
224
+ const resolve = deps.resolveTarget ?? resolveClaudeWorkTarget;
225
+ return (cwd) => {
226
+ try {
227
+ const { target, legacyClient } = resolve({
228
+ cwd,
229
+ kankakuDir: paths.kankakuDir,
230
+ homeDir: deps.env.HOME || homedir(),
231
+ env: deps.env,
232
+ });
233
+ return {
234
+ ...(target !== undefined ? { target } : {}),
235
+ ...(legacyClient !== undefined ? { legacyClient } : {}),
236
+ };
237
+ }
238
+ catch (error) {
239
+ deps.stderr(`kankaku: work target: ${error instanceof Error ? error.message : String(error)}`);
240
+ return {};
241
+ }
242
+ };
243
+ }
244
+ catch {
245
+ return () => ({});
246
+ }
247
+ }
212
248
  async function syncHeavy(trigger, cwd, deps) {
213
249
  try {
214
250
  if (deps.autoSync)
@@ -39,10 +39,15 @@ export function recoverStaleSessions(input) {
39
39
  const prompts = splitPrompts(events);
40
40
  const last = prompts[prompts.length - 1];
41
41
  if (last && last.open) {
42
- const core = replayPrompt(last, { settledAt: input.now });
42
+ // Close at the prompt's last recorded activity, never at recovery
43
+ // time (which is the next session start, possibly hours later).
44
+ // `now` is only a fallback when no usable timestamp exists.
45
+ const lastTs = last.events[last.events.length - 1]?.ts;
46
+ const settledAt = typeof lastTs === "number" && Number.isFinite(lastTs) ? lastTs : input.now;
47
+ const core = replayPrompt(last, { settledAt });
43
48
  if (core) {
44
49
  const model = readCost(input.env, sessionId)?.model;
45
- records.push(buildClaudeRecord(core, state, sessionId, model));
50
+ records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd)));
46
51
  }
47
52
  }
48
53
  }
package/dist/record.js CHANGED
@@ -1,12 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** `version` from a `package.json`, or `undefined` when unreadable — never guessed. */
5
+ export function readPackageVersion(file) {
6
+ try {
7
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
8
+ return typeof pkg.version === "string" && pkg.version !== "" ? pkg.version : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ // Resolved once per hook process; `src/` and `dist/` share the same depth.
15
+ const PLUGIN_VERSION = readPackageVersion(join(dirname(dirname(fileURLToPath(import.meta.url))), "package.json"));
1
16
  /**
2
17
  * Attaches the orchestrator metadata a replayed {@link WorkRecordCore}
3
- * needs to become a persistable {@link WorkRecord}. Phase 1: no
4
- * `client`/`clientId` (hub sync is phase 2). `model` is the statusline
18
+ * needs to become a persistable {@link WorkRecord}. `assignment` carries the
19
+ * resolved hub target (`clientId`, `clientName`, `projectId`, `projectName`)
20
+ * and the legacy `client` label, stamped exactly as the pi extension does;
21
+ * omitted, the record is unassigned. `model` is the statusline
5
22
  * model id (from `src/cost-store.ts#readCost`, since T7 no longer a field
6
23
  * of `SessionState`) — passed in explicitly rather than read here, so the
7
24
  * caller decides which cost snapshot's model applies.
25
+ *
26
+ * Also stamps who MEASURED the record (`agent`, `plugin`, `pluginVersion`),
27
+ * so a worklog later synced by another tool (the kankaku TUI) keeps the
28
+ * right identity. `agentVersion` is omitted: Claude Code passes its version
29
+ * to no hook payload, and spawning `claude --version` from a hook is not
30
+ * acceptable.
8
31
  */
9
- export function buildClaudeRecord(core, state, sessionId, model) {
32
+ export function buildClaudeRecord(core, state, sessionId, model, assignment = {}) {
33
+ const { target, legacyClient } = assignment;
10
34
  return {
11
35
  ...core,
12
36
  role: "orchestrator",
@@ -15,6 +39,18 @@ export function buildClaudeRecord(core, state, sessionId, model) {
15
39
  project: state.cwd,
16
40
  sessionId,
17
41
  mode: "claude-code",
42
+ agent: "claude-code",
43
+ plugin: "kankaku-claude",
44
+ ...(PLUGIN_VERSION !== undefined ? { pluginVersion: PLUGIN_VERSION } : {}),
18
45
  ...(model ? { model: `anthropic/${model}` } : {}),
46
+ ...(legacyClient !== undefined ? { client: legacyClient } : {}),
47
+ ...(target !== undefined
48
+ ? {
49
+ clientId: target.clientId,
50
+ clientName: target.clientName,
51
+ ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
52
+ ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
53
+ }
54
+ : {}),
19
55
  };
20
56
  }
@@ -0,0 +1,71 @@
1
+ import { join } from "node:path";
2
+ import { CachedCatalog, readProjectClient, readProjectTargetIds, resolveHubCredentials, } from "kankaku/hub";
3
+ import { formatWorkTargetLabel, isValidClient, resolveClient, resolveWorkTarget, resolveWorkTargetSource, } from "kankaku/domain";
4
+ /**
5
+ * Resolves the work target for a session, for record stamping and display.
6
+ *
7
+ * Sources, in order: the project's `config.json` ids, then the cached
8
+ * catalog's `repo_paths` match for `cwd`. The catalog comes only from the
9
+ * cache file that every real sync refreshes — there is no fetch, no refresh
10
+ * and no network here, and nothing throws: an unusable cache means "no
11
+ * target". The precedence and eligibility rules (active, not the
12
+ * "unassigned" client, project belongs to the client) live entirely in the
13
+ * library's `resolveWorkTarget`.
14
+ *
15
+ * Only the heavy hooks (Stop, SessionEnd, recovery) and the CLI call this;
16
+ * the per-tool-call hooks never do.
17
+ */
18
+ export function resolveClaudeWorkTarget(input) {
19
+ try {
20
+ return resolveUnsafe(input);
21
+ }
22
+ catch {
23
+ return { reason: "no catalog cache" };
24
+ }
25
+ }
26
+ function resolveUnsafe(input) {
27
+ const projectClient = readProjectClient(input.kankakuDir);
28
+ const legacyFallback = resolveClient({ env: input.env.KANKAKU_CLIENT, project: projectClient });
29
+ const withLegacy = (result) => legacyFallback !== undefined ? { ...result, legacyClient: legacyFallback } : result;
30
+ const snapshot = readCatalogCache(input);
31
+ if (!snapshot)
32
+ return withLegacy({ reason: "no catalog cache" });
33
+ const resolveInput = {
34
+ project: readProjectTargetIds(input.kankakuDir),
35
+ cwd: input.cwd,
36
+ clients: snapshot.clients,
37
+ projects: snapshot.projects,
38
+ };
39
+ const target = resolveWorkTarget(resolveInput);
40
+ const source = resolveWorkTargetSource(resolveInput);
41
+ if (!target || (source !== "project" && source !== "repoPaths")) {
42
+ return withLegacy({ reason: `no match for ${input.cwd}` });
43
+ }
44
+ // Mirrors the pi extension: with a hub target the legacy label is the
45
+ // client's code, omitted when it is not a valid label.
46
+ return {
47
+ target,
48
+ source,
49
+ ...(isValidClient(target.clientCode) ? { legacyClient: target.clientCode } : {}),
50
+ };
51
+ }
52
+ function readCatalogCache(input) {
53
+ // The cache is keyed by hub url; the url comes from credentials (env or
54
+ // file), which are read locally. No credentials, no way to trust a cache.
55
+ const hub = resolveHubCredentials({ env: input.env, homeDir: () => input.homeDir });
56
+ if (!hub.credentials)
57
+ return undefined;
58
+ return new CachedCatalog({
59
+ filePath: join(input.homeDir, ".kankaku", "catalog.json"),
60
+ url: hub.credentials.url,
61
+ clock: { now: () => 0 }, // read() never consults the clock
62
+ fetchCatalog: () => Promise.reject(new Error("the hook path never fetches")),
63
+ }).read();
64
+ }
65
+ /** The `target:` line printed by `/kankaku:status` and `/kankaku:doctor`. */
66
+ export function formatTargetLine(result) {
67
+ if (!result.target)
68
+ return `target: none (${result.reason ?? "unresolved"})`;
69
+ const source = result.source === "project" ? "project config" : "repo_paths";
70
+ return `target: ${formatWorkTargetLabel(result.target)} (source: ${source})`;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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": "^0.10.0"
30
+ "kankaku": "^0.11.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.4",