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.
@@ -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,
@@ -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"])) &&
@@ -32,6 +32,19 @@ export interface Project {
32
32
  active: boolean;
33
33
  }
34
34
 
35
+ /**
36
+ * A hub `tasks` row: something kankaku can link a session's `task_entries`
37
+ * row to (`task_entries.task`), never create. See `domain/hub-entry.ts` for
38
+ * the create-only assignment rule this feeds.
39
+ */
40
+ export interface HubTask {
41
+ id: string;
42
+ title: string;
43
+ projectId: string;
44
+ status: "open" | "doing" | "done";
45
+ externalRef?: string;
46
+ }
47
+
35
48
  export interface WorkTarget {
36
49
  clientId: string;
37
50
  clientCode: string;
@@ -39,12 +52,23 @@ export interface WorkTarget {
39
52
  projectId?: string;
40
53
  projectCode?: string;
41
54
  projectName?: string;
55
+ /** Hub `tasks` record id, kept only when it belongs to `projectId`. See {@link resolveWorkTarget}. */
56
+ hubTaskId?: string;
57
+ /** Denormalised alongside `hubTaskId` for display. */
58
+ hubTaskTitle?: string;
42
59
  }
43
60
 
44
61
  /** ids picked from the session, or from the project's `.kankaku/config.json`. */
45
62
  export interface WorkTargetCandidate {
46
63
  clientId: string;
47
64
  projectId?: string;
65
+ /**
66
+ * A hub task the user picked for this session (`/kankaku task pick`).
67
+ * Only ever set on the session-level candidate — the project config file
68
+ * and `repoPaths` matching never carry one (task linking is session-only,
69
+ * see AGENTS.md).
70
+ */
71
+ hubTaskId?: string;
48
72
  }
49
73
 
50
74
  /**
@@ -63,6 +87,8 @@ export interface ResolveWorkTargetInput {
63
87
  cwd: string;
64
88
  clients: Client[];
65
89
  projects: Project[];
90
+ /** The hub's known tasks, to validate a candidate's `hubTaskId` against. Defaults to `[]` so existing callers compile unchanged. */
91
+ tasks?: HubTask[];
66
92
  }
67
93
 
68
94
  export type WorkTargetSourceName = "session" | "project" | "repoPaths";
@@ -81,7 +107,18 @@ function findUsableProject(projects: Project[], projectId: string, clientId: str
81
107
  return project;
82
108
  }
83
109
 
84
- function buildTarget(client: Client, project: Project | undefined): WorkTarget {
110
+ /**
111
+ * A hub task usable as a link target: exists and belongs to `projectId`.
112
+ * Any {@link HubTask.status} is accepted — a task marked `done` mid-session
113
+ * keeps linking — only the project match matters here.
114
+ */
115
+ function findUsableTask(tasks: HubTask[], taskId: string, projectId: string): HubTask | undefined {
116
+ const task = tasks.find((candidate) => candidate.id === taskId);
117
+ if (!task || task.projectId !== projectId) return undefined;
118
+ return task;
119
+ }
120
+
121
+ function buildTarget(client: Client, project: Project | undefined, task: HubTask | undefined): WorkTarget {
85
122
  return {
86
123
  clientId: client.id,
87
124
  clientCode: client.code,
@@ -93,6 +130,7 @@ function buildTarget(client: Client, project: Project | undefined): WorkTarget {
93
130
  projectName: project.name,
94
131
  }
95
132
  : {}),
133
+ ...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
96
134
  };
97
135
  }
98
136
 
@@ -101,13 +139,17 @@ function buildTarget(client: Client, project: Project | undefined): WorkTarget {
101
139
  * file) into a target, or `undefined` when the client id does not resolve
102
140
  * to a usable client. A projectId that does not resolve to a usable
103
141
  * project of that client is dropped — the client-only target is still
104
- * returned — rather than failing the whole candidate.
142
+ * returned — rather than failing the whole candidate. A `hubTaskId` is
143
+ * kept only when it resolves to a task of the resolved project; a target
144
+ * with no project never carries one.
105
145
  */
