kankaku 0.6.0 → 0.7.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.
Files changed (44) hide show
  1. package/README.md +103 -14
  2. package/dist/adapters/cached-catalog.d.ts +8 -2
  3. package/dist/adapters/cached-catalog.js +13 -3
  4. package/dist/adapters/pocketbase-catalog.d.ts +7 -5
  5. package/dist/adapters/pocketbase-catalog.js +22 -5
  6. package/dist/adapters/pocketbase-sink.d.ts +2 -1
  7. package/dist/adapters/pocketbase-sink.js +2 -1
  8. package/dist/domain/hub-entry.d.ts +12 -3
  9. package/dist/domain/hub-entry.js +14 -4
  10. package/dist/domain/task-view.d.ts +4 -0
  11. package/dist/domain/task-view.js +2 -0
  12. package/dist/domain/work-record.d.ts +18 -0
  13. package/dist/domain/work-record.js +18 -0
  14. package/dist/domain/work-target.d.ts +30 -1
  15. package/dist/domain/work-target.js +33 -11
  16. package/dist/ports/catalog.d.ts +4 -2
  17. package/package.json +1 -1
  18. package/src/adapters/cached-catalog.ts +22 -6
  19. package/src/adapters/hub-actions.ts +83 -0
  20. package/src/adapters/kankaku-command.ts +236 -244
  21. package/src/adapters/panel/kankaku-panel.ts +274 -0
  22. package/src/adapters/panel/panel-items.ts +77 -0
  23. package/src/adapters/panel/panel-lines.ts +13 -0
  24. package/src/adapters/panel/panel-theme.ts +32 -0
  25. package/src/adapters/panel/screens/about.ts +69 -0
  26. package/src/adapters/panel/screens/doctor.ts +85 -0
  27. package/src/adapters/panel/screens/export.ts +119 -0
  28. package/src/adapters/panel/screens/report.ts +141 -0
  29. package/src/adapters/panel/screens/sync.ts +126 -0
  30. package/src/adapters/panel/screens/target.ts +367 -0
  31. package/src/adapters/pi-tracker.ts +110 -6
  32. package/src/adapters/pocketbase-catalog.ts +36 -7
  33. package/src/adapters/pocketbase-sink.ts +15 -3
  34. package/src/adapters/report-views.ts +98 -0
  35. package/src/adapters/report.ts +66 -16
  36. package/src/adapters/session-target.ts +111 -4
  37. package/src/adapters/target-picker.ts +46 -1
  38. package/src/domain/hub-entry.ts +20 -5
  39. package/src/domain/panel-model.ts +267 -0
  40. package/src/domain/task-view.ts +6 -0
  41. package/src/domain/work-record.ts +23 -0
  42. package/src/domain/work-target.ts +60 -11
  43. package/src/extension.ts +19 -8
  44. package/src/ports/catalog.ts +4 -2
@@ -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,34 @@ 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;
84
+ /**
85
+ * Set the linked hub task directly, bypassing the picker — the panel's
86
+ * target screen (`adapters/panel/screens/target.ts`) uses this once the
87
+ * user has already chosen a task from its own `SelectList`. Builds the
88
+ * session override from the current {@link effectiveTarget}'s candidate
89
+ * plus `hubTaskId` and appends the session entry, exactly like
90
+ * {@link pickTask}'s own persistence step. A no-op when there is no
91
+ * effective target with a project.
92
+ */
93
+ setTask(pi: ExtensionAPI, hubTaskId: string): void;
94
+ /**
95
+ * `/kankaku target remember` / the panel's "Remember" row: persists the
96
+ * current {@link effectiveTarget}'s `clientId`/`projectId` (never
97
+ * `hubTaskId` — task linking stays session-only) to the project's
98
+ * `.kankaku/config.json`. Returns `false`, without persisting anything,
99
+ * when there is no effective target.
100
+ */
101
+ rememberTarget(): boolean;
67
102
  /** Current effective target (session > project config > repoPaths), regardless of role. */
