kankaku-claude 0.11.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.
@@ -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.11.0",
4
+ "version": "0.12.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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
+
7
20
  ## 0.11.0 — 2026-09-29
8
21
 
9
22
  ### Added
package/README.md CHANGED
@@ -142,6 +142,8 @@ anything behind in whatever project happens to be open.
142
142
  session id, whether its process is still alive, whether a prompt is open,
143
143
  and the last cost the statusline reported, preceded by the resolved work
144
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.
145
147
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
146
148
  `node dist/cli.js setup`).
147
149
  - `/kankaku:sync` — manually syncs recent local work records to the hub
@@ -222,7 +224,36 @@ crash recovery), never on the per-tool-call hooks. `/kankaku:status` and
222
224
  `/kankaku:doctor` print `target: <client> · <project> (source: ...)`, or
223
225
  `target: none (<reason>)`.
224
226
 
225
- There is nothing to pick inside Claude Code yet. Assignment is create-only
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
226
257
  on the hub: a row already uploaded as unassigned stays that way until it is
227
258
  reassigned in the web app; a later sync does not move it.
228
259
 
@@ -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
@@ -1,4 +1,3 @@
1
- import { homedir } from "node:os";
2
1
  import { basename, join } from "node:path";
3
2
  import { JsonlWorkLog } from "kankaku/hub";
4
3
  import { listStateFiles, resolveKankakuDir } from "./paths.js";
@@ -7,8 +6,9 @@ import { readCost } from "./cost-store.js";
7
6
  import { formatReport } from "./report.js";
8
7
  import { runSyncCli } from "./sync-cli.js";
9
8
  import { runDoctor } from "./doctor.js";
10
- import { formatTargetLine, resolveClaudeWorkTarget } from "./work-target.js";
11
- const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor> [--days N]\n";
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";
12
12
  /** CLI commands, resolved from `deps.cwd`. */