106
- function resolveCandidate(candidate: WorkTargetCandidate, clients: Client[], projects: Project[]): WorkTarget | undefined {
146
+ function resolveCandidate(candidate: WorkTargetCandidate, clients: Client[], projects: Project[], tasks: HubTask[]): WorkTarget | undefined {
107
147
  const client = findUsableClient(clients, candidate.clientId);
108
148
  if (!client) return undefined;
109
149
  const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
110
- return buildTarget(client, project);
150
+ const task =
151
+ project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
152
+ return buildTarget(client, project, task);
111
153
  }
112
154
 
113
155
  /** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
@@ -139,10 +181,11 @@ function isCwdWithin(cwd: string, repoPath: string): boolean {
139
181
  /** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
140
182
  export function resolveWorkTargetSource(input: ResolveWorkTargetInput): WorkTargetSourceName | undefined {
141
183
  const { session } = input;
184
+ const tasks = input.tasks ?? [];
142
185
  if (session === "skipped") return undefined;
143
- if (session !== undefined && resolveCandidate(session, input.clients, input.projects) !== undefined) return "session";
186
+ if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined) return "session";
144
187
 
145
- if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects) !== undefined) return "project";
188
+ if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined) return "project";
146
189
 
147
190
  const matched = matchByRepoPath(input.cwd, input.projects);
148
191
  if (matched && findUsableClient(input.clients, matched.clientId)) return "repoPaths";
@@ -158,28 +201,34 @@ export function resolveWorkTargetSource(input: ResolveWorkTargetInput): WorkTarg
158
201
  */
159
202
  export function resolveWorkTarget(input: ResolveWorkTargetInput): WorkTarget | undefined {
160
203
  const { session } = input;
204
+ const tasks = input.tasks ?? [];
161
205
  if (session === "skipped") return undefined;
162
206
 
163
207
  if (session !== undefined) {
164
- const resolved = resolveCandidate(session, input.clients, input.projects);
208
+ const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
165
209
  if (resolved) return resolved;
166
210
  }
167
211
 
168
212
  if (input.project !== undefined) {
169
- const resolved = resolveCandidate(input.project, input.clients, input.projects);
213
+ const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
170
214
  if (resolved) return resolved;
171
215
  }
172
216
 
173
217
  const matched = matchByRepoPath(input.cwd, input.projects);
174
218
  if (matched) {
175
219
  const client = findUsableClient(input.clients, matched.clientId);
176
- if (client) return buildTarget(client, matched);
220
+ if (client) return buildTarget(client, matched, undefined);
177
221
  }
178
222
 
179
223
  return undefined;
180
224
  }
181
225
 
182
- /** `<clientName>` or `<clientName> · <projectName>` — the display label used by the status bar and the "remember" prompt. */
226
+ /**
227
+ * `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
228
+ * appended as `... › <hubTaskTitle>` — the display label used by the
229
+ * status bar and the "remember" prompt.
230
+ */
183
231
  export function formatWorkTargetLabel(target: WorkTarget): string {
184
- return target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
232
+ const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
233
+ return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
185
234
  }
package/src/extension.ts CHANGED
@@ -240,6 +240,7 @@ export default function kankaku(pi: ExtensionAPI): void {
240
240
  client,
241
241
  clients: snapshot?.clients ?? [],
242
242
  projects: snapshot?.projects ?? [],
243
+ tasks: snapshot?.tasks ?? [],
243
244
  machine: machineName,
244
245
  promptMode: syncConfig.promptMode,
245
246
  syncRecords: syncConfig.syncRecords,
@@ -1,11 +1,13 @@
1
- import type { Client, Project } from "../domain/work-target.ts";
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
2
 
3
- /** A cached read of the hub's clients/projects, plus when and against which hub URL it was fetched. */
3
+ /** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
4
4
  export interface CatalogSnapshot {
5
5
  fetchedAt: number;
6
6
  url: string;
7
7
  clients: Client[];
8
8
  projects: Project[];
9
+ /** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
10
+ tasks?: HubTask[];
9
11
  }
10
12
 
11
13
  /**