68
103
  effectiveTarget(): WorkTarget | undefined;
69
104
  /** Which source produced {@link effectiveTarget}. */
@@ -81,7 +116,11 @@ function candidateFrom(target: WorkTarget): WorkTargetCandidate {
81
116
  }
82
117
 
83
118
  function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
84
- return { clientId: ids.clientId, ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}) };
119
+ return {
120
+ clientId: ids.clientId,
121
+ ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}),
122
+ ...(ids.hubTaskId !== undefined ? { hubTaskId: ids.hubTaskId } : {}),
123
+ };
85
124
  }
86
125
 
87
126
  /**
@@ -113,7 +152,11 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
113
152
  if (data?.skipped === true) {
114
153
  sessionOverride = "skipped";
115
154
  } else if (typeof data?.clientId === "string") {
116
- sessionOverride = { clientId: data.clientId, ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}) };
155
+ sessionOverride = {
156
+ clientId: data.clientId,
157
+ ...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}),
158
+ ...(typeof data.hubTaskId === "string" ? { hubTaskId: data.hubTaskId } : {}),
159
+ };
117
160
  } else {
118
161
  sessionOverride = undefined;
119
162
  }
@@ -131,6 +174,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
131
174
  cwd: cwd(),
132
175
  clients: snapshot?.clients ?? [],
133
176
  projects: snapshot?.projects ?? [],
177
+ tasks: snapshot?.tasks ?? [],
134
178
  });
135
179
  }
136
180
 
@@ -320,5 +364,68 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
320
364
  pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
321
365
  }
322
366
 
323
- return { restore, ensurePicked, pick, setExplicit, clear, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
367
+ function setTask(pi: ExtensionAPI, hubTaskId: string): void {
368
+ const target = effectiveTarget();
369
+ if (!target || target.projectId === undefined) return;
370
+
371
+ const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId };
372
+ sessionOverride = ids;
373
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
374
+ }
375
+
376
+ function rememberTarget(): boolean {
377
+ const target = effectiveTarget();
378
+ if (!target) return false;
379
+ deps.persistProjectConfig(candidateFrom(target));
380
+ return true;
381
+ }
382
+
383
+ async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
384
+ if (deps.role !== "orchestrator" || !ctx.hasUI) return;
385
+
386
+ const target = effectiveTarget();
387
+ if (!target || target.projectId === undefined) {
388
+ ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
389
+ return;
390
+ }
391
+
392
+ const tasks = deps.catalog.read()?.tasks ?? [];
393
+ const result = await pickHubTask(ctx, tasks, target.projectId);
394
+
395
+ if (result.kind === "empty") {
396
+ ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
397
+ return;
398
+ }
399
+ if (result.kind === "skipped") return;
400
+
401
+ setTask(pi, result.task.id);
402
+
403
+ const updated = effectiveTarget();
404
+ if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
405
+ }
406
+
407
+ function clearTask(pi: ExtensionAPI): void {
408
+ if (sessionOverride === undefined || sessionOverride === "skipped") return;
409
+ if (sessionOverride.hubTaskId === undefined) return;
410
+ const { hubTaskId: _hubTaskId, ...rest } = sessionOverride;
411
+ sessionOverride = rest;
412
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
413
+ }
414
+
415
+ return {
416
+ restore,
417
+ ensurePicked,
418
+ pick,
419
+ setExplicit,
420
+ clear,
421
+ pickTask,
422
+ clearTask,
423
+ setTask,
424
+ rememberTarget,
425
+ effectiveTarget,
426
+ effectiveSource,
427
+ runTarget,
428
+ idleTarget,
429
+ endRun,
430
+ };
324
431
  }
@@ -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
+ }
@@ -13,13 +13,15 @@
13
13
  import type { TaskView } from "./task-view.ts";
14
14
  import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
15
15
  import { finiteOrZero } from "./work-record.ts";
16
- import type { Client, Project } from "./work-target.ts";
16
+ import type { Client, HubTask, Project } from "./work-target.ts";
17
17
 
18
18
  export type PromptPrivacyMode = "none" | "truncated" | "full";
19
19
 
20
20
  export interface HubEntryContext {
21
21
  clients: Client[];
22
22
  projects: Project[];
23
+ /** The hub's known tasks, to validate a task's linked `hubTaskId` against. See {@link resolveTaskAssignment}. */
24
+ tasks: HubTask[];
23
25
  /** This machine's hostname or `KANKAKU_MACHINE`. */
