kankaku-pi 1.0.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,320 @@
1
+ /** Current schema version for {@link WorkRecord}. */
2
+ export const WORK_RECORD_SCHEMA = 1;
3
+
4
+ export type WorkRole = "orchestrator" | "subagent";
5
+
6
+ export type WorkStatus = "completed" | "aborted" | "interrupted";
7
+
8
+ export interface UsageTotals {
9
+ input: number;
10
+ output: number;
11
+ cacheRead: number;
12
+ cacheWrite: number;
13
+ cost: number;
14
+ }
15
+
16
+ export interface SubagentSpan {
17
+ toolCallId: string;
18
+ agent: string;
19
+ mode: string;
20
+ taskId?: string;
21
+ ms: number;
22
+ /**
23
+ * The {@link SubagentProfile}'s `id` that matched this tool call
24
+ * (`domain/subagent-profile.ts`, ADR 0020), when unambiguous. Omitted
25
+ * when no profile's tool name matched at all, or when 2+ profiles
26
+ * registered the same tool name and could not be told apart
27
+ * (SUBAGENT-REQ-005) — `/kankaku doctor` surfaces both cases. Optional so
28
+ * an older-format span (written before profiles existed) still validates.
29
+ */
30
+ profile?: string;
31
+ /**
32
+ * C1 (CRITICAL fix, SUBAGENT-REQ-006 revised): nested LLM usage the
33
+ * matched profile's `readResult` forwarded from this span's tool result,
34
+ * kept SEPARATE from the orchestrator's own `WorkRecordCore.usage` —
35
+ * never folded in at write time (`domain/work-tracker.ts#onToolEnd`).
36
+ * `domain/task-view.ts#buildTasks` (ADR 0006: aggregation across
37
+ * spans/children stays in exactly this one place) is the only place that
38
+ * decides whether to add it to a task's total, based on whether a joined
39
+ * child record with the SAME `profile` already carries this same cost
40
+ * through its own confirmed-marker ancestry join — see
41
+ * `task-view.ts#unjoinedForwardedUsage`. Never set for an ambiguous
42
+ * tool-name match (nothing money-affecting is ever taken from one — see
43
+ * `domain/subagent-profile.ts#safeAmbiguousResultInfo`) or when the
44
+ * matched profile's `readResult` reported no usage at all. Optional so an
45
+ * older-format span (written before this field existed) still validates.
46
+ */
47
+ forwardedUsage?: Partial<UsageTotals>;
48
+ }
49
+
50
+ /**
51
+ * Identity of the tracked ancestor process a `subagent` record discovered
52
+ * via the machine-wide process registry (`~/.kankaku/run/<pid>.json`, see
53
+ * `ports/process-registry.ts`). Used to reunite a cross-worktree child with
54
+ * its orchestrator locally, before `buildTasks` runs (ADR 0023) — never
55
+ * set on an `orchestrator` record.
56
+ */
57
+ export interface OrchestratorRef {
58
+ pid: number;
59
+ project: string;
60
+ startedAt: string;
61
+ /**
62
+ * The real orchestrator's resolved, absolute kankaku directory (its
63
+ * `RegistryEntry.dir`), when known — carried through a nested
64
+ * subagent-of-subagent chain via `domain/ancestry-match.ts#resolveOrchestratorRef`
65
+ * so a grandchild can route its writes (F1) straight to the true root's
66
+ * directory without a fresh registry lookup for an ancestor that may no
67
+ * longer even be alive. Optional so an older-format entry/record (written
68
+ * before this field existed) still validates and — when absent — the
69
+ * reader simply falls back to its own local directory rather than
70
+ * routing anywhere (see `adapters/extension.ts`'s write-routing).
71
+ */
72
+ dir?: string;
73
+ }
74
+
75
+ /**
76
+ * Fields the pure {@link WorkTracker} state machine can compute on its own,
77
+ * with no knowledge of the pi process or session it runs in.
78
+ */
79
+ /** What started a record: absent = a user prompt (every record before this field existed). */
80
+ export type RunTrigger = "extension";
81
+
82
+ /** The `prompt` of a record no user prompt started — see {@link RunTrigger}. */
83
+ export const EXTENSION_RUN_PROMPT = "(no user prompt — run started by an extension)";
84
+
85
+ export interface WorkRecordCore {
86
+ schema: number;
87
+ id: string;
88
+ prompt: string;
89
+ /**
90
+ * `"extension"` when an extension, not the user, started this record's
91
+ * first run (e.g. gentle-pi waking the orchestrator because a background
92
+ * subagent finished). Optional and additive: `WORK_RECORD_SCHEMA` is
93
+ * unchanged and an older record without it still validates.
94
+ */
95
+ trigger?: RunTrigger;
96
+ startedAt: string;
97
+ settledAt: string;
98
+ wallMs: number;
99
+ waitingMs: number;
100
+ workMs: number;
101
+ /**
102
+ * Agent loops inside this record: the first one plus every
103
+ * `agent.continue()` pi ran before settling it (auto-retry after a provider
104
+ * error, overflow recovery, a queued steer/follow-up). Informational only —
105
+ * never used for time or cost.
106
+ */
107
+ runs: number;
108
+ turns: number;
109
+ tools: Record<string, number>;
110
+ subagents: SubagentSpan[];
111
+ /**
112
+ * Union milliseconds per tag spent in tool calls matched by a
113
+ * {@link SegmentRule} (e.g. `review`). Optional so older persisted
114
+ * records without this field still satisfy the type; callers reading
115
+ * from disk should treat a missing value as `{}`.
116
+ */
117
+ segments?: Record<string, number>;
118
+ usage: UsageTotals;
119
+ status: WorkStatus;
120
+ /**
121
+ * `true` when at least one turn of this run reported a real (finite)
122
+ * provider `cost` figure — as opposed to every turn's cost being
123
+ * absent/non-finite (a subscription/OAuth provider that reports token
124
+ * usage but no cost). Omitted (never `false`) when no turn ever observed
125
+ * one, so an old persisted record without this field reads exactly the
126
+ * same as a run that genuinely never saw a cost figure — both correctly
127
+ * map to `cost_quality: "unknown"` in `domain/hub-entry.ts`. Optional:
128
+ * adding it did not bump `WORK_RECORD_SCHEMA`.
129
+ */
130
+ costObserved?: true;
131
+ }
132
+
133
+ /** Process/session metadata the adapter layer attaches before persisting a record. */
134
+ export interface WorkRecordMetadata {
135
+ role: WorkRole;
136
+ pid: number;
137
+ parentPid: number;
138
+ project: string;
139
+ sessionId?: string;
140
+ sessionFile?: string;
141
+ mode?: string;
142
+ model?: string;
143
+ /**
144
+ * The model's reasoning effort when the record settled, as pi names it
145
+ * (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). Optional and
146
+ * additive; absent on older records and on a pi too old to report it.
147
+ */
148
+ thinkingLevel?: string;
149
+ /** Who this work is billed to. See {@link resolveClient} in `client-label.ts`. */
150
+ client?: string;
151
+ /** pi's session display name at the time this record settled. */
152
+ sessionName?: string;
153
+ /**
154
+ * Absolute session directory, set only when pi's session manager reports
155
+ * it as *non-default* (`SessionManager#usesDefaultSessionDir()` false) —
156
+ * exactly the condition under which pi's own `formatResumeCommand` adds
157
+ * `--session-dir` to the printed resume command. Omitted for an ordinary
158
+ * default-location session, so most records never carry this at all.
159
+ * Local-only today: the hub has no field for it yet (see README
160
+ * "Subagents" / AGENTS.md for the recommended `session_dir` migration).
161
+ */
162
+ sessionDir?: string;
163
+ /** Hub (PocketBase) client record id, when a hub target is active for this run. See `domain/work-target.ts`. */
164
+ clientId?: string;
165
+ /** Hub client display name, denormalised alongside `clientId` for readability. */
166
+ clientName?: string;
167
+ /** Hub (PocketBase) project record id, when the active hub target has a project. */
168
+ projectId?: string;
169
+ /** Hub project display name, denormalised alongside `projectId`. */
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;
175
+ /** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
176
+ machine?: string;
177
+ /**
178
+ * Set only on an `orchestrator` record that could not be positively
179
+ * proven top-level (ADR 0022's four-state classification, applied on top
180
+ * of this still-binary `role`): no recognised child-env-marker matched,
181
+ * but a live tracked ancestor process was found in the machine-wide
182
+ * registry. Never counted as a new task locally or synced to the hub
183
+ * until the ambiguity is resolved (see README "Subagents"). Omitted
184
+ * entirely for a confirmed orchestrator, so a record from a build
185
+ * predating this field is indistinguishable from a confirmed one.
186
+ */
187
+ roleConfidence?: "uncertain";
188
+ /**
189
+ * The tracked ancestor a `subagent` record discovered via the
190
+ * machine-wide process registry. See {@link OrchestratorRef}.
191
+ */
192
+ orchestratorRef?: OrchestratorRef;
193
+ /**
194
+ * The {@link SubagentProfile} `id` (ADR 0020) whose child-env marker(s)
195
+ * confirmed THIS process's `role: "subagent"` classification — e.g.
196
+ * `"gentle-pi"` or `"pi-subagents"`. Set only when exactly one profile's
197
+ * marker matched (SUBAGENT-REQ-005 never guesses); omitted when this
198
+ * process's role came from ancestry alone (no known marker present, e.g.
199
+ * pi's bundled reference example) or from 2+ markers matching at once.
200
+ * `/kankaku doctor` reports which profile matched each record
201
+ * (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
202
+ */
203
+ profile?: string;
204
+ /**
205
+ * Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
206
+ * Who measured, not who syncs — a different process (a standalone
207
+ * `kankaku` TUI, or a session for a different agent sharing the same
208
+ * `worklog.jsonl`) may later push this record to the hub, and must never
209
+ * overwrite this identity with its own. See kankaku-hub `docs/contract.md`
210
+ * "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
211
+ * additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
212
+ * it still validates.
213
+ */
214
+ agent?: string;
215
+ /** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
216
+ agentVersion?: string;
217
+ /** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
218
+ plugin?: string;
219
+ /** This integration's own version, from its `package.json`, read once. */
220
+ pluginVersion?: string;
221
+ }
222
+
223
+ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
224
+
225
+ export function emptyUsage(): UsageTotals {
226
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
227
+ }
228
+
229
+ /** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
230
+ export function finiteOrZero(value: unknown): number {
231
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
232
+ }
233
+
234
+ /**
235
+ * Share of prompt input tokens served from the provider's prompt cache:
236
+ * `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
237
+ * the provider's `input_tokens`, which already excludes cached tokens, so
238
+ * the three fields are disjoint and this sum is the true denominator.
239
+ * Returns `undefined` when the denominator is `0` (nothing to compute a
240
+ * ratio from) rather than `0`, so callers never render a misleading `0%`.
241
+ * Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
242
+ */
243
+ export function cacheHitRatio(usage: { input: number; cacheRead: number; cacheWrite: number }): number | undefined {
244
+ const input = finiteOrZero(usage.input);
245
+ const cacheRead = finiteOrZero(usage.cacheRead);
246
+ const cacheWrite = finiteOrZero(usage.cacheWrite);
247
+ const denominator = input + cacheRead + cacheWrite;
248
+ return denominator === 0 ? undefined : cacheRead / denominator;
249
+ }
250
+
251
+ const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
252
+ const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
253
+
254
+ /** A finite, non-negative number: durations such as `wallMs` can never be negative. */
255
+ function isNonNegativeFinite(value: unknown): boolean {
256
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
257
+ }
258
+
259
+ function isOrchestratorRef(value: unknown): value is OrchestratorRef {
260
+ if (!value || typeof value !== "object") return false;
261
+ const ref = value as Record<string, unknown>;
262
+ return (
263
+ typeof ref["pid"] === "number" &&
264
+ typeof ref["project"] === "string" &&
265
+ typeof ref["startedAt"] === "string" &&
266
+ (ref["dir"] === undefined || typeof ref["dir"] === "string")
267
+ );
268
+ }
269
+
270
+ /**
271
+ * Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
272
+ * skips lines that parse as JSON but fail this check, so a torn write or a
273
+ * record from an incompatible schema does not crash task/session views.
274
+ */
275
+ export function isWorkRecord(value: unknown): value is WorkRecord {
276
+ if (!value || typeof value !== "object") return false;
277
+ const record = value as Record<string, unknown>;
278
+
279
+ return (
280
+ typeof record["schema"] === "number" &&
281
+ typeof record["id"] === "string" &&
282
+ ROLES.has(record["role"] as WorkRole) &&
283
+ typeof record["pid"] === "number" &&
284
+ typeof record["parentPid"] === "number" &&
285
+ typeof record["project"] === "string" &&
286
+ typeof record["prompt"] === "string" &&
287
+ typeof record["startedAt"] === "string" &&
288
+ typeof record["settledAt"] === "string" &&
289
+ isNonNegativeFinite(record["wallMs"]) &&
290
+ isNonNegativeFinite(record["waitingMs"]) &&
291
+ isNonNegativeFinite(record["workMs"]) &&
292
+ typeof record["runs"] === "number" &&
293
+ typeof record["turns"] === "number" &&
294
+ typeof record["tools"] === "object" &&
295
+ record["tools"] !== null &&
296
+ Array.isArray(record["subagents"]) &&
297
+ typeof record["usage"] === "object" &&
298
+ record["usage"] !== null &&
299
+ STATUSES.has(record["status"] as WorkStatus) &&
300
+ (record["client"] === undefined || typeof record["client"] === "string") &&
301
+ (record["sessionName"] === undefined || typeof record["sessionName"] === "string") &&
302
+ (record["sessionDir"] === undefined || typeof record["sessionDir"] === "string") &&
303
+ (record["thinkingLevel"] === undefined || typeof record["thinkingLevel"] === "string") &&
304
+ (record["clientId"] === undefined || typeof record["clientId"] === "string") &&
305
+ (record["clientName"] === undefined || typeof record["clientName"] === "string") &&
306
+ (record["projectId"] === undefined || typeof record["projectId"] === "string") &&
307
+ (record["projectName"] === undefined || typeof record["projectName"] === "string") &&
308
+ (record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
309
+ (record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
310
+ (record["machine"] === undefined || typeof record["machine"] === "string") &&
311
+ (record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
312
+ (record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
313
+ (record["costObserved"] === undefined || record["costObserved"] === true) &&
314
+ (record["profile"] === undefined || typeof record["profile"] === "string") &&
315
+ (record["agent"] === undefined || typeof record["agent"] === "string") &&
316
+ (record["agentVersion"] === undefined || typeof record["agentVersion"] === "string") &&
317
+ (record["plugin"] === undefined || typeof record["plugin"] === "string") &&
318
+ (record["pluginVersion"] === undefined || typeof record["pluginVersion"] === "string")
319
+ );
320
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Hub work-target resolution: pure, no I/O.
3
+ *
4
+ * A `WorkTarget` identifies who a piece of work is billed to (client) and,
5
+ * optionally, which project it belongs to, both by PocketBase record id.
6
+ * Names travel alongside for display; no aggregation ever keys on a name
7
+ * (see `kankaku-pocketbase-proposal.md` D1).
8
+ *
9
+ * Resolution mirrors `client-label.ts#resolveClient`'s precedence pattern:
10
+ * session > project config file > catalog `repo_paths` match for the cwd >
11
+ * none. An id from any source that no longer resolves to an active,
12
+ * non-"unassigned" catalog row is treated as absent for that source and
13
+ * resolution falls through to the next one.
14
+ */
15
+
16
+ export interface Client {
17
+ id: string;
18
+ name: string;
19
+ code: string;
20
+ active: boolean;
21
+ /** `true` only for the single "Sin determinar" row; never offered by the picker. */
22
+ unassigned?: boolean;
23
+ }
24
+
25
+ export interface Project {
26
+ id: string;
27
+ name: string;
28
+ code?: string;
29
+ clientId: string;
30
+ /** Absolute paths that map to this project; enables auto-selection by cwd. */
31
+ repoPaths: string[];
32
+ active: boolean;
33
+ }
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
+
48
+ export interface WorkTarget {
49
+ clientId: string;
50
+ clientCode: string;
51
+ clientName: string;
52
+ projectId?: string;
53
+ projectCode?: string;
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;
59
+ }
60
+
61
+ /** ids picked from the session, or from the project's `.kankaku/config.json`. */
62
+ export interface WorkTargetCandidate {
63
+ clientId: string;
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;
72
+ }
73
+
74
+ /**
75
+ * The session-level override: `undefined` when no session entry exists yet
76
+ * (resolution falls through to the project/repo-paths sources), the literal
77
+ * `"skipped"` when the user explicitly declined the picker (resolution
78
+ * stops here, at "no target", and does not fall through), or explicit ids.
79
+ */
80
+ export type WorkTargetSessionOverride = "skipped" | WorkTargetCandidate | undefined;
81
+
82
+ export interface ResolveWorkTargetInput {
83
+ session?: WorkTargetSessionOverride;
84
+ /** ids from the project's `.kankaku/config.json`. */
85
+ project?: WorkTargetCandidate;
86
+ /** Current working directory, matched against `Project.repoPaths`. */
87
+ cwd: string;
88
+ clients: Client[];
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[];
92
+ }
93
+
94
+ export type WorkTargetSourceName = "session" | "project" | "repoPaths";
95
+
96
+ /** A client usable as a work target: exists, active, and not the "unassigned" row. */
97
+ function findUsableClient(clients: Client[], clientId: string): Client | undefined {
98
+ const client = clients.find((candidate) => candidate.id === clientId);
99
+ if (!client || !client.active || client.unassigned) return undefined;
100
+ return client;
101
+ }
102
+
103
+ /** A project usable as a work target: exists, active, and belongs to `clientId`. */
104
+ function findUsableProject(projects: Project[], projectId: string, clientId: string): Project | undefined {
105
+ const project = projects.find((candidate) => candidate.id === projectId);
106
+ if (!project || !project.active || project.clientId !== clientId) return undefined;
107
+ return project;
108
+ }
109
+
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 {
122
+ return {
123
+ clientId: client.id,
124
+ clientCode: client.code,
125
+ clientName: client.name,
126
+ ...(project !== undefined
127
+ ? {
128
+ projectId: project.id,
129
+ ...(project.code !== undefined ? { projectCode: project.code } : {}),
130
+ projectName: project.name,
131
+ }
132
+ : {}),
133
+ ...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Resolve a candidate (from the session override or the project config
139
+ * file) into a target, or `undefined` when the client id does not resolve
140
+ * to a usable client. A projectId that does not resolve to a usable
141
+ * project of that client is dropped — the client-only target is still
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.
145
+ */
146
+ function resolveCandidate(candidate: WorkTargetCandidate, clients: Client[], projects: Project[], tasks: HubTask[]): WorkTarget | undefined {
147
+ const client = findUsableClient(clients, candidate.clientId);
148
+ if (!client) return undefined;
149
+ const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
150
+ const task =
151
+ project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
152
+ return buildTarget(client, project, task);
153
+ }
154
+
155
+ /** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
156
+ function matchByRepoPath(cwd: string, projects: Project[]): Project | undefined {
157
+ let best: Project | undefined;
158
+ let bestLength = -1;
159
+
160
+ for (const project of projects) {
161
+ if (!project.active) continue;
162
+ for (const repoPath of project.repoPaths) {
163
+ if (!isCwdWithin(cwd, repoPath)) continue;
164
+ if (repoPath.length > bestLength) {
165
+ bestLength = repoPath.length;
166
+ best = project;
167
+ }
168
+ }
169
+ }
170
+
171
+ return best;
172
+ }
173
+
174
+ /** `true` when `cwd` equals `repoPath`, or is a subdirectory of it. */
175
+ function isCwdWithin(cwd: string, repoPath: string): boolean {
176
+ if (cwd === repoPath) return true;
177
+ const withSeparator = repoPath.endsWith("/") ? repoPath : `${repoPath}/`;
178
+ return cwd.startsWith(withSeparator);
179
+ }
180
+
181
+ /** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
182
+ export function resolveWorkTargetSource(input: ResolveWorkTargetInput): WorkTargetSourceName | undefined {
183
+ const { session } = input;
184
+ const tasks = input.tasks ?? [];
185
+ if (session === "skipped") return undefined;
186
+ if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined) return "session";
187
+
188
+ if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined) return "project";
189
+
190
+ const matched = matchByRepoPath(input.cwd, input.projects);
191
+ if (matched && findUsableClient(input.clients, matched.clientId)) return "repoPaths";
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Resolve the effective {@link WorkTarget}: session override > project
198
+ * config file > catalog `repo_paths` match for `cwd` > none. A `"skipped"`
199
+ * session override stops resolution immediately (the user explicitly
200
+ * declined), returning `undefined` rather than falling through.
201
+ */
202
+ export function resolveWorkTarget(input: ResolveWorkTargetInput): WorkTarget | undefined {
203
+ const { session } = input;
204
+ const tasks = input.tasks ?? [];
205
+ if (session === "skipped") return undefined;
206
+
207
+ if (session !== undefined) {
208
+ const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
209
+ if (resolved) return resolved;
210
+ }
211
+
212
+ if (input.project !== undefined) {
213
+ const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
214
+ if (resolved) return resolved;
215
+ }
216
+
217
+ const matched = matchByRepoPath(input.cwd, input.projects);
218
+ if (matched) {
219
+ const client = findUsableClient(input.clients, matched.clientId);
220
+ if (client) return buildTarget(client, matched, undefined);
221
+ }
222
+
223
+ return undefined;
224
+ }
225
+
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
+ */
231
+ export function formatWorkTargetLabel(target: WorkTarget): string {
232
+ const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
233
+ return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
234
+ }