kankaku-claude 1.3.0 → 1.4.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": "1.3.0",
4
+ "version": "1.4.0",
5
5
  "author": {
6
6
  "name": "soyunninja"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ 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
+ ## 1.4.0 — 2026-09-30
8
+
9
+ ### Added
10
+
11
+ - **`/kankaku:target`.** Chooses the client and project a Claude Code session
12
+ works for, from inside the session: with no argument it prints the current
13
+ target and its source and the active clients numbered; `<number | code |
14
+ text>` picks a client and lists its projects, a following pick sets the
15
+ project, `<client> <project>` sets both, and `clear` returns to the
16
+ automatic resolution. The pick is session-only (stored next to the task
17
+ link, never in `config.json`) and wins over the project config and
18
+ `repo_paths` when records are written; `/kankaku:status` and
19
+ `/kankaku:doctor` show `source: session`. Changing the project drops the
20
+ task link unless the linked task belongs to the new project.
21
+
22
+ ## 1.3.1 — 2026-09-30
23
+
24
+ No changes in this package; released in lockstep with kankaku-pi and
25
+ kankaku 1.3.1.
26
+
7
27
  ## 1.3.0 — 2026-09-29
8
28
 
9
29
  ### Added
package/README.md CHANGED
@@ -153,6 +153,9 @@ anything behind in whatever project happens to be open.
153
153
  session upgraded mid-way, or resumed without its state) — no later
154
154
  prompt carries that part.
155
155
  The resolved work target comes first (wraps `node dist/cli.js status`).
156
+ - `/kankaku:target` — chooses the client and project this session works
157
+ for (wraps `node dist/cli.js target`); see "Choosing the client and
158
+ project for a session" below.
156
159
  - `/kankaku:task` — links this session to a hub task (wraps
157
160
  `node dist/cli.js task`); see "Linking a task" below.
158
161
  - `/kankaku:setup` — prints the `statusLine` snippet described above (wraps
@@ -215,9 +218,10 @@ Each record is stamped with a hub client and project when one resolves, so
215
218
  the hub files the task under the right client instead of "Sin determinar".
216
219
  Sources, in order:
217
220
 
218
- 1. the project's `<KANKAKU_DIR>/config.json` ids (`clientId`, optional
221
+ 1. the session's own pick, made with `/kankaku:target` (see below);
222
+ 2. the project's `<KANKAKU_DIR>/config.json` ids (`clientId`, optional
219
223
  `projectId`);
220
- 2. the cached catalog's `repo_paths`: the active project whose path equals
224
+ 3. the cached catalog's `repo_paths`: the active project whose path equals
221
225
  the session's working directory, or contains it.
222
226
 
223
227
  Inactive clients and projects, and the "unassigned" client, are never used.
@@ -235,6 +239,53 @@ crash recovery), never on the per-tool-call hooks. `/kankaku:status` and
235
239
  `/kankaku:doctor` print `target: <client> · <project> (source: ...)`, or
236
240
  `target: none (<reason>)`.
237
241
 
242
+ ### Choosing the client and project for a session
243
+
244
+ `/kankaku:target` picks the client and project the records of this session
245
+ are filed under, for the cases where the folder does not say (or says the
246
+ wrong thing):
247
+
248
+ - `/kankaku:target` prints the current target and its source (`session`,
249
+ `project config` or `repo_paths`), then the active clients numbered;
250
+ Claude then asks which one you want. The unassigned client is never
251
+ listed.
252
+ - `/kankaku:target <number | code | text>` picks a client: from the last
253
+ list shown in this session (a purely numeric argument is always a list
254
+ number), by client code (exact, case-insensitive), or by a unique
255
+ case-insensitive part of its name. It prints `client set to <Client>` and
256
+ that client's active projects numbered. A following
257
+ `/kankaku:target <number | code | text>` that matches one of those
258
+ projects sets the project (`target set to <Client> · <Project>`).
259
+ - `/kankaku:target <client> <project>` sets both at once; the project must
260
+ belong to the client, otherwise nothing changes.
261
+ - `/kankaku:target clear` removes the pick and prints what the automatic
262
+ resolution gives now.
263
+
264
+ Text that matches nothing, or several entries, changes nothing (several are
265
+ listed so one can be picked by number). Inactive entries are never
266
+ selectable.
267
+
268
+ The pick is session-only, like `/kankaku target pick|clear` in pi: it is
269
+ stored in `<KANKAKU_DIR>/claude/<session>.target.json` next to the task
270
+ link, removed when the session ends, and **`config.json` is never written**.
271
+ For a permanent choice use `kankaku setup` or the project's `config.json`.
272
+ Precedence at record time is the library's: session pick, then `config.json`,
273
+ then `repo_paths`; a picked client or project that is no longer active in
274
+ the catalog falls through to the next source. Records carry the picked
275
+ client and project (`clientId`, `clientName`, `projectId`, `projectName`,
276
+ and the legacy `client` label = the client code when it is a valid label),
277
+ and `/kankaku:status` and `/kankaku:doctor` print `(source: session)`.
278
+
279
+ Changing or clearing the client or project also decides the task link: it is
280
+ kept only when the linked task belongs to the resulting project, and the
281
+ command says which happened (`task link kept`, or `task link dropped
282
+ (<title> is not in <Project>)`). Picking only a client always drops it,
283
+ since no project is chosen yet. `/kankaku:task` lists the tasks of the
284
+ resulting project. Listing and picking refresh the catalog first (bounded to
285
+ 3 seconds) and fall back to the cache (`catalog from cache, <age> old`);
286
+ without a cache and without a hub the command says `no catalog` and exits
287
+ with 1.
288
+
238
289
  ### Linking a task
