kankaku-claude 0.10.0 → 0.12.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +53 -0
- package/README.md +84 -7
- package/commands/task.md +14 -0
- package/dist/cli-core.js +9 -3
- package/dist/cli.js +3 -1
- package/dist/doctor.js +8 -1
- package/dist/find-session.js +43 -0
- package/dist/handle-hook.js +46 -3
- package/dist/inflight-recovery.js +11 -3
- package/dist/paths.js +5 -0
- package/dist/record.js +41 -3
- package/dist/session-target-store.js +39 -0
- package/dist/task-cli.js +114 -0
- package/dist/task-select.js +50 -0
- package/dist/work-target.js +123 -0
- package/package.json +2 -2
|
@@ -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.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "soyunninja"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,59 @@ 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.12.0 — 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`/kankaku:task` links the session to a hub task.** It lists the open
|
|
12
|
+
tasks of the project resolved for the folder, and picks one by number, hub
|
|
13
|
+
id or title text (`clear` removes the link). The link is session-only, kept
|
|
14
|
+
in `<KANKAKU_DIR>/claude/<session>.target.json`, and the records written at
|
|
15
|
+
Stop, SessionEnd and crash recovery carry `hubTaskId` and `hubTaskTitle`
|
|
16
|
+
while the task still belongs to the resolved project. `/kankaku:status` and
|
|
17
|
+
`/kankaku:doctor` print the `task:` line. The CLI finds its session from its
|
|
18
|
+
own process; `KANKAKU_CLAUDE_SESSION` names it explicitly.
|
|
19
|
+
|
|
20
|
+
## 0.11.0 — 2026-09-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Records resolve their client and project automatically.** Records
|
|
25
|
+
written at Stop, SessionEnd and crash recovery now carry `clientId`,
|
|
26
|
+
`clientName`, `projectId` and `projectName` (and the legacy `client`
|
|
27
|
+
label) when the project `config.json` ids or the cached catalog's
|
|
28
|
+
`repo_paths` match the session's working directory, so the hub no longer
|
|
29
|
+
files them under the unassigned client. The catalog is read from
|
|
30
|
+
`~/.kankaku/catalog.json` only: no network, no delay, and no target when
|
|
31
|
+
the cache is missing. Records already on disk are not rewritten, and rows
|
|
32
|
+
already uploaded as unassigned stay so until reassigned in the web app.
|
|
33
|
+
- `/kankaku:status` and `/kankaku:doctor` print the resolved target and its
|
|
34
|
+
source, or `target: none (<reason>)`.
|
|
35
|
+
|
|
36
|
+
## 0.10.2 — 2026-09-29
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Records synced by another tool were attributed to the syncer.** Records
|
|
41
|
+
carried no `agent`/`plugin`, so when the kankaku TUI synced a worklog
|
|
42
|
+
written by Claude Code, the hub row was created as agent `unknown`,
|
|
43
|
+
plugin `kankaku-tui`. Every record (settled, settled at SessionEnd, or
|
|
44
|
+
recovered as `interrupted`) now carries `agent: "claude-code"`,
|
|
45
|
+
`plugin: "kankaku-claude"` and `pluginVersion`. Records already on disk
|
|
46
|
+
are not rewritten.
|
|
47
|
+
|
|
48
|
+
## 0.10.1 — 2026-09-29
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **A prompt interrupted by a hung session was recorded with the time until
|
|
53
|
+
the next session started.** Crash recovery settled the open prompt at
|
|
54
|
+
recovery time, so a session that hung and was recovered the next morning
|
|
55
|
+
produced a record with a 14.4 h wall time. The prompt is now closed at
|
|
56
|
+
the timestamp of the last event recorded for it (its own
|
|
57
|
+
`UserPromptSubmit` when nothing followed); a waiting span still open is
|
|
58
|
+
closed at the same instant, and the record stays `interrupted`.
|
|
59
|
+
|
|
7
60
|
## 0.10.0 — 2026-09-28
|
|
8
61
|
|
|
9
62
|
### 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
|
|
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.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,17 @@ 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
|
|
143
|
+
and the last cost the statusline reported, preceded by the resolved work
|
|
144
|
+
target (wraps `node dist/cli.js status`).
|
|
145
|
+
- `/kankaku:task` — links this session to a hub task (wraps
|
|
146
|
+
`node dist/cli.js task`); see "Linking a task" below.
|
|
135
147
|
- `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
|
|
136
148
|
`node dist/cli.js setup`).
|
|
137
149
|
- `/kankaku:sync` — manually syncs recent local work records to the hub
|
|
@@ -186,6 +198,65 @@ window, and record settings above apply to both manual and automatic sync.
|
|
|
186
198
|
Automatic runs use kankaku's change detection and per-prompt throttle; session
|
|
187
199
|
boundaries are not throttled. Set `KANKAKU_SYNC_AUTO=0` to opt out.
|
|
188
200
|
|
|
201
|
+
### Client and project assignment
|
|
202
|
+
|
|
203
|
+
Each record is stamped with a hub client and project when one resolves, so
|
|
204
|
+
the hub files the task under the right client instead of "Sin determinar".
|
|
205
|
+
Sources, in order:
|
|
206
|
+
|
|
207
|
+
1. the project's `<KANKAKU_DIR>/config.json` ids (`clientId`, optional
|
|
208
|
+
`projectId`);
|
|
209
|
+
2. the cached catalog's `repo_paths`: the active project whose path equals
|
|
210
|
+
the session's working directory, or contains it.
|
|
211
|
+
|
|
212
|
+
Inactive clients and projects, and the "unassigned" client, are never used.
|
|
213
|
+
The catalog is read from the cache file `~/.kankaku/catalog.json` only; a
|
|
214
|
+
hook never fetches it and never waits on the network. Every real sync
|
|
215
|
+
refreshes that cache, and `kankaku catalog refresh` refreshes it on demand.
|
|
216
|
+
Without a readable cache (or without hub credentials, which name the hub the
|
|
217
|
+
cache belongs to) no target resolves and records stay unassigned, exactly as
|
|
218
|
+
before. The legacy `client` label is the client's code when it is a valid
|
|
219
|
+
label; without a hub target it comes from `KANKAKU_CLIENT`, then the
|
|
220
|
+
`client` in `config.json`.
|
|
221
|
+
|
|
222
|
+
The target is resolved when a record is written (`Stop`, `SessionEnd`,
|
|
223
|
+
crash recovery), never on the per-tool-call hooks. `/kankaku:status` and
|
|
224
|
+
`/kankaku:doctor` print `target: <client> · <project> (source: ...)`, or
|
|
225
|
+
`target: none (<reason>)`.
|
|
226
|
+
|
|
227
|
+
### Linking a task
|
|
228
|
+
|
|
229
|
+
`/kankaku:task` links the session to one hub task of the project resolved
|
|
230
|
+
for the folder, so the records written from then on carry that task:
|
|
231
|
+
|
|
232
|
+
- `/kankaku:task` lists the project's open tasks (status `open` or `doing`),
|
|
233
|
+
numbered, and marks the linked one; Claude then asks which one you want.
|
|
234
|
+
- `/kankaku:task <number>` picks from the last list shown in this session,
|
|
235
|
+
`/kankaku:task <id>` by hub id, and `/kankaku:task <text>` by a unique
|
|
236
|
+
case-insensitive part of the title (several matches are listed and nothing
|
|
237
|
+
changes). A purely numeric argument is always a list number.
|
|
238
|
+
- `/kankaku:task clear` removes the link.
|
|
239
|
+
|
|
240
|
+
The link is session-only, like `/kankaku task pick` in pi: it is stored in
|
|
241
|
+
`<KANKAKU_DIR>/claude/<session>.target.json`, never in `config.json`, and is
|
|
242
|
+
removed when the session ends. The list refreshes the catalog first (bounded
|
|
243
|
+
to 3 seconds) and falls back to the cache, saying how old it is; the hooks
|
|
244
|
+
still only read the cache. A record keeps the link only while the task still
|
|
245
|
+
belongs to the resolved project in the catalog; otherwise it is dropped and
|
|
246
|
+
`/kankaku:status` and `/kankaku:doctor` print
|
|
247
|
+
`task: none (linked task "<title>" is not in project <name>)`. Both print
|
|
248
|
+
`task: <title>` or `task: none` otherwise.
|
|
249
|
+
|
|
250
|
+
The CLI finds its session from its own process: it walks up to the Claude
|
|
251
|
+
Code process and takes the session state of this project whose `pid` matches
|
|
252
|
+
(most recent activity first). Without a session it prints
|
|
253
|
+
`no active Claude Code session found for this folder`. Set
|
|
254
|
+
`KANKAKU_CLAUDE_SESSION=<session id>` to name the session explicitly (tests,
|
|
255
|
+
scripting); it wins over the process lookup. Overriding the client or project
|
|
256
|
+
from Claude Code is not supported yet. Assignment is create-only
|
|
257
|
+
on the hub: a row already uploaded as unassigned stays that way until it is
|
|
258
|
+
reassigned in the web app; a later sync does not move it.
|
|
259
|
+
|
|
189
260
|
## Where the files live
|
|
190
261
|
|
|
191
262
|
- `<KANKAKU_DIR>/worklog.jsonl` — the append-only log of settled records,
|
|
@@ -218,6 +289,12 @@ and appended as one `status: "interrupted"` record before its files are
|
|
|
218
289
|
deleted; a dead session with no open prompt just has its files deleted. This
|
|
219
290
|
also runs for the current session's own leftover state at `SessionEnd`.
|
|
220
291
|
|
|
292
|
+
An interrupted prompt is closed at its last recorded activity — the
|
|
293
|
+
timestamp of the last event logged for it, or its own start when nothing
|
|
294
|
+
followed — never at the moment of recovery, so a session that hung and was
|
|
295
|
+
recovered hours later does not report those hours as work. A waiting span
|
|
296
|
+
still open is closed at that same instant.
|
|
297
|
+
|
|
221
298
|
## Limitations
|
|
222
299
|
|
|
223
300
|
- **`turns` is always 1 per run.** Claude Code hooks give no way to observe
|
package/commands/task.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Link this session to a hub task (list open tasks, pick one, or clear the link)
|
|
3
|
+
argument-hint: [number | id | text | clear]
|
|
4
|
+
allowed-tools: Bash(node:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Run the kankaku CLI task command and show its output to the user verbatim,
|
|
8
|
+
inside a code block, with no summarizing or reformatting:
|
|
9
|
+
|
|
10
|
+
!node "${CLAUDE_PLUGIN_ROOT}/dist/cli.js" task $ARGUMENTS
|
|
11
|
+
|
|
12
|
+
If no argument was given and the output lists tasks, ask the user which one
|
|
13
|
+
they want (by number), then run the same CLI command with that number and show
|
|
14
|
+
its output verbatim in a code block. Never pick a task on the user's behalf.
|
package/dist/cli-core.js
CHANGED
|
@@ -6,7 +6,9 @@ import { readCost } from "./cost-store.js";
|
|
|
6
6
|
import { formatReport } from "./report.js";
|
|
7
7
|
import { runSyncCli } from "./sync-cli.js";
|
|
8
8
|
import { runDoctor } from "./doctor.js";
|
|
9
|
-
|
|
9
|
+
import { runTaskCli } from "./task-cli.js";
|
|
10
|
+
import { formatTargetLine, formatTaskLine, resolveSessionWorkTarget } from "./work-target.js";
|
|
11
|
+
const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor|task> [--days N]\n";
|
|
10
12
|
/** CLI commands, resolved from `deps.cwd`. */
|
|
11
13
|
export async function runCli(argv, deps) {
|
|
12
14
|
const [command, ...rest] = argv;
|
|
@@ -19,6 +21,8 @@ export async function runCli(argv, deps) {
|
|
|
19
21
|
return runSetup(deps);
|
|
20
22
|
case "sync":
|
|
21
23
|
return runSyncCli(rest, deps);
|
|
24
|
+
case "task":
|
|
25
|
+
return runTaskCli(rest, deps);
|
|
22
26
|
case "doctor":
|
|
23
27
|
return { stdout: runDoctor(deps), exitCode: 0 };
|
|
24
28
|
default:
|
|
@@ -43,13 +47,15 @@ function runStatus(deps) {
|
|
|
43
47
|
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
44
48
|
const claudeDir = join(kankakuDir, "claude");
|
|
45
49
|
const files = listStateFiles(claudeDir);
|
|
50
|
+
const resolved = resolveSessionWorkTarget(deps);
|
|
51
|
+
const targetLine = `${formatTargetLine(resolved)}\n${formatTaskLine(resolved)}`;
|
|
46
52
|
if (files.length === 0) {
|
|
47
|
-
return { stdout:
|
|
53
|
+
return { stdout: `${targetLine}\nNo active sessions.\n`, exitCode: 0 };
|
|
48
54
|
}
|
|
49
55
|
const lines = files
|
|
50
56
|
.sort()
|
|
51
57
|
.map((file) => formatStatusLine(sessionIdFromStateFile(file), file, deps.isAlive, deps.env));
|
|
52
|
-
return { stdout: lines.join("\n") + "\n", exitCode: 0 };
|
|
58
|
+
return { stdout: [targetLine, ...lines].join("\n") + "\n", exitCode: 0 };
|
|
53
59
|
}
|
|
54
60
|
function formatStatusLine(sessionId, stateFile, isAlive, env) {
|
|
55
61
|
const state = readState(stateFile);
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { runCli } from "./cli-core.js";
|
|
4
|
-
import { isAlive } from "./claude-pid.js";
|
|
4
|
+
import { isAlive, runPsProcess } from "./claude-pid.js";
|
|
5
5
|
// src/cli.ts -> src -> repo root (the directory that contains .claude-plugin/).
|
|
6
6
|
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
7
|
const result = await runCli(process.argv.slice(2), {
|
|
@@ -10,6 +10,8 @@ const result = await runCli(process.argv.slice(2), {
|
|
|
10
10
|
now: () => Date.now(),
|
|
11
11
|
isAlive,
|
|
12
12
|
pluginRoot,
|
|
13
|
+
pid: process.pid,
|
|
14
|
+
runPs: runPsProcess,
|
|
13
15
|
});
|
|
14
16
|
if (result.stdout)
|
|
15
17
|
process.stdout.write(result.stdout);
|
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, formatTaskLine, resolveSessionWorkTarget } from "./work-target.js";
|
|
8
9
|
function metadata(file) {
|
|
9
10
|
try {
|
|
10
11
|
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -22,6 +23,10 @@ function syncWindowHours(env) {
|
|
|
22
23
|
const hours = Number(env.KANKAKU_SYNC_WINDOW_HOURS);
|
|
23
24
|
return env.KANKAKU_SYNC_WINDOW_HOURS && Number.isFinite(hours) && hours > 0 ? hours : 24;
|
|
24
25
|
}
|
|
26
|
+
function workTargetLines(deps) {
|
|
27
|
+
const resolved = resolveSessionWorkTarget(deps);
|
|
28
|
+
return [formatTargetLine(resolved), formatTaskLine(resolved)];
|
|
29
|
+
}
|
|
25
30
|
/** Read-only, local diagnostic. No sync runner or network client is reachable from this path. */
|
|
26
31
|
export function runDoctor(deps) {
|
|
27
32
|
const dir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
@@ -64,7 +69,7 @@ export function runDoctor(deps) {
|
|
|
64
69
|
const target = hub.credentials?.url ?? store.read()?.target ?? "";
|
|
65
70
|
const { state, pending, staleOutsideWindow } = computeSyncStatus(log, store, target, syncWindowHours(deps.env));
|
|
66
71
|
const hubState = hub.invalidReason ? "invalid URL" : hub.credentials ? "configured" : "unconfigured";
|
|
67
|
-
const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor"];
|
|
72
|
+
const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor", "task"];
|
|
68
73
|
const present = commands.filter((name) => existsSync(join(deps.pluginRoot, "commands", `${name}.md`))).length;
|
|
69
74
|
const actions = ["/kankaku:status", "/kankaku:sync-status"];
|
|
70
75
|
if (!costsVisible)
|
|
@@ -88,6 +93,8 @@ export function runDoctor(deps) {
|
|
|
88
93
|
`active sessions: ${states.length}`,
|
|
89
94
|
`alive: ${alive}; dead: ${dead}; open prompts: ${open}; unreadable: ${unreadable}`,
|
|
90
95
|
`cost files visible: ${costsVisible ? "yes" : "no"} (current HOME)`,
|
|
96
|
+
"", "## Work target",
|
|
97
|
+
...workTargetLines(deps),
|
|
91
98
|
"", "## Hub / sync",
|
|
92
99
|
`hub: ${hubState}`,
|
|
93
100
|
`pending: ${pending}`,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { resolveClaudePid } from "./claude-pid.js";
|
|
4
|
+
import { readEventLog } from "./event-log.js";
|
|
5
|
+
import { listStateFiles } from "./paths.js";
|
|
6
|
+
import { readState } from "./session-state.js";
|
|
7
|
+
const SESSION_ID = /^[\w.-]+$/;
|
|
8
|
+
function activity(stateFile) {
|
|
9
|
+
let latest = 0;
|
|
10
|
+
try {
|
|
11
|
+
latest = statSync(stateFile).mtimeMs;
|
|
12
|
+
}
|
|
13
|
+
catch { /* unreadable: oldest */ }
|
|
14
|
+
const events = readEventLog(stateFile.replace(/\.state\.json$/, ".events.jsonl"));
|
|
15
|
+
const last = events[events.length - 1]?.ts;
|
|
16
|
+
return typeof last === "number" && last > latest ? last : latest;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The Claude Code session this CLI process runs under: `KANKAKU_CLAUDE_SESSION`
|
|
20
|
+
* (a plain session id) wins; otherwise the CLI's pid is walked up to the
|
|
21
|
+
* Claude process and matched against the project's session state files
|
|
22
|
+
* (`pid`), most recent activity first. `undefined` when nothing matches.
|
|
23
|
+
*/
|
|
24
|
+
export function findSession(input) {
|
|
25
|
+
const override = input.env.KANKAKU_CLAUDE_SESSION?.trim();
|
|
26
|
+
if (override && SESSION_ID.test(override))
|
|
27
|
+
return override;
|
|
28
|
+
if (input.pid === undefined || input.runPs === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
const claudePid = resolveClaudePid({ startPid: input.pid, runPs: input.runPs });
|
|
31
|
+
if (!Number.isSafeInteger(claudePid) || claudePid <= 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const list = input.listStateFiles ?? listStateFiles;
|
|
34
|
+
let best;
|
|
35
|
+
for (const file of list(input.claudeDir)) {
|
|
36
|
+
if (readState(file)?.pid !== claudePid)
|
|
37
|
+
continue;
|
|
38
|
+
const at = activity(file);
|
|
39
|
+
if (!best || at > best.at)
|
|
40
|
+
best = { id: basename(file).replace(/\.state\.json$/, ""), at };
|
|
41
|
+
}
|
|
42
|
+
return best?.id;
|
|
43
|
+
}
|
package/dist/handle-hook.js
CHANGED
|
@@ -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
|
|
138
|
+
const assignment = await assignmentResolver(paths, deps);
|
|
139
|
+
const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd, sessionId));
|
|
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
|
|
200
|
+
const assignment = await assignmentResolver(paths, deps);
|
|
201
|
+
const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd, sessionId));
|
|
199
202
|
const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
|
|
200
203
|
log.append(record);
|
|
201
204
|
}
|
|
@@ -209,6 +212,46 @@ 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 { readSessionTarget } = await import("./session-target-store.js");
|
|
225
|
+
const { resolveTargetFile } = await import("./paths.js");
|
|
226
|
+
const resolve = deps.resolveTarget ?? resolveClaudeWorkTarget;
|
|
227
|
+
return (cwd, sessionId) => {
|
|
228
|
+
try {
|
|
229
|
+
// The session-only task link (`/kankaku:task`); heavy hooks only.
|
|
230
|
+
const stored = readSessionTarget(resolveTargetFile(paths.claudeDir, sessionId));
|
|
231
|
+
const { target, legacyClient } = resolve({
|
|
232
|
+
cwd,
|
|
233
|
+
kankakuDir: paths.kankakuDir,
|
|
234
|
+
homeDir: deps.env.HOME || homedir(),
|
|
235
|
+
env: deps.env,
|
|
236
|
+
...(stored?.hubTaskId !== undefined
|
|
237
|
+
? { taskLink: { hubTaskId: stored.hubTaskId, ...(stored.hubTaskTitle !== undefined ? { hubTaskTitle: stored.hubTaskTitle } : {}) } }
|
|
238
|
+
: {}),
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
...(target !== undefined ? { target } : {}),
|
|
242
|
+
...(legacyClient !== undefined ? { legacyClient } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
deps.stderr(`kankaku: work target: ${error instanceof Error ? error.message : String(error)}`);
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return () => ({});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
212
255
|
async function syncHeavy(trigger, cwd, deps) {
|
|
213
256
|
try {
|
|
214
257
|
if (deps.autoSync)
|
|
@@ -223,7 +266,7 @@ async function syncHeavy(trigger, cwd, deps) {
|
|
|
223
266
|
}
|
|
224
267
|
}
|
|
225
268
|
function deleteSessionFiles(paths) {
|
|
226
|
-
for (const file of [paths.stateFile, paths.eventsFile]) {
|
|
269
|
+
for (const file of [paths.stateFile, paths.eventsFile, paths.targetFile]) {
|
|
227
270
|
try {
|
|
228
271
|
unlinkSync(file);
|
|
229
272
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { unlinkSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
|
-
import { listStateFiles } from "./paths.js";
|
|
3
|
+
import { listStateFiles, resolveTargetFile } from "./paths.js";
|
|
4
4
|
import { readState } from "./session-state.js";
|
|
5
5
|
import { readEventLog } from "./event-log.js";
|
|
6
6
|
import { splitPrompts, replayPrompt } from "./replay.js";
|
|
@@ -26,9 +26,11 @@ export function recoverStaleSessions(input) {
|
|
|
26
26
|
continue;
|
|
27
27
|
const state = readState(stateFile);
|
|
28
28
|
const eventsFile = stateFile.replace(/\.state\.json$/, ".events.jsonl");
|
|
29
|
+
const targetFile = resolveTargetFile(input.claudeDir, sessionId);
|
|
29
30
|
if (!state) {
|
|
30
31
|
safeUnlink(stateFile);
|
|
31
32
|
safeUnlink(eventsFile);
|
|
33
|
+
safeUnlink(targetFile);
|
|
32
34
|
continue;
|
|
33
35
|
}
|
|
34
36
|
const dead = state.pid <= 0 || !input.isAlive(state.pid);
|
|
@@ -39,15 +41,21 @@ export function recoverStaleSessions(input) {
|
|
|
39
41
|
const prompts = splitPrompts(events);
|
|
40
42
|
const last = prompts[prompts.length - 1];
|
|
41
43
|
if (last && last.open) {
|
|
42
|
-
|
|
44
|
+
// Close at the prompt's last recorded activity, never at recovery
|
|
45
|
+
// time (which is the next session start, possibly hours later).
|
|
46
|
+
// `now` is only a fallback when no usable timestamp exists.
|
|
47
|
+
const lastTs = last.events[last.events.length - 1]?.ts;
|
|
48
|
+
const settledAt = typeof lastTs === "number" && Number.isFinite(lastTs) ? lastTs : input.now;
|
|
49
|
+
const core = replayPrompt(last, { settledAt });
|
|
43
50
|
if (core) {
|
|
44
51
|
const model = readCost(input.env, sessionId)?.model;
|
|
45
|
-
records.push(buildClaudeRecord(core, state, sessionId, model));
|
|
52
|
+
records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd, sessionId)));
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
safeUnlink(stateFile);
|
|
50
57
|
safeUnlink(eventsFile);
|
|
58
|
+
safeUnlink(targetFile);
|
|
51
59
|
deleteCost(input.env, sessionId);
|
|
52
60
|
}
|
|
53
61
|
return records;
|
package/dist/paths.js
CHANGED
|
@@ -18,6 +18,7 @@ export function resolvePaths(input) {
|
|
|
18
18
|
claudeDir,
|
|
19
19
|
eventsFile: join(claudeDir, `${input.sessionId}.events.jsonl`),
|
|
20
20
|
stateFile: join(claudeDir, `${input.sessionId}.state.json`),
|
|
21
|
+
targetFile: resolveTargetFile(claudeDir, input.sessionId),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
/** Every `*.state.json` path in `claudeDir`, absolute. Empty array when the directory does not exist. */
|
|
@@ -33,3 +34,7 @@ export function listStateFiles(claudeDir) {
|
|
|
33
34
|
.filter((name) => name.endsWith(".state.json"))
|
|
34
35
|
.map((name) => join(claudeDir, name));
|
|
35
36
|
}
|
|
37
|
+
/** `<claudeDir>/<sessionId>.target.json`: the session-only task link (see `session-target-store.ts`). */
|
|
38
|
+
export function resolveTargetFile(claudeDir, sessionId) {
|
|
39
|
+
return join(claudeDir, `${sessionId}.target.json`);
|
|
40
|
+
}
|
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}.
|
|
4
|
-
*
|
|
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,20 @@ 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
|
+
...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
|
|
54
|
+
...(target.hubTaskId !== undefined && target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
19
57
|
};
|
|
20
58
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
function str(value) {
|
|
4
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
/** `undefined` when the file is absent or malformed. Extra or invalid fields are dropped. */
|
|
7
|
+
export function readSessionTarget(file) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
16
|
+
return undefined;
|
|
17
|
+
const o = parsed;
|
|
18
|
+
if (o.hubTaskId !== undefined && typeof o.hubTaskId !== "string")
|
|
19
|
+
return undefined;
|
|
20
|
+
if (o.lastList !== undefined && !Array.isArray(o.lastList))
|
|
21
|
+
return undefined;
|
|
22
|
+
const hubTaskId = str(o.hubTaskId);
|
|
23
|
+
const hubTaskTitle = str(o.hubTaskTitle);
|
|
24
|
+
const projectId = str(o.projectId);
|
|
25
|
+
return {
|
|
26
|
+
...(hubTaskId !== undefined ? { hubTaskId } : {}),
|
|
27
|
+
...(hubTaskId !== undefined && hubTaskTitle !== undefined ? { hubTaskTitle } : {}),
|
|
28
|
+
...(hubTaskId !== undefined && projectId !== undefined ? { projectId } : {}),
|
|
29
|
+
...(hubTaskId !== undefined && typeof o.pickedAt === "number" ? { pickedAt: o.pickedAt } : {}),
|
|
30
|
+
lastList: (Array.isArray(o.lastList) ? o.lastList : []).filter((id) => typeof id === "string"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Atomic tmp+rename write, creating parent directories as needed. */
|
|
34
|
+
export function writeSessionTarget(file, link) {
|
|
35
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
36
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
37
|
+
writeFileSync(tmp, JSON.stringify(link));
|
|
38
|
+
renameSync(tmp, file);
|
|
39
|
+
}
|
package/dist/task-cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CachedCatalog, PocketBaseClient, createPocketBaseCatalogFetcher, resolveHubCredentials } from "kankaku/hub";
|
|
4
|
+
import { findSession } from "./find-session.js";
|
|
5
|
+
import { resolveKankakuDir, resolveTargetFile } from "./paths.js";
|
|
6
|
+
import { readSessionTarget, writeSessionTarget } from "./session-target-store.js";
|
|
7
|
+
import { listOpenTasks, selectTask } from "./task-select.js";
|
|
8
|
+
import { resolveClaudeWorkTarget } from "./work-target.js";
|
|
9
|
+
const DEFAULT_REFRESH_MS = 3000;
|
|
10
|
+
const NO_SESSION = "no active Claude Code session found for this folder\n";
|
|
11
|
+
const HINT = "pick one with: /kankaku:task <number>";
|
|
12
|
+
const say = (lines, exitCode) => ({ stdout: lines.join("\n") + "\n", exitCode });
|
|
13
|
+
/**
|
|
14
|
+
* `task [list | clear | <number> | <hub id> | <text>]`: links the hub task
|
|
15
|
+
* this Claude Code session is working on, session-only (see
|
|
16
|
+
* `session-target-store.ts`). Not a hook: it may use the network, bounded.
|
|
17
|
+
*/
|
|
18
|
+
export async function runTaskCli(args, deps) {
|
|
19
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
20
|
+
const claudeDir = join(kankakuDir, "claude");
|
|
21
|
+
const session = findSession({
|
|
22
|
+
claudeDir,
|
|
23
|
+
env: deps.env,
|
|
24
|
+
...(deps.pid !== undefined ? { pid: deps.pid } : {}),
|
|
25
|
+
...(deps.runPs !== undefined ? { runPs: deps.runPs } : {}),
|
|
26
|
+
});
|
|
27
|
+
if (!session)
|
|
28
|
+
return { stdout: NO_SESSION, exitCode: 1 };
|
|
29
|
+
const file = resolveTargetFile(claudeDir, session);
|
|
30
|
+
const stored = readSessionTarget(file) ?? { lastList: [] };
|
|
31
|
+
const arg = args.join(" ").trim();
|
|
32
|
+
if (arg === "clear") {
|
|
33
|
+
if (stored.hubTaskId === undefined)
|
|
34
|
+
return say(["no task linked"], 0);
|
|
35
|
+
writeSessionTarget(file, { lastList: stored.lastList });
|
|
36
|
+
return say(["task link cleared"], 0);
|
|
37
|
+
}
|
|
38
|
+
const homeDir = deps.env.HOME || homedir();
|
|
39
|
+
const note = await refreshCatalog(deps, homeDir);
|
|
40
|
+
const prefix = note?.text ? [note.text] : [];
|
|
41
|
+
const resolved = resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir, env: deps.env });
|
|
42
|
+
const projectId = resolved.target?.projectId;
|
|
43
|
+
if (!resolved.target || projectId === undefined) {
|
|
44
|
+
const reason = resolved.reason ?? "the resolved client has no project";
|
|
45
|
+
return say([...prefix, `no project resolved for this folder (${reason}); a task belongs to a project`], 1);
|
|
46
|
+
}
|
|
47
|
+
const projectName = resolved.target.projectName ?? projectId;
|
|
48
|
+
const open = listOpenTasks(note?.tasks ?? [], projectId);
|
|
49
|
+
if (arg === "" || arg === "list") {
|
|
50
|
+
if (open.length === 0)
|
|
51
|
+
return say([...prefix, `no open tasks in project ${projectName}`], 0);
|
|
52
|
+
writeSessionTarget(file, { ...stored, lastList: open.map((task) => task.id) });
|
|
53
|
+
return say([...prefix, `open tasks in project ${projectName}:`, ...numbered(open, stored.hubTaskId), HINT], 0);
|
|
54
|
+
}
|
|
55
|
+
const selection = selectTask(arg, { open, lastList: stored.lastList });
|
|
56
|
+
if (selection.kind === "unknown")
|
|
57
|
+
return say([...prefix, selection.message], 1);
|
|
58
|
+
if (selection.kind === "ambiguous") {
|
|
59
|
+
writeSessionTarget(file, { ...stored, lastList: selection.candidates.map((task) => task.id) });
|
|
60
|
+
return say([...prefix, `several open tasks match "${arg}":`, ...numbered(selection.candidates, stored.hubTaskId), HINT], 1);
|
|
61
|
+
}
|
|
62
|
+
const task = selection.task;
|
|
63
|
+
writeSessionTarget(file, {
|
|
64
|
+
hubTaskId: task.id, hubTaskTitle: task.title, projectId, pickedAt: deps.now(), lastList: stored.lastList,
|
|
65
|
+
});
|
|
66
|
+
return say([...prefix, `linked: ${task.title}`], 0);
|
|
67
|
+
}
|
|
68
|
+
function numbered(tasks, linkedId) {
|
|
69
|
+
return tasks.map((task, index) => {
|
|
70
|
+
const ref = task.externalRef ? ` (${task.externalRef})` : "";
|
|
71
|
+
const linked = task.id === linkedId;
|
|
72
|
+
return `${linked ? "*" : " "} ${index + 1}. ${task.title}${ref}${linked ? " [linked]" : ""}`;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Best-effort refresh bounded by `catalogTimeoutMs`; on failure the cached
|
|
77
|
+
* snapshot is used and its age reported. `undefined` when there is neither
|
|
78
|
+
* credentials nor a cache (the project lookup then reports why).
|
|
79
|
+
*/
|
|
80
|
+
async function refreshCatalog(deps, homeDir) {
|
|
81
|
+
const hub = resolveHubCredentials({ env: deps.env, homeDir: () => homeDir });
|
|
82
|
+
if (!hub.credentials)
|
|
83
|
+
return undefined;
|
|
84
|
+
const client = new PocketBaseClient({ ...hub.credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
|
|
85
|
+
const catalog = new CachedCatalog({
|
|
86
|
+
filePath: join(homeDir, ".kankaku", "catalog.json"),
|
|
87
|
+
url: hub.credentials.url,
|
|
88
|
+
clock: { now: deps.now },
|
|
89
|
+
fetchCatalog: createPocketBaseCatalogFetcher(client),
|
|
90
|
+
});
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
let timer;
|
|
93
|
+
const timeout = new Promise((resolve) => {
|
|
94
|
+
timer = setTimeout(() => { controller.abort(); resolve(undefined); }, deps.catalogTimeoutMs ?? DEFAULT_REFRESH_MS);
|
|
95
|
+
});
|
|
96
|
+
const fresh = await Promise.race([catalog.refresh(controller.signal), timeout]);
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (fresh)
|
|
99
|
+
return { tasks: fresh.tasks ?? [] };
|
|
100
|
+
const cached = catalog.read();
|
|
101
|
+
if (!cached)
|
|
102
|
+
return undefined;
|
|
103
|
+
return { tasks: cached.tasks ?? [], text: `catalog from cache, ${formatAge(deps.now() - cached.fetchedAt)} old` };
|
|
104
|
+
}
|
|
105
|
+
function formatAge(ms) {
|
|
106
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
107
|
+
if (seconds < 60)
|
|
108
|
+
return `${seconds}s`;
|
|
109
|
+
const minutes = Math.floor(seconds / 60);
|
|
110
|
+
if (minutes < 60)
|
|
111
|
+
return `${minutes}m`;
|
|
112
|
+
const hours = Math.floor(minutes / 60);
|
|
113
|
+
return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure task selection for `/kankaku:task`. Mirrors pi's picker
|
|
3
|
+
* (`adapters/target-picker.ts#pickHubTask`): a task is pickable when its
|
|
4
|
+
* status is not `done` (the hub's statuses are `open | doing | done`, see
|
|
5
|
+
* `HubTask` in `kankaku/domain`) and it belongs to the resolved project.
|
|
6
|
+
*/
|
|
7
|
+
export function isOpenTask(task) {
|
|
8
|
+
return task.status !== "done";
|
|
9
|
+
}
|
|
10
|
+
/** The project's open tasks, sorted by title (the order the list is numbered in). */
|
|
11
|
+
export function listOpenTasks(tasks, projectId) {
|
|
12
|
+
return tasks
|
|
13
|
+
.filter((task) => task.projectId === projectId && isOpenTask(task))
|
|
14
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `arg` is one of: a list number (a purely numeric argument is ALWAYS a
|
|
18
|
+
* number, never an id), a hub id, or a case-insensitive title substring
|
|
19
|
+
* that must match exactly one open task.
|
|
20
|
+
*/
|
|
21
|
+
export function selectTask(arg, ctx) {
|
|
22
|
+
const text = arg.trim();
|
|
23
|
+
if (text === "")
|
|
24
|
+
return { kind: "unknown", message: "no task given" };
|
|
25
|
+
if (/^-?\d+$/.test(text))
|
|
26
|
+
return selectByNumber(Number(text), ctx);
|
|
27
|
+
const byId = ctx.open.find((task) => task.id === text);
|
|
28
|
+
if (byId)
|
|
29
|
+
return { kind: "picked", task: byId };
|
|
30
|
+
const needle = text.toLowerCase();
|
|
31
|
+
const matches = ctx.open.filter((task) => task.title.toLowerCase().includes(needle));
|
|
32
|
+
if (matches.length === 1)
|
|
33
|
+
return { kind: "picked", task: matches[0] };
|
|
34
|
+
if (matches.length > 1)
|
|
35
|
+
return { kind: "ambiguous", candidates: matches };
|
|
36
|
+
return { kind: "unknown", message: `no open task matches "${text}"` };
|
|
37
|
+
}
|
|
38
|
+
function selectByNumber(n, ctx) {
|
|
39
|
+
if (ctx.lastList.length === 0) {
|
|
40
|
+
return { kind: "unknown", message: "no task list shown yet in this session; list the tasks first" };
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isSafeInteger(n) || n < 1 || n > ctx.lastList.length) {
|
|
43
|
+
return { kind: "unknown", message: `${n} is not on the list (1-${ctx.lastList.length})` };
|
|
44
|
+
}
|
|
45
|
+
const id = ctx.lastList[n - 1];
|
|
46
|
+
const task = ctx.open.find((candidate) => candidate.id === id);
|
|
47
|
+
if (!task)
|
|
48
|
+
return { kind: "unknown", message: `task ${n} is no longer open; list the tasks again` };
|
|
49
|
+
return { kind: "picked", task };
|
|
50
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CachedCatalog, readProjectClient, readProjectTargetIds, resolveHubCredentials, } from "kankaku/hub";
|
|
4
|
+
import { formatWorkTargetLabel, isValidClient, resolveClient, resolveWorkTarget, resolveWorkTargetSource, } from "kankaku/domain";
|
|
5
|
+
import { findSession } from "./find-session.js";
|
|
6
|
+
import { resolveKankakuDir, resolveTargetFile } from "./paths.js";
|
|
7
|
+
import { readSessionTarget } from "./session-target-store.js";
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the work target for a session, for record stamping and display.
|
|
10
|
+
*
|
|
11
|
+
* Sources, in order: the project's `config.json` ids, then the cached
|
|
12
|
+
* catalog's `repo_paths` match for `cwd`. The catalog comes only from the
|
|
13
|
+
* cache file that every real sync refreshes — there is no fetch, no refresh
|
|
14
|
+
* and no network here, and nothing throws: an unusable cache means "no
|
|
15
|
+
* target". The precedence and eligibility rules (active, not the
|
|
16
|
+
* "unassigned" client, project belongs to the client) live entirely in the
|
|
17
|
+
* library's `resolveWorkTarget`.
|
|
18
|
+
*
|
|
19
|
+
* Only the heavy hooks (Stop, SessionEnd, recovery) and the CLI call this;
|
|
20
|
+
* the per-tool-call hooks never do.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveClaudeWorkTarget(input) {
|
|
23
|
+
try {
|
|
24
|
+
return resolveUnsafe(input);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { reason: "no catalog cache" };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function resolveUnsafe(input) {
|
|
31
|
+
const projectClient = readProjectClient(input.kankakuDir);
|
|
32
|
+
const legacyFallback = resolveClient({ env: input.env.KANKAKU_CLIENT, project: projectClient });
|
|
33
|
+
const link = input.taskLink;
|
|
34
|
+
const dropped = link !== undefined ? { droppedTask: { title: link.hubTaskTitle ?? link.hubTaskId } } : {};
|
|
35
|
+
const withLegacy = (result) => {
|
|
36
|
+
const base = result.target === undefined ? { ...result, ...dropped } : result;
|
|
37
|
+
return legacyFallback !== undefined ? { ...base, legacyClient: legacyFallback } : base;
|
|
38
|
+
};
|
|
39
|
+
const snapshot = readCatalogCache(input);
|
|
40
|
+
if (!snapshot)
|
|
41
|
+
return withLegacy({ reason: "no catalog cache" });
|
|
42
|
+
const resolveInput = {
|
|
43
|
+
project: readProjectTargetIds(input.kankakuDir),
|
|
44
|
+
cwd: input.cwd,
|
|
45
|
+
clients: snapshot.clients,
|
|
46
|
+
projects: snapshot.projects,
|
|
47
|
+
};
|
|
48
|
+
const target = resolveWorkTarget(resolveInput);
|
|
49
|
+
const source = resolveWorkTargetSource(resolveInput);
|
|
50
|
+
if (!target || (source !== "project" && source !== "repoPaths")) {
|
|
51
|
+
return withLegacy({ reason: `no match for ${input.cwd}` });
|
|
52
|
+
}
|
|
53
|
+
// Mirrors the pi extension: with a hub target the legacy label is the
|
|
54
|
+
// client's code, omitted when it is not a valid label.
|
|
55
|
+
// A linked task is validated by the library as the session source's task:
|
|
56
|
+
// same client and project as resolved above, so it can only ever add the
|
|
57
|
+
// task (kept when it belongs to that project and is still in the catalog).
|
|
58
|
+
const withTask = link !== undefined && target.projectId !== undefined
|
|
59
|
+
? resolveWorkTarget({
|
|
60
|
+
...resolveInput,
|
|
61
|
+
session: { clientId: target.clientId, projectId: target.projectId, hubTaskId: link.hubTaskId },
|
|
62
|
+
tasks: snapshot.tasks ?? [],
|
|
63
|
+
})
|
|
64
|
+
: undefined;
|
|
65
|
+
const finalTarget = withTask?.hubTaskId !== undefined ? withTask : target;
|
|
66
|
+
return {
|
|
67
|
+
target: finalTarget,
|
|
68
|
+
source,
|
|
69
|
+
...(link !== undefined && finalTarget.hubTaskId === undefined ? dropped : {}),
|
|
70
|
+
...(isValidClient(target.clientCode) ? { legacyClient: target.clientCode } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function readCatalogCache(input) {
|
|
74
|
+
// The cache is keyed by hub url; the url comes from credentials (env or
|
|
75
|
+
// file), which are read locally. No credentials, no way to trust a cache.
|
|
76
|
+
const hub = resolveHubCredentials({ env: input.env, homeDir: () => input.homeDir });
|
|
77
|
+
if (!hub.credentials)
|
|
78
|
+
return undefined;
|
|
79
|
+
return new CachedCatalog({
|
|
80
|
+
filePath: join(input.homeDir, ".kankaku", "catalog.json"),
|
|
81
|
+
url: hub.credentials.url,
|
|
82
|
+
clock: { now: () => 0 }, // read() never consults the clock
|
|
83
|
+
fetchCatalog: () => Promise.reject(new Error("the hook path never fetches")),
|
|
84
|
+
}).read();
|
|
85
|
+
}
|
|
86
|
+
/** The `target:` line printed by `/kankaku:status` and `/kankaku:doctor`. */
|
|
87
|
+
export function formatTargetLine(result) {
|
|
88
|
+
if (!result.target)
|
|
89
|
+
return `target: none (${result.reason ?? "unresolved"})`;
|
|
90
|
+
const source = result.source === "project" ? "project config" : "repo_paths";
|
|
91
|
+
// The task has its own line (see formatTaskLine); keep this one to client and project.
|
|
92
|
+
const { hubTaskId: _id, hubTaskTitle: _title, ...bare } = result.target;
|
|
93
|
+
return `target: ${formatWorkTargetLabel(bare)} (source: ${source})`;
|
|
94
|
+
}
|
|
95
|
+
/** The `task:` line printed by `/kankaku:status` and `/kankaku:doctor`. */
|
|
96
|
+
export function formatTaskLine(result) {
|
|
97
|
+
if (result.target?.hubTaskTitle !== undefined)
|
|
98
|
+
return `task: ${result.target.hubTaskTitle}`;
|
|
99
|
+
if (result.droppedTask === undefined)
|
|
100
|
+
return "task: none";
|
|
101
|
+
const where = result.target?.projectName !== undefined ? `is not in project ${result.target.projectName}` : "needs a project";
|
|
102
|
+
return `task: none (linked task "${result.droppedTask.title}" ${where})`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The work target for display (`status`, `doctor`): the folder's target plus
|
|
106
|
+
* the task linked by the session this process runs under, when one is found.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSessionWorkTarget(deps) {
|
|
109
|
+
const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
|
|
110
|
+
const claudeDir = join(kankakuDir, "claude");
|
|
111
|
+
const session = findSession({
|
|
112
|
+
claudeDir, env: deps.env,
|
|
113
|
+
...(deps.pid !== undefined ? { pid: deps.pid } : {}),
|
|
114
|
+
...(deps.runPs !== undefined ? { runPs: deps.runPs } : {}),
|
|
115
|
+
});
|
|
116
|
+
const stored = session !== undefined ? readSessionTarget(resolveTargetFile(claudeDir, session)) : undefined;
|
|
117
|
+
return resolveClaudeWorkTarget({
|
|
118
|
+
cwd: deps.cwd, kankakuDir, homeDir: deps.env.HOME || homedir(), env: deps.env,
|
|
119
|
+
...(stored?.hubTaskId !== undefined
|
|
120
|
+
? { taskLink: { hubTaskId: stored.hubTaskId, ...(stored.hubTaskTitle !== undefined ? { hubTaskTitle: stored.hubTaskTitle } : {}) } }
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kankaku-claude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.
|
|
30
|
+
"kankaku": "^0.12.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.13.4",
|