kankaku 0.6.0 → 0.6.5

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/README.md CHANGED
@@ -114,7 +114,9 @@ remains valid.
114
114
  `clientId`, `clientName`, `projectId`, `projectName` and `machine` are only
115
115
  present once a hub is configured (see "Hub (PocketBase)"); every report and
116
116
  export written before this feature, or by a user without a hub, is
117
- unaffected.
117
+ unaffected. `hubTaskId`/`hubTaskTitle` are set alongside them only when a
118
+ hub task is linked to the session (`/kankaku task pick`) — see "Linking to
119
+ a hub task" below.
118
120
 
119
121
  `sessionDir` is present only when pi's session manager reports a
120
122
  *non-default* session directory (`--session-dir`, or a resumed session
@@ -686,7 +688,11 @@ the future, this is the signal that would surface it.
686
688
  ## The `/kankaku` command
687
689
 
688
690
  Run `/kankaku` inside pi to see today's totals (work, waiting, record count)
689
- per role, plus a union-based tasks segment. In the interactive TUI the report
691
+ per role, plus a union-based tasks segment. Each totals line also shows
692
+ `cache hit NN%` when tokens were recorded: the share of prompt input tokens
693
+ served from the provider's prompt cache, cache reads over input plus cache
694
+ reads plus cache writes; the segment is omitted, not shown as `0%`, when no
695
+ tokens were recorded. In the interactive TUI the report
690
696
  is appended to the chat transcript as a durable card that is never sent to
691
697
  the LLM; without a UI (print or RPC mode) it falls back to a notification.
692
698
  Arguments are whitespace-separated and order-insensitive:
@@ -719,6 +725,9 @@ The following are available only when a hub is configured (see "Hub
719
725
  produced it. `/kankaku target pick` runs the picker again (works
720
726
  mid-session; the new target applies to records settled afterwards).
721
727
  `/kankaku target clear` clears the session-level target.
728
+ - `/kankaku task` (or `/kankaku task pick`) — link this session to an
729
+ open/doing hub task of the effective project. `/kankaku task clear`
730
+ drops the link. See "Linking to a hub task" below.
722
731
  - `/kankaku catalog refresh` — force a catalog refresh and report the
723
732
  client/project counts.
724
733
  - `/kankaku projects` — one line per project (work/waiting/wall time, cost,
@@ -825,6 +834,33 @@ task view exposes it from the orchestrator record only.
825
834
  The status bar shows `💼 <client> · <project>` (or just `💼 <client>` without
826
835
  a project) in place of the legacy client label, both idle and during a run.
827
836
 
837
+ ### Linking to a hub task
838
+
839
+ `/kankaku task` (or `/kankaku task pick`) links the current session to one
840
+ of the effective project's existing hub `tasks` rows: a `ctx.ui.select`
841
+ picker lists the project's `open`/`doing` tasks, sorted by title (colliding
842
+ titles are disambiguated with the task's external reference, or its id).
843
+ `/kankaku task clear` drops the link, keeping the rest of the session
844
+ target. kankaku never creates a task from pi — this only links to one
845
+ that already exists in the hub.
846
+
847
+ The link is **session-only**: unlike `clientId`/`projectId`, it is never
848
+ persisted to `<KANKAKU_DIR>/config.json`, and it is never asked for at
849
+ `session_start` — you always link a task explicitly, with `/kankaku task`.
850
+ Any target change (`/kankaku target pick`, `/kankaku target clear`, or the
851
+ legacy `/kankaku client <name>`) drops the linked task, since a new client
852
+ or project makes the old task's link meaningless. A task whose project no
853
+ longer matches the effective project (e.g. after a target change or a
854
+ reassignment in the hub) is also dropped by the domain resolver, never
855
+ silently linked across projects. A task already marked `done` when linked
856
+ keeps linking for the rest of the session — only the picker itself hides
857
+ `done` tasks, so you cannot accidentally pick a closed one, but finishing
858
+ the picked task in the hub mid-session does not break the link. Subagent
859
+ records never carry a linked task, exactly like `clientId`/`projectId` —
860
+ the task view exposes it from the orchestrator record only, and
861
+ `formatWorkTargetLabel` appends it to the status-bar/report label as
862
+ `<client> · <project> › <task title>`.
863
+
828
864
  ### Caching and offline behaviour
829
865
 
830
866
  The catalog (clients/projects) is cached machine-wide at
@@ -905,10 +941,13 @@ web app) can move a task from one client/project to another directly in
905
941
  PocketBase — for example, moving a "Sin determinar" row to its real
906
942
  client once you have identified it. A later re-sync of that same task
907
943
  **must never undo that**: on create kankaku sends the full row, including
908
- `client`/`project`/`legacy_client_label`; on every subsequent update it
909
- sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and never
910
- touches assignment fields again. If you need kankaku itself to change a
911
- task's assignment, do it in the web app, not by re-syncing.
944
+ `client`/`project`/`task`/`legacy_client_label`; on every subsequent update
945
+ it sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
946
+ never touches assignment fields again. `task` (the linked `tasks` relation)
947
+ is create-only for the exact same reason: reassigning which task a row
948
+ belongs to in the web app is never undone by a later sync. If you need
949
+ kankaku itself to change a task's assignment, do it in the web app, not by
950
+ re-syncing.
912
951
 
913
952
  **Historical ("Sin determinar") records.** A record with no `clientId`, or
914
953
  whose `clientId` no longer resolves in the catalog, is routed to the hub's
@@ -1252,8 +1291,10 @@ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
1252
1291
  is not; the compiled entry points are today consumed as a library, not a
1253
1292
  binary.
1254
1293
  - Linking a `task_entries` row to an existing `tasks` record (phase 3 in the
1255
- hub's own data model) — kankaku never invents tasks; it would only ever
1256
- link to one created in the manager.
1294
+ hub's own data model) has shipped: `/kankaku task pick`/`clear` (see
1295
+ "Linking to a hub task" above). Creating a task from pi
1296
+ (`/kankaku task new`) is deliberately not included — kankaku never
1297
+ invents tasks; it only ever links to one already created in the manager.
1257
1298
  - Generic subagent detection (phase 6): 6a fixed the two correctness bugs
1258
1299
  described in "Subagents" above (a phantom-orchestrator double count; a
1259
1300
  gentle-pi cross-worktree child's work going missing). 6b added the
@@ -1,4 +1,4 @@
1
- import type { Client, Project } from "../domain/work-target.ts";
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
2
  import type { Clock } from "../ports/clock.ts";
3
3
  import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
4
4
  export interface CachedCatalogDeps {
@@ -9,10 +9,16 @@ export interface CachedCatalogDeps {
9
9
  clock: Clock;
10
10
  /** Cache TTL in ms. Defaults to 6 hours. */
11
11
  ttlMs?: number;
12
- /** Fetch a fresh `{ clients, projects }` pair, e.g. `createPocketBaseCatalogFetcher(...)`. */
12
+ /**
13
+ * Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
14
+ * `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
15
+ * on the real PocketBase fetcher) so a test double that only cares about
16
+ * clients/projects keeps compiling unchanged.
17
+ */
13
18
  fetchCatalog: (signal?: AbortSignal) => Promise<{
14
19
  clients: Client[];
15
20
  projects: Project[];
21
+ tasks?: HubTask[];
16
22
  }>;
17
23
  }
18
24
  /**
@@ -9,6 +9,9 @@ function isClientArray(value) {
9
9
  function isProjectArray(value) {
10
10
  return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
11
11
  }
12
+ function isHubTaskArray(value) {
13
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
14
+ }
12
15
  function isCatalogSnapshot(value) {
13
16
  if (!value || typeof value !== "object")
14
17
  return false;
@@ -16,7 +19,8 @@ function isCatalogSnapshot(value) {
16
19
  return (typeof record["fetchedAt"] === "number" &&
17
20
  typeof record["url"] === "string" &&
18
21
  isClientArray(record["clients"]) &&
19
- isProjectArray(record["projects"]));
22
+ isProjectArray(record["projects"]) &&
23
+ (record["tasks"] === undefined || isHubTaskArray(record["tasks"])));
20
24
  }
21
25
  /**
22
26
  * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
@@ -49,8 +53,14 @@ export class CachedCatalog {
49
53
  }
50
54
  async refresh(signal) {
51
55
  try {
52
- const { clients, projects } = await this.deps.fetchCatalog(signal);
53
- const snapshot = { fetchedAt: this.deps.clock.now(), url: this.deps.url, clients, projects };
56
+ const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
57
+ const snapshot = {
58
+ fetchedAt: this.deps.clock.now(),
59
+ url: this.deps.url,
60
+ clients,
61
+ projects,
62
+ ...(tasks !== undefined ? { tasks } : {}),
63
+ };
54
64
  this.writeDisk(snapshot);
55
65
  this.memo = snapshot;
56
66
  this.memoized = true;
@@ -1,14 +1,16 @@
1
- import type { Client, Project } from "../domain/work-target.ts";
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
2
  import type { PocketBaseClient } from "./pocketbase-client.ts";
3
3
  /**
4
4
  * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
5
- * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
6
- * `projects` record (no `active` filter, so the domain resolver can tell
7
- * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
8
- * `signal`, when given, bounds both fetches (see
5
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
6
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
7
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
8
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
9
+ * title. `signal`, when given, bounds every fetch (see
9
10
  * `pocketbase-client.ts#request`).
10
11
  */
11
12
  export declare function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{
12
13
  clients: Client[];
13
14
  projects: Project[];
15
+ tasks: HubTask[];
14
16
  }>;
@@ -1,3 +1,4 @@
1
+ const TASK_STATUSES = new Set(["open", "doing", "done"]);
1
2
  function mapClient(record) {
2
3
  return {
3
4
  id: record.id,
@@ -17,23 +18,39 @@ function mapProject(record) {
17
18
  active: record.active === true,
18
19
  };
19
20
  }
21
+ /** `open` on any unrecognised or missing status, rather than dropping the task. */
22
+ function mapTaskStatus(status) {
23
+ return status !== undefined && TASK_STATUSES.has(status) ? status : "open";
24
+ }
25
+ function mapTask(record) {
26
+ return {
27
+ id: record.id,
28
+ title: record.title ?? "",
29
+ projectId: record.project,
30
+ status: mapTaskStatus(record.status),
31
+ ...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
32
+ };
33
+ }
20
34
  /**
21
35
  * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
22
- * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
23
- * `projects` record (no `active` filter, so the domain resolver can tell
24
- * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
25
- * `signal`, when given, bounds both fetches (see
36
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
37
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
38
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
39
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
40
+ * title. `signal`, when given, bounds every fetch (see
26
41
  * `pocketbase-client.ts#request`).
27
42
  */
28
43
  export function createPocketBaseCatalogFetcher(client) {
29
44
  return async (signal) => {
30
- const [clientRecords, projectRecords] = await Promise.all([
45
+ const [clientRecords, projectRecords, taskRecords] = await Promise.all([
31
46
  client.list("clients", { sort: "name" }, signal),
32
47
  client.list("projects", { sort: "name" }, signal),
48
+ client.list("tasks", { sort: "title" }, signal),
33
49
  ]);
34
50
  return {
35
51
  clients: clientRecords.map(mapClient),
36
52
  projects: projectRecords.map(mapProject),
53
+ tasks: taskRecords.map(mapTask),
37
54
  };
38
55
  };
39
56
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
14
14
  import type { TaskView } from "../domain/task-view.ts";
15
- import type { Client, Project } from "../domain/work-target.ts";
15
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
16
16
  import type { PushTaskResult, WorkSink } from "../ports/work-sink.ts";
17
17
  import type { PocketBaseClient } from "./pocketbase-client.ts";
18
18
  export interface PocketBaseSinkDeps {
@@ -20,6 +20,7 @@ export interface PocketBaseSinkDeps {
20
20
  /** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
21
21
  clients: Client[];
22
22
  projects: Project[];
23
+ tasks: HubTask[];
23
24
  machine: string;
24
25
  promptMode: PromptPrivacyMode;
25
26
  /** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
@@ -76,6 +76,7 @@ export class PocketBaseSink {
76
76
  return {
77
77
  clients: this.deps.clients,
78
78
  projects: this.deps.projects,
79
+ tasks: this.deps.tasks,
79
80
  machine: this.deps.machine,
80
81
  promptMode: this.deps.promptMode,
81
82
  agent: this.deps.agent ?? "pi",
@@ -139,7 +140,7 @@ export class PocketBaseSink {
139
140
  }
140
141
  }
141
142
  async pushOne(task, existingId) {
142
- const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
143
+ const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
143
144
  const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
144
145
  try {
145
146
  const { id, created } = await this.upsertTaskEntry(task, existingId);
@@ -11,11 +11,13 @@
11
11
  */
12
12
  import type { TaskView } from "./task-view.ts";
13
13
  import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
14
- import type { Client, Project } from "./work-target.ts";
14
+ import type { Client, HubTask, Project } from "./work-target.ts";
15
15
  export type PromptPrivacyMode = "none" | "truncated" | "full";
16
16
  export interface HubEntryContext {
17
17
  clients: Client[];
18
18
  projects: Project[];
19
+ /** The hub's known tasks, to validate a task's linked `hubTaskId` against. See {@link resolveTaskAssignment}. */
20
+ tasks: HubTask[];
19
21
  /** This machine's hostname or `KANKAKU_MACHINE`. */
20
22
  machine: string;
21
23
  /** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
@@ -34,13 +36,16 @@ export interface TaskAssignment {
34
36
  clientId: string;
35
37
  /** `projects` relation id, or `""`. */
36
38
  projectId: string;
39
+ /** `tasks` relation id, or `""` when no usable hub task could be resolved. See {@link resolveTaskAssignment}. */
40
+ hubTaskId: string;
37
41
  /** Only set (non-empty) for a task routed to the unassigned client. */
38
42
  legacyClientLabel: string;
39
43
  /** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
40
44
  routedToUnassigned: boolean;
41
45
  }
42
46
  /**
43
- * Resolve which client/project a task's `task_entries` row should link to.
47
+ * Resolve which client/project/hub-task a task's `task_entries` row should
48
+ * link to.
44
49
  *
45
50
  * - A task whose `clientId` still exists in `clients` links to that client
46
51
  * (regardless of its `active` flag — this is a historical fact, not a
@@ -51,8 +56,12 @@ export interface TaskAssignment {
51
56
  * `unassigned: true`), carrying forward the record's free-text `client`
52
57
  * label (or its `clientName` when the label itself is absent) as
53
58
  * `legacyClientLabel` — the historical backfill rule (proposal §5.3).
59
+ * - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
60
+ * the resolved `projectId` (non-empty); otherwise it is `""` — including
61
+ * whenever the client fell back to unassigned, since there is then no
62
+ * resolved project for a task to belong to.
54
63
  */
55
- export declare function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[]): TaskAssignment;
64
+ export declare function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[], tasks: HubTask[]): TaskAssignment;
56
65
  /**
57
66
  * Privacy transform for a prompt about to leave the machine
58
67
  * (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
@@ -11,7 +11,8 @@
11
11
  */
12
12
  import { finiteOrZero } from "./work-record.js";
13
13
  /**
14
- * Resolve which client/project a task's `task_entries` row should link to.
14
+ * Resolve which client/project/hub-task a task's `task_entries` row should
15
+ * link to.
15
16
  *
16
17
  * - A task whose `clientId` still exists in `clients` links to that client
17
18
  * (regardless of its `active` flag — this is a historical fact, not a
@@ -22,14 +23,22 @@ import { finiteOrZero } from "./work-record.js";
22
23
  * `unassigned: true`), carrying forward the record's free-text `client`
23
24
  * label (or its `clientName` when the label itself is absent) as
24
25
  * `legacyClientLabel` — the historical backfill rule (proposal §5.3).
26
+ * - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
27
+ * the resolved `projectId` (non-empty); otherwise it is `""` — including
28
+ * whenever the client fell back to unassigned, since there is then no
29
+ * resolved project for a task to belong to.
25
30
  */
26
- export function resolveTaskAssignment(task, clients, projects) {
31
+ export function resolveTaskAssignment(task, clients, projects, tasks) {
27
32
  const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
28
33
  if (client) {
29
34
  const project = task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
35
+ const hubTask = project !== undefined && task.hubTaskId !== undefined
36
+ ? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
37
+ : undefined;
30
38
  return {
31
39
  clientId: client.id,
32
40
  projectId: project ? project.id : "",
41
+ hubTaskId: hubTask ? hubTask.id : "",
33
42
  legacyClientLabel: "",
34
43
  routedToUnassigned: false,
35
44
  };
@@ -38,6 +47,7 @@ export function resolveTaskAssignment(task, clients, projects) {
38
47
  return {
39
48
  clientId: unassigned ? unassigned.id : "",
40
49
  projectId: "",
50
+ hubTaskId: "",
41
51
  legacyClientLabel: task.client ?? task.clientName ?? "",
42
52
  routedToUnassigned: true,
43
53
  };
@@ -118,12 +128,12 @@ export function computeSubagentLinkage(task) {
118
128
  }
119
129
  /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
120
130
  export function buildTaskEntryCreatePayload(task, ctx) {
121
- const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects);
131
+ const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
122
132
  return {
123
133
  task_id: task.id,
124
134
  client: assignment.clientId,
125
135
  project: assignment.projectId,
126
- task: "",
136
+ task: assignment.hubTaskId,
127
137
  started_at: toPbDate(task.startedAt),
128
138
  ended_at: toPbDate(task.endedAt),
129
139
  wall_ms: task.wallMs,
@@ -33,6 +33,10 @@ export interface TaskView {
33
33
  projectId?: string;
34
34
  /** Hub project display name, from the orchestrator record only. */
35
35
  projectName?: string;
36
+ /** Hub task record id, from the orchestrator record only. See `domain/work-target.ts#HubTask`. */
37
+ hubTaskId?: string;
38
+ /** Hub task title, from the orchestrator record only. */
39
+ hubTaskTitle?: string;
36
40
  /**
37
41
  * Per-tag total milliseconds across the orchestrator and every subagent,
38
42
  * summed rather than unioned: unlike `wallMs`, segment intervals are not
@@ -295,6 +295,8 @@ function buildTaskView(orchestrator, subagents) {
295
295
  ...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
296
296
  ...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
297
297
  ...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
298
+ ...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
299
+ ...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
298
300
  project: orchestrator.project,
299
301
  prompt: orchestrator.prompt,
300
302
  startedAt: orchestrator.startedAt,
@@ -159,6 +159,10 @@ export interface WorkRecordMetadata {
159
159
  projectId?: string;
160
160
  /** Hub project display name, denormalised alongside `projectId`. */
161
161
  projectName?: string;
162
+ /** Hub `tasks` record id linked for this session (`/kankaku task pick`), when one is active. See `domain/work-target.ts#HubTask`. */
163
+ hubTaskId?: string;
164
+ /** Hub task title, denormalised alongside `hubTaskId`. */
165
+ hubTaskTitle?: string;
162
166
  /** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
163
167
  machine?: string;
164
168
  /**
@@ -193,6 +197,20 @@ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
193
197
  export declare function emptyUsage(): UsageTotals;
194
198
  /** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
195
199
  export declare function finiteOrZero(value: unknown): number;
200
+ /**
201
+ * Share of prompt input tokens served from the provider's prompt cache:
202
+ * `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
203
+ * the provider's `input_tokens`, which already excludes cached tokens, so
204
+ * the three fields are disjoint and this sum is the true denominator.
205
+ * Returns `undefined` when the denominator is `0` (nothing to compute a
206
+ * ratio from) rather than `0`, so callers never render a misleading `0%`.
207
+ * Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
208
+ */
209
+ export declare function cacheHitRatio(usage: {
210
+ input: number;
211
+ cacheRead: number;
212
+ cacheWrite: number;
213
+ }): number | undefined;
196
214
  /**
197
215
  * Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
198
216
  * skips lines that parse as JSON but fail this check, so a torn write or a
@@ -9,6 +9,22 @@ export function emptyUsage() {
9
9
  export function finiteOrZero(value) {
10
10
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
11
11
  }
12
+ /**
13
+ * Share of prompt input tokens served from the provider's prompt cache:
14
+ * `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
15
+ * the provider's `input_tokens`, which already excludes cached tokens, so
16
+ * the three fields are disjoint and this sum is the true denominator.
17
+ * Returns `undefined` when the denominator is `0` (nothing to compute a
18
+ * ratio from) rather than `0`, so callers never render a misleading `0%`.
19
+ * Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
20
+ */
21
+ export function cacheHitRatio(usage) {
22
+ const input = finiteOrZero(usage.input);
23
+ const cacheRead = finiteOrZero(usage.cacheRead);
24
+ const cacheWrite = finiteOrZero(usage.cacheWrite);
25
+ const denominator = input + cacheRead + cacheWrite;
26
+ return denominator === 0 ? undefined : cacheRead / denominator;
27
+ }
12
28
  const ROLES = new Set(["orchestrator", "subagent"]);
13
29
  const STATUSES = new Set(["completed", "aborted", "interrupted"]);
14
30
  /** A finite, non-negative number: durations such as `wallMs` can never be negative. */
@@ -61,6 +77,8 @@ export function isWorkRecord(value) {
61
77
  (record["clientName"] === undefined || typeof record["clientName"] === "string") &&
62
78
  (record["projectId"] === undefined || typeof record["projectId"] === "string") &&
63
79
  (record["projectName"] === undefined || typeof record["projectName"] === "string") &&
80
+ (record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
81
+ (record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
64
82
  (record["machine"] === undefined || typeof record["machine"] === "string") &&
65
83
  (record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
66
84
  (record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
@@ -29,6 +29,18 @@ export interface Project {
29
29
  repoPaths: string[];
30
30
  active: boolean;
31
31
  }
32
+ /**
33
+ * A hub `tasks` row: something kankaku can link a session's `task_entries`
34
+ * row to (`task_entries.task`), never create. See `domain/hub-entry.ts` for
35
+ * the create-only assignment rule this feeds.
36
+ */
37
+ export interface HubTask {
38
+ id: string;
39
+ title: string;
40
+ projectId: string;
41
+ status: "open" | "doing" | "done";
42
+ externalRef?: string;
43
+ }
32
44
  export interface WorkTarget {
33
45
  clientId: string;
34
46
  clientCode: string;
@@ -36,11 +48,22 @@ export interface WorkTarget {
36
48
  projectId?: string;
37
49
  projectCode?: string;
38
50
  projectName?: string;
51
+ /** Hub `tasks` record id, kept only when it belongs to `projectId`. See {@link resolveWorkTarget}. */
52
+ hubTaskId?: string;
53
+ /** Denormalised alongside `hubTaskId` for display. */
54
+ hubTaskTitle?: string;
39
55
  }
40
56
  /** ids picked from the session, or from the project's `.kankaku/config.json`. */
41
57
  export interface WorkTargetCandidate {
42
58
  clientId: string;
43
59
  projectId?: string;
60
+ /**
61
+ * A hub task the user picked for this session (`/kankaku task pick`).
62
+ * Only ever set on the session-level candidate — the project config file
63
+ * and `repoPaths` matching never carry one (task linking is session-only,
64
+ * see AGENTS.md).
65
+ */
66
+ hubTaskId?: string;
44
67
  }
45
68
  /**
46
69
  * The session-level override: `undefined` when no session entry exists yet
@@ -57,6 +80,8 @@ export interface ResolveWorkTargetInput {
57
80
  cwd: string;
58
81
  clients: Client[];
59
82
  projects: Project[];
83
+ /** The hub's known tasks, to validate a candidate's `hubTaskId` against. Defaults to `[]` so existing callers compile unchanged. */
84
+ tasks?: HubTask[];
60
85
  }
61
86
  export type WorkTargetSourceName = "session" | "project" | "repoPaths";
62
87
  /** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
@@ -68,5 +93,9 @@ export declare function resolveWorkTargetSource(input: ResolveWorkTargetInput):
68
93
  * declined), returning `undefined` rather than falling through.
69
94
  */
70
95
  export declare function resolveWorkTarget(input: ResolveWorkTargetInput): WorkTarget | undefined;
71
- /** `<clientName>` or `<clientName> · <projectName>` — the display label used by the status bar and the "remember" prompt. */
96
+ /**
97
+ * `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
98
+ * appended as `... › <hubTaskTitle>` — the display label used by the
99
+ * status bar and the "remember" prompt.
100
+ */
72
101
  export declare function formatWorkTargetLabel(target: WorkTarget): string;
@@ -26,7 +26,18 @@ function findUsableProject(projects, projectId, clientId) {
26
26
  return undefined;
27
27
  return project;
28
28
  }
29
- function buildTarget(client, project) {
29
+ /**
30
+ * A hub task usable as a link target: exists and belongs to `projectId`.
31
+ * Any {@link HubTask.status} is accepted — a task marked `done` mid-session
32
+ * keeps linking — only the project match matters here.
33
+ */
34
+ function findUsableTask(tasks, taskId, projectId) {
35
+ const task = tasks.find((candidate) => candidate.id === taskId);
36
+ if (!task || task.projectId !== projectId)
37
+ return undefined;
38
+ return task;
39
+ }
40
+ function buildTarget(client, project, task) {
30
41
  return {
31
42
  clientId: client.id,
32
43
  clientCode: client.code,
@@ -38,6 +49,7 @@ function buildTarget(client, project) {
38
49
  projectName: project.name,
39
50
  }
40
51
  : {}),
52
+ ...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
41
53
  };
42
54
  }
43
55
  /**
@@ -45,14 +57,17 @@ function buildTarget(client, project) {
45
57
  * file) into a target, or `undefined` when the client id does not resolve
46
58
  * to a usable client. A projectId that does not resolve to a usable
47
59
  * project of that client is dropped — the client-only target is still
48
- * returned — rather than failing the whole candidate.
60
+ * returned — rather than failing the whole candidate. A `hubTaskId` is
61
+ * kept only when it resolves to a task of the resolved project; a target
62
+ * with no project never carries one.
49
63
  */
50
- function resolveCandidate(candidate, clients, projects) {
64
+ function resolveCandidate(candidate, clients, projects, tasks) {
51
65
  const client = findUsableClient(clients, candidate.clientId);
52
66
  if (!client)
53
67
  return undefined;
54
68
  const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
55
- return buildTarget(client, project);
69
+ const task = project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
70
+ return buildTarget(client, project, task);
56
71
  }
57
72
  /** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
58
73
  function matchByRepoPath(cwd, projects) {
@@ -82,11 +97,12 @@ function isCwdWithin(cwd, repoPath) {
82
97
  /** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
83
98
  export function resolveWorkTargetSource(input) {
84
99
  const { session } = input;
100
+ const tasks = input.tasks ?? [];
85
101
  if (session === "skipped")
86
102
  return undefined;
87
- if (session !== undefined && resolveCandidate(session, input.clients, input.projects) !== undefined)
103
+ if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined)
88
104
  return "session";
89
- if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects) !== undefined)
105
+ if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined)
90
106
  return "project";
91
107
  const matched = matchByRepoPath(input.cwd, input.projects);
92
108
  if (matched && findUsableClient(input.clients, matched.clientId))
@@ -101,15 +117,16 @@ export function resolveWorkTargetSource(input) {
101
117
  */
102
118
  export function resolveWorkTarget(input) {
103
119
  const { session } = input;
120
+ const tasks = input.tasks ?? [];
104
121
  if (session === "skipped")
105
122
  return undefined;
106
123
  if (session !== undefined) {
107
- const resolved = resolveCandidate(session, input.clients, input.projects);
124
+ const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
108
125
  if (resolved)
109
126
  return resolved;
110
127
  }
111
128
  if (input.project !== undefined) {
112
- const resolved = resolveCandidate(input.project, input.clients, input.projects);
129
+ const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
113
130
  if (resolved)
114
131
  return resolved;
115
132
  }
@@ -117,11 +134,16 @@ export function resolveWorkTarget(input) {
117
134
  if (matched) {
118
135
  const client = findUsableClient(input.clients, matched.clientId);
119
136
  if (client)
120
- return buildTarget(client, matched);
137
+ return buildTarget(client, matched, undefined);
121
138
  }
122
139
  return undefined;
123
140
  }
124
- /** `<clientName>` or `<clientName> · <projectName>` — the display label used by the status bar and the "remember" prompt. */
141
+ /**
142
+ * `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
143
+ * appended as `... › <hubTaskTitle>` — the display label used by the
144
+ * status bar and the "remember" prompt.
145
+ */
125
146
  export function formatWorkTargetLabel(target) {
126
- return target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
147
+ const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
148
+ return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
127
149
  }
@@ -1,10 +1,12 @@
1
- import type { Client, Project } from "../domain/work-target.ts";
2
- /** A cached read of the hub's clients/projects, plus when and against which hub URL it was fetched. */
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
+ /** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
3
3
  export interface CatalogSnapshot {
4
4
  fetchedAt: number;
5
5
  url: string;
6
6
  clients: Client[];
7
7
  projects: Project[];
8
+ /** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
9
+ tasks?: HubTask[];
8
10
  }
9
11
  /**
10
12
  * Read-only access to the hub catalog (clients/projects). `read()` is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.6.0",
3
+ "version": "0.6.5",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",