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,526 @@
1
+ import { unionMs } from "./intervals.ts";
2
+ import { emptyUsage, finiteOrZero } from "./work-record.ts";
3
+ import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
4
+
5
+ /**
6
+ * One orchestrator run plus every subagent it spawned, with `wallMs`
7
+ * recomputed as the union of the orchestrator's span and each child's span
8
+ * — never their sum — because background children keep running in parallel
9
+ * after the orchestrator settles.
10
+ */
11
+ export interface TaskView {
12
+ id: string;
13
+ sessionId?: string;
14
+ project: string;
15
+ prompt: string;
16
+ startedAt: string;
17
+ endedAt: string;
18
+ wallMs: number;
19
+ waitingMs: number;
20
+ workMs: number;
21
+ status: WorkStatus;
22
+ orchestrator: WorkRecord;
23
+ subagents: WorkRecord[];
24
+ usage: UsageTotals;
25
+ /** Who this task is billed to, from the orchestrator record only — subagent children do not carry their own. */
26
+ client?: string;
27
+ /** pi's session display name, from the orchestrator record. */
28
+ sessionName?: string;
29
+ /** Non-default session directory, from the orchestrator record. See `WorkRecordMetadata.sessionDir`. Local-only today — no hub field yet. */
30
+ sessionDir?: string;
31
+ /** Hub client record id, from the orchestrator record only. See `domain/work-target.ts`. */
32
+ clientId?: string;
33
+ /** Hub client display name, from the orchestrator record only. */
34
+ clientName?: string;
35
+ /** Hub project record id, from the orchestrator record only. */
36
+ projectId?: string;
37
+ /** Hub project display name, from the orchestrator record only. */
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;
43
+ /**
44
+ * Per-tag total milliseconds across the orchestrator and every subagent,
45
+ * summed rather than unioned: unlike `wallMs`, segment intervals are not
46
+ * persisted on disk, so once a record settles its per-tag total is all
47
+ * that remains, and there is nothing left to union across records.
48
+ */
49
+ segments: Record<string, number>;
50
+ }
51
+
52
+ /** One or more tasks grouped by their pi session, with the same union rule. */
53
+ export interface SessionView {
54
+ sessionId: string;
55
+ project: string;
56
+ startedAt: string;
57
+ endedAt: string;
58
+ wallMs: number;
59
+ waitingMs: number;
60
+ workMs: number;
61
+ tasks: TaskView[];
62
+ usage: UsageTotals;
63
+ /** Per-tag total milliseconds summed across the session's tasks. See {@link TaskView.segments}. */
64
+ segments: Record<string, number>;
65
+ }
66
+
67
+ function toMs(iso: string): number {
68
+ return Date.parse(iso);
69
+ }
70
+
71
+ /**
72
+ * Sum per-tag milliseconds across several segment maps (older records
73
+ * without one count as `{}`). Accumulated in a `Map`, then emitted via
74
+ * `Object.fromEntries` (never `result[tag] = ...` on a plain object) so a
75
+ * tag from a hand-edited worklog line named `__proto__` or `constructor`
76
+ * becomes an own data property with the right total instead of silently
77
+ * reading (and arithmetically corrupting) an inherited `Object.prototype`
78
+ * value. Mirrors `work-tracker.ts`'s own segment-building convention.
79
+ */
80
+ function sumSegments(segmentMaps: Array<Record<string, number> | undefined>): Record<string, number> {
81
+ const totals = new Map<string, number>();
82
+ for (const segments of segmentMaps) {
83
+ for (const [tag, ms] of Object.entries(segments ?? {})) {
84
+ totals.set(tag, (totals.get(tag) ?? 0) + ms);
85
+ }
86
+ }
87
+ return Object.fromEntries(totals);
88
+ }
89
+
90
+ /**
91
+ * Sum several {@link UsageTotals}, tolerating a missing entry (a record
92
+ * without a `usage` field) and missing or non-finite numeric fields on an
93
+ * entry — both treated as zero rather than corrupting the sum with
94
+ * `undefined`/`NaN`.
95
+ */
96
+ export function sumUsage(totals: Array<Partial<UsageTotals> | undefined>): UsageTotals {
97
+ const usage = emptyUsage();
98
+ for (const total of totals) {
99
+ usage.input += finiteOrZero(total?.input);
100
+ usage.output += finiteOrZero(total?.output);
101
+ usage.cacheRead += finiteOrZero(total?.cacheRead);
102
+ usage.cacheWrite += finiteOrZero(total?.cacheWrite);
103
+ usage.cost += finiteOrZero(total?.cost);
104
+ }
105
+ return usage;
106
+ }
107
+
108
+ /**
109
+ * A record is only eligible to anchor a new task when it is a *confirmed*
110
+ * orchestrator: `role === "orchestrator"` and not flagged `uncertain`
111
+ * (ADR 0022). An uncertain record is never dropped — see
112
+ * {@link uncertainRecords} — but it never anchors a task locally and is
113
+ * therefore never synced as one either (SUBAGENT-REQ-014), since sync
114
+ * (`adapters/sync-runner.ts`) uploads exactly what {@link buildTasks}
115
+ * produces.
116
+ */
117
+ function isConfirmedOrchestrator(record: WorkRecord): boolean {
118
+ return record.role === "orchestrator" && record.roleConfidence !== "uncertain";
119
+ }
120
+
121
+ /** How long after a record settled a child it launched may still start and be joined to it by span evidence (see {@link matchChildren}). */
122
+ const LATE_CHILD_GRACE_MS = 5000;
123
+
124
+ /** `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. */
125
+ function hasUnclaimedSpan(orchestrator: WorkRecord, child: WorkRecord, alreadyAssigned: WorkRecord[]): boolean {
126
+ const sameKind = (profile: string | undefined): boolean => child.profile === undefined || profile === undefined || profile === child.profile;
127
+ const spans = orchestrator.subagents.filter((span) => sameKind(span.profile)).length;
128
+ const claimed = alreadyAssigned.filter((other) => sameKind(other.profile)).length;
129
+ return spans > claimed;
130
+ }
131
+
132
+ /** A pid means nothing across machines. Only decidable when both records name theirs (`machine` is written when a hub is configured). */
133
+ function differentMachines(a: WorkRecord, b: WorkRecord): boolean {
134
+ return a.machine !== undefined && b.machine !== undefined && a.machine !== b.machine;
135
+ }
136
+
137
+ /**
138
+ * The same record can be in the log twice under one id: written at settle,
139
+ * then written again by crash recovery because the process died before its
140
+ * checkpoint was cleared. Count it once, keeping the most complete copy —
141
+ * the later `settledAt` (a checkpoint taken while a newer run was folded in
142
+ * is later AND larger than the settled copy; a stale one is earlier).
143
+ */
144
+ /** Later `settledAt` wins; on a tie the copy carrying more cost, then more wall time — never insertion order. */
145
+ function moreComplete(candidate: WorkRecord, seen: WorkRecord): boolean {
146
+ const byEnd = toMs(candidate.settledAt) - toMs(seen.settledAt);
147
+ if (byEnd !== 0) return byEnd > 0;
148
+ const byCost = finiteOrZero(candidate.usage?.cost) - finiteOrZero(seen.usage?.cost);
149
+ if (byCost !== 0) return byCost > 0;
150
+ return candidate.wallMs > seen.wallMs;
151
+ }
152
+
153
+ export function dedupeById(records: WorkRecord[]): WorkRecord[] {
154
+ const byId = new Map<string, WorkRecord>();
155
+ for (const record of records) {
156
+ const seen = byId.get(record.id);
157
+ if (!seen || moreComplete(record, seen)) byId.set(record.id, record);
158
+ }
159
+ if (byId.size === records.length) return records;
160
+ const kept = new Set(byId.values());
161
+ return records.filter((record) => kept.delete(record));
162
+ }
163
+
164
+ /**
165
+ * Second pass, only for a child no orchestrator window contains: join it to
166
+ * the most recent record of the very process that launched it, as PROVEN by
167
+ * its `orchestratorRef` (written at child start from a live registry entry
168
+ * whose OS start time was checked — never inferred here). `ref.startedAt`
169
+ * is when that process last REGISTERED, which `/new`, `/resume`, `/fork` and
170
+ * `/reload` re-stamp: a parent record older than the last re-registration is
171
+ * therefore not eligible. That only ever misses a rescue (the child stays an
172
+ * orphan, as before); it can never produce a wrong join.
173
+ *
174
+ * Why a child can start outside every window: its parent's run was never
175
+ * recorded (a run an extension started, before `WorkTracker.onAgentStart`
176
+ * existed), or the parent crashed before settling. Without this pass that
177
+ * child's time and cost stay in the log forever and never reach a task.
178
+ *
179
+ * Why this is an identity join and not the time-only join SUBAGENT-REQ-007
180
+ * forbids: a candidate must carry the referenced pid AND have started inside
181
+ * `[ref.startedAt, child.startedAt]`. The referenced process was alive at
182
+ * both ends of that interval, and the OS cannot hand a live process's pid to
183
+ * another one, so any record with that pid in that interval was written by
184
+ * that exact process. A record older than `ref.startedAt` (a previous owner
185
+ * of a reused pid) or newer than the child is never eligible; a child with
186
+ * no `orchestratorRef` is never rescued at all.
187
+ */
188
+ function rescueByParentIdentity(child: WorkRecord, orchestrators: WorkRecord[], assigned: Map<string, WorkRecord[]>): WorkRecord | undefined {
189
+ const ref = child.orchestratorRef;
190
+ if (!ref) return undefined;
191
+ const processStart = toMs(ref.startedAt);
192
+ const childStart = toMs(child.startedAt);
193
+ if (!Number.isFinite(processStart) || !Number.isFinite(childStart)) return undefined;
194
+
195
+ let best: WorkRecord | undefined;
196
+ let bestStart = Number.NEGATIVE_INFINITY;
197
+ let bestLaunched = false;
198
+ for (const orchestrator of orchestrators) {
199
+ if (orchestrator.pid !== ref.pid) continue;
200
+ // A pid is only unique on ONE machine: a worklog shared between two
201
+ // (a synced KANKAKU_DIR) must never join across them.
202
+ if (differentMachines(orchestrator, child)) continue;
203
+ const start = toMs(orchestrator.startedAt);
204
+ if (!(start >= processStart && start <= childStart)) continue;
205
+ // Among the proven process's records, one that actually launched a
206
+ // subagent of this kind beats a newer one that launched nothing.
207
+ const launched = hasUnclaimedSpan(orchestrator, child, assigned.get(orchestrator.id) ?? []);
208
+ if (best === undefined || (launched && !bestLaunched) || (launched === bestLaunched && start > bestStart)) {
209
+ best = orchestrator;
210
+ bestStart = start;
211
+ bestLaunched = launched;
212
+ }
213
+ }
214
+ return best;
215
+ }
216
+
217
+ /**
218
+ * Match every subagent record to the orchestrator record it belongs to:
219
+ * `parentPid === orchestrator.pid` and the child's `startedAt` falls inside
220
+ * the orchestrator's `[startedAt, settledAt]` window. `project` is a
221
+ * *hint*, never a hard filter (ADR 0021, SUBAGENT-REQ-008): among several
222
+ * candidates matching on pid/time (a reused pid, or a genuine cross-project
223
+ * match), a same-project one is always preferred; a cross-project candidate
224
+ * is only ever eligible here because its record already lives in the same
225
+ * `worklog.jsonl` this array was read from — F1's write-side routing
226
+ * (`extension.ts`, `domain/ancestry-match.ts#resolveOrchestratorRef`)
227
+ * reunites a verified cross-worktree child with its orchestrator by writing
228
+ * straight into the orchestrator's own directory, so no later registry
229
+ * lookup is ever needed here — this function itself does no registry
230
+ * lookups and stays pure. Each child is assigned at most once; unmatched
231
+ * children are orphans.
232
+ */
233
+ function matchChildren(allRecords: WorkRecord[]): {
234
+ childrenByOrchestratorId: Map<string, WorkRecord[]>;
235
+ orphans: WorkRecord[];
236
+ } {
237
+ const records = dedupeById(allRecords);
238
+ const orchestrators = records.filter(isConfirmedOrchestrator);
239
+ const subagents = records.filter((record) => record.role === "subagent").sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
240
+
241
+ const childrenByOrchestratorId = new Map<string, WorkRecord[]>();
242
+ for (const orchestrator of orchestrators) {
243
+ childrenByOrchestratorId.set(orchestrator.id, []);
244
+ }
245
+
246
+ const orphans: WorkRecord[] = [];
247
+
248
+ for (const child of subagents) {
249
+ const childStart = toMs(child.startedAt);
250
+ let best: WorkRecord | undefined;
251
+ let bestStart = Number.NEGATIVE_INFINITY;
252
+ let bestSameProject = false;
253
+ let bestLaunched = false;
254
+
255
+ for (const orchestrator of orchestrators) {
256
+ if (orchestrator.pid !== child.parentPid) continue;
257
+ if (differentMachines(orchestrator, child)) continue;
258
+
259
+ const parentStart = toMs(orchestrator.startedAt);
260
+ const parentEnd = toMs(orchestrator.settledAt);
261
+ if (childStart < parentStart) continue;
262
+ // Evidence that THIS record launched a subagent of the child's kind
263
+ // and has not been given a child for every such span yet.
264
+ const launched = hasUnclaimedSpan(orchestrator, child, childrenByOrchestratorId.get(orchestrator.id)!);
265
+ // A record can be closed the instant the next run begins (see
266
+ // WorkTracker.settleAll): its background child then appears a moment
267
+ // AFTER it settled, inside the next record's window. Only a record with
268
+ // span evidence gets this short grace — never time alone.
269
+ const inWindow = childStart <= parentEnd;
270
+ if (!inWindow && !(launched && childStart - parentEnd <= LATE_CHILD_GRACE_MS)) continue;
271
+
272
+ const sameProject = orchestrator.project === child.project;
273
+ const better =
274
+ best === undefined ||
275
+ (launched && !bestLaunched) ||
276
+ (launched === bestLaunched && sameProject && !bestSameProject) ||
277
+ (launched === bestLaunched && sameProject === bestSameProject && parentStart > bestStart);
278
+ if (better) {
279
+ best = orchestrator;
280
+ bestStart = parentStart;
281
+ bestSameProject = sameProject;
282
+ bestLaunched = launched;
283
+ }
284
+ }
285
+
286
+ best ??= rescueByParentIdentity(child, orchestrators, childrenByOrchestratorId);
287
+
288
+ if (best) {
289
+ childrenByOrchestratorId.get(best.id)!.push(child);
290
+ } else {
291
+ orphans.push(child);
292
+ }
293
+ }
294
+
295
+ return { childrenByOrchestratorId, orphans };
296
+ }
297
+
298
+ /**
299
+ * C1 (CRITICAL fix): the reconciliation ADR 0006 keeps in exactly this one
300
+ * place — the only spot that decides whether a subagent span's
301
+ * `forwardedUsage` (`domain/work-tracker.ts#onToolEnd`, SUBAGENT-REQ-006
302
+ * revised) actually gets added to this task's total, or is assumed already
303
+ * covered by a joined child record's own `usage`.
304
+ *
305
+ * There is no explicit per-span correlation id today (same limitation
306
+ * `computeSubagentLinkage` in `domain/hub-entry.ts` already documents), so
307
+ * this reconciles at PROFILE granularity, not per span: a span's forwarded
308
+ * usage is excluded only when this task also has at least one joined child
309
+ * record confirmed by the SAME profile (`WorkRecord.profile`) — i.e. a
310
+ * profile that can BOTH forward usage AND be ancestry-joined for the same
311
+ * kind of call (today, only a user-configured profile that declares both a
312
+ * child-env marker and usage forwarding — see `buildConfiguredProfile`'s
313
+ * doc comment; none of the built-ins can be both at once, so their spans'
314
+ * forwarded usage is never excluded here). A span whose profile is
315
+ * `undefined` (an ambiguous match — see `safeAmbiguousResultInfo`) never
316
+ * carries `forwardedUsage` in the first place, so it never reaches this
317
+ * function at all.
318
+ */
319
+ function unjoinedForwardedUsage(orchestrator: WorkRecord, subagents: WorkRecord[]): Array<Partial<UsageTotals>> {
320
+ // Only a DIRECT child can be the process behind one of this record's
321
+ // spans; a grandchild joined by {@link rescueByParentIdentity} never is.
322
+ // Deliberately NOT narrowed to children that started inside the window:
323
+ // gentle-pi's child process starts ~90 ms after `subagent_run` returns,
324
+ // often after this record settled, and it IS that span's child — narrowing
325
+ // billed it twice. The other error (a later, unrelated launch of the same
326
+ // profile cancelling this span's forwarded usage) under-bills instead,
327
+ // which is the safer side to be wrong on.
328
+ const launchedHere = subagents.filter((child) => child.parentPid === orchestrator.pid);
329
+ // One joined child accounts for ONE span of its profile, not for all of
330
+ // them: three spans and one child record means two children never wrote a
331
+ // record, and their forwarded usage is the only trace of their cost.
332
+ const remaining = new Map<string, number>();
333
+ for (const child of launchedHere) {
334
+ if (child.profile !== undefined) remaining.set(child.profile, (remaining.get(child.profile) ?? 0) + 1);
335
+ }
336
+ const kept: Array<Partial<UsageTotals>> = [];
337
+ for (const span of orchestrator.subagents) {
338
+ if (span.forwardedUsage === undefined) continue;
339
+ const left = span.profile !== undefined ? (remaining.get(span.profile) ?? 0) : 0;
340
+ if (left > 0) {
341
+ remaining.set(span.profile!, left - 1);
342
+ continue;
343
+ }
344
+ kept.push(span.forwardedUsage);
345
+ }
346
+ return kept;
347
+ }
348
+
349
+ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskView {
350
+ const parentStart = toMs(orchestrator.startedAt);
351
+ const parentEnd = toMs(orchestrator.settledAt);
352
+
353
+ const intervals = [
354
+ { start: parentStart, end: parentEnd },
355
+ ...subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
356
+ ];
357
+ const wallMs = unionMs(intervals);
358
+
359
+ const endedAtMs = Math.max(parentEnd, ...subagents.map((child) => toMs(child.settledAt)));
360
+ const waitingMs = orchestrator.waitingMs;
361
+ const workMs = wallMs - waitingMs;
362
+ const usage = sumUsage([orchestrator.usage, ...subagents.map((child) => child.usage), ...unjoinedForwardedUsage(orchestrator, subagents)]);
363
+ const segments = sumSegments([orchestrator.segments, ...subagents.map((child) => child.segments)]);
364
+
365
+ return {
366
+ id: orchestrator.id,
367
+ ...(orchestrator.sessionId !== undefined ? { sessionId: orchestrator.sessionId } : {}),
368
+ ...(orchestrator.client !== undefined ? { client: orchestrator.client } : {}),
369
+ ...(orchestrator.sessionName !== undefined ? { sessionName: orchestrator.sessionName } : {}),
370
+ ...(orchestrator.sessionDir !== undefined ? { sessionDir: orchestrator.sessionDir } : {}),
371
+ ...(orchestrator.clientId !== undefined ? { clientId: orchestrator.clientId } : {}),
372
+ ...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
373
+ ...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
374
+ ...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
375
+ ...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
376
+ ...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
377
+ project: orchestrator.project,
378
+ prompt: orchestrator.prompt,
379
+ startedAt: orchestrator.startedAt,
380
+ endedAt: new Date(endedAtMs).toISOString(),
381
+ wallMs,
382
+ waitingMs,
383
+ workMs,
384
+ status: orchestrator.status,
385
+ orchestrator,
386
+ subagents,
387
+ usage,
388
+ segments,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Build one {@link TaskView} per *confirmed* orchestrator record, sorted by
394
+ * `startedAt`. An orchestrator-role record flagged `roleConfidence:
395
+ * "uncertain"` (ADR 0022) never anchors a task here — see
396
+ * {@link isConfirmedOrchestrator} and {@link uncertainRecords}.
397
+ */
398
+ export function buildTasks(allRecords: WorkRecord[]): TaskView[] {
399
+ const records = dedupeById(allRecords);
400
+ const orchestrators = records.filter(isConfirmedOrchestrator).sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
401
+
402
+ const { childrenByOrchestratorId } = matchChildren(records);
403
+
404
+ return orchestrators.map((orchestrator) => buildTaskView(orchestrator, childrenByOrchestratorId.get(orchestrator.id) ?? []));
405
+ }
406
+
407
+ /** Subagent records that could not be matched to any orchestrator record. */
408
+ export function orphanSubagents(records: WorkRecord[]): WorkRecord[] {
409
+ return matchChildren(records).orphans;
410
+ }
411
+
412
+ /**
413
+ * Orchestrator-role records that could not be positively proven top-level
414
+ * (ADR 0022's "uncertain" state): no recognised child-env-marker matched,
415
+ * but a live tracked ancestor process was found. Never counted as a new
416
+ * task ({@link buildTasks} excludes them) and never synced as one, but
417
+ * never dropped either — surfaced here so `/kankaku doctor` and the report
418
+ * hint (SUBAGENT-REQ-017) can make the gap visible instead of silent.
419
+ */
420
+ export function uncertainRecords(records: WorkRecord[]): WorkRecord[] {
421
+ return records.filter((record) => record.role === "orchestrator" && record.roleConfidence === "uncertain");
422
+ }
423
+
424
+ /** One cluster of confirmed-orchestrator records sharing a pid with overlapping `[startedAt, settledAt]` windows — see {@link detectSameProcessOverlaps}. */
425
+ export interface SameProcessOverlap {
426
+ pid: number;
427
+ recordIds: string[];
428
+ /** The union (never the sum) of every clustered record's own wall-time window, via `unionMs`. */
429
+ unionedWallMs: number;
430
+ }
431
+
432
+ /**
433
+ * SUBAGENT-REQ-015: flag confirmed-orchestrator records that share an OS
434
+ * pid AND overlap in time — a pattern that should never occur if pi only
435
+ * ever runs one session per process at a time, but is the observable
436
+ * signature an in-process nested session mechanism (if one existed) would
437
+ * leave behind: two independent `WorkTracker` records, same pid, running
438
+ * concurrently. Purely informational (`/kankaku doctor` reads this, see
439
+ * `adapters/kankaku-command.ts`) — it never changes {@link buildTasks}'
440
+ * own per-task `wallMs`, so a plain pi run or today's gentle-pi setup is
441
+ * completely unaffected; each flagged record still anchors its own
442
+ * `TaskView` exactly as before. `unionedWallMs` is provided (via the
443
+ * existing `unionMs` primitive, ADR 0006 — the interval-union rule stays
444
+ * in exactly this one place) so a human reading the doctor report can see
445
+ * what the corrected total would be, without kankaku silently changing any
446
+ * number on its own.
447
+ */
448
+ export function detectSameProcessOverlaps(records: WorkRecord[]): SameProcessOverlap[] {
449
+ const orchestratorsByPid = new Map<number, WorkRecord[]>();
450
+ for (const record of records.filter(isConfirmedOrchestrator)) {
451
+ const group = orchestratorsByPid.get(record.pid);
452
+ if (group) {
453
+ group.push(record);
454
+ } else {
455
+ orchestratorsByPid.set(record.pid, [record]);
456
+ }
457
+ }
458
+
459
+ const overlaps: SameProcessOverlap[] = [];
460
+
461
+ for (const [pid, group] of orchestratorsByPid) {
462
+ if (group.length < 2) continue;
463
+
464
+ const intervals = group.map((record) => ({ start: toMs(record.startedAt), end: toMs(record.settledAt) }));
465
+ const anyOverlap = intervals.some((a, i) => intervals.some((b, j) => i !== j && a.start < b.end && b.start < a.end));
466
+ if (!anyOverlap) continue;
467
+
468
+ overlaps.push({
469
+ pid,
470
+ recordIds: group.map((record) => record.id),
471
+ unionedWallMs: unionMs(intervals),
472
+ });
473
+ }
474
+
475
+ return overlaps;
476
+ }
477
+
478
+ /**
479
+ * Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
480
+ * `wallMs` is the union of every interval — orchestrator and subagent alike
481
+ * — across all of the session's tasks, not a sum of per-task `wallMs`.
482
+ */
483
+ export function buildSessions(tasks: TaskView[]): SessionView[] {
484
+ const groups = new Map<string, TaskView[]>();
485
+ for (const task of tasks) {
486
+ const key = task.sessionId ?? "unknown";
487
+ const list = groups.get(key);
488
+ if (list) {
489
+ list.push(task);
490
+ } else {
491
+ groups.set(key, [task]);
492
+ }
493
+ }
494
+
495
+ const sessions: SessionView[] = [];
496
+
497
+ for (const [sessionId, sessionTasks] of groups) {
498
+ const intervals = sessionTasks.flatMap((task) => [
499
+ { start: toMs(task.orchestrator.startedAt), end: toMs(task.orchestrator.settledAt) },
500
+ ...task.subagents.map((child) => ({ start: toMs(child.startedAt), end: toMs(child.settledAt) })),
501
+ ]);
502
+
503
+ const wallMs = unionMs(intervals);
504
+ const waitingMs = sessionTasks.reduce((sum, task) => sum + task.waitingMs, 0);
505
+ const workMs = wallMs - waitingMs;
506
+ const startedAtMs = Math.min(...sessionTasks.map((task) => toMs(task.startedAt)));
507
+ const endedAtMs = Math.max(...sessionTasks.map((task) => toMs(task.endedAt)));
508
+ const usage = sumUsage(sessionTasks.map((task) => task.usage));
509
+ const segments = sumSegments(sessionTasks.map((task) => task.segments));
510
+
511
+ sessions.push({
512
+ sessionId,
513
+ project: sessionTasks[0]!.project,
514
+ startedAt: new Date(startedAtMs).toISOString(),
515
+ endedAt: new Date(endedAtMs).toISOString(),
516
+ wallMs,
517
+ waitingMs,
518
+ workMs,
519
+ tasks: sessionTasks,
520
+ usage,
521
+ segments,
522
+ });
523
+ }
524
+
525
+ return sessions.sort((a, b) => toMs(a.startedAt) - toMs(b.startedAt));
526
+ }