kankaku-claude 0.1.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
9
  ### Added
10
10
 
11
+ - `/kankaku:sync-status` and `/kankaku:sync-all` slash commands for local
12
+ sync state inspection and full hub sync, respectively.
13
+
11
14
  - Phase 1: per-prompt work records from Claude Code hooks. Writes one
12
15
  `WorkRecord` per user prompt to `<KANKAKU_DIR>/worklog.jsonl`, in the same
13
16
  schema the kankaku pi extension writes, so kankaku's existing
@@ -28,8 +31,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
28
31
  `/kankaku:status`, `/kankaku:setup` slash commands.
29
32
 
30
33
  - Manual hub sync via `node src/cli.ts sync [all|status]` and the
31
- `/kankaku:sync` slash command (default sync only). No automatic sync hooks;
32
- prompts are omitted by default.
34
+ `/kankaku:sync` slash command (default sync only). Prompts are omitted by default.
35
+ - Best-effort automatic hub sync on `SessionStart`, `Stop`, and `SessionEnd`;
36
+ disabled with `KANKAKU_SYNC_AUTO=0`. Missing credentials and sync failures
37
+ never block local records or cleanup.
33
38
 
34
39
  ### Fixed
35
40
 
package/README.md CHANGED
@@ -20,7 +20,7 @@ Records are written in kankaku's own `WorkRecord` schema
20
20
  (`WORK_RECORD_SCHEMA = 1`), the exact one the kankaku pi extension writes to
21
21
  its own `worklog.jsonl`. This means kankaku's existing report and export
22
22
  tooling can read this plugin's worklog unchanged. kankaku-claude also exposes
23
- manual hub sync through kankaku's public hub adapters (below).
23
+ manual and best-effort automatic hub sync through kankaku's public hub adapters (below).
24
24
 
25
25
  ## Requirements
26
26
 
@@ -85,8 +85,11 @@ anything behind in whatever project happens to be open.
85
85
  and the last cost the statusline reported (wraps `node src/cli.ts status`).
86
86
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
87
87
  `node src/cli.ts setup`).
88
- - `/kankaku:sync` — manually syncs local work records to the hub (wraps
89
- `node src/cli.ts sync`; the slash command does not forward arguments).
88
+ - `/kankaku:sync` — manually syncs recent local work records to the hub
89
+ (wraps `node src/cli.ts sync`; the slash command does not forward arguments).
90
+ - `/kankaku:sync-status` — inspects local pending counts and sync state
91
+ (wraps `node src/cli.ts sync status`; no hub request or credentials required).
92
+ - `/kankaku:sync-all` — requests a full sync (wraps `node src/cli.ts sync all`).
90
93
 
91
94
  The report, status, and setup subcommands are also available directly via
92
95
  `node src/cli.ts <report|status|setup>`; `report` accepts `--days N` and
@@ -96,9 +99,10 @@ defaults to the last 7 days.
96
99
 
97
100
  Configure hub credentials with `KANKAKU_PB_URL`, `KANKAKU_PB_EMAIL`, and
98
101
  `KANKAKU_PB_PASSWORD`, or use `~/.kankaku/credentials.json` (the shared
99
- kankaku hub credential file). Then run `/kankaku:sync` or
100
- `node src/cli.ts sync` in the project whose worklog you want to upload.
101
- For options not forwarded by the slash command, use the CLI directly:
102
+ kankaku hub credential file). Then run `/kankaku:sync` for recent records or
103
+ `/kankaku:sync-all` for a full sync in the project whose worklog you want to
104
+ upload. Use `/kankaku:sync-status` to inspect local sync state without a hub
105
+ request or credentials. The same operations are available directly via CLI:
102
106
 
103
107
  | Command | Purpose |
104
108
  |---------|---------|
@@ -114,8 +118,19 @@ Optional environment settings:
114
118
  | `KANKAKU_SYNC_PROMPT` | Prompt privacy: omitted by default; set `truncated` or `full` to include prompts. |
115
119
  | `KANKAKU_SYNC_WINDOW_HOURS` | Recent sync window in hours (defaults to 24). |
116
120
  | `KANKAKU_SYNC_RECORDS` | Set to `0` to disable uploading raw `work_records` children; consolidated `task_entries` still sync. |
121
+ | `KANKAKU_SYNC_AUTO` | Set to `0` to disable automatic sync; manual sync remains available. |
122
+ | `KANKAKU_SYNC_MIN_INTERVAL_MINUTES` | Minimum interval between automatic per-prompt attempts (defaults to 5; `0` disables throttling). |
117
123
 
