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.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import type { Client, Project } from "../domain/work-target.ts";
3
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
4
4
  import type { Clock } from "../ports/clock.ts";
5
5
  import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
6
6
  import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.ts";
@@ -16,6 +16,10 @@ function isProjectArray(value: unknown): value is Project[] {
16
16
  return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as Project).id === "string");
17
17
  }
18
18
 
19
+ function isHubTaskArray(value: unknown): value is HubTask[] {
20
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as HubTask).id === "string");
21
+ }
22
+
19
23
  function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
20
24
  if (!value || typeof value !== "object") return false;
21
25
  const record = value as Record<string, unknown>;
@@ -23,7 +27,8 @@ function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
23
27
  typeof record["fetchedAt"] === "number" &&
24
28
  typeof record["url"] === "string" &&
25
29
  isClientArray(record["clients"]) &&
26
- isProjectArray(record["projects"])
30
+ isProjectArray(record["projects"]) &&
31
+ (record["tasks"] === undefined || isHubTaskArray(record["tasks"]))
27
32
  );
28
33
  }
29
34
 
@@ -35,8 +40,13 @@ export interface CachedCatalogDeps {
35
40
  clock: Clock;
36
41
  /** Cache TTL in ms. Defaults to 6 hours. */
37
42
  ttlMs?: number;
38
- /** Fetch a fresh `{ clients, projects }` pair, e.g. `createPocketBaseCatalogFetcher(...)`. */
39
- fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[] }>;
43
+ /**
44
+ * Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
45
+ * `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
46
+ * on the real PocketBase fetcher) so a test double that only cares about
47
+ * clients/projects keeps compiling unchanged.
48
+ */
49
+ fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks?: HubTask[] }>;
40
50
  }
41
51
 
42
52
  /**
@@ -73,8 +83,14 @@ export class CachedCatalog implements Catalog {
73
83
 
74
84
  async refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined> {
75
85
  try {
76
- const { clients, projects } = await this.deps.fetchCatalog(signal);
77
- const snapshot: CatalogSnapshot = { fetchedAt: this.deps.clock.now(), url: this.deps.url, clients, projects };
86
+ const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
87
+ const snapshot: CatalogSnapshot = {
88
+ fetchedAt: this.deps.clock.now(),
89
+ url: this.deps.url,
90
+ clients,
91
+ projects,
92
+ ...(tasks !== undefined ? { tasks } : {}),
93
+ };
78
94
  this.writeDisk(snapshot);
79
95
  this.memo = snapshot;
80
96
  this.memoized = true;
@@ -46,8 +46,9 @@ export function notifyError(ctx: ExtensionContext, error: unknown): void {
46
46
 
47
47
  const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
48
48
  /** Only offered when the hub is configured, so completions are unchanged for users without one. */
49
- const HUB_COMMAND_TOKENS = ["target", "projects", "catalog", "sync", "backfill"];
49
+ const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
50
50
  const TARGET_TOKENS = ["pick", "clear"];
51
+ const TASK_TOKENS = ["pick", "clear"];
51
52
  const CATALOG_TOKENS = ["refresh"];
52
53
  const SYNC_TOKENS = ["all", "status"];
53
54
 
@@ -328,6 +329,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
328
329
  notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
329
330
  }
330
331
 