24
26
  machine: string;
25
27
  /** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
@@ -39,6 +41,8 @@ export interface TaskAssignment {
39
41
  clientId: string;
40
42
  /** `projects` relation id, or `""`. */
41
43
  projectId: string;
44
+ /** `tasks` relation id, or `""` when no usable hub task could be resolved. See {@link resolveTaskAssignment}. */
45
+ hubTaskId: string;
42
46
  /** Only set (non-empty) for a task routed to the unassigned client. */
43
47
  legacyClientLabel: string;
44
48
  /** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
@@ -46,7 +50,8 @@ export interface TaskAssignment {
46
50
  }
47
51
 
48
52
  /**
49
- * Resolve which client/project a task's `task_entries` row should link to.
53
+ * Resolve which client/project/hub-task a task's `task_entries` row should
54
+ * link to.
50
55
  *
51
56
  * - A task whose `clientId` still exists in `clients` links to that client
52
57
  * (regardless of its `active` flag — this is a historical fact, not a
@@ -57,16 +62,25 @@ export interface TaskAssignment {
57
62
  * `unassigned: true`), carrying forward the record's free-text `client`
58
63
  * label (or its `clientName` when the label itself is absent) as
59
64
  * `legacyClientLabel` — the historical backfill rule (proposal §5.3).
65
+ * - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
66
+ * the resolved `projectId` (non-empty); otherwise it is `""` — including
67
+ * whenever the client fell back to unassigned, since there is then no
68
+ * resolved project for a task to belong to.
60
69
  */
