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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +53 -2
- package/commands/target.md +19 -0
- package/dist/catalog-refresh.js +44 -0
- package/dist/cli-core.js +4 -1
- package/dist/doctor.js +1 -1
- package/dist/handle-hook.js +3 -5
- package/dist/session-target-store.js +45 -6
- package/dist/target-cli.js +119 -0
- package/dist/target-select.js +150 -0
- package/dist/task-cli.js +15 -51
- package/dist/work-target.js +7 -7
- 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": "1.
|
|
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
|
|
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
|
-
|
|
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)
|
package/dist/handle-hook.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
...(
|
|
51
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
29
52
|
...(hubTaskId !== undefined && typeof o.pickedAt === "number" ? { pickedAt: o.pickedAt } : {}),
|
|
30
|
-
lastList
|
|
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 {
|
|
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,
|
|
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
|
|
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(
|
|
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:
|
|
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(),
|
|
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
|
-
}
|
package/dist/work-target.js
CHANGED
|
@@ -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
|
|
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 ||
|
|
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
|
|
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
|
+
"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.
|
|
30
|
+
"kankaku-pi": "^1.4.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^24.13.4",
|