332
+ /**
333
+ * Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
334
+ * token. Default (no args) is `pick`. `pickTask`/`clearTask`
335
+ * (`session-target.ts`) notify their own outcome directly, so this only
336
+ * dispatches and refreshes the idle status line.
337
+ */
338
+ async function handleTaskCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
339
+ const sessionTarget = deps.sessionTarget;
340
+ if (!sessionTarget) {
341
+ notifyError(ctx, new Error("hub is not configured"));
342
+ return;
343
+ }
344
+
345
+ if (rest.length === 1 && rest[0] === "clear") {
346
+ sessionTarget.clearTask(pi);
347
+ deps.refreshIdleStatus(ctx);
348
+ showReport(ctx, { title: "task", lines: ["task link cleared for this session"] });
349
+ return;
350
+ }
351
+
352
+ if (rest.length === 0 || (rest.length === 1 && rest[0] === "pick")) {
353
+ await sessionTarget.pickTask(pi, ctx);
354
+ deps.refreshIdleStatus(ctx);
355
+ return;
356
+ }
357
+
358
+ notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
359
+ }
360
+
331
361
  /** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
332
362
  async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
333
363
  const catalog = deps.catalog;
@@ -598,6 +628,8 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
598
628
  "'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
599
629
  "When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
600
630
  "'target pick' to run the picker again, 'target clear' to clear the session target, " +
631
+ "'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
632
+ "'task clear' to drop the link, " +
601
633
  "'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
602
634
  "'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
603
635
  "'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
@@ -615,6 +647,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
615
647
  const prefix = targetMatch[1] ?? "";
616
648
  return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
617
649
  }
650
+ const taskMatch = /^task\s+(\S*)$/.exec(argumentPrefix);
651
+ if (taskMatch) {
652
+ const prefix = taskMatch[1] ?? "";
653
+ return TASK_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
654
+ }
618
655
  const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
619
656
  if (catalogMatch) {
620
657
  const prefix = catalogMatch[1] ?? "";
@@ -647,6 +684,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
647
684
  return;
648
685
  }
649
686
 
687
+ if (tokens[0] === "task") {
688
+ await handleTaskCommand(tokens.slice(1), ctx);
689
+ return;
690
+ }
691
+
650
692
  if (tokens[0] === "catalog") {
651
693
  await handleCatalogCommand(tokens.slice(1), ctx);
652
694
  return;
@@ -386,6 +386,8 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
386
386
  clientName: target.clientName,
387
387
  ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
388
388
  ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
389
+ ...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
390
+ ...(target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
389
391
  }
390
392
  : {}),
391
393
  ...(deps.machine !== undefined ? { machine: deps.machine } : {}),
@@ -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 { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
3
3
 
4
4
  interface ClientRecord extends PocketBaseRecord {
@@ -16,6 +16,15 @@ interface ProjectRecord extends PocketBaseRecord {
16
16
  active?: boolean;
17
17
  }
18
18
 
19
+ interface TaskRecord extends PocketBaseRecord {
20
+ title?: string;
21
+ project: string;
22
+ status?: string;
23
+ external_ref?: string;
24
+ }
25
+
26
+ const TASK_STATUSES = new Set(["open", "doing", "done"]);
27
+
19
28
  function mapClient(record: ClientRecord): Client {
20
29
  return {
21
30
  id: record.id,
@@ -37,24 +46,44 @@ function mapProject(record: ProjectRecord): Project {
37
46
  };
38
47
  }
39
48
 
49
+ /** `open` on any unrecognised or missing status, rather than dropping the task. */
50
+ function mapTaskStatus(status: string | undefined): HubTask["status"] {
51
+ return status !== undefined && TASK_STATUSES.has(status) ? (status as HubTask["status"]) : "open";
52
+ }
53
+
54
+ function mapTask(record: TaskRecord): HubTask {
55
+ return {
56
+ id: record.id,
57
+ title: record.title ?? "",
58
+ projectId: record.project,
59
+ status: mapTaskStatus(record.status),
60
+ ...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
61
+ };
62
+ }
63
+
40
64
  /**
41
65
  * Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
42
- * needs, backed by a {@link PocketBaseClient}. Fetches every `clients` and
43
- * `projects` record (no `active` filter, so the domain resolver can tell
44
- * "deleted" from "inactive"; see `domain/work-target.ts`), sorted by name.
45
- * `signal`, when given, bounds both fetches (see
66
+ * needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
67
+ * `projects` and `tasks` record in parallel (no `active`/`status` filter,
68
+ * so the domain resolver can tell "deleted" from "inactive"/"done"; see
69
+ * `domain/work-target.ts`), clients/projects sorted by name and tasks by
70
+ * title. `signal`, when given, bounds every fetch (see
46
71
  * `pocketbase-client.ts#request`).
47
72
  */