61
- export function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[]): TaskAssignment {
70
+ export function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[], tasks: HubTask[]): TaskAssignment {
62
71
  const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
63
72
 
64
73
  if (client) {
65
74
  const project =
66
75
  task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
76
+ const hubTask =
77
+ project !== undefined && task.hubTaskId !== undefined
78
+ ? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
79
+ : undefined;
67
80
  return {
68
81
  clientId: client.id,
69
82
  projectId: project ? project.id : "",
83
+ hubTaskId: hubTask ? hubTask.id : "",
70
84
  legacyClientLabel: "",
71
85
  routedToUnassigned: false,
72
86
  };
@@ -76,6 +90,7 @@ export function resolveTaskAssignment(task: TaskView, clients: Client[], project
76
90
  return {
77
91
  clientId: unassigned ? unassigned.id : "",
78
92
  projectId: "",
93
+ hubTaskId: "",
79
94
  legacyClientLabel: task.client ?? task.clientName ?? "",
80
95
  routedToUnassigned: true,
81
96
  };
@@ -208,12 +223,12 @@ export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project"
208
223
 
209
224
  /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
210
225
  export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload {
211
- const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects);
226
+ const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
212
227
  return {
213
228
  task_id: task.id,
214
229
  client: assignment.clientId,
215
230
  project: assignment.projectId,
216
- task: "",
231
+ task: assignment.hubTaskId,
217
232
  started_at: toPbDate(task.startedAt),
218
233
  ended_at: toPbDate(task.endedAt),
219
234
  wall_ms: task.wallMs,
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Pure model for the `/kankaku` panel (see odd/tasks/kankaku-panel.md): the
3
+ * set of screens, the root menu, an immutable navigation stack, the
4
+ * title/footer-hint text every adapter screen renders from, and pure row
5
+ * builders (e.g. {@link buildTargetRows}) that turn kankaku state into row
6
+ * models for a screen's `SettingsList`. No I/O, no pi imports — see
7
+ * AGENTS.md "Architecture (hexagonal)".
8
+ */
9
+
10
+ import type { WorkTarget } from "./work-target.ts";
11
+
12
+ export type PanelScreenId = "root" | "target" | "report" | "sync" | "export" | "doctor" | "about";
13
+
14
+ /** One row of the root menu (`rootMenu`): a section the panel can navigate to. */
15
+ export interface PanelMenuItem {
16
+ id: PanelScreenId;
17
+ label: string;
18
+ description: string;
19
+ /** Offered only when the hub (PocketBase) is configured; see {@link rootMenu}. */
20
+ hubOnly: boolean;
21
+ }
22
+
23
+ const ROOT_MENU_ITEMS: PanelMenuItem[] = [
24
+ // Not hub-only: the legacy billing label (`/kankaku client <name>`) is
25
+ // set from this same screen and works with no hub configured at all.
26
+ { id: "target", label: "Target", description: "Billing client, project, hub task, legacy label", hubOnly: false },
27
+ { id: "report", label: "Report", description: "Today/all totals, tasks, sessions, clients, projects", hubOnly: false },
28
+ { id: "sync", label: "Sync", description: "Status, sync now, sync all, backfill, catalog refresh", hubOnly: true },
29
+ { id: "export", label: "Export", description: "Write today's or every task as csv/json", hubOnly: false },
30
+ { id: "doctor", label: "Doctor", description: "Orphan/uncertain subagent counts, ancestor detection", hubOnly: false },
31
+ { id: "about", label: "About", description: "Versions, KANKAKU_DIR, env-only config", hubOnly: false },
32
+ ];
33
+
34
+ /**
35
+ * The root menu's rows, in a fixed order. `hubOnly` rows (only `sync`;
36
+ * `target` is not hub-only — see `ROOT_MENU_ITEMS`'s comment) are omitted
37
+ * entirely when the hub is not configured, so the panel offers exactly what
38
+ * `/kankaku` itself would today.
39
+ */
40
+ export function rootMenu(options: { hubConfigured: boolean }): PanelMenuItem[] {
41
+ return ROOT_MENU_ITEMS.filter((item) => options.hubConfigured || !item.hubOnly);
42
+ }
43
+
44
+ /** Immutable navigation stack: `stack[0]` is always `"root"`. */
45
+ export interface PanelNav {
46
+ stack: PanelScreenId[];
47
+ }
48
+
49
+ /** A fresh navigation stack, positioned at the root screen. */
50
+ export function navRoot(): PanelNav {
51
+ return { stack: ["root"] };
52
+ }
53
+
54
+ /** Push a screen onto the stack, returning a new {@link PanelNav}; the input is never mutated. */
55
+ export function navPush(nav: PanelNav, id: PanelScreenId): PanelNav {
56
+ return { stack: [...nav.stack, id] };
57
+ }
58
+
59
+ /**
60
+ * Pop the current screen, returning a new {@link PanelNav} and whether the
61
+ * panel should close. At the root, there is nothing left to pop: the stack
62
+ * is returned unchanged and `closed` is `true` — the caller closes the
63
+ * overlay instead of navigating.
64
+ */
65
+ export function navBack(nav: PanelNav): { nav: PanelNav; closed: boolean } {
66
+ if (nav.stack.length <= 1) {
67
+ return { nav, closed: true };
68
+ }
69
+ return { nav: { stack: nav.stack.slice(0, -1) }, closed: false };
70
+ }
71
+
72
+ /** The screen currently on top of the stack. */
73
+ export function navCurrent(nav: PanelNav): PanelScreenId {
74
+ return nav.stack[nav.stack.length - 1] ?? "root";
75
+ }
76
+
77
+ const SCREEN_TITLES: Record<Exclude<PanelScreenId, "root">, string> = {
78
+ target: "Target",
79
+ report: "Report",
80
+ sync: "Sync",
81
+ export: "Export",
82
+ doctor: "Doctor",
83
+ about: "About",
84
+ };
85
+
86
+ /** `kankaku` at root, `kankaku · <Screen>` on every other screen. */
87
+ export function panelTitle(screen: PanelScreenId): string {
88
+ if (screen === "root") return "kankaku";
89
+ return `kankaku · ${SCREEN_TITLES[screen]}`;
90
+ }
91
+
92
+ /** One clickable/keyboard hint shown in the panel's footer. */
93
+ export interface PanelHint {
94
+ key: string;
95
+ label: string;
96
+ }
97
+
98
+ /**
99
+ * The footer hint row for a screen: navigation hints, an optional search
100
+ * hint when the current body supports it, and how Escape/`q` behave — back
101
+ * at root closes the panel outright, so root shows only `esc close`; every
102
+ * other screen shows both `esc back` and `q close`.
103
+ */
104
+ export function footerHints(screen: PanelScreenId, options: { searchable: boolean }): PanelHint[] {
105
+ const hints: PanelHint[] = [{ key: "↑↓", label: "move" }];
106
+
107
+ if (screen === "root") {
108
+ hints.push({ key: "enter", label: "open" });
109
+ if (options.searchable) hints.push({ key: "/", label: "search" });
110
+ hints.push({ key: "esc", label: "close" });
111
+ return hints;
112
+ }
113
+
114
+ hints.push({ key: "enter", label: "select" });
115
+ if (options.searchable) hints.push({ key: "/", label: "search" });
116
+ hints.push({ key: "esc", label: "back" }, { key: "q", label: "close" });
117
+ return hints;
118
+ }
119
+
120
+ /** One row of a `SettingsList`-backed screen (e.g. {@link buildTargetRows}'s output). */
121
+ export interface PanelRow {
122
+ id: string;
123
+ label: string;
124
+ value: string;
125
+ description?: string;
126
+ }
127
+
128
+ const NONE_VALUE = "— none —";
129
+
130
+ /** Pure input for {@link buildTargetRows}. */
131
+ export interface TargetRowsInput {
132
+ /** Whether the hub (PocketBase) is configured; see `rootMenu`'s `target` row, which is offered either way. */
133
+ hubConfigured: boolean;
134
+ /** The current effective hub target, if any (`SessionTarget#effectiveTarget()`). */
135
+ target?: WorkTarget;
136
+ /** Which source produced {@link target} (already formatted by the caller as `"session" | "config" | "repoPaths"`), or `undefined` for none. */
137
+ source?: string;
138
+ /** The current effective legacy billing label (`SessionClient#effectiveClient()`), if any. */
139
+ legacyLabel?: string;
140
+ /** Which source produced {@link legacyLabel} (already formatted by the caller), or `undefined` for none. */
141
+ legacySource?: string;
142
+ }
143
+
144
+ /**
145
+ * Build the target screen's rows from kankaku's current billing-target
146
+ * state. Pure: the caller (`adapters/panel/screens/target.ts`) resolves
147
+ * `target`/`source`/`legacyLabel`/`legacySource` from `SessionTarget`/
148
+ * `SessionClient` and formats each source name as a plain string, so this
149
+ * function never depends on their concrete types.
150
+ *
151
+ * `client`/`project`/`task`/`source`/`remember` are included only when
152
+ * {@link TargetRowsInput.hubConfigured} is `true` — the panel's `target`
153
+ * screen is offered either way (see `rootMenu`), but those rows only make
154
+ * sense once a hub exists to resolve them against. `legacy` is always
155
+ * included: the legacy billing label works with no hub at all.
156
+ */
157
+ export function buildTargetRows(input: TargetRowsInput): PanelRow[] {
158
+ const rows: PanelRow[] = [];
159
+ const target = input.target;
160
+ const hasClient = target !== undefined;
161
+ const hasProject = target !== undefined && target.projectId !== undefined;
162
+
163
+ if (input.hubConfigured) {
164
+ rows.push({
165
+ id: "client",
166
+ label: "Client",
167
+ value: hasClient ? `${target.clientName} (${target.clientCode})` : NONE_VALUE,
168
+ });
169
+
170
+ rows.push({
171
+ id: "project",
172
+ label: "Project",
173
+ value: hasProject ? (target.projectName ?? NONE_VALUE) : NONE_VALUE,
174
+ ...(hasClient ? {} : { description: "pick a client first" }),
175
+ });
176
+
177
+ rows.push({
178
+ id: "task",
179
+ label: "Task",
180
+ value: hasProject && target.hubTaskTitle !== undefined ? target.hubTaskTitle : NONE_VALUE,
181
+ ...(hasProject ? {} : { description: "pick a project first" }),
182
+ });
183
+
184
+ rows.push({
185
+ id: "source",
186
+ label: "Source",
187
+ value: input.source ?? "none",
188
+ });
189
+
190
+ rows.push({
191
+ id: "remember",
192
+ label: "Remember",
193
+ value: "save to config.json",
194
+ description: "Saves the client and project (never the linked task) to this repository's .kankaku/config.json.",
195
+ });
196
+ }
197
+
198
+ rows.push({
199
+ id: "legacy",
200
+ label: "Legacy label",
201
+ value: input.legacyLabel ?? NONE_VALUE,
202
+ description: `Source: ${input.legacySource ?? "none"}`,
203
+ });
204
+
205
+ return rows;
206
+ }
207
+
208
+ /**
209
+ * Pure input for {@link buildAboutRows}. Every env-only value is passed in
210
+ * already extracted from `KankakuConfig` (see `config.ts`) rather than the
211
+ * config object itself: `KankakuConfig` lives outside `src/domain/`, and
212
+ * this module must import nothing but `src/domain/`/`src/ports/` (see
213
+ * AGENTS.md "Architecture (hexagonal)").
214
+ */
215
+ export interface AboutRowsInput {
216
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`). */
217
+ pluginVersion?: string;
218
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`). */
219
+ agentVersion?: string;
220
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`). */
221
+ kankakuDir: string;
222
+ /** The hub (PocketBase) URL, when configured. */
223
+ hubUrl?: string;
224
+ /** `KankakuConfig.interactiveTools`. */
225
+ interactiveTools: string[];
226
+ /** `KankakuConfig.segmentRules.length`. */
227
+ segmentRuleCount: number;
228
+ /** `KankakuConfig.subagentProfiles`' ids. */
229
+ subagentProfileNames: string[];
230
+ /** `KankakuConfig.client`. */
231
+ client?: string;
232
+ }
233
+
234
+ /**
235
+ * Build the panel's `about` screen rows: versions, the resolved directory,
236
+ * the hub URL, and every env-only setting (`KANKAKU_INTERACTIVE_TOOLS`,
237
+ * `KANKAKU_SEGMENTS`, `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV`,
238
+ * `KANKAKU_CLIENT`) with its env var name as the row's description — every
239
+ * row is read-only (see `screens/about.ts`).
240
+ */
241
+ export function buildAboutRows(input: AboutRowsInput): PanelRow[] {
242
+ return [
243
+ { id: "kankaku", label: "kankaku", value: input.pluginVersion ?? "unknown" },
244
+ { id: "pi", label: "pi", value: input.agentVersion ?? "unknown" },
245
+ { id: "directory", label: "Directory", value: input.kankakuDir },
246
+ { id: "hub", label: "Hub", value: input.hubUrl ?? "not configured" },
247
+ {
248
+ id: "interactive-tools",
249
+ label: "Interactive tools",
250
+ value: input.interactiveTools.length > 0 ? input.interactiveTools.join(", ") : NONE_VALUE,
251
+ description: "KANKAKU_INTERACTIVE_TOOLS",
252
+ },
253
+ {
254
+ id: "segments",
255
+ label: "Segments",
256
+ value: `${input.segmentRuleCount} rule(s)`,
257
+ description: "KANKAKU_SEGMENTS",
258
+ },
259
+ {
260
+ id: "subagent-profiles",
261
+ label: "Subagent profiles",
262
+ value: input.subagentProfileNames.length > 0 ? input.subagentProfileNames.join(", ") : NONE_VALUE,
263
+ description: "KANKAKU_SUBAGENT_TOOLS / KANKAKU_SUBAGENT_CHILD_ENV",
264
+ },
265
+ { id: "client", label: "Client", value: input.client ?? NONE_VALUE, description: "KANKAKU_CLIENT" },
266
+ ];
267
+ }
@@ -36,6 +36,10 @@ export interface TaskView {
36
36
  projectId?: string;
37
37
  /** Hub project display name, from the orchestrator record only. */
38
38
  projectName?: string;
39
+ /** Hub task record id, from the orchestrator record only. See `domain/work-target.ts#HubTask`. */
40
+ hubTaskId?: string;
41
+ /** Hub task title, from the orchestrator record only. */
42
+ hubTaskTitle?: string;
39
43
  /**
40
44
  * Per-tag total milliseconds across the orchestrator and every subagent,
41
45
  * summed rather than unioned: unlike `wallMs`, segment intervals are not
@@ -368,6 +372,8 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
368
372
  ...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
369
373
  ...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
370
374
  ...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
375
+ ...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
376
+ ...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
371
377
  project: orchestrator.project,
372
378
  prompt: orchestrator.prompt,
373
379
  startedAt: orchestrator.startedAt,
@@ -168,6 +168,10 @@ export interface WorkRecordMetadata {
168
168
  projectId?: string;
169
169
  /** Hub project display name, denormalised alongside `projectId`. */
170
170
  projectName?: string;
171
+ /** Hub `tasks` record id linked for this session (`/kankaku task pick`), when one is active. See `domain/work-target.ts#HubTask`. */
172
+ hubTaskId?: string;
173
+ /** Hub task title, denormalised alongside `hubTaskId`. */
174
+ hubTaskTitle?: string;
171
175
  /** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
172
176
  machine?: string;
173
177
  /**
@@ -210,6 +214,23 @@ export function finiteOrZero(value: unknown): number {
210
214
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
211
215
  }
212
216
 
217
+ /**
218
+ * Share of prompt input tokens served from the provider's prompt cache:
219
+ * `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
220
+ * the provider's `input_tokens`, which already excludes cached tokens, so
221
+ * the three fields are disjoint and this sum is the true denominator.
222
+ * Returns `undefined` when the denominator is `0` (nothing to compute a
223
+ * ratio from) rather than `0`, so callers never render a misleading `0%`.
224
+ * Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
225
+ */
226
+ export function cacheHitRatio(usage: { input: number; cacheRead: number; cacheWrite: number }): number | undefined {
227
+ const input = finiteOrZero(usage.input);
228
+ const cacheRead = finiteOrZero(usage.cacheRead);
229
+ const cacheWrite = finiteOrZero(usage.cacheWrite);
230
+ const denominator = input + cacheRead + cacheWrite;
231
+ return denominator === 0 ? undefined : cacheRead / denominator;
232
+ }
233
+
213
234
  const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
214
235
  const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
215
236
 
@@ -267,6 +288,8 @@ export function isWorkRecord(value: unknown): value is WorkRecord {
267
288
  (record["clientName"] === undefined || typeof record["clientName"] === "string") &&
268
289
  (record["projectId"] === undefined || typeof record["projectId"] === "string") &&
269
290
  (record["projectName"] === undefined || typeof record["projectName"] === "string") &&
291
+ (record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
292
+ (record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
270
293
  (record["machine"] === undefined || typeof record["machine"] === "string") &&
271
294
  (record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
272
295
  (record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&