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,428 @@
1
+ import { unionMs } from "./intervals.js";
2
+ import { emptyUsage, finiteOrZero } from "./work-record.js";
3
+ function toMs(iso) {
4
+ return Date.parse(iso);
5
+ }
6
+ /**
7
+ * Sum per-tag milliseconds across several segment maps (older records
8
+ * without one count as `{}`). Accumulated in a `Map`, then emitted via
9
+ * `Object.fromEntries` (never `result[tag] = ...` on a plain object) so a
10
+ * tag from a hand-edited worklog line named `__proto__` or `constructor`
11
+ * becomes an own data property with the right total instead of silently
12
+ * reading (and arithmetically corrupting) an inherited `Object.prototype`
13
+ * value. Mirrors `work-tracker.ts`'s own segment-building convention.
14
+ */
15
+ function sumSegments(segmentMaps) {
16
+ const totals = new Map();
17
+ for (const segments of segmentMaps) {
18
+ for (const [tag, ms] of Object.entries(segments ?? {})) {
19
+ totals.set(tag, (totals.get(tag) ?? 0) + ms);
20
+ }
21
+ }
22
+ return Object.fromEntries(totals);
23
+ }
24
+ /**
25
+ * Sum several {@link UsageTotals}, tolerating a missing entry (a record
26
+ * without a `usage` field) and missing or non-finite numeric fields on an
27
+ * entry — both treated as zero rather than corrupting the sum with
28
+ * `undefined`/`NaN`.
29
+ */
30
+ export function sumUsage(totals) {
31
+ const usage = emptyUsage();
32
+ for (const total of totals) {
33
+ usage.input += finiteOrZero(total?.input);
34
+ usage.output += finiteOrZero(total?.output);
35
+ usage.cacheRead += finiteOrZero(total?.cacheRead);
36
+ usage.cacheWrite += finiteOrZero(total?.cacheWrite);
37
+ usage.cost += finiteOrZero(total?.cost);
38
+ }
39
+ return usage;
40
+ }
41
+ /**
42
+ * A record is only eligible to anchor a new task when it is a *confirmed*
43
+ * orchestrator: `role === "orchestrator"` and not flagged `uncertain`
44
+ * (ADR 0022). An uncertain record is never dropped — see
45
+ * {@link uncertainRecords} — but it never anchors a task locally and is
46
+ * therefore never synced as one either (SUBAGENT-REQ-014), since sync
47
+ * (`adapters/sync-runner.ts`) uploads exactly what {@link buildTasks}
48
+ * produces.
49
+ */
50
+ function isConfirmedOrchestrator(record) {
51
+ return record.role === "orchestrator" && record.roleConfidence !== "uncertain";
52
+ }
53
+ /** How long after a record settled a child it launched may still start and be joined to it by span evidence (see {@link matchChildren}). */
54
+ const LATE_CHILD_GRACE_MS = 5000;
55
+ /** `true` when `orchestrator` opened more subagent spans of `child`'s profile than it has been given children for. A missing profile on either side matches any. */
56
+ function hasUnclaimedSpan(orchestrator, child, alreadyAssigned) {
57
+ const sameKind = (profile) => child.profile === undefined || profile === undefined || profile === child.profile;
58
+ const spans = orchestrator.subagents.filter((span) => sameKind(span.profile)).length;
59
+ const claimed = alreadyAssigned.filter((other) => sameKind(other.profile)).length;
60
+ return spans > claimed;
61
+ }
62
+ /** A pid means nothing across machines. Only decidable when both records name theirs (`machine` is written when a hub is configured). */
63
+ function differentMachines(a, b) {
64
+ return a.machine !== undefined && b.machine !== undefined && a.machine !== b.machine;
65
+ }
66
+ /**
67
+ * The same record can be in the log twice under one id: written at settle,
68
+ * then written again by crash recovery because the process died before its
69
+ * checkpoint was cleared. Count it once, keeping the most complete copy —
70
+ * the later `settledAt` (a checkpoint taken while a newer run was folded in
71
+ * is later AND larger than the settled copy; a stale one is earlier).
72
+ */
73
+ /** Later `settledAt` wins; on a tie the copy carrying more cost, then more wall time — never insertion order. */
74
+ function moreComplete(candidate, seen) {
75
+ const byEnd = toMs(candidate.settledAt) - toMs(seen.settledAt);
76
+ if (byEnd !== 0)
77
+ return byEnd > 0;
78
+ const byCost = finiteOrZero(candidate.usage?.cost) - finiteOrZero(seen.usage?.cost);
79
+ if (byCost !== 0)
80
+ return byCost > 0;
81
+ return candidate.wallMs > seen.wallMs;
82
+ }
83
+ export function dedupeById(records) {
84
+ const byId = new Map();
85
+ for (const record of records) {
86
+ const seen = byId.get(record.id);
87
+ if (!seen || moreComplete(record, seen))
88
+ byId.set(record.id, record);
89
+ }
90
+ if (byId.size === records.length)
91
+ return records;
92
+ const kept = new Set(byId.values());
93
+ return records.filter((record) => kept.delete(record));
94
+ }
95
+ /**
96
+ * Second pass, only for a child no orchestrator window contains: join it to
97
+ * the most recent record of the very process that launched it, as PROVEN by
98
+ * its `orchestratorRef` (written at child start from a live registry entry
99
+ * whose OS start time was checked — never inferred here). `ref.startedAt`
100
+ * is when that process last REGISTERED, which `/new`, `/resume`, `/fork` and
101
+ * `/reload` re-stamp: a parent record older than the last re-registration is
102
+ * therefore not eligible. That only ever misses a rescue (the child stays an
103
+ * orphan, as before); it can never produce a wrong join.
104
+ *
105
+ * Why a child can start outside every window: its parent's run was never
106
+ * recorded (a run an extension started, before `WorkTracker.onAgentStart`
107
+ * existed), or the parent crashed before settling. Without this pass that
108
+ * child's time and cost stay in the log forever and never reach a task.
109
+ *
110
+ * Why this is an identity join and not the time-only join SUBAGENT-REQ-007
111
+ * forbids: a candidate must carry the referenced pid AND have started inside
112
+ * `[ref.startedAt, child.startedAt]`. The referenced process was alive at
113
+ * both ends of that interval, and the OS cannot hand a live process's pid to
114
+ * another one, so any record with that pid in that interval was written by
115
+ * that exact process. A record older than `ref.startedAt` (a previous owner
116
+ * of a reused pid) or newer than the child is never eligible; a child with
117
+ * no `orchestratorRef` is never rescued at all.
118
+ */
119
+ function rescueByParentIdentity(child, orchestrators, assigned) {
120
+ const ref = child.orchestratorRef;
121
+ if (!ref)
122
+ return undefined;
123
+ const processStart = toMs(ref.startedAt);
124
+ const childStart = toMs(child.startedAt);
125
+ if (!Number.isFinite(processStart) || !Number.isFinite(childStart))
126
+ return undefined;
127
+ let best;
128
+ let bestStart = Number.NEGATIVE_INFINITY;
129
+ let bestLaunched = false;
130
+ for (const orchestrator of orchestrators) {
131
+ if (orchestrator.pid !== ref.pid)
132
+ continue;
133
+ // A pid is only unique on ONE machine: a worklog shared between two
134
+ // (a synced KANKAKU_DIR) must never join across them.
135
+ if (differentMachines(orchestrator, child))
136
+ continue;
137
+ const start = toMs(orchestrator.startedAt);
138
+ if (!(start >= processStart && start <= childStart))
139
+ continue;
140
+ // Among the proven process's records, one that actually launched a
141
+ // subagent of this kind beats a newer one that launched nothing.
142
+ const launched = hasUnclaimedSpan(orchestrator, child, assigned.get(orchestrator.id) ?? []);
143
+ if (best === undefined || (launched && !bestLaunched) || (launched === bestLaunched && start > bestStart)) {
144
+ best = orchestrator;
145
+ bestStart = start;
146
+ bestLaunched = launched;
147
+ }
148
+ }
149
+ return best;
150
+ }
151
+ /**
152
+ * Match every subagent record to the orchestrator record it belongs to:
153
+ * `parentPid === orchestrator.pid` and the child's `startedAt` falls inside
154
+ * the orchestrator's `[startedAt, settledAt]` window. `project` is a
155
+ * *hint*, never a hard filter (ADR 0021, SUBAGENT-REQ-008): among several
156
+ * candidates matching on pid/time (a reused pid, or a genuine cross-project
157
+ * match), a same-project one is always preferred; a cross-project candidate
158
+ * is only ever eligible here because its record already lives in the same
159
+ * `worklog.jsonl` this array was read from — F1's write-side routing
160
+ * (`extension.ts`, `domain/ancestry-match.ts#resolveOrchestratorRef`)
161
+ * reunites a verified cross-worktree child with its orchestrator by writing
162
+ * straight into the orchestrator's own directory, so no later registry
163
+ * lookup is ever needed here — this function itself does no registry
164
+ * lookups and stays pure. Each child is assigned at most once; unmatched
165
+ * children are orphans.
166
+ */
167
+ function matchChildren(allRecords) {
168
+ const records = dedupeById(allRecords);
169
+ const orchestrators = records.filter(isConfirmedOrchestrator);
170
+ const subagents = records.filter((record) => record.role === "subagent").sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
171
+ const childrenByOrchestratorId = new Map();
172
+ for (const orchestrator of orchestrators) {
173
+ childrenByOrchestratorId.set(orchestrator.id, []);
174
+ }
175
+ const orphans = [];
176
+ for (const child of subagents) {
177
+ const childStart = toMs(child.startedAt);
178
+ let best;
179
+ let bestStart = Number.NEGATIVE_INFINITY;
180
+ let bestSameProject = false;
181
+ let bestLaunched = false;
182
+ for (const orchestrator of orchestrators) {
183
+ if (orchestrator.pid !== child.parentPid)
184
+ continue;
185
+ if (differentMachines(orchestrator, child))
186
+ continue;
187
+ const parentStart = toMs(orchestrator.startedAt);
188
+ const parentEnd = toMs(orchestrator.settledAt);
189
+ if (childStart < parentStart)
190
+ continue;
191
+ // Evidence that THIS record launched a subagent of the child's kind
192
+ // and has not been given a child for every such span yet.
193
+ const launched = hasUnclaimedSpan(orchestrator, child, childrenByOrchestratorId.get(orchestrator.id));
194
+ // A record can be closed the instant the next run begins (see
195
+ // WorkTracker.settleAll): its background child then appears a moment
196
+ // AFTER it settled, inside the next record's window. Only a record with
197
+ // span evidence gets this short grace — never time alone.
198
+ const inWindow = childStart <= parentEnd;
199
+ if (!inWindow && !(launched && childStart - parentEnd <= LATE_CHILD_GRACE_MS))
200
+ continue;
201
+ const sameProject = orchestrator.project === child.project;
202
+ const better = best === undefined ||
203
+ (launched && !bestLaunched) ||
204
+ (launched === bestLaunched && sameProject && !bestSameProject) ||
205
+ (launched === bestLaunched && sameProject === bestSameProject && parentStart > bestStart);
206
+ if (better) {
207
+ best = orchestrator;
208
+ bestStart = parentStart;
209
+ bestSameProject = sameProject;
210
+ bestLaunched = launched;
211
+ }
212
+ }
213
+ best ??= rescueByParentIdentity(child, orchestrators, childrenByOrchestratorId);
214
+ if (best) {
215
+ childrenByOrchestratorId.get(best.id).push(child);
216
+ }
217
+ else {
218
+ orphans.push(child);
219
+ }
220
+ }
221
+ return { childrenByOrchestratorId, orphans };
222
+ }
223
+ /**
224
+ * C1 (CRITICAL fix): the reconciliation ADR 0006 keeps in exactly this one
225
+ * place — the only spot that decides whether a subagent span's
226
+ * `forwardedUsage` (`domain/work-tracker.ts#onToolEnd`, SUBAGENT-REQ-006
227
+ * revised) actually gets added to this task's total, or is assumed already
228
+ * covered by a joined child record's own `usage`.
229
+ *
230
+ * There is no explicit per-span correlation id today (same limitation
231
+ * `computeSubagentLinkage` in `domain/hub-entry.ts` already documents), so
232
+ * this reconciles at PROFILE granularity, not per span: a span's forwarded
233
+ * usage is excluded only when this task also has at least one joined child
234
+ * record confirmed by the SAME profile (`WorkRecord.profile`) — i.e. a
235
+ * profile that can BOTH forward usage AND be ancestry-joined for the same
236
+ * kind of call (today, only a user-configured profile that declares both a
237
+ * child-env marker and usage forwarding — see `buildConfiguredProfile`'s
238
+ * doc comment; none of the built-ins can be both at once, so their spans'
239
+ * forwarded usage is never excluded here). A span whose profile is
240
+ * `undefined` (an ambiguous match — see `safeAmbiguousResultInfo`) never
241
+ * carries `forwardedUsage` in the first place, so it never reaches this
242
+ * function at all.
243
+ */
244
+ function unjoinedForwardedUsage(orchestrator, subagents) {
245
+ // Only a DIRECT child can be the process behind one of this record's
246
+ // spans; a grandchild joined by {@link rescueByParentIdentity} never is.
247
+ // Deliberately NOT narrowed to children that started inside the window:
248
+ // gentle-pi's child process starts ~90 ms after `subagent_run` returns,
249
+ // often after this record settled, and it IS that span's child — narrowing
250
+ // billed it twice. The other error (a later, unrelated launch of the same
251
+ // profile cancelling this span's forwarded usage) under-bills instead,
252
+ // which is the safer side to be wrong on.
253
+ const launchedHere = subagents.filter((child) => child.parentPid === orchestrator.pid);
254
+ // One joined child accounts for ONE span of its profile, not for all of
255
+ // them: three spans and one child record means two children never wrote a
256
+ // record, and their forwarded usage is the only trace of their cost.
257
+ const remaining = new Map();
258
+ for (const child of launchedHere) {
259
+ if (child.profile !== undefined)
260
+ remaining.set(child.profile, (remaining.get(child.profile) ?? 0) + 1);
261
+ }
262
+ const kept = [];
263
+ for (const span of orchestrator.subagents) {
264
+ if (span.forwardedUsage === undefined)
265
+ continue;
266
+ const left = span.profile !== undefined ? (remaining.get(span.profile) ?? 0) : 0;
267
+ if (left > 0) {
268
+ remaining.set(span.profile, left - 1);
269
+ continue;
270
+ }
271
+ kept.push(span.forwardedUsage);
272
+ }
273
+ return kept;
274
+ }
275
+ function buildTaskView(orchestrator, subagents) {
276
+ const parentStart = toMs(orchestrator.startedAt);
277
+ const parentEnd = toMs(orchestrator.settledAt);
278
+ const intervals = [
279
+ { start: parentStart, end: parentEnd },
280
+ ...subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
281
+ ];
282
+ const wallMs = unionMs(intervals);
283
+ const endedAtMs = Math.max(parentEnd, ...subagents.map((child) => toMs(child.settledAt)));
284
+ const waitingMs = orchestrator.waitingMs;
285
+ const workMs = wallMs - waitingMs;
286
+ const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage), ...unjoinedForwardedUsage(orchestrator, subagents)]);
287
+ const segments = sumSegments([orchestrator.segments, ...subagents.map((child) => child.segments)]);
288
+ return {
289
+ id: orchestrator.id,
290
+ ...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
291
+ ...(orchestrator.client !== undefined ? { client: orchestrator.client } : {}),
292
+ ...(orchestrator.sessionName !== undefined ? { sessionName: orchestrator.sessionName } : {}),
293
+ ...(orchestrator.sessionDir !== undefined ? { sessionDir: orchestrator.sessionDir } : {}),
294
+ ...(orchestrator.clientId !== undefined ? { clientId: orchestrator.clientId } : {}),
295
+ ...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
296
+ ...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
297
+ ...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
298
+ ...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
299
+ ...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
300
+ project: orchestrator.project,
301
+ prompt: orchestrator.prompt,
302
+ startedAt: orchestrator.startedAt,
303
+ endedAt: new Date(endedAtMs).toISOString(),
304
+ wallMs,
305
+ waitingMs,
306
+ workMs,
307
+ status: orchestrator.status,
308
+ orchestrator,
309
+ subagents,
310
+ usage,
311
+ segments,
312
+ };
313
+ }
314
+ /**
315
+ * Build one {@link TaskView} per *confirmed* orchestrator record, sorted by
316
+ * `startedAt`. An orchestrator-role record flagged `roleConfidence:
317
+ * "uncertain"` (ADR 0022) never anchors a task here — see
318
+ * {@link isConfirmedOrchestrator} and {@link uncertainRecords}.
319
+ */
320
+ export function buildTasks(allRecords) {
321
+ const records = dedupeById(allRecords);
322
+ const orchestrators = records.filter(isConfirmedOrchestrator).sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
323
+ const { childrenByOrchestratorId } = matchChildren(records);
324
+ return orchestrators.map((orchestrator) => buildTaskView(orchestrator, childrenByOrchestratorId.get(orchestrator.id) ?? []));
325
+ }
326
+ /** Subagent records that could not be matched to any orchestrator record. */
327
+ export function orphanSubagents(records) {
328
+ return matchChildren(records).orphans;
329
+ }
330
+ /**
331
+ * Orchestrator-role records that could not be positively proven top-level
332
+ * (ADR 0022's "uncertain" state): no recognised child-env-marker matched,
333
+ * but a live tracked ancestor process was found. Never counted as a new
334
+ * task ({@link buildTasks} excludes them) and never synced as one, but
335
+ * never dropped either — surfaced here so `/kankaku doctor` and the report
336
+ * hint (SUBAGENT-REQ-017) can make the gap visible instead of silent.
337
+ */
338
+ export function uncertainRecords(records) {
339
+ return records.filter((record) => record.role === "orchestrator" && record.roleConfidence === "uncertain");
340
+ }
341
+ /**
342
+ * SUBAGENT-REQ-015: flag confirmed-orchestrator records that share an OS
343
+ * pid AND overlap in time — a pattern that should never occur if pi only
344
+ * ever runs one session per process at a time, but is the observable
345
+ * signature an in-process nested session mechanism (if one existed) would
346
+ * leave behind: two independent `WorkTracker` records, same pid, running
347
+ * concurrently. Purely informational (`/kankaku doctor` reads this, see
348
+ * `adapters/kankaku-command.ts`) — it never changes {@link buildTasks}'
349
+ * own per-task `wallMs`, so a plain pi run or today's gentle-pi setup is
350
+ * completely unaffected; each flagged record still anchors its own
351
+ * `TaskView` exactly as before. `unionedWallMs` is provided (via the
352
+ * existing `unionMs` primitive, ADR 0006 — the interval-union rule stays
353
+ * in exactly this one place) so a human reading the doctor report can see
354
+ * what the corrected total would be, without kankaku silently changing any
355
+ * number on its own.
356
+ */
357
+ export function detectSameProcessOverlaps(records) {
358
+ const orchestratorsByPid = new Map();
359
+ for (const record of records.filter(isConfirmedOrchestrator)) {
360
+ const group = orchestratorsByPid.get(record.pid);
361
+ if (group) {
362
+ group.push(record);
363
+ }
364
+ else {
365
+ orchestratorsByPid.set(record.pid, [record]);
366
+ }
367
+ }
368
+ const overlaps = [];
369
+ for (const [pid, group] of orchestratorsByPid) {
370
+ if (group.length < 2)
371
+ continue;
372
+ const intervals = group.map((record) => ({ start: toMs(record.startedAt), end: toMs(record.settledAt) }));
373
+ const anyOverlap = intervals.some((a, i) => intervals.some((b, j) => i !== j && a.start < b.end && b.start < a.end));
374
+ if (!anyOverlap)
375
+ continue;
376
+ overlaps.push({
377
+ pid,
378
+ recordIds: group.map((record) => record.id),
379
+ unionedWallMs: unionMs(intervals),
380
+ });
381
+ }
382
+ return overlaps;
383
+ }
384
+ /**
385
+ * Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
386
+ * `wallMs` is the union of every interval — orchestrator and subagent alike
387
+ * — across all of the session's tasks, not a sum of per-task `wallMs`.
388
+ */
389
+ export function buildSessions(tasks) {
390
+ const groups = new Map();
391
+ for (const task of tasks) {
392
+ const key = task.sessionId ?? "unknown";
393
+ const list = groups.get(key);
394
+ if (list) {
395
+ list.push(task);
396
+ }
397
+ else {
398
+ groups.set(key, [task]);
399
+ }
400
+ }
401
+ const sessions = [];
402
+ for (const [sessionId, sessionTasks] of groups) {
403
+ const intervals = sessionTasks.flatMap((task) => [
404
+ { start: toMs(task.orchestrator.startedAt), end: toMs(task.orchestrator.settledAt) },
405
+ ...task.subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
406
+ ]);
407
+ const wallMs = unionMs(intervals);
408
+ const waitingMs = sessionTasks.reduce((sum, task) => sum + task.waitingMs, 0);
409
+ const workMs = wallMs - waitingMs;
410
+ const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
411
+ const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
412
+ const usage = sumUsage(sessionTasks.map((task) => task.usage));
413
+ const segments = sumSegments(sessionTasks.map((task) => task.segments));
414
+ sessions.push({
415
+ sessionId,
416
+ project: sessionTasks[0].project,
417
+ startedAt: new Date(startedAtMs).toISOString(),
418
+ endedAt: new Date(endedAtMs).toISOString(),
419
+ wallMs,
420
+ waitingMs,
421
+ workMs,
422
+ tasks: sessionTasks,
423
+ usage,
424
+ segments,
425
+ });
426
+ }
427
+ return sessions.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
428
+ }
@@ -0,0 +1,236 @@
1
+ /** Current schema version for {@link WorkRecord}. */
2
+ export declare const WORK_RECORD_SCHEMA = 1;
3
+ export type WorkRole = "orchestrator" | "subagent";
4
+ export type WorkStatus = "completed" | "aborted" | "interrupted";
5
+ export interface UsageTotals {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ cost: number;
11
+ }
12
+ export interface SubagentSpan {
13
+ toolCallId: string;
14
+ agent: string;
15
+ mode: string;
16
+ taskId?: string;
17
+ ms: number;
18
+ /**
19
+ * The {@link SubagentProfile}'s `id` that matched this tool call
20
+ * (`domain/subagent-profile.ts`, ADR 0020), when unambiguous. Omitted
21
+ * when no profile's tool name matched at all, or when 2+ profiles
22
+ * registered the same tool name and could not be told apart
23
+ * (SUBAGENT-REQ-005) — `/kankaku doctor` surfaces both cases. Optional so
24
+ * an older-format span (written before profiles existed) still validates.
25
+ */
26
+ profile?: string;
27
+ /**
28
+ * C1 (CRITICAL fix, SUBAGENT-REQ-006 revised): nested LLM usage the
29
+ * matched profile's `readResult` forwarded from this span's tool result,
30
+ * kept SEPARATE from the orchestrator's own `WorkRecordCore.usage` —
31
+ * never folded in at write time (`domain/work-tracker.ts#onToolEnd`).
32
+ * `domain/task-view.ts#buildTasks` (ADR 0006: aggregation across
33
+ * spans/children stays in exactly this one place) is the only place that
34
+ * decides whether to add it to a task's total, based on whether a joined
35
+ * child record with the SAME `profile` already carries this same cost
36
+ * through its own confirmed-marker ancestry join — see
37
+ * `task-view.ts#unjoinedForwardedUsage`. Never set for an ambiguous
38
+ * tool-name match (nothing money-affecting is ever taken from one — see
39
+ * `domain/subagent-profile.ts#safeAmbiguousResultInfo`) or when the
40
+ * matched profile's `readResult` reported no usage at all. Optional so an
41
+ * older-format span (written before this field existed) still validates.
42
+ */
43
+ forwardedUsage?: Partial<UsageTotals>;
44
+ }
45
+ /**
46
+ * Identity of the tracked ancestor process a `subagent` record discovered
47
+ * via the machine-wide process registry (`~/.kankaku/run/<pid>.json`, see
48
+ * `ports/process-registry.ts`). Used to reunite a cross-worktree child with
49
+ * its orchestrator locally, before `buildTasks` runs (ADR 0023) — never
50
+ * set on an `orchestrator` record.
51
+ */
52
+ export interface OrchestratorRef {
53
+ pid: number;
54
+ project: string;
55
+ startedAt: string;
56
+ /**
57
+ * The real orchestrator's resolved, absolute kankaku directory (its
58
+ * `RegistryEntry.dir`), when known — carried through a nested
59
+ * subagent-of-subagent chain via `domain/ancestry-match.ts#resolveOrchestratorRef`
60
+ * so a grandchild can route its writes (F1) straight to the true root's
61
+ * directory without a fresh registry lookup for an ancestor that may no
62
+ * longer even be alive. Optional so an older-format entry/record (written
63
+ * before this field existed) still validates and — when absent — the
64
+ * reader simply falls back to its own local directory rather than
65
+ * routing anywhere (see `adapters/extension.ts`'s write-routing).
66
+ */
67
+ dir?: string;
68
+ }
69
+ /**
70
+ * Fields the pure {@link WorkTracker} state machine can compute on its own,
71
+ * with no knowledge of the pi process or session it runs in.
72
+ */
73
+ /** What started a record: absent = a user prompt (every record before this field existed). */
74
+ export type RunTrigger = "extension";
75
+ /** The `prompt` of a record no user prompt started — see {@link RunTrigger}. */
76
+ export declare const EXTENSION_RUN_PROMPT = "(no user prompt \u2014 run started by an extension)";
77
+ export interface WorkRecordCore {
78
+ schema: number;
79
+ id: string;
80
+ prompt: string;
81
+ /**
82
+ * `"extension"` when an extension, not the user, started this record's
83
+ * first run (e.g. gentle-pi waking the orchestrator because a background
84
+ * subagent finished). Optional and additive: `WORK_RECORD_SCHEMA` is
85
+ * unchanged and an older record without it still validates.
86
+ */
87
+ trigger?: RunTrigger;
88
+ startedAt: string;
89
+ settledAt: string;
90
+ wallMs: number;
91
+ waitingMs: number;
92
+ workMs: number;
93
+ /**
94
+ * Agent loops inside this record: the first one plus every
95
+ * `agent.continue()` pi ran before settling it (auto-retry after a provider
96
+ * error, overflow recovery, a queued steer/follow-up). Informational only —
97
+ * never used for time or cost.
98
+ */
99
+ runs: number;
100
+ turns: number;
101
+ tools: Record<string, number>;
102
+ subagents: SubagentSpan[];
103
+ /**
104
+ * Union milliseconds per tag spent in tool calls matched by a
105
+ * {@link SegmentRule} (e.g. `review`). Optional so older persisted
106
+ * records without this field still satisfy the type; callers reading
107
+ * from disk should treat a missing value as `{}`.
108
+ */
109
+ segments?: Record<string, number>;
110
+ usage: UsageTotals;
111
+ status: WorkStatus;
112
+ /**
113
+ * `true` when at least one turn of this run reported a real (finite)
114
+ * provider `cost` figure — as opposed to every turn's cost being
115
+ * absent/non-finite (a subscription/OAuth provider that reports token
116
+ * usage but no cost). Omitted (never `false`) when no turn ever observed
117
+ * one, so an old persisted record without this field reads exactly the
118
+ * same as a run that genuinely never saw a cost figure — both correctly
119
+ * map to `cost_quality: "unknown"` in `domain/hub-entry.ts`. Optional:
120
+ * adding it did not bump `WORK_RECORD_SCHEMA`.
121
+ */
122
+ costObserved?: true;
123
+ }
124
+ /** Process/session metadata the adapter layer attaches before persisting a record. */
125
+ export interface WorkRecordMetadata {
126
+ role: WorkRole;
127
+ pid: number;
128
+ parentPid: number;
129
+ project: string;
130
+ sessionId?: string;
131
+ sessionFile?: string;
132
+ mode?: string;
133
+ model?: string;
134
+ /**
135
+ * The model's reasoning effort when the record settled, as pi names it
136
+ * (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`). Optional and
137
+ * additive; absent on older records and on a pi too old to report it.
138
+ */
139
+ thinkingLevel?: string;
140
+ /** Who this work is billed to. See {@link resolveClient} in `client-label.ts`. */
141
+ client?: string;
142
+ /** pi's session display name at the time this record settled. */
143
+ sessionName?: string;
144
+ /**
145
+ * Absolute session directory, set only when pi's session manager reports
146
+ * it as *non-default* (`SessionManager#usesDefaultSessionDir()` false) —
147
+ * exactly the condition under which pi's own `formatResumeCommand` adds
148
+ * `--session-dir` to the printed resume command. Omitted for an ordinary
149
+ * default-location session, so most records never carry this at all.
150
+ * Local-only today: the hub has no field for it yet (see README
151
+ * "Subagents" / AGENTS.md for the recommended `session_dir` migration).
152
+ */
153
+ sessionDir?: string;
154
+ /** Hub (PocketBase) client record id, when a hub target is active for this run. See `domain/work-target.ts`. */
155
+ clientId?: string;
156
+ /** Hub client display name, denormalised alongside `clientId` for readability. */
157
+ clientName?: string;
158
+ /** Hub (PocketBase) project record id, when the active hub target has a project. */
159
+ projectId?: string;
160
+ /** Hub project display name, denormalised alongside `projectId`. */
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;
166
+ /** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
167
+ machine?: string;
168
+ /**
169
+ * Set only on an `orchestrator` record that could not be positively
170
+ * proven top-level (ADR 0022's four-state classification, applied on top
171
+ * of this still-binary `role`): no recognised child-env-marker matched,
172
+ * but a live tracked ancestor process was found in the machine-wide
173
+ * registry. Never counted as a new task locally or synced to the hub
174
+ * until the ambiguity is resolved (see README "Subagents"). Omitted
175
+ * entirely for a confirmed orchestrator, so a record from a build
176
+ * predating this field is indistinguishable from a confirmed one.
177
+ */
178
+ roleConfidence?: "uncertain";
179
+ /**
180
+ * The tracked ancestor a `subagent` record discovered via the
181
+ * machine-wide process registry. See {@link OrchestratorRef}.
182
+ */
183
+ orchestratorRef?: OrchestratorRef;
184
+ /**
185
+ * The {@link SubagentProfile} `id` (ADR 0020) whose child-env marker(s)
186
+ * confirmed THIS process's `role: "subagent"` classification — e.g.
187
+ * `"gentle-pi"` or `"pi-subagents"`. Set only when exactly one profile's
188
+ * marker matched (SUBAGENT-REQ-005 never guesses); omitted when this
189
+ * process's role came from ancestry alone (no known marker present, e.g.
190
+ * pi's bundled reference example) or from 2+ markers matching at once.
191
+ * `/kankaku doctor` reports which profile matched each record
192
+ * (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
193
+ */
194
+ profile?: string;
195
+ /**
196
+ * Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
197
+ * Who measured, not who syncs — a different process (a standalone
198
+ * `kankaku` TUI, or a session for a different agent sharing the same
199
+ * `worklog.jsonl`) may later push this record to the hub, and must never
200
+ * overwrite this identity with its own. See kankaku-hub `docs/contract.md`
201
+ * "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
202
+ * additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
203
+ * it still validates.
204
+ */
205
+ agent?: string;
206
+ /** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
207
+ agentVersion?: string;
208
+ /** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
209
+ plugin?: string;
210
+ /** This integration's own version, from its `package.json`, read once. */
211
+ pluginVersion?: string;
212
+ }
213
+ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
214
+ export declare function emptyUsage(): UsageTotals;
215
+ /** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
216
+ export declare function finiteOrZero(value: unknown): number;
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 declare function cacheHitRatio(usage: {
227
+ input: number;
228
+ cacheRead: number;
229
+ cacheWrite: number;
230
+ }): number | undefined;
231
+ /**
232
+ * Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
233
+ * skips lines that parse as JSON but fail this check, so a torn write or a
234
+ * record from an incompatible schema does not crash task/session views.
235
+ */
236
+ export declare function isWorkRecord(value: unknown): value is WorkRecord;