239
290
 
240
291
  `/kankaku:task` links the session to one hub task of the project resolved
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Choose the client and project this session works for (list clients, pick one, or clear)
3
+ argument-hint: [number | code | text] [project] | clear
4
+ allowed-tools: Bash(node:*)
5
+ ---
6
+
7
+ Run the kankaku CLI target 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" target $ARGUMENTS
11
+
12
+ If the output lists clients or projects, ask the user which one they want,
13
+ by number, then run the same CLI command with that number and show its output
14
+ verbatim in a code block.
15
+
16
+ Never pick a client or a project on the user's behalf.
17
+
18
+ The choice lasts for this session only: it does not change the project's
19
+ `config.json`.
@@ -0,0 +1,44 @@
1
+ import { join } from "node:path";
2
+ import { CachedCatalog, PocketBaseClient, createPocketBaseCatalogFetcher, resolveHubCredentials } from "kankaku-pi/hub";
3
+ const DEFAULT_REFRESH_MS = 3000;
4
+ /**
5
+ * Best-effort refresh bounded by `catalogTimeoutMs`; on failure the cached
6
+ * snapshot is used and its age reported. `undefined` when there is neither
7
+ * credentials nor a cache (the caller then reports why). CLI commands only:
8
+ * the hooks never call this.
9
+ */
10
+ export async function refreshCatalog(deps, homeDir) {
11
+ const hub = resolveHubCredentials({ env: deps.env, homeDir: () => homeDir });
12
+ if (!hub.credentials)
13
+ return undefined;
14
+ const client = new PocketBaseClient({ ...hub.credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
15
+ const catalog = new CachedCatalog({
16
+ filePath: join(homeDir, ".kankaku", "catalog.json"),
17
+ url: hub.credentials.url,
18
+ clock: { now: deps.now },
19
+ fetchCatalog: createPocketBaseCatalogFetcher(client),
20
+ });
21
+ const controller = new AbortController();
22
+ let timer;
23
+ const timeout = new Promise((resolve) => {
24
+ timer = setTimeout(() => { controller.abort(); resolve(undefined); }, deps.catalogTimeoutMs ?? DEFAULT_REFRESH_MS);
25
+ });
26
+ const fresh = await Promise.race([catalog.refresh(controller.signal), timeout]);
27
+ clearTimeout(timer);
28
+ if (fresh)
29
+ return { snapshot: fresh };
30
+ const cached = catalog.read();
31
+ if (!cached)
32
+ return undefined;
33
+ return { snapshot: cached, text: `catalog from cache, ${formatAge(deps.now() - cached.fetchedAt)} old` };
34
+ }
35
+ function formatAge(ms) {
36
+ const seconds = Math.max(0, Math.round(ms / 1000));
37
+ if (seconds < 60)
38
+ return `${seconds}s`;
39
+ const minutes = Math.floor(seconds / 60);
40
+ if (minutes < 60)
41
+ return `${minutes}m`;
42
+ const hours = Math.floor(minutes / 60);
43
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
44
+ }
package/dist/cli-core.js CHANGED
@@ -7,8 +7,9 @@ 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 { runTargetCli } from "./target-cli.js";
10
11
  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
+ const USAGE = "usage: node dist/cli.js <report|status|setup|sync|doctor|task|target> [--days N]\n";
12
13
  /** CLI commands, resolved from `deps.cwd`. */
13
14
  export async function runCli(argv, deps) {
14
15
  const [command, ...rest] = argv;
@@ -23,6 +24,8 @@ export async function runCli(argv, deps) {
23
24
  return runSyncCli(rest, deps);
24
25
  case "task":
25
26
  return runTaskCli(rest, deps);
27
+ case "target":
28
+ return runTargetCli(rest, deps);
26
29
  case "doctor":
27
30
  return { stdout: runDoctor(deps), exitCode: 0 };
28
31
  default:
package/dist/doctor.js CHANGED
@@ -69,7 +69,7 @@ export function runDoctor(deps) {
69
69
  const target = hub.credentials?.url ?? store.read()?.target ?? "";
70
70
  const { state, pending, staleOutsideWindow } = computeSyncStatus(log, store, target, syncWindowHours(deps.env));
71
71
  const hubState = hub.invalidReason ? "invalid URL" : hub.credentials ? "configured" : "unconfigured";
72
- const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor", "task"];
72
+ const commands = ["report", "status", "setup", "sync", "sync-status", "sync-all", "doctor", "task", "target"];
73
73
  const present = commands.filter((name) => existsSync(join(deps.pluginRoot, "commands", `${name}.md`))).length;
74
74
  const actions = ["/kankaku:status", "/kankaku:sync-status"];
75
75
  if (!costsVisible)
@@ -301,21 +301,19 @@ async function assignmentResolver(paths, deps) {
301
301
  try {
302
302
  const { resolveClaudeWorkTarget } = await import("./work-target.js");
303
303
  const { homedir } = await import("node:os");
304
- const { readSessionTarget } = await import("./session-target-store.js");
304
+ const { readSessionTarget, sessionInputs } = await import("./session-target-store.js");
305
305
  const { resolveTargetFile } = await import("./paths.js");
306
306
  const resolve = deps.resolveTarget ?? resolveClaudeWorkTarget;
307
307
  return (cwd, sessionId) => {
308
308
  try {
309
- // The session-only task link (`/kankaku:task`); heavy hooks only.
309
+ // The session-only target and task link (`/kankaku:target`, `/kankaku:task`); heavy hooks only.
310
310
  const stored = readSessionTarget(resolveTargetFile(paths.claudeDir, sessionId));
311
311
  const { target, legacyClient } = resolve({
312
312
  cwd,
313
313
  kankakuDir: paths.kankakuDir,
314
314
  homeDir: deps.env.HOME || homedir(),
315
315
  env: deps.env,
316
- ...(stored?.hubTaskId !== undefined
317
- ? { taskLink: { hubTaskId: stored.hubTaskId, ...(stored.hubTaskTitle !== undefined ? { hubTaskTitle: stored.hubTaskTitle } : {}) } }
318
- : {}),
316
+ ...(stored !== undefined ? sessionInputs(stored) : {}),
319
317
  });
320
318
  return {
321
319
  ...(target !== undefined ? { target } : {}),
@@ -1,8 +1,25 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ const LIST_KINDS = ["clients", "projects", "tasks"];
3
4
  function str(value) {
4
5
  return typeof value === "string" && value !== "" ? value : undefined;
5
6
  }
7
+ function strings(value) {
8
+ return value.filter((id) => typeof id === "string");
9
+ }
10
+ /** A plain array is the pre-`target` format: the ids of a task list. `undefined` when malformed. */
11
+ function readList(value) {
12
+ if (value === undefined)
13
+ return { kind: "tasks", ids: [] };
14
+ if (Array.isArray(value))
15
+ return { kind: "tasks", ids: strings(value) };
16
+ if (typeof value !== "object" || value === null)
17
+ return undefined;
18
+ const { kind, ids } = value;
19
+ if (typeof kind !== "string" || !LIST_KINDS.includes(kind) || !Array.isArray(ids))
20
+ return undefined;
21
+ return { kind: kind, ids: strings(ids) };
22
+ }
6
23
  /** `undefined` when the file is absent or malformed. Extra or invalid fields are dropped. */
7
24
  export function readSessionTarget(file) {
8
25
  let parsed;
@@ -15,21 +32,43 @@ export function readSessionTarget(file) {
15
32
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
16
33
  return undefined;
17
34
  const o = parsed;
18
- if (o.hubTaskId !== undefined && typeof o.hubTaskId !== "string")
19
- return undefined;
20
- if (o.lastList !== undefined && !Array.isArray(o.lastList))
35
+ for (const key of ["hubTaskId", "clientId", "projectId"]) {
36
+ if (o[key] !== undefined && typeof o[key] !== "string")
37
+ return undefined;
38
+ }
39
+ const lastList = readList(o.lastList);
40
+ if (lastList === undefined)
21
41
  return undefined;
22
42
  const hubTaskId = str(o.hubTaskId);
23
43
  const hubTaskTitle = str(o.hubTaskTitle);
24
- const projectId = str(o.projectId);
44
+ const clientId = str(o.clientId);
45
+ const projectId = hubTaskId !== undefined || clientId !== undefined ? str(o.projectId) : undefined;
25
46
  return {
47
+ ...(clientId !== undefined ? { clientId } : {}),
48
+ ...(clientId !== undefined && typeof o.pickedTargetAt === "number" ? { pickedTargetAt: o.pickedTargetAt } : {}),
26
49
  ...(hubTaskId !== undefined ? { hubTaskId } : {}),
27
50
  ...(hubTaskId !== undefined && hubTaskTitle !== undefined ? { hubTaskTitle } : {}),
28
- ...(hubTaskId !== undefined && projectId !== undefined ? { projectId } : {}),
51
+ ...(projectId !== undefined ? { projectId } : {}),
29
52
  ...(hubTaskId !== undefined && typeof o.pickedAt === "number" ? { pickedAt: o.pickedAt } : {}),
30
- lastList: (Array.isArray(o.lastList) ? o.lastList : []).filter((id) => typeof id === "string"),
53
+ lastList,
31
54
  };
32
55
  }
56
+ /** What the work-target resolution takes from a session's file: the `/kankaku:target` pick and the task link. */
57
+ export function sessionInputs(link) {
58
+ return {
59
+ ...(link.clientId !== undefined
60
+ ? { sessionTarget: { clientId: link.clientId, ...(link.projectId !== undefined ? { projectId: link.projectId } : {}) } }
61
+ : {}),
62
+ ...(link.hubTaskId !== undefined
63
+ ? { taskLink: { hubTaskId: link.hubTaskId, ...(link.hubTaskTitle !== undefined ? { hubTaskTitle: link.hubTaskTitle } : {}) } }
64
+ : {}),
65
+ };
66
+ }
67
+ /** The same file content without the task link (the target and the list are kept). */
68
+ export function withoutTaskLink(link) {
69
+ const { hubTaskId: _id, hubTaskTitle: _title, pickedAt: _at, projectId, ...rest } = link;
70
+ return { ...rest, ...(link.clientId !== undefined && projectId !== undefined ? { projectId } : {}) };
71
+ }
33
72
  /** Atomic tmp+rename write, creating parent directories as needed. */
34
73
  export function writeSessionTarget(file, link) {
35
74
  mkdirSync(dirname(file), { recursive: true });
@@ -0,0 +1,119 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { refreshCatalog } from "./catalog-refresh.js";
4
+ import { findSession } from "./find-session.js";
5
+ import { resolveKankakuDir, resolveTargetFile } from "./paths.js";
6
+ import { readSessionTarget, sessionInputs, withoutTaskLink, writeSessionTarget } from "./session-target-store.js";
7
+ import { activeClients, activeProjects, pickBoth, pickTarget, taskLinkOutcome } from "./target-select.js";
8
+ import { formatTargetLine, resolveClaudeWorkTarget } from "./work-target.js";
9
+ const NO_SESSION = "no active Claude Code session found for this folder\n";
10
+ const NO_CATALOG = "no catalog: configure the hub or run kankaku catalog refresh";
11
+ const HINT = "pick one with: /kankaku:target <number>";
12
+ const EMPTY_LIST = { kind: "tasks", ids: [] };
13
+ const say = (lines, exitCode) => ({ stdout: lines.join("\n") + "\n", exitCode });
14
+ /**
15
+ * `target [<number | code | text> [<project>] | clear]`: picks the client
16
+ * and project this Claude Code session works for, session-only (see
17
+ * `session-target-store.ts`); it never touches the project's `config.json`.
18
+ * Not a hook: it may use the network, bounded, like `task`.
19
+ */
20
+ export async function runTargetCli(args, deps) {
21
+ const kankakuDir = resolveKankakuDir(deps.env.KANKAKU_DIR ?? ".kankaku", deps.cwd);
22
+ const claudeDir = join(kankakuDir, "claude");
23
+ const session = findSession({
24
+ claudeDir,
25
+ env: deps.env,
26
+ ...(deps.pid !== undefined ? { pid: deps.pid } : {}),
27
+ ...(deps.runPs !== undefined ? { runPs: deps.runPs } : {}),
28
+ });
29
+ if (!session)
30
+ return { stdout: NO_SESSION, exitCode: 1 };
31
+ const file = resolveTargetFile(claudeDir, session);
32
+ const stored = readSessionTarget(file) ?? { lastList: EMPTY_LIST };
33
+ const words = args.map((word) => word.trim()).filter((word) => word !== "");
34
+ const homeDir = deps.env.HOME || homedir();
35
+ const catalog = await refreshCatalog(deps, homeDir);
36
+ const prefix = catalog?.text ? [catalog.text] : [];
37
+ const resolve = (sessionTarget) => resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir, env: deps.env, ...(sessionTarget ? { sessionTarget } : {}) });
38
+ if (words.length === 1 && words[0].toLowerCase() === "clear") {
39
+ return clear({ file, stored, prefix, tasks: catalog?.snapshot.tasks ?? [], automatic: resolve() });
40
+ }
41
+ if (!catalog)
42
+ return say([NO_CATALOG], 1);
43
+ const { clients, projects, tasks = [] } = catalog.snapshot;
44
+ const link = sessionInputs(stored).taskLink;
45
+ const ctx = {
46
+ clients, projects, lastList: stored.lastList, ...(stored.clientId !== undefined ? { clientId: stored.clientId } : {}),
47
+ };
48
+ if (words.length === 0) {
49
+ const shown = activeClients(clients);
50
+ writeSessionTarget(file, { ...stored, lastList: { kind: "clients", ids: shown.map((client) => client.id) } });
51
+ const current = formatTargetLine(resolve(sessionInputs(stored).sessionTarget));
52
+ return say([...prefix, current, ...(shown.length > 0 ? [...clientLines(shown), HINT] : ["no active clients"])], 0);
53
+ }
54
+ const pick = choose(words, ctx);
55
+ if (pick.kind === "unknown")
56
+ return say([...prefix, pick.message], 1);
57
+ if (pick.kind === "ambiguous") {
58
+ const isClients = pick.of === "clients";
59
+ writeSessionTarget(file, { ...stored, lastList: { kind: pick.of, ids: pick.candidates.map((entry) => entry.id) } });
60
+ const shown = isClients ? clientLines(pick.candidates) : projectLines(pick.candidates);
61
+ const owner = clients.find((client) => client.id === stored.clientId);
62
+ const what = isClients || owner === undefined ? pick.of : `${pick.of} of ${owner.name}`;
63
+ return say([...prefix, `several ${what} match "${words.join(" ")}":`, ...shown, HINT], 1);
64
+ }
65
+ const now = deps.now();
66
+ if (pick.kind === "client") {
67
+ const outcome = taskLinkOutcome(link, tasks, undefined);
68
+ const shown = activeProjects(projects, pick.client.id);
69
+ const { projectId: _project, ...rest } = stored.hubTaskId !== undefined ? withoutTaskLink(stored) : stored;
70
+ writeSessionTarget(file, {
71
+ ...rest, clientId: pick.client.id, pickedTargetAt: now, lastList: { kind: "projects", ids: shown.map((project) => project.id) },
72
+ });
73
+ return say([
74
+ ...prefix, `client set to ${pick.client.name}`, ...(outcome ? [outcome.message] : []),
75
+ ...(shown.length > 0 ? [...projectLines(shown), HINT] : ["no active projects"]),
76
+ ], 0);
77
+ }
78
+ const client = pick.kind === "both" ? pick.client : clients.find((entry) => entry.id === stored.clientId);
79
+ const outcome = taskLinkOutcome(link, tasks, pick.project);
80
+ const base = outcome?.keep === false ? withoutTaskLink(stored) : stored;
81
+ writeSessionTarget(file, {
82
+ ...base, clientId: client.id, projectId: pick.project.id, pickedTargetAt: now,
83
+ lastList: pick.kind === "both" ? EMPTY_LIST : stored.lastList,
84
+ });
85
+ return say([...prefix, `target set to ${client.name} · ${pick.project.name}`, ...(outcome ? [outcome.message] : [])], 0);
86
+ }
87
+ /**
88
+ * One word is `pickTarget`'s. Several words are `<client> <project>` (see
89
+ * `pickBoth`); when that finds nothing they may still be one multi-word name
90
+ * ("Acme Corp"), which is then tried as a whole.
91
+ */
92
+ function choose(words, ctx) {
93
+ if (words.length === 1)
94
+ return pickTarget(words[0], ctx);
95
+ const both = pickBoth(words, ctx);
96
+ if (both.kind !== "unknown")
97
+ return both;
98
+ const whole = pickTarget(words.join(" "), ctx);
99
+ return whole.kind === "client" || whole.kind === "project" ? whole : both;
100
+ }
101
+ function clear({ file, stored, prefix, tasks, automatic }) {
102
+ if (stored.clientId === undefined)
103
+ return say([...prefix, "no session target to clear"], 0);
104
+ const project = automatic.target?.projectId !== undefined
105
+ ? { id: automatic.target.projectId, name: automatic.target.projectName ?? automatic.target.projectId }
106
+ : undefined;
107
+ const outcome = taskLinkOutcome(sessionInputs(stored).taskLink, tasks, project);
108
+ const { clientId: _client, pickedTargetAt: _at, projectId: _project, ...rest } = stored;
109
+ const kept = outcome?.keep === true && project !== undefined ? { ...rest, projectId: project.id } : withoutTaskLink(rest);
110
+ writeSessionTarget(file, { ...kept, lastList: EMPTY_LIST });
111
+ const back = automatic.target !== undefined ? formatTargetLine(automatic).replace(/^target: /, "") : `none (${automatic.reason ?? "unresolved"})`;
112
+ return say([...prefix, `target cleared for this session; back to ${back}`, ...(outcome ? [outcome.message] : [])], 0);
113
+ }
114
+ function clientLines(clients) {
115
+ return clients.map((client, index) => ` ${index + 1}. ${client.name} (${client.code})`);
116
+ }
117
+ function projectLines(projects) {
118
+ return projects.map((project, index) => ` ${index + 1}. ${project.name}${project.code ? ` (${project.code})` : ""}`);
119
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Pure selection logic for `/kankaku:target`. Only active entries are ever
3
+ * selectable, and a client flagged `unassigned` never is. A numeric argument
4
+ * is ALWAYS a number on the last list printed for the session (the same rule
5
+ * as `task-select.ts`), never a code or a name.
6
+ */
7
+ export function activeClients(clients) {
8
+ return clients.filter((client) => client.active && client.unassigned !== true).sort(byName);
9
+ }
10
+ /** The client's active projects, by name (the order the list is numbered in). */
11
+ export function activeProjects(projects, clientId) {
12
+ return projects.filter((project) => project.active && project.clientId === clientId).sort(byName);
13
+ }
14
+ function byName(a, b) {
15
+ return a.name.localeCompare(b.name);
16
+ }
17
+ const NUMBER = /^-?\d+$/;
18
+ /** Exact code, else exact name, else a unique case-insensitive name substring; all case-insensitive. */
19
+ function match(text, entries) {
20
+ const needle = text.toLowerCase();
21
+ const stages = [
22
+ entries.filter((entry) => entry.code?.toLowerCase() === needle),
23
+ entries.filter((entry) => entry.name.toLowerCase() === needle),
24
+ entries.filter((entry) => entry.name.toLowerCase().includes(needle)),
25
+ ];
26
+ for (const found of stages) {
27
+ if (found.length === 1)
28
+ return { kind: "picked", item: found[0] };
29
+ if (found.length > 1)
30
+ return { kind: "ambiguous", candidates: found };
31
+ }
32
+ return { kind: "none" };
33
+ }
34
+ function usableClient(clients, id) {
35
+ return activeClients(clients).find((client) => client.id === id);
36
+ }
37
+ function unknown(message) {
38
+ return { kind: "unknown", message };
39
+ }
40
+ /** A number on the last list, checked against the kind wanted and against the entries still eligible. */
41
+ function byNumber(n, wanted, list, eligible) {
42
+ if (list.ids.length === 0)
43
+ return { message: `no ${wanted.slice(0, -1)} list shown yet in this session; list the ${wanted} first` };
44
+ if (list.kind !== wanted) {
45
+ return { message: `the last list shown was ${list.kind}, not ${wanted}; list the ${wanted} first` };
46
+ }
47
+ if (!Number.isSafeInteger(n) || n < 1 || n > list.ids.length)
48
+ return { message: `${n} is not on the list (1-${list.ids.length})` };
49
+ const item = eligible.find((entry) => entry.id === list.ids[n - 1]);
50
+ return item ? { item } : { message: `${wanted.slice(0, -1)} ${n} is no longer available; list the ${wanted} again` };
51
+ }
52
+ function pickClient(text, ctx) {
53
+ const clients = activeClients(ctx.clients);
54
+ if (NUMBER.test(text)) {
55
+ const r = byNumber(Number(text), "clients", ctx.lastList, clients);
56
+ return "item" in r ? { kind: "client", client: r.item } : unknown(r.message);
57
+ }
58
+ const m = match(text, clients);
59
+ if (m.kind === "picked")
60
+ return { kind: "client", client: m.item };
61
+ if (m.kind === "ambiguous")
62
+ return { kind: "ambiguous", of: "clients", candidates: m.candidates };
63
+ return unknown(`no client matches "${text}"`);
64
+ }
65
+ /**
66
+ * One argument of `target <arg>`. With a projects list on screen (and the
67
+ * session client still eligible) a number can only mean one of THOSE
68
+ * projects, and text is tried against them first, then against the clients.
69
+ */
70
+ export function pickTarget(arg, ctx) {
71
+ const text = arg.trim();
72
+ if (text === "")
73
+ return unknown("no client given");
74
+ const sessionClient = ctx.clientId !== undefined ? usableClient(ctx.clients, ctx.clientId) : undefined;
75
+ if (ctx.lastList.kind !== "projects" || sessionClient === undefined)
76
+ return pickClient(text, ctx);
77
+ const projects = activeProjects(ctx.projects, sessionClient.id);
78
+ if (NUMBER.test(text)) {
79
+ const r = byNumber(Number(text), "projects", ctx.lastList, projects);
80
+ return "item" in r ? { kind: "project", project: r.item } : unknown(r.message);
81
+ }
82
+ const m = match(text, projects);
83
+ if (m.kind === "picked")
84
+ return { kind: "project", project: m.item };
85
+ if (m.kind === "ambiguous")
86
+ return { kind: "ambiguous", of: "projects", candidates: m.candidates };
87
+ const client = pickClient(text, ctx);
88
+ return client.kind === "unknown" ? unknown(`no project of ${sessionClient.name} or client matches "${text}"`) : client;
89
+ }
90
+ function pickProjectOf(client, text, ctx) {
91
+ if (NUMBER.test(text))
92
+ return unknown(`a project is given by code or name here, not by number: "${text}"`);
93
+ const m = match(text, activeProjects(ctx.projects, client.id));
94
+ if (m.kind === "picked")
95
+ return { kind: "project", project: m.item };
96
+ if (m.kind === "ambiguous")
97
+ return { kind: "ambiguous", of: "projects", candidates: m.candidates };
98
+ const others = ctx.projects.filter((project) => project.clientId !== client.id && usableClient(ctx.clients, project.clientId));
99
+ const other = match(text, others);
100
+ const owner = other.kind === "picked" ? usableClient(ctx.clients, other.item.clientId) : undefined;
101
+ if (other.kind === "picked" && owner)
102
+ return unknown(`${other.item.name} belongs to ${owner.name}, not ${client.name}`);
103
+ return unknown(`no project of ${client.name} matches "${text}"`);
104
+ }
105
+ /**
106
+ * `target <client> <project>`: the arguments are split at every position
107
+ * (a name can hold spaces); exactly one split must yield a client and one
108
+ * of ITS projects. A number can name the client (from the clients list) but
109
+ * never the project.
110
+ */
111
+ export function pickBoth(args, ctx) {
112
+ if (args.length < 2)
113
+ return unknown("give a client and a project");
114
+ const successes = [];
115
+ let failure;
116
+ let clientFailure;
117
+ for (let i = 1; i < args.length; i++) {
118
+ const client = pickClient(args.slice(0, i).join(" "), ctx);
119
+ if (client.kind !== "client") {
120
+ clientFailure ??= client;
121
+ continue;
122
+ }
123
+ const project = pickProjectOf(client.client, args.slice(i).join(" "), ctx);
124
+ if (project.kind === "project")
125
+ successes.push({ client: client.client, project: project.project });
126
+ else
127
+ failure ??= project;
128
+ }
129
+ if (successes.length === 1)
130
+ return { kind: "both", ...successes[0] };
131
+ if (successes.length > 1)
132
+ return unknown(`"${args.join(" ")}" can be split into a client and a project in more than one way; use the client code`);
133
+ return failure ?? clientFailure ?? unknown("give a client and a project");
134
+ }
135
+ /**
136
+ * The task-link rule for any change of the session target: the link
137
+ * survives only when the linked task belongs to the resulting project.
138
+ * `undefined` when there is no link to decide about.
139
+ */
140
+ export function taskLinkOutcome(link, tasks, project) {
141
+ if (link === undefined)
142
+ return undefined;
143
+ const title = link.hubTaskTitle ?? link.hubTaskId;
144
+ if (project === undefined)
145
+ return { keep: false, message: `task link dropped (${title} needs a project)` };
146
+ const task = tasks.find((candidate) => candidate.id === link.hubTaskId);
147
+ if (task?.projectId === project.id)
148
+ return { keep: true, message: "task link kept" };
149
+ return { keep: false, message: `task link dropped (${title} is not in ${project.name})` };
150
+ }
package/dist/task-cli.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { CachedCatalog, PocketBaseClient, createPocketBaseCatalogFetcher, resolveHubCredentials } from "kankaku-pi/hub";
3
+ import { refreshCatalog } from "./catalog-refresh.js";
4
4
  import { findSession } from "./find-session.js";
5
5
  import { resolveKankakuDir, resolveTargetFile } from "./paths.js";
6
- import { readSessionTarget, writeSessionTarget } from "./session-target-store.js";
6
+ import { readSessionTarget, sessionInputs, withoutTaskLink, writeSessionTarget } from "./session-target-store.js";
7
7
  import { listOpenTasks, selectTask } from "./task-select.js";
8
8
  import { resolveClaudeWorkTarget } from "./work-target.js";
9
- const DEFAULT_REFRESH_MS = 3000;
10
9
  const NO_SESSION = "no active Claude Code session found for this folder\n";
11
10
  const HINT = "pick one with: /kankaku:task <number>";
12
11
  const say = (lines, exitCode) => ({ stdout: lines.join("\n") + "\n", exitCode });
@@ -27,41 +26,46 @@ export async function runTaskCli(args, deps) {
27
26
  if (!session)
28
27
  return { stdout: NO_SESSION, exitCode: 1 };
29
28
  const file = resolveTargetFile(claudeDir, session);
30
- const stored = readSessionTarget(file) ?? { lastList: [] };
29
+ const stored = readSessionTarget(file) ?? { lastList: { kind: "tasks", ids: [] } };
31
30
  const arg = args.join(" ").trim();
32
31
  if (arg === "clear") {
33
32
  if (stored.hubTaskId === undefined)
34
33
  return say(["no task linked"], 0);
35
- writeSessionTarget(file, { lastList: stored.lastList });
34
+ writeSessionTarget(file, withoutTaskLink(stored));
36
35
  return say(["task link cleared"], 0);
37
36
  }
38
37
  const homeDir = deps.env.HOME || homedir();
39
38
  const note = await refreshCatalog(deps, homeDir);
40
39
  const prefix = note?.text ? [note.text] : [];
41
- const resolved = resolveClaudeWorkTarget({ cwd: deps.cwd, kankakuDir, homeDir, env: deps.env });
40
+ const tasks = note?.snapshot.tasks ?? [];
41
+ const shownIds = stored.lastList.kind === "tasks" ? stored.lastList.ids : [];
42
+ const { sessionTarget } = sessionInputs(stored);
43
+ const resolved = resolveClaudeWorkTarget({
44
+ cwd: deps.cwd, kankakuDir, homeDir, env: deps.env, ...(sessionTarget !== undefined ? { sessionTarget } : {}),
45
+ });
42
46
  const projectId = resolved.target?.projectId;
43
47
  if (!resolved.target || projectId === undefined) {
44
48
  const reason = resolved.reason ?? "the resolved client has no project";
45
49
  return say([...prefix, `no project resolved for this folder (${reason}); a task belongs to a project`], 1);
46
50
  }
47
51
  const projectName = resolved.target.projectName ?? projectId;
48
- const open = listOpenTasks(note?.tasks ?? [], projectId);
52
+ const open = listOpenTasks(tasks, projectId);
49
53
  if (arg === "" || arg === "list") {
50
54
  if (open.length === 0)
51
55
  return say([...prefix, `no open tasks in project ${projectName}`], 0);
52
- writeSessionTarget(file, { ...stored, lastList: open.map((task) => task.id) });
56
+ writeSessionTarget(file, { ...stored, lastList: { kind: "tasks", ids: open.map((task) => task.id) } });
53
57
  return say([...prefix, `open tasks in project ${projectName}:`, ...numbered(open, stored.hubTaskId), HINT], 0);
54
58
  }
55
- const selection = selectTask(arg, { open, lastList: stored.lastList });
59
+ const selection = selectTask(arg, { open, lastList: shownIds });
56
60
  if (selection.kind === "unknown")
57
61
  return say([...prefix, selection.message], 1);
58
62
  if (selection.kind === "ambiguous") {
59
- writeSessionTarget(file, { ...stored, lastList: selection.candidates.map((task) => task.id) });
63
+ writeSessionTarget(file, { ...stored, lastList: { kind: "tasks", ids: selection.candidates.map((task) => task.id) } });
60
64
  return say([...prefix, `several open tasks match "${arg}":`, ...numbered(selection.candidates, stored.hubTaskId), HINT], 1);
61
65
  }
62
66
  const task = selection.task;
63
67
  writeSessionTarget(file, {
64
- hubTaskId: task.id, hubTaskTitle: task.title, projectId, pickedAt: deps.now(), lastList: stored.lastList,
68
+ ...stored, hubTaskId: task.id, hubTaskTitle: task.title, projectId, pickedAt: deps.now(),
65
69
  });
66
70
  return say([...prefix, `linked: ${task.title}`], 0);
67
71
  }
@@ -72,43 +76,3 @@ function numbered(tasks, linkedId) {
72
76
  return `${linked ? "*" : " "} ${index + 1}. ${task.title}${ref}${linked ? " [linked]" : ""}`;
73
77
  });
74
78
  }
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
- }
@@ -4,11 +4,12 @@ import { CachedCatalog, readProjectClient, readProjectTargetIds, resolveHubCrede
4
4
  import { formatWorkTargetLabel, isValidClient, resolveClient, resolveWorkTarget, resolveWorkTargetSource, } from "kankaku-pi/domain";
5
5
  import { findSession } from "./find-session.js";
6
6
  import { resolveKankakuDir, resolveTargetFile } from "./paths.js";
7
- import { readSessionTarget } from "./session-target-store.js";
7
+ import { readSessionTarget, sessionInputs } from "./session-target-store.js";
8
8
  /**
9
9
  * Resolves the work target for a session, for record stamping and display.
10
10
  *
11
- * Sources, in order: the project's `config.json` ids, then the cached
11
+ * Sources, in order: the session's `/kankaku:target` pick, the project's
12
+ * `config.json` ids, then the cached
12
13
  * catalog's `repo_paths` match for `cwd`. The catalog comes only from the
13
14
  * cache file that every real sync refreshes — there is no fetch, no refresh
14
15
  * and no network here, and nothing throws: an unusable cache means "no
@@ -40,6 +41,7 @@ function resolveUnsafe(input) {
40
41
  if (!snapshot)
41
42
  return withLegacy({ reason: "no catalog cache" });
42
43
  const resolveInput = {
44
+ ...(input.sessionTarget !== undefined ? { session: input.sessionTarget } : {}),
43
45
  project: readProjectTargetIds(input.kankakuDir),
44
46
  cwd: input.cwd,
45
47
  clients: snapshot.clients,
@@ -47,7 +49,7 @@ function resolveUnsafe(input) {
47
49
  };
48
50
  const target = resolveWorkTarget(resolveInput);
49
51
  const source = resolveWorkTargetSource(resolveInput);
50
- if (!target || (source !== "project" && source !== "repoPaths")) {
52
+ if (!target || source === undefined) {
51
53
  return withLegacy({ reason: `no match for ${input.cwd}` });
52
54
  }
53
55
  // Mirrors the pi extension: with a hub target the legacy label is the
@@ -87,7 +89,7 @@ function readCatalogCache(input) {
87
89
  export function formatTargetLine(result) {
88
90
  if (!result.target)
89
91
  return `target: none (${result.reason ?? "unresolved"})`;
90
- const source = result.source === "project" ? "project config" : "repo_paths";
92
+ const source = result.source === "session" ? "session" : result.source === "project" ? "project config" : "repo_paths";
91
93
  // The task has its own line (see formatTaskLine); keep this one to client and project.
92
94
  const { hubTaskId: _id, hubTaskTitle: _title, ...bare } = result.target;
93
95
  return `target: ${formatWorkTargetLabel(bare)} (source: ${source})`;
@@ -116,8 +118,6 @@ export function resolveSessionWorkTarget(deps) {
116
118
  const stored = session !== undefined ? readSessionTarget(resolveTargetFile(claudeDir, session)) : undefined;
117
119
  return resolveClaudeWorkTarget({
118
120
  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
- : {}),
121
+ ...(stored !== undefined ? sessionInputs(stored) : {}),
122
122
  });
123
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku-claude",
3
- "version": "1.3.0",
3
+ "version": "1.4.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-pi": "^1.3.0"
30
+ "kankaku-pi": "^1.4.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^24.13.4",