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
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
@@ -685,13 +687,53 @@ the future, this is the signal that would surface it.
685
687
 
686
688
  ## The `/kankaku` command
687
689
 
688
- 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
690
- is appended to the chat transcript as a durable card that is never sent to
691
- the LLM; without a UI (print or RPC mode) it falls back to a notification.
692
- Arguments are whitespace-separated and order-insensitive:
693
-
694
- - `/kankaku` — today's role totals and tasks segment, each with its estimated cost.
690
+ `/kankaku` with no arguments, run inside pi's TUI, opens an overlay panel
691
+ modelled on pi's own `/settings` (the same pi-tui `SettingsList`/
692
+ `SelectList` widgets, the same keys) from which every kankaku view and
693
+ action is reachable:
694
+
695
+ - **Target** — billing client, project, hub task, and the legacy label
696
+ (see "Billing labels" and "Hub (PocketBase)" below). The Task row lists
697
+ the open/doing hub tasks of the current project; picking one links the
698
+ session, exactly like `/kankaku task pick` below — **session-only**,
699
+ never persisted (see "Linking to a hub task").
700
+ - **Report** — today/all totals, tasks, sessions, clients, and projects:
701
+ the same five views `/kankaku`'s subcommands produce.
702
+ - **Sync** (hub only) — status, sync now, sync all, backfill, and a
703
+ catalog refresh.
704
+ - **Export** — write today's or every task as csv/json.
705
+ - **Doctor** — orphan/uncertain subagent counts and ancestor-detection
706
+ availability.
707
+ - **About** — versions, the resolved `KANKAKU_DIR`, the hub URL, and every
708
+ env-only setting, read-only.
709
+
710
+ Keys: `↑↓` move, `Enter` open a section or select a value, `Esc` go back
711
+ (or close the panel at the root), `q` close from anywhere. Mouse: the
712
+ footer hints and list rows are clickable, but only in pi's fullscreen
713
+ mode — pi does not dispatch mouse events in its regular (non-fullscreen)
714
+ mode, so there the panel is keyboard-only.
715
+
716
+ Every subcommand below is unchanged, and is exactly what headless (print
717
+ or RPC) mode still uses — `/kankaku` there keeps showing today's totals
718
+ directly, never the panel, since there is no UI to open one in. Run
719
+ `/kankaku <subcommand>` (with arguments) in the TUI to skip the panel and
720
+ go straight to that subcommand's output, exactly as before the panel
721
+ existed.
722
+
723
+ Plain `/kankaku` (headless, or any subcommand below) shows today's totals
724
+ (work, waiting, record count) per role, plus a union-based tasks segment.
725
+ Each totals line also shows `cache hit NN%` when tokens were recorded: the
726
+ share of prompt input tokens served from the provider's prompt cache, cache
727
+ reads over input plus cache reads plus cache writes; the segment is
728
+ omitted, not shown as `0%`, when no tokens were recorded. In the
729
+ interactive TUI the report is appended to the chat transcript as a durable
730
+ card that is never sent to the LLM; without a UI (print or RPC mode) it
731
+ falls back to a notification. Arguments are whitespace-separated and
732
+ order-insensitive:
733
+
734
+ - `/kankaku` (headless only — the TUI opens the panel instead, whose
735
+ Report screen defaults to the same view) — today's role totals and
736
+ tasks segment, each with its estimated cost.
695
737
  - `/kankaku all` — same, but across every record.
696
738
  - `/kankaku tasks` — one line per task (time, union wall/work, cost,
697
739
  subagent count, truncated prompt) for the **current pi session**. Add `all` for