13
13
  export async function runCli(argv, deps) {
14
14
  const [command, ...rest] = argv;
@@ -21,6 +21,8 @@ export async function runCli(argv, deps) {
21
21
  return runSetup(deps);
22
22
  case "sync":
23
23
  return runSyncCli(rest, deps);
24
+ case "task":
25
+ return runTaskCli(rest, deps);
24
26
  case "doctor":
25
27
  return { stdout: runDoctor(deps), exitCode: 0 };
26
28
  default:
@@ -45,7 +47,8 @@ function runStatus(deps) {
45
47
  const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
46
48
  const claudeDir = join(kankakuDir, "claude");
47
49
  const files = listStateFiles(claudeDir);
48
- const targetLine = formatTargetLine(resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir: deps.env.HOME || homedir(), env: deps.env }));
50
+ const resolved = resolveSessionWorkTarget(deps);
51
+ const targetLine = `${formatTargetLine(resolved)}\n${formatTaskLine(resolved)}`;
49
52
  if (files.length === 0) {
50
53
  return { stdout: `${targetLine}\nNo active sessions.\n`, exitCode: 0 };
51
54
  }
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,7 +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
+ import { formatTargetLine, formatTaskLine, resolveSessionWorkTarget } from "./work-target.js";
9
9
  function metadata(file) {
10
10
  try {
11
11
  const value = JSON.parse(readFileSync(file, "utf8"));
@@ -23,6 +23,10 @@ function syncWindowHours(env) {
23
23
  const hours = Number(env.KANKAKU_SYNC_WINDOW_HOURS);
24
24
  return env.KANKAKU_SYNC_WINDOW_HOURS && Number.isFinite(hours) && hours > 0 ? hours : 24;
25
25
  }
26
+ function workTargetLines(deps) {
27
+ const resolved = resolveSessionWorkTarget(deps);
28
+ return [formatTargetLine(resolved), formatTaskLine(resolved)];
29
+ }
26
30
  /** Read-only, local diagnostic. No sync runner or network client is reachable from this path. */
27
31
  export function runDoctor(deps) {
28
32
  const dir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
@@ -65,7 +69,7 @@ export function runDoctor(deps) {
65
69
  const target = hub.credentials?.url ?? store.read()?.target ?? "";
66
70
  const { state, pending, staleOutsideWindow } = computeSyncStatus(log, store, target, syncWindowHours(deps.env));
67
71
  const hubState = hub.invalidReason ? "invalid URL" : hub.credentials ? "configured" : "unconfigured";
68
- const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor"];
72
+ const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor", "task"];
69
73
  const present = commands.filter((name) => existsSync(join(deps.pluginRoot, "commands", `${name}.md`))).length;
70
74
  const actions = ["/kankaku:status", "/kankaku:sync-status"];
71
75
  if (!costsVisible)
@@ -90,7 +94,7 @@ export function runDoctor(deps) {
90
94
  `alive: ${alive}; dead: ${dead}; open prompts: ${open}; unreadable: ${unreadable}`,
91
95
  `cost files visible: ${costsVisible ? "yes" : "no"} (current HOME)`,
92
96
  "", "## Work target",
93
- formatTargetLine(resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir: dir, homeDir: deps.env.HOME || homedir(), env: deps.env })),
97
+ ...workTargetLines(deps),
94
98
  "", "## Hub / sync",
95
99
  `hub: ${hubState}`,
96
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
+ }
@@ -136,7 +136,7 @@ async function handleStop(paths, sessionId, deps) {
136
136
  const core = replayPrompt(last, { cost: costDelta });
137
137
  if (core) {
138
138
  const assignment = await assignmentResolver(paths, deps);
139
- const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd));
139
+ const record = buildClaudeRecord(core, state, sessionId, cost?.model, assignment(state.cwd, sessionId));
140
140
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
141
141
  log.append(record);
142
142
  }
@@ -198,7 +198,7 @@ async function handleSessionEnd(paths, sessionId, cwd, ts, deps) {
198
198
  if (core) {
199
199
  const model = readCost(deps.env, sessionId)?.model;
200
200
  const assignment = await assignmentResolver(paths, deps);
201
- const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd));
201
+ const record = buildClaudeRecord(core, state, sessionId, model, assignment(state.cwd, sessionId));
202
202
  const log = deps.log ?? new JsonlWorkLog(paths.kankakuDir);
203
203
  log.append(record);
204
204
  }
@@ -221,14 +221,21 @@ async function assignmentResolver(paths, deps) {
221
221
  try {
222
222
  const { resolveClaudeWorkTarget } = await import("./work-target.js");
223
223
  const { homedir } = await import("node:os");
224
+ const { readSessionTarget } = await import("./session-target-store.js");
225
+ const { resolveTargetFile } = await import("./paths.js");
224
226
  const resolve = deps.resolveTarget ?? resolveClaudeWorkTarget;
225
- return (cwd) => {
227
+ return (cwd, sessionId) => {
226
228
  try {
229
+ // The session-only task link (`/kankaku:task`); heavy hooks only.
230
+ const stored = readSessionTarget(resolveTargetFile(paths.claudeDir, sessionId));
227
231
  const { target, legacyClient } = resolve({
228
232
  cwd,
229
233
  kankakuDir: paths.kankakuDir,
230
234
  homeDir: deps.env.HOME || homedir(),
231
235
  env: deps.env,
236
+ ...(stored?.hubTaskId !== undefined
237
+ ? { taskLink: { hubTaskId: stored.hubTaskId, ...(stored.hubTaskTitle !== undefined ? { hubTaskTitle: stored.hubTaskTitle } : {}) } }
238
+ : {}),
232
239
  });
233
240
  return {
234
241
  ...(target !== undefined ? { target } : {}),
@@ -259,7 +266,7 @@ async function syncHeavy(trigger, cwd, deps) {
259
266
  }
260
267
  }
261
268
  function deleteSessionFiles(paths) {
262
- for (const file of [paths.stateFile, paths.eventsFile]) {
269
+ for (const file of [paths.stateFile, paths.eventsFile, paths.targetFile]) {
263
270
  try {
264
271
  unlinkSync(file);
265
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);
@@ -47,12 +49,13 @@ export function recoverStaleSessions(input) {
47
49
  const core = replayPrompt(last, { settledAt });
48
50
  if (core) {
49
51
  const model = readCost(input.env, sessionId)?.model;
50
- records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd)));
52
+ records.push(buildClaudeRecord(core, state, sessionId, model, input.resolveAssignment?.(state.cwd, sessionId)));
51
53
  }
52
54
  }
53
55
  }
54
56
  safeUnlink(stateFile);
55
57
  safeUnlink(eventsFile);
58
+ safeUnlink(targetFile);
56
59
  deleteCost(input.env, sessionId);
57
60
  }
58
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
@@ -50,6 +50,8 @@ export function buildClaudeRecord(core, state, sessionId, model, assignment = {}
50
50
  clientName: target.clientName,
51
51
  ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
52
52
  ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
53
+ ...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
54
+ ...(target.hubTaskId !== undefined && target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
53
55
  }
54
56
  : {}),
55
57
  };
@@ -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
+ }
@@ -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
+ }
@@ -1,6 +1,10 @@
1
+ import { homedir } from "node:os";
1
2
  import { join } from "node:path";
2
3
  import { CachedCatalog, readProjectClient, readProjectTargetIds, resolveHubCredentials, } from "kankaku/hub";
3
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";
4
8
  /**
5
9
  * Resolves the work target for a session, for record stamping and display.
6
10
  *
@@ -26,7 +30,12 @@ export function resolveClaudeWorkTarget(input) {
26
30
  function resolveUnsafe(input) {
27
31
  const projectClient = readProjectClient(input.kankakuDir);
28
32
  const legacyFallback = resolveClient({ env: input.env.KANKAKU_CLIENT, project: projectClient });
29
- const withLegacy = (result) => legacyFallback !== undefined ? { ...result, legacyClient: legacyFallback } : result;
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
+ };
30
39
  const snapshot = readCatalogCache(input);
31
40
  if (!snapshot)
32
41
  return withLegacy({ reason: "no catalog cache" });
@@ -43,9 +52,21 @@ function resolveUnsafe(input) {
43
52
  }
44
53
  // Mirrors the pi extension: with a hub target the legacy label is the
45
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;
46
66
  return {
47
- target,
67
+ target: finalTarget,
48
68
  source,
69
+ ...(link !== undefined && finalTarget.hubTaskId === undefined ? dropped : {}),
49
70
  ...(isValidClient(target.clientCode) ? { legacyClient: target.clientCode } : {}),
50
71
  };
51
72
  }
@@ -67,5 +88,36 @@ export function formatTargetLine(result) {
67
88
  if (!result.target)
68
89
  return `target: none (${result.reason ?? "unresolved"})`;
69
90
  const source = result.source === "project" ? "project config" : "repo_paths";
70
- return `target: ${formatWorkTargetLabel(result.target)} (source: ${source})`;
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
+ });
71
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "0.11.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.11.0"
30
+ "kankaku": "^0.12.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.4",