48
- export function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[] }> {
73
+ export function createPocketBaseCatalogFetcher(
74
+ client: PocketBaseClient,
75
+ ): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks: HubTask[] }> {
49
76
  return async (signal?: AbortSignal) => {
50
- const [clientRecords, projectRecords] = await Promise.all([
77
+ const [clientRecords, projectRecords, taskRecords] = await Promise.all([
51
78
  client.list<ClientRecord>("clients", { sort: "name" }, signal),
52
79
  client.list<ProjectRecord>("projects", { sort: "name" }, signal),
80
+ client.list<TaskRecord>("tasks", { sort: "title" }, signal),
53
81
  ]);
54
82
 
55
83
  return {
56
84
  clients: clientRecords.map(mapClient),
57
85
  projects: projectRecords.map(mapProject),
86
+ tasks: taskRecords.map(mapTask),
58
87
  };
59
88
  };
60
89
  }
@@ -14,7 +14,7 @@
14
14
  import { buildTaskEntryCreatePayload, buildTaskEntryUpdatePayload, buildWorkRecordPayload, resolveTaskAssignment, taskWorkRecords } from "../domain/hub-entry.ts";
15
15
  import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
16
16
  import type { TaskView } from "../domain/task-view.ts";
17
- import type { Client, Project } from "../domain/work-target.ts";
17
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
18
18
  import type { PushOutcome, PushTaskResult, WorkSink } from "../ports/work-sink.ts";
19
19
  import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
20
20
  import { PocketBaseError } from "./pocketbase-client.ts";
@@ -24,6 +24,7 @@ export interface PocketBaseSinkDeps {
24
24
  /** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
25
25
  clients: Client[];
26
26
  projects: Project[];
27
+ tasks: HubTask[];
27
28
  machine: string;
28
29
  promptMode: PromptPrivacyMode;
29
30
  /** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
@@ -105,10 +106,21 @@ export class PocketBaseSink implements WorkSink {
105
106
  return found.get(value);
106
107
  }
107
108
 
108
- private get entryContext(): { clients: Client[]; projects: Project[]; machine: string; promptMode: PromptPrivacyMode; agent: string; agentVersion?: string; plugin: string; pluginVersion?: string } {
109
+ private get entryContext(): {
110
+ clients: Client[];
111
+ projects: Project[];
112
+ tasks: HubTask[];
113
+ machine: string;
114
+ promptMode: PromptPrivacyMode;
115
+ agent: string;
116
+ agentVersion?: string;
117
+ plugin: string;
118
+ pluginVersion?: string;
119
+ } {
109
120
  return {
110
121
  clients: this.deps.clients,
111
122
  projects: this.deps.projects,
123
+ tasks: this.deps.tasks,
112
124
  machine: this.deps.machine,
113
125
  promptMode: this.deps.promptMode,
114
126
  agent: this.deps.agent ?? "pi",
@@ -177,7 +189,7 @@ export class PocketBaseSink implements WorkSink {
177
189
  }
178
190
 
179
191
  private async pushOne(task: TaskView, existingId: string | undefined): Promise<PushOutcome> {
180
- const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
192
+ const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
181
193
  const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
182
194
 
183
195
  try {
@@ -1,7 +1,7 @@
1
1
  import { buildTasks, uncertainRecords } from "../domain/task-view.ts";
2
2
  import type { SessionView, TaskView } from "../domain/task-view.ts";
3
3
  import { localDay } from "../domain/day.ts";
4
- import { finiteOrZero } from "../domain/work-record.ts";
4
+ import { cacheHitRatio, finiteOrZero } from "../domain/work-record.ts";
5
5
  import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
6
6
 
7
7
  export { localDay } from "../domain/day.ts";
@@ -13,6 +13,12 @@ export interface RoleTotals {
13
13
  count: number;
14
14
  /** Estimated cost in USD, as priced by pi's model table. */
15
15
  cost: number;
16
+ /** Prompt input tokens, summed across every record of this role. */
17
+ input: number;
18
+ /** Prompt-cache read tokens, summed across every record of this role. */
19
+ cacheRead: number;
20
+ /** Prompt-cache write tokens, summed across every record of this role. */
21
+ cacheWrite: number;
16
22
  /** Per-tag total milliseconds summed across every record of this role. */
17
23
  segments: Record<string, number>;
18
24
  }
@@ -23,6 +29,12 @@ export interface TaskTotals {
23
29
  workMs: number;
24
30
  /** Estimated cost in USD, orchestrator and subagents combined. */
25
31
  cost: number;
32
+ /** Prompt input tokens, orchestrator and subagents combined. */
33
+ input: number;
34
+ /** Prompt-cache read tokens, orchestrator and subagents combined. */
35
+ cacheRead: number;
36
+ /** Prompt-cache write tokens, orchestrator and subagents combined. */
37
+ cacheWrite: number;
26
38
  /** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
27
39
  segments: Record<string, number>;
28
40
  }
@@ -39,7 +51,7 @@ export interface SummarizeOptions {
39
51
  const ROLES: WorkRole[] = ["orchestrator", "subagent"];
40
52
 
41
53
  function emptyTotals(): RoleTotals {
42
- return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
54
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
43
55
  }
44
56
 
45
57
  /**
@@ -67,7 +79,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
67
79
  const summary: Summary = {
68
80
  orchestrator: emptyTotals(),
69
81
  subagent: emptyTotals(),
70
- tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
82
+ tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
71
83
  };
72
84
  // Accumulated in Maps (see `addSegments`) and only converted to the
73
85
  // returned plain objects at the very end, via `Object.fromEntries`.
@@ -82,6 +94,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
82
94
  totals.wallMs += record.wallMs;
83
95
  totals.count += 1;
84
96
  totals.cost += finiteOrZero(record.usage.cost);
97
+ totals.input += finiteOrZero(record.usage.input);
98
+ totals.cacheRead += finiteOrZero(record.usage.cacheRead);
99
+ totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
85
100
  addSegments(segmentsByRole[record.role], record.segments);
86
101
  }
87
102
 
@@ -91,6 +106,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
91
106
  summary.tasks.wallMs += task.wallMs;
92
107
  summary.tasks.workMs += task.workMs;
93
108
  summary.tasks.cost += task.usage.cost;
109
+ summary.tasks.input += finiteOrZero(task.usage.input);
110
+ summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
111
+ summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
94
112
  addSegments(taskSegments, task.segments);
95
113
  }
96
114
 
@@ -131,6 +149,12 @@ function formatTime(iso: string): string {
131
149
  return `${hours}:${minutes}`;
132
150
  }
133
151
 
152
+ /** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
153
+ function formatCacheHit(usage: { input: number; cacheRead: number; cacheWrite: number }): string | undefined {
154
+ const ratio = cacheHitRatio(usage);
155
+ return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
156
+ }
157
+
134
158
  /** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
135
159
  function formatSegmentTags(segments: Record<string, number>): string | undefined {
136
160
  const tags = Object.keys(segments)
@@ -144,10 +168,14 @@ function formatSegmentTags(segments: Record<string, number>): string | undefined
144
168
  export function formatReport(summary: Summary): string {
145
169
  const lines = ROLES.map((role) => {
146
170
  const totals = summary[role];
147
- return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}`;
171
+ const cacheHit = formatCacheHit(totals);
172
+ const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
173
+ return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
148
174
  });
175
+ const tasksCacheHit = formatCacheHit(summary.tasks);
176
+ const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
149
177
  lines.push(
150
- `tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
178
+ `tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`,
151
179
  );
152
180
  const segmentTags = formatSegmentTags(summary.tasks.segments);
153
181
  if (segmentTags !== undefined) {
@@ -173,7 +201,9 @@ export function formatTasks(tasks: TaskView[]): string {
173
201
  const segmentTags = formatSegmentTags(task.segments);
174
202
  const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
175
203
  const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
176
- return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
204
+ const cacheHit = formatCacheHit(task.usage);
205
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
206
+ return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
177
207
  })
178
208
  .join("\n");
179
209
  }
@@ -184,6 +214,12 @@ export interface ClientTotals {
184
214
  workMs: number;
185
215
  /** Estimated cost in USD, summed across this client's tasks. */
186
216
  cost: number;
217
+ /** Prompt input tokens, summed across this client's tasks. */
218
+ input: number;
219
+ /** Prompt-cache read tokens, summed across this client's tasks. */
220
+ cacheRead: number;
221
+ /** Prompt-cache write tokens, summed across this client's tasks. */
222
+ cacheWrite: number;
187
223
  count: number;
188
224
  }
189
225
 
@@ -201,11 +237,14 @@ export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>
201
237
  const totals = new Map<string, ClientTotals>();
202
238
  for (const task of tasks) {
203
239
  const key = task.client ?? NO_CLIENT;
204
- const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
240
+ const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
205
241
  entry.wallMs += task.wallMs;
206
242
  entry.waitingMs += task.waitingMs;
207
243
  entry.workMs += task.workMs;
208
244
  entry.cost += finiteOrZero(task.usage.cost);
245
+ entry.input += finiteOrZero(task.usage.input);
246
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
247
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
209
248
  entry.count += 1;
210
249
  totals.set(key, entry);
211
250
  }
@@ -220,6 +259,12 @@ export interface ProjectTotals {
220
259
  workMs: number;
221
260
  /** Estimated cost in USD, summed across this project's tasks. */
222
261
  cost: number;
262
+ /** Prompt input tokens, summed across this project's tasks. */
263
+ input: number;
264
+ /** Prompt-cache read tokens, summed across this project's tasks. */
265
+ cacheRead: number;
266
+ /** Prompt-cache write tokens, summed across this project's tasks. */
267
+ cacheWrite: number;
223
268
  count: number;
224
269
  }
225
270
 
@@ -237,11 +282,14 @@ export function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals
237
282
  for (const task of tasks) {
238
283
  const key = task.projectId ?? NO_PROJECT;
239
284
  const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
240
- const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
285
+ const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
241
286
  entry.wallMs += task.wallMs;
242
287
  entry.waitingMs += task.waitingMs;
243
288
  entry.workMs += task.workMs;
244
289
  entry.cost += finiteOrZero(task.usage.cost);
290
+ entry.input += finiteOrZero(task.usage.input);
291
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
292
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
245
293
  entry.count += 1;
246
294
  totals.set(key, entry);
247
295
  }
@@ -253,10 +301,11 @@ export function formatProjects(totals: Map<string, ProjectTotals>): string {
253
301
  if (totals.size === 0) return "no projects";
254
302
  return Array.from(totals.values())
255
303
  .sort((a, b) => a.name.localeCompare(b.name))
256
- .map(
257
- (t) =>
258
- `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
259
- )
304
+ .map((t) => {
305
+ const cacheHit = formatCacheHit(t);
306
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
307
+ return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
308
+ })
260
309
  .join("\n");
261
310
  }
262
311
 
@@ -265,10 +314,11 @@ export function formatClients(totals: Map<string, ClientTotals>): string {
265
314
  if (totals.size === 0) return "no clients";
266
315
  return Array.from(totals.entries())
267
316
  .sort(([a], [b]) => a.localeCompare(b))
268
- .map(
269
- ([client, t]) =>
270
- `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)} tasks ${t.count}`,
271
- )
317
+ .map(([client, t]) => {
318
+ const cacheHit = formatCacheHit(t);
319
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
320
+ return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
321
+ })
272
322
  .join("\n");
273
323
  }
274
324
 
@@ -3,7 +3,7 @@ import { formatWorkTargetLabel, resolveWorkTarget, resolveWorkTargetSource } fro
3
3
  import type { WorkTarget, WorkTargetCandidate, WorkTargetSessionOverride, WorkTargetSourceName } from "../domain/work-target.ts";
4
4
  import type { WorkRole } from "../domain/work-record.ts";
5
5
  import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
6
- import { pickTarget } from "./target-picker.ts";
6
+ import { pickHubTask, pickTarget } from "./target-picker.ts";
7
7
 
8
8
  /** Persisted as a `kankaku-target` custom session entry so the session-level target survives a reload. */
9
9
  export interface KankakuTargetEntryData {
@@ -11,6 +11,13 @@ export interface KankakuTargetEntryData {
11
11
  projectId?: string;
12
12
  /** `true` when the user explicitly declined the picker; distinct from "no entry yet". */
13
13
  skipped?: boolean;
14
+ /**
15
+ * A hub task picked for this session (`/kankaku task pick`). Session-only:
16
+ * never written to the project's `.kankaku/config.json`, and dropped by
17
+ * any target change (`pick`/`setExplicit`/`clear`/`ensurePicked`'s
18
+ * silent resolution) since those all build a fresh candidate without it.
19
+ */
20
+ hubTaskId?: string;
14
21
  }
15
22
 
16
23
  export const TARGET_ENTRY_TYPE = "kankaku-target";
@@ -64,6 +71,16 @@ export interface SessionTarget {
64
71
  setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void;
65
72
  /** Clear the session-level override; resolution falls back to the project config file / `repo_paths`. */
66
73
  clear(pi: ExtensionAPI): void;
74
+ /**
75
+ * `/kankaku task pick`: shows the hub-task picker for the effective
76
+ * project's open/doing tasks and links the pick to the session (never
77
+ * persisted to the project config file). Notifies when there is no
78
+ * effective client/project yet, or the project has no open/doing task.
79
+ * A no-op unless `role === "orchestrator"` and `ctx.hasUI`.
80
+ */
81
+ pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
82
+ /** `/kankaku task clear`: drops the linked hub task, keeping the rest of the session target. A no-op when none is linked. */
83
+ clearTask(pi: ExtensionAPI): void;
67
84
  /** Current effective target (session > project config > repoPaths), regardless of role. */
68
85
  effectiveTarget(): WorkTarget | undefined;
69
86
  /** Which source produced {@link effectiveTarget}. */
@@ -81,7 +98,11 @@ function candidateFrom(target: WorkTarget): WorkTargetCandidate {
81
98
  }
82
99
 
83
100
  function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
84
- return { clientId: ids.clientId, ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}) };
101
+ return {
102
+ clientId: ids.clientId,
103
+ ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}),
104
+ ...(ids.hubTaskId !== undefined ? { hubTaskId: ids.hubTaskId } : {}),
105
+ };
85
106
  }
86
107
 
87
108
  /**
@@ -113,7 +134,11 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
113
134
  if (data?.skipped === true) {
114
135
  sessionOverride = "skipped";
115
136
  } else if (typeof data?.clientId === "string") {
116
- sessionOverride = { clientId: data.clientId, ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}) };
137
+ sessionOverride = {
138
+ clientId: data.clientId,
139
+ ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}),
140
+ ...(typeof data.hubTaskId === "string" ? { hubTaskId: data.hubTaskId } : {}),
141
+ };
117
142
  } else {
118
143
  sessionOverride = undefined;
119
144
  }
@@ -131,6 +156,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
131
156
  cwd: cwd(),
132
157
  clients: snapshot?.clients ?? [],
133
158
  projects: snapshot?.projects ?? [],
159
+ tasks: snapshot?.tasks ?? [],
134
160
  });
135
161
  }
136
162
 
@@ -320,5 +346,39 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
320
346
  pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
321
347
  }
322
348
 
323
- return { restore, ensurePicked, pick, setExplicit, clear, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
349
+ async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
350
+ if (deps.role !== "orchestrator" || !ctx.hasUI) return;
351
+
352
+ const target = effectiveTarget();
353
+ if (!target || target.projectId === undefined) {
354
+ ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
355
+ return;
356
+ }
357
+
358
+ const tasks = deps.catalog.read()?.tasks ?? [];
359
+ const result = await pickHubTask(ctx, tasks, target.projectId);
360
+
361
+ if (result.kind === "empty") {
362
+ ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
363
+ return;
364
+ }
365
+ if (result.kind === "skipped") return;
366
+
367
+ const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId: result.task.id };
368
+ sessionOverride = ids;
369
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
370
+
371
+ const updated = effectiveTarget();
372
+ if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
373
+ }
374
+
375
+ function clearTask(pi: ExtensionAPI): void {
376
+ if (sessionOverride === undefined || sessionOverride === "skipped") return;
377
+ if (sessionOverride.hubTaskId === undefined) return;
378
+ const { hubTaskId: _hubTaskId, ...rest } = sessionOverride;
379
+ sessionOverride = rest;
380
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
381
+ }
382
+
383
+ return { restore, ensurePicked, pick, setExplicit, clear, pickTask, clearTask, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
324
384
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { Client, Project, WorkTarget } from "../domain/work-target.ts";
2
+ import type { Client, HubTask, Project, WorkTarget } from "../domain/work-target.ts";
3
3
 
4
4
  const SKIP_OPTION = "— skip —";
5
5
  const NO_PROJECT_OPTION = "(no project)";
@@ -80,3 +80,48 @@ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog):
80
80
 
81
81
  return { kind: "picked", target };
82
82
  }
83
+
84
+ export type PickHubTaskResult = { kind: "picked"; task: HubTask } | { kind: "skipped" } | { kind: "empty" };
85
+
86
+ /**
87
+ * Build `label -> task` options for {@link pickHubTask}, sorted by title.
88
+ * When two tasks share the same title, disambiguate every colliding label
89
+ * with the task's `externalRef` when it has one, or its bare id otherwise —
90
+ * unlike {@link labelOptions}, a hub task has no `code`, and every task
91
+ * needs a disambiguator, not just the ones lucky enough to have one.
92
+ */
93
+ function labelTaskOptions(tasks: HubTask[]): LabeledOption<HubTask>[] {
94
+ const sorted = [...tasks].sort((a, b) => a.title.localeCompare(b.title));
95
+ const titleCounts = new Map<string, number>();
96
+ for (const task of sorted) {
97
+ titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
98
+ }
99
+
100
+ return sorted.map((task) => {
101
+ const collides = (titleCounts.get(task.title) ?? 0) > 1;
102
+ const label = collides ? `${task.title} (${task.externalRef ?? task.id})` : task.title;
103
+ return { label, item: task };
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Run the hub-task picker (`ctx.ui.select`) for `projectId`'s open/doing
109
+ * tasks. Pure UI interaction: no network, no persistence — the caller
110
+ * (`session-target.ts#pickTask`) decides what to do with the result.
111
+ * `{ kind: "empty" }` is returned without ever prompting when the project
112
+ * has no open/doing task, so the caller can tell "nothing to pick from"
113
+ * apart from "the user skipped".
114
+ */
115
+ export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string): Promise<PickHubTaskResult> {
116
+ const pickable = tasks.filter((task) => task.status !== "done" && task.projectId === projectId);
117
+ if (pickable.length === 0) return { kind: "empty" };
118
+
119
+ const taskOptions = labelTaskOptions(pickable);
120
+ const choice = await ctx.ui.select("kankaku — task", [...taskOptions.map((option) => option.label), SKIP_OPTION]);
121
+ if (choice === undefined || choice === SKIP_OPTION) return { kind: "skipped" };
122
+
123
+ const task = taskOptions.find((option) => option.label === choice)?.item;
124
+ if (!task) return { kind: "skipped" };
125
+
126
+ return { kind: "picked", task };
127
+ }