@@ -719,6 +761,9 @@ The following are available only when a hub is configured (see "Hub
719
761
  produced it. `/kankaku target pick` runs the picker again (works
720
762
  mid-session; the new target applies to records settled afterwards).
721
763
  `/kankaku target clear` clears the session-level target.
764
+ - `/kankaku task` (or `/kankaku task pick`) — link this session to an
765
+ open/doing hub task of the effective project. `/kankaku task clear`
766
+ drops the link. See "Linking to a hub task" below.
722
767
  - `/kankaku catalog refresh` — force a catalog refresh and report the
723
768
  client/project counts.
724
769
  - `/kankaku projects` — one line per project (work/waiting/wall time, cost,
@@ -825,6 +870,45 @@ task view exposes it from the orchestrator record only.
825
870
  The status bar shows `💼 <client> · <project>` (or just `💼 <client>` without
826
871
  a project) in place of the legacy client label, both idle and during a run.
827
872
 
873
+ Mid-session, the `/kankaku` panel's Target screen (see "The `/kankaku`
874
+ command" above) is the interactive way to change the client or project
875
+ without going through `/kankaku target pick`'s picker dialog — it edits the
876
+ same session-level target, applies through the same `SessionTarget`, and
877
+ has its own "Remember" row for `<KANKAKU_DIR>/config.json`.
878
+
879
+ ### Linking to a hub task
880
+
881
+ `/kankaku task` (or `/kankaku task pick`) links the current session to one
882
+ of the effective project's existing hub `tasks` rows: a `ctx.ui.select`
883
+ picker lists the project's `open`/`doing` tasks, sorted by title (colliding
884
+ titles are disambiguated with the task's external reference, or its id).
885
+ `/kankaku task clear` drops the link, keeping the rest of the session
886
+ target. kankaku never creates a task from pi — this only links to one
887
+ that already exists in the hub.
888
+
889
+ The link is **session-only**: unlike `clientId`/`projectId`, it is never
890
+ persisted to `<KANKAKU_DIR>/config.json`, and it is never asked for at
891
+ `session_start` — you always link a task explicitly, with `/kankaku task`.
892
+ Any target change (`/kankaku target pick`, `/kankaku target clear`, or the
893
+ legacy `/kankaku client <name>`) drops the linked task, since a new client
894
+ or project makes the old task's link meaningless. A task whose project no
895
+ longer matches the effective project (e.g. after a target change or a
896
+ reassignment in the hub) is also dropped by the domain resolver, never
897
+ silently linked across projects. A task already marked `done` when linked
898
+ keeps linking for the rest of the session — only the picker itself hides
899
+ `done` tasks, so you cannot accidentally pick a closed one, but finishing
900
+ the picked task in the hub mid-session does not break the link. Subagent
901
+ records never carry a linked task, exactly like `clientId`/`projectId` —
902
+ the task view exposes it from the orchestrator record only, and
903
+ `formatWorkTargetLabel` appends it to the status-bar/report label as
904
+ `<client> · <project> › <task title>`.
905
+
906
+ The `/kankaku` panel's Target screen's Task row is the interactive
907
+ equivalent of `/kankaku task pick`/`clear`: it lists the same open/doing
908
+ tasks, applies the link through the same `SessionTarget.setTask`, and
909
+ stays just as session-only — picking a task there is never persisted to
910
+ `config.json` either.
911
+
828
912
  ### Caching and offline behaviour
829
913
 
830
914
  The catalog (clients/projects) is cached machine-wide at
@@ -905,10 +989,13 @@ web app) can move a task from one client/project to another directly in
905
989
  PocketBase — for example, moving a "Sin determinar" row to its real
906
990
  client once you have identified it. A later re-sync of that same task
907
991
  **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.
992
+ `client`/`project`/`task`/`legacy_client_label`; on every subsequent update
993
+ it sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
994
+ never touches assignment fields again. `task` (the linked `tasks` relation)
995
+ is create-only for the exact same reason: reassigning which task a row
996
+ belongs to in the web app is never undone by a later sync. If you need
997
+ kankaku itself to change a task's assignment, do it in the web app, not by
998
+ re-syncing.
912
999
 
913
1000
  **Historical ("Sin determinar") records.** A record with no `clientId`, or
914
1001
  whose `clientId` no longer resolves in the catalog, is routed to the hub's
@@ -1252,8 +1339,10 @@ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
1252
1339
  is not; the compiled entry points are today consumed as a library, not a
1253
1340
  binary.
1254
1341
  - 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.
1342
+ hub's own data model) has shipped: `/kankaku task pick`/`clear` (see
1343
+ "Linking to a hub task" above). Creating a task from pi
1344
+ (`/kankaku task new`) is deliberately not included — kankaku never
1345
+ invents tasks; it only ever links to one already created in the manager.
1257
1346
  - Generic subagent detection (phase 6): 6a fixed the two correctness bugs
1258
1347
  described in "Subagents" above (a phantom-orchestrator double count; a
1259
1348
  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;