118
- This first slice is **manual-only**: no automatic sync hooks run yet.
124
+ ### Automatic hub sync
125
+
126
+ With valid hub credentials, heavy hooks attempt a best-effort sync at session start
127
+ (after recovery), after each settled prompt has been appended, and on session end
128
+ (after any interrupted record is appended, before cleanup). Missing/invalid
129
+ credentials silently skip automatic sync. Failures are reported on stderr but
130
+ never prevent local worklog writes or session cleanup. Prompt privacy, machine,
131
+ window, and record settings above apply to both manual and automatic sync.
132
+ Automatic runs use kankaku's change detection and per-prompt throttle; session
133
+ boundaries are not throttled. Set `KANKAKU_SYNC_AUTO=0` to opt out.
119
134
 
120
135
  ## Where the files live
121
136
 
@@ -170,9 +185,9 @@ also runs for the current session's own leftover state at `SessionEnd`.
170
185
  document a link between a `SubagentStart`/`SubagentStop` pair and the
171
186
  `tool_use_id` that launched it, so kankaku-claude cannot join them; the
172
187
  subagent's own time is not separately measured here.
173
- - **Hub sync is manual-only.** Use `/kankaku:sync` or the direct CLI as
174
- described above; no automatic sync hooks run yet. Prompts are omitted
175
- from uploads unless `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
188
+ - **Hub sync is best-effort.** Use `/kankaku:sync` to retry recent records or
189
+ `/kankaku:sync-all` to request a full sync. Prompts are omitted from uploads
190
+ unless `KANKAKU_SYNC_PROMPT` is `truncated` or `full`.
176
191
 
177
192
  See [kankaku.io](https://kankaku.io) for the pi extension this plugin shares
178
193
  its worklog format with.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Request a full sync of local kankaku work records to the hub
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI sync all command and show its output to the user verbatim,
7
+ inside a code block, with no summarizing or reformatting:
8
+
9
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync all
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Show local kankaku hub sync status
3
+ allowed-tools: Bash(node:*)
4
+ ---
5
+
6
+ Run the kankaku CLI sync status command and show its output to the user verbatim,
7
+ inside a code block, with no summarizing or reformatting:
8
+
9
+ !node "${CLAUDE_PLUGIN_ROOT}/src/cli.ts" sync status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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": [
@@ -0,0 +1,32 @@
1
+ import type { SyncTrigger } from "kankaku/hub";
2
+ import type { SyncCliDeps } from "./sync-cli.ts";
3
+
4
+ export interface AutoSyncDeps extends SyncCliDeps {
5
+ stderr: (message: string) => void;
6
+ }
7
+
8
+ /** Best-effort heavy-hook sync; credential/transport errors never escape into record handling. */
9
+ export async function autoSync(trigger: SyncTrigger, deps: AutoSyncDeps): Promise<void> {
10
+ if (deps.env.KANKAKU_SYNC_AUTO === "0") return;
11
+ try {
12
+ const { resolveHubCredentials } = await import("kankaku/hub");
13
+ const { homedir } = await import("node:os");
14
+ const hub = resolveHubCredentials({ env: deps.env, homeDir: deps.homeDir ?? (() => deps.env.HOME || homedir()) });
15
+ if (!hub.credentials || hub.invalidReason) return;
16
+ const { syncConfigured } = await import("./sync-cli.ts");
17
+ // One deadline across catalog and upload requests; the public client also
18
+ // imposes its own per-request timeout. No timer persists after the hook.
19
+ const deadline = AbortSignal.timeout(8_000);
20
+ const doFetch = deps.fetch ?? fetch;
21
+ const boundedFetch: typeof fetch = (input, init) => doFetch(input, {
22
+ ...init,
23
+ signal: init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline,
24
+ });
25
+ const result = await syncConfigured({ ...deps, fetch: boundedFetch }, hub.credentials, { trigger });
26
+ if (result.error || result.failed.length) {
27
+ deps.stderr(`kankaku auto-sync: ${result.error ?? result.failed.map((f) => f.reason).join("; ")}`);
28
+ }
29
+ } catch (error) {
30
+ deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ }
@@ -7,6 +7,7 @@ import { splitPrompts, type PromptEvents } from "./prompts.ts";
7
7
  import { readCost, deleteCost, sweepStaleCostFiles } from "./cost-store.ts";
8
8
  import type { Event } from "./events.ts";
9
9
  import type { WorkLog } from "kankaku/ports";
10
+ import type { SyncTrigger } from "kankaku/hub";
10
11
 
11
12
  export interface HandleHookDeps {
12
13
  env: NodeJS.ProcessEnv;
@@ -18,6 +19,8 @@ export interface HandleHookDeps {
18
19
  isAlive: (pid: number) => boolean;
19
20
  runPs: (pid: number) => PsInfo | undefined;
20
21
  stderr: (message: string) => void;
22
+ /** Optional heavy-hook seam for tests. */
23
+ autoSync?: (trigger: SyncTrigger) => Promise<void>;
21
24
  }
22
25
 
23
26
  const STOP_COST_WAIT_POLL_MS = 100;
@@ -118,7 +121,7 @@ export async function handleHook(input: unknown, deps: HandleHookDeps): Promise<
118
121
  case "SessionEnd": {
119
122
  const reason = readString(raw.reason) ?? "other";
120
123
  appendEvent(paths.eventsFile, { ts, event: "SessionEnd", reason });
121
- await handleSessionEnd(paths, sessionId, ts, deps);
124
+ await handleSessionEnd(paths, sessionId, cwd, ts, deps);
122
125
  return;
123
126
  }
124
127
  default:
@@ -166,6 +169,7 @@ async function handleStop(paths: ResolvedPaths, sessionId: string, deps: HandleH
166
169
  writeState(paths.stateFile, { ...state, promptOpen: null, permissionOpen: null });
167
170
  const keep = events.slice(0, events.length - last.events.length);
168
171
  dropSettledPrompts(paths.eventsFile, keep);
172
+ if (core) await syncHeavy("agent_settled", state.cwd, deps);
169
173
  }
170
174
 
171
175
  async function handleSessionStart(
@@ -194,7 +198,10 @@ async function handleSessionStart(
194
198
  }
195
199
 
196
200
  const existing = readState(paths.stateFile);
197
- if (existing) return; // resume/fork on an existing state: keep it as-is
201
+ if (existing) {
202
+ await syncHeavy("session_start", cwd, deps);
203
+ return; // resume/fork on an existing state: keep it as-is
204
+ }
198
205
 
199
206
  const pid = resolveClaudePid({ startPid: process.ppid, runPs: deps.runPs });
200
207
  const resolvedInfo = deps.runPs(pid);
@@ -209,9 +216,10 @@ async function handleSessionStart(
209
216
  permissionOpen: null,
210
217
  };
211
218
  writeState(paths.stateFile, state);
219
+ await syncHeavy("session_start", cwd, deps);
212
220
  }
213
221
 
214
- async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, ts: number, deps: HandleHookDeps): Promise<void> {
222
+ async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, cwd: string, ts: number, deps: HandleHookDeps): Promise<void> {
215
223
  const state = readState(paths.stateFile);
216
224
  if (state?.promptOpen) {
217
225
  const { replayPrompt } = await import("./replay.ts");
@@ -230,8 +238,24 @@ async function handleSessionEnd(paths: ResolvedPaths, sessionId: string, ts: num
230
238
  }
231
239
  }
232
240
  }
233
- deleteSessionFiles(paths);
234
- deleteCost(deps.env, sessionId);
241
+ try {
242
+ await syncHeavy("session_shutdown", state?.cwd ?? cwd, deps);
243
+ } finally {
244
+ deleteSessionFiles(paths);
245
+ deleteCost(deps.env, sessionId);
246
+ }
247
+ }
248
+
249
+ async function syncHeavy(trigger: SyncTrigger, cwd: string, deps: HandleHookDeps): Promise<void> {
250
+ try {
251
+ if (deps.autoSync) await deps.autoSync(trigger);
252
+ else {
253
+ const { autoSync } = await import("./auto-sync.ts");
254
+ await autoSync(trigger, { env: deps.env, cwd, now: deps.now, stderr: deps.stderr });
255
+ }
256
+ } catch (error) {
257
+ deps.stderr(`kankaku auto-sync: ${error instanceof Error ? error.message : String(error)}`);
258
+ }
235
259
  }
236
260
 
237
261
  function deleteSessionFiles(paths: ResolvedPaths): void {
package/src/sync-cli.ts CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  computeSyncStatus, createPocketBaseCatalogFetcher, resolveHubCredentials,
8
8
  runSync, safeHomeDir,
9
9
  } from "kankaku/hub";
10
+ import type { SyncTrigger } from "kankaku/hub";
10
11
  import { resolveKankakuDir } from "./paths.ts";
11
12
  import type { CliResult } from "./cli-core.ts";
13
+ import type { WorkSink } from "kankaku/ports";
12
14
 
13
15
  export interface SyncCliDeps {
14
16
  env: NodeJS.ProcessEnv;
@@ -66,26 +68,7 @@ export async function runSyncCli(args: string[], deps: SyncCliDeps): Promise<Cli
66
68
  if (hub.invalidReason) return { stdout: "", stderr: `kankaku sync: invalid hub URL: ${hub.invalidReason}\n`, exitCode: 1 };
67
69
  if (!hub.credentials) return { stdout: "", stderr: "kankaku sync: hub credentials are not configured (KANKAKU_PB_URL, KANKAKU_PB_EMAIL, KANKAKU_PB_PASSWORD).\n", exitCode: 1 };
68
70
 
69
- const credentials = hub.credentials;
70
- const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
71
- const catalog = new CachedCatalog({
72
- filePath: join(safeHomeDir(homeDir) ?? tmpdir(), ".kankaku", "catalog.json"),
73
- url: credentials.url,
74
- clock: { now: deps.now },
75
- fetchCatalog: createPocketBaseCatalogFetcher(client),
76
- });
77
- // Refresh before resolving assignments; on an offline hub the cached snapshot remains usable.
78
- await catalog.refresh();
79
- const snapshot = catalog.read();
80
- const sink = new PocketBaseSink({
81
- client, clients: snapshot?.clients ?? [], projects: snapshot?.projects ?? [],
82
- machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? hostname)(),
83
- promptMode: promptMode(deps.env), syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
84
- agent: "claude-code", plugin: "kankaku-claude", pluginVersion: packageVersion(),
85
- });
86
- const summary = await runSync({
87
- log, sink, stateStore, clock: { now: deps.now }, target: credentials.url, windowHours: hours,
88
- }, { full: args[0] === "all" });
71
+ const summary = await syncConfigured(deps, hub.credentials, { full: args[0] === "all" });
89
72
  const stdout = `uploaded: ${summary.uploaded}, updated: ${summary.updated}, skipped: ${summary.skipped}, failed: ${summary.failed.length}\n`;
90
73
  if (summary.locked) return { stdout, stderr: "kankaku sync: another sync is running.\n", exitCode: 1 };
91
74
  if (summary.error || summary.failed.length) {
@@ -97,3 +80,43 @@ export async function runSyncCli(args: string[], deps: SyncCliDeps): Promise<Cli
97
80
  return { stdout: "", stderr: `kankaku sync: ${error instanceof Error ? error.message : String(error)}\n`, exitCode: 1 };
98
81
  }
99
82
  }
83
+
84
+ /** Shared manual/automatic hub adapters and Claude attribution. */
85
+ export async function syncConfigured(
86
+ deps: SyncCliDeps,
87
+ credentials: NonNullable<ReturnType<typeof resolveHubCredentials>["credentials"]>,
88
+ options: { full?: boolean; trigger?: SyncTrigger } = {},
89
+ ) {
90
+ const homeDir = deps.homeDir ?? (() => deps.env.HOME || homedir());
91
+ const dir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
92
+ const log = new JsonlWorkLog(dir);
93
+ const stateStore = new SyncStateStore({ dir, pid: process.pid, now: deps.now });
94
+ // Build the catalog only after runSync's unchanged-log and throttle gates.
95
+ // The public runner still owns locking, planning and the sync watermark.
96
+ const sink: WorkSink = { push: async (tasks) => {
97
+ if (tasks.length === 0) return [];
98
+ const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
99
+ const catalog = new CachedCatalog({
100
+ filePath: join(safeHomeDir(homeDir) ?? tmpdir(), ".kankaku", "catalog.json"),
101
+ url: credentials.url,
102
+ clock: { now: deps.now },
103
+ fetchCatalog: createPocketBaseCatalogFetcher(client),
104
+ });
105
+ // On an offline hub the cached snapshot remains usable.
106
+ await catalog.refresh();
107
+ const snapshot = catalog.read();
108
+ return new PocketBaseSink({
109
+ client, clients: snapshot?.clients ?? [], projects: snapshot?.projects ?? [],
110
+ machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? hostname)(),
111
+ promptMode: promptMode(deps.env), syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
112
+ agent: "claude-code", plugin: "kankaku-claude", pluginVersion: packageVersion(),
113
+ }).push(tasks);
114
+ } };
115
+ const interval = Number(deps.env.KANKAKU_SYNC_MIN_INTERVAL_MINUTES);
116
+ const minAutoIntervalMs = deps.env.KANKAKU_SYNC_MIN_INTERVAL_MINUTES !== undefined && Number.isFinite(interval) && interval >= 0
117
+ ? interval * 60_000 : undefined;
118
+ return runSync({
119
+ log, sink, stateStore, clock: { now: deps.now }, target: credentials.url,
120
+ windowHours: windowHours(deps.env), ...(minAutoIntervalMs !== undefined ? { minAutoIntervalMs } : {}),
121
+ }, options);
122
+ }