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,265 @@
1
+ /**
2
+ * Map a {@link TaskView} (and its `WorkRecord`s) to the hub's `task_entries`
3
+ * / `work_records` payload shapes. Pure, no I/O — see `contract.md` in
4
+ * kankaku-hub for the exact field names/types this mirrors.
5
+ *
6
+ * The CRITICAL rule this file encodes: assignment (`client`/`project`/
7
+ * `task`/`legacy_client_label`) is create-only. The owner reassigns rows in
8
+ * the web; a later re-sync of the same task must never undo that, so
9
+ * `buildTaskEntryUpdatePayload` never includes those fields. See
10
+ * `buildTaskEntryCreatePayload` vs `buildTaskEntryUpdatePayload`.
11
+ */
12
+ import { finiteOrZero } from "./work-record.js";
13
+ /**
14
+ * Resolve which client/project/hub-task a task's `task_entries` row should
15
+ * link to.
16
+ *
17
+ * - A task whose `clientId` still exists in `clients` links to that client
18
+ * (regardless of its `active` flag — this is a historical fact, not a
19
+ * future selection), and to `projectId` too when it still exists and
20
+ * belongs to that client; otherwise the project relation is empty.
21
+ * - A task with no `clientId`, or whose `clientId` no longer resolves,
22
+ * routes to the catalog's unassigned client (the row with
23
+ * `unassigned: true`), carrying forward the record's free-text `client`
24
+ * label (or its `clientName` when the label itself is absent) as
25
+ * `legacyClientLabel` — the historical backfill rule (proposal §5.3).
26
+ * - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
27
+ * the resolved `projectId` (non-empty); otherwise it is `""` — including
28
+ * whenever the client fell back to unassigned, since there is then no
29
+ * resolved project for a task to belong to.
30
+ */
31
+ export function resolveTaskAssignment(task, clients, projects, tasks) {
32
+ const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
33
+ if (client) {
34
+ const project = task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
35
+ const hubTask = project !== undefined && task.hubTaskId !== undefined
36
+ ? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
37
+ : undefined;
38
+ return {
39
+ clientId: client.id,
40
+ projectId: project ? project.id : "",
41
+ hubTaskId: hubTask ? hubTask.id : "",
42
+ legacyClientLabel: "",
43
+ routedToUnassigned: false,
44
+ };
45
+ }
46
+ const unassigned = clients.find((candidate) => candidate.unassigned === true);
47
+ return {
48
+ clientId: unassigned ? unassigned.id : "",
49
+ projectId: "",
50
+ hubTaskId: "",
51
+ legacyClientLabel: task.client ?? task.clientName ?? "",
52
+ routedToUnassigned: true,
53
+ };
54
+ }
55
+ /**
56
+ * Privacy transform for a prompt about to leave the machine
57
+ * (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
58
+ * `truncated` keeps the first 120 chars plus an ellipsis marker when
59
+ * anything was cut, `full` sends it verbatim.
60
+ */
61
+ export function applyPromptPrivacy(prompt, mode) {
62
+ if (mode === "full")
63
+ return prompt;
64
+ if (mode === "truncated")
65
+ return prompt.length > 120 ? `${prompt.slice(0, 120)}…` : prompt;
66
+ return "";
67
+ }
68
+ /**
69
+ * PocketBase `date` fields read back as `"YYYY-MM-DD HH:MM:SS.mmmZ"` (space,
70
+ * not `T`) though both forms are accepted on write; kankaku always sends
71
+ * the space form so a round trip through the contract's documented shape
72
+ * never depends on PocketBase's own normalization.
73
+ */
74
+ function toPbDate(iso) {
75
+ return iso.replace("T", " ");
76
+ }
77
+ /**
78
+ * Whether waiting time (time blocked on the human) was actually observed
79
+ * for this task, as opposed to `work_ms` being an unmeasured upper bound
80
+ * equal to `wall_ms`. pi always instruments `ui_prompt_start`/`_end` and
81
+ * interactive-tool spans (`domain/work-tracker.ts`), so this is always
82
+ * `"measured"` for kankaku/pi — a constant here, not computed from a
83
+ * record, kept as its own function (rather than a literal in the payload)
84
+ * so the reasoning has one documented home.
85
+ */
86
+ export function computeWaitingQuality() {
87
+ return "measured";
88
+ }
89
+ /**
90
+ * Whether this task's cost figure came from a real provider-reported cost
91
+ * (`"measured"`) or not (`"unknown"`) — never `"estimated"`: kankaku has no
92
+ * token-based price-table estimator today, only a real provider figure or
93
+ * nothing. A task counts as measured when its own orchestrator record OR
94
+ * any of its joined subagent records observed one (see
95
+ * `WorkRecord.costObserved`, set by `domain/work-tracker.ts`).
96
+ */
97
+ export function computeCostQuality(task) {
98
+ const observed = task.orchestrator.costObserved === true || task.subagents.some((child) => child.costObserved === true);
99
+ return observed ? "measured" : "unknown";
100
+ }
101
+ /**
102
+ * Whether this task's subagent tool spans (`orchestrator.subagents`,
103
+ * `SubagentSpan[]` — the orchestrator's own tool-call bookkeeping) are
104
+ * accounted for by joined child `WorkRecord`s (`task.subagents`, from
105
+ * `domain/task-view.ts#matchChildren`). There is no explicit per-span
106
+ * correlation id today (README "Subagents" > "Limitations": upstream
107
+ * gentle-pi does not hand a child its own task id), so this is a
108
+ * task-level approximation, not a per-span one:
109
+ *
110
+ * - `not_applicable`: the orchestrator opened no subagent spans at all.
111
+ * - `linked`: at least as many child records were joined as spans were
112
+ * opened — plausibly every span is accounted for (a gentle-pi subagent
113
+ * spawns exactly one child process per span, so counts normally match
114
+ * 1:1).
115
+ * - `unlinked`: fewer joined children than spans (including zero) — at
116
+ * least one span's time is only visible inside the orchestrator's own
117
+ * tool-call span, with no corroborating child record. This also covers
118
+ * a fully in-process subagent mechanism (no separate OS process at all,
119
+ * invisible to the registry/ancestry machinery) and a genuine join miss
120
+ * alike — kankaku cannot tell those apart from here, so it reports the
121
+ * conservative, visible-gap answer rather than guessing "linked".
122
+ */
123
+ export function computeSubagentLinkage(task) {
124
+ const spanCount = task.orchestrator.subagents.length;
125
+ if (spanCount === 0)
126
+ return "not_applicable";
127
+ return task.subagents.length >= spanCount ? "linked" : "unlinked";
128
+ }
129
+ /**
130
+ * The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
131
+ * for a task's payload: the orchestrator record's own fields, taken as a
132
+ * unit, when it carries an `agent` — a missing version on the record is
133
+ * omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
134
+ * syncing process's own), exactly as before this feature existed. See
135
+ * `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
136
+ */
137
+ function resolveTaskIdentity(task, ctx) {
138
+ const orchestrator = task.orchestrator;
139
+ if (orchestrator.agent !== undefined) {
140
+ // The record's four fields are taken as a unit. A record written by an
141
+ // integration that stamped `agent` but not `plugin` gets the syncing
142
+ // context's `plugin` ONLY on create, so the row is never blank; the
143
+ // update payload below never resends a plugin the record does not own.
144
+ return {
145
+ agent: orchestrator.agent,
146
+ agentVersion: orchestrator.agentVersion,
147
+ plugin: orchestrator.plugin ?? ctx.plugin,
148
+ pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
149
+ };
150
+ }
151
+ return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
152
+ }
153
+ /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
154
+ export function buildTaskEntryCreatePayload(task, ctx) {
155
+ const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
156
+ const identity = resolveTaskIdentity(task, ctx);
157
+ return {
158
+ task_id: task.id,
159
+ client: assignment.clientId,
160
+ project: assignment.projectId,
161
+ task: assignment.hubTaskId,
162
+ started_at: toPbDate(task.startedAt),
163
+ ended_at: toPbDate(task.endedAt),
164
+ wall_ms: task.wallMs,
165
+ waiting_ms: task.waitingMs,
166
+ work_ms: task.workMs,
167
+ input: finiteOrZero(task.usage.input),
168
+ output: finiteOrZero(task.usage.output),
169
+ cache_read: finiteOrZero(task.usage.cacheRead),
170
+ cache_write: finiteOrZero(task.usage.cacheWrite),
171
+ cost: finiteOrZero(task.usage.cost),
172
+ segments: task.segments,
173
+ subagent_count: task.subagents.length,
174
+ runs: task.orchestrator.runs,
175
+ turns: task.orchestrator.turns,
176
+ status: task.status,
177
+ session_id: task.sessionId ?? "",
178
+ session_name: task.sessionName ?? "",
179
+ machine: ctx.machine,
180
+ model: task.orchestrator.model ?? "",
181
+ ...(task.orchestrator.thinkingLevel !== undefined ? { thinking_level: task.orchestrator.thinkingLevel } : {}),
182
+ prompt: applyPromptPrivacy(task.prompt, ctx.promptMode),
183
+ legacy_client_label: assignment.legacyClientLabel,
184
+ repo_project: task.project,
185
+ schema: task.orchestrator.schema,
186
+ agent: identity.agent,
187
+ ...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
188
+ ...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
189
+ plugin: identity.plugin,
190
+ ...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
191
+ waiting_quality: computeWaitingQuality(),
192
+ cost_quality: computeCostQuality(task),
193
+ subagent_linkage: computeSubagentLinkage(task),
194
+ };
195
+ }
196
+ /**
197
+ * Build the `task_entries` payload for an **update** request: measurement
198
+ * fields only — never `client`, `project`, `task` or `legacy_client_label`,
199
+ * so a re-sync can never undo a reassignment made in the web. See the
200
+ * module docs' CRITICAL rule.
201
+ *
202
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
203
+ * the orchestrator record itself carries a who-measured `agent` — otherwise
204
+ * they are OMITTED entirely (never sent as `ctx`'s own identity), so a
205
+ * re-sync of a legacy record by a different process never overwrites the
206
+ * row's original identity in the hub. See `domain/work-record.ts`'s
207
+ * "Measurement rules" and {@link resolveTaskIdentity}.
208
+ */
209
+ export function buildTaskEntryUpdatePayload(task, ctx) {
210
+ const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, agent, agent_version, plugin, plugin_version, ...rest } = buildTaskEntryCreatePayload(task, ctx);
211
+ if (task.orchestrator.agent === undefined)
212
+ return rest;
213
+ // `plugin`/`plugin_version` are resent only when the record itself carries
214
+ // them; a record with `agent` but no `plugin` must never have the syncing
215
+ // process's plugin written over the row's original one.
216
+ const recordHasPlugin = task.orchestrator.plugin !== undefined;
217
+ return {
218
+ ...rest,
219
+ agent,
220
+ ...(agent_version !== undefined ? { agent_version } : {}),
221
+ ...(recordHasPlugin ? { plugin } : {}),
222
+ ...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
223
+ };
224
+ }
225
+ /**
226
+ * Build one `work_records` payload from a raw {@link WorkRecord}
227
+ * (orchestrator or subagent), linked to its parent `task_entries` row by
228
+ * PocketBase id. `work_records` carries no assignment fields, so there is
229
+ * no create/update distinction here — this payload is sent as-is either way.
230
+ */
231
+ export function buildWorkRecordPayload(record, taskEntryRecordId, ctx) {
232
+ return {
233
+ kankaku_id: record.id,
234
+ task_entry: taskEntryRecordId,
235
+ rollup: false,
236
+ role: record.role,
237
+ pid: record.pid,
238
+ parent_pid: record.parentPid,
239
+ started_at: toPbDate(record.startedAt),
240
+ settled_at: toPbDate(record.settledAt),
241
+ wall_ms: record.wallMs,
242
+ waiting_ms: record.waitingMs,
243
+ work_ms: record.workMs,
244
+ runs: record.runs,
245
+ turns: record.turns,
246
+ status: record.status,
247
+ model: record.model ?? "",
248
+ ...(record.thinkingLevel !== undefined ? { thinking_level: record.thinkingLevel } : {}),
249
+ input: finiteOrZero(record.usage.input),
250
+ output: finiteOrZero(record.usage.output),
251
+ cache_read: finiteOrZero(record.usage.cacheRead),
252
+ cache_write: finiteOrZero(record.usage.cacheWrite),
253
+ cost: finiteOrZero(record.usage.cost),
254
+ segments: record.segments ?? {},
255
+ tools: record.tools,
256
+ session_id: record.sessionId ?? "",
257
+ prompt: applyPromptPrivacy(record.prompt, ctx.promptMode),
258
+ machine: ctx.machine,
259
+ schema: record.schema,
260
+ };
261
+ }
262
+ /** Every `WorkRecord` folded into a task: the orchestrator plus its subagents, in that order. */
263
+ export function taskWorkRecords(task) {
264
+ return [task.orchestrator, ...task.subagents];
265
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public library entrypoint (`kankaku-pi/domain`): the pure domain layer, with
3
+ * no I/O and no pi imports. See AGENTS.md "Architecture (hexagonal)" and
4
+ * odd/tasks/library-exports.md.
5
+ */
6
+ export * from "./ancestry-match.ts";
7
+ export * from "./client-label.ts";
8
+ export * from "./day.ts";
9
+ export * from "./export.ts";
10
+ export * from "./hub-entry.ts";
11
+ export * from "./intervals.ts";
12
+ export * from "./registry-health.ts";
13
+ export * from "./segment-rule.ts";
14
+ export * from "./subagent-profile.ts";
15
+ export * from "./sync-plan.ts";
16
+ export * from "./task-view.ts";
17
+ export * from "./work-record.ts";
18
+ export * from "./work-target.ts";
19
+ export * from "./work-tracker.ts";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public library entrypoint (`kankaku-pi/domain`): the pure domain layer, with
3
+ * no I/O and no pi imports. See AGENTS.md "Architecture (hexagonal)" and
4
+ * odd/tasks/library-exports.md.
5
+ */
6
+ export * from "./ancestry-match.js";
7
+ export * from "./client-label.js";
8
+ export * from "./day.js";
9
+ export * from "./export.js";
10
+ export * from "./hub-entry.js";
11
+ export * from "./intervals.js";
12
+ export * from "./registry-health.js";
13
+ export * from "./segment-rule.js";
14
+ export * from "./subagent-profile.js";
15
+ export * from "./sync-plan.js";
16
+ export * from "./task-view.js";
17
+ export * from "./work-record.js";
18
+ export * from "./work-target.js";
19
+ export * from "./work-tracker.js";
@@ -0,0 +1,17 @@
1
+ export interface Interval {
2
+ start: number;
3
+ end: number;
4
+ }
5
+ /**
6
+ * Restrict every interval to `[windowStart, windowEnd]` and drop any that
7
+ * become empty (or invalid) after clamping.
8
+ */
9
+ export declare function clampIntervals(intervals: Interval[], windowStart: number, windowEnd: number): Interval[];
10
+ /**
11
+ * Total duration covered by the union of the given intervals — never the
12
+ * sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
13
+ */
14
+ export declare function unionMs(intervals: Array<{
15
+ start: number;
16
+ end: number;
17
+ }>): number;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Restrict every interval to `[windowStart, windowEnd]` and drop any that
3
+ * become empty (or invalid) after clamping.
4
+ */
5
+ export function clampIntervals(intervals, windowStart, windowEnd) {
6
+ return intervals
7
+ .map((interval) => ({
8
+ start: Math.max(interval.start, windowStart),
9
+ end: Math.min(interval.end, windowEnd),
10
+ }))
11
+ .filter((interval) => interval.end > interval.start);
12
+ }
13
+ /**
14
+ * Total duration covered by the union of the given intervals — never the
15
+ * sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
16
+ */
17
+ export function unionMs(intervals) {
18
+ const sorted = [...intervals].sort((a, b) => a.start - b.start);
19
+ let total = 0;
20
+ let currentStart;
21
+ let currentEnd;
22
+ for (const interval of sorted) {
23
+ if (interval.end <= interval.start)
24
+ continue;
25
+ if (currentStart === undefined || currentEnd === undefined) {
26
+ currentStart = interval.start;
27
+ currentEnd = interval.end;
28
+ continue;
29
+ }
30
+ if (interval.start <= currentEnd) {
31
+ currentEnd = Math.max(currentEnd, interval.end);
32
+ }
33
+ else {
34
+ total += currentEnd - currentStart;
35
+ currentStart = interval.start;
36
+ currentEnd = interval.end;
37
+ }
38
+ }
39
+ if (currentStart !== undefined && currentEnd !== undefined) {
40
+ total += currentEnd - currentStart;
41
+ }
42
+ return total;
43
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Decide which machine-wide {@link RegistryEntry} rows a sweep should keep
3
+ * vs discard, and why. Pure, no I/O — `adapters/machine-process-registry.ts`
4
+ * drives this with real `isAlive`/`liveStartId`/`now`, and
5
+ * `adapters/kankaku-command.ts`'s `/kankaku doctor` reuses it (cheap mode,
6
+ * no fresh identity re-verification) to report registry health.
7
+ */
8
+ import type { RegistryEntry } from "../ports/process-registry.ts";
9
+ /** Sane last-resort ceiling on an entry's age, regardless of aliveness/identity: 7 days. */
10
+ export declare const DEFAULT_MAX_ENTRY_AGE_MS: number;
11
+ export type DiscardReason = "dead"
12
+ /** The pid is alive, but its live start identity no longer matches what this entry recorded: the OS has reused this pid for a different process instance. */
13
+ | "stale-reuse" | "over-age";
14
+ export interface RegistryClassifyDeps {
15
+ isAlive: (pid: number) => boolean;
16
+ /** Live start identity for a pid, from the same ancestry snapshot the caller already took. `undefined` means "unknown" — never treated as evidence of reuse. */
17
+ liveStartId: (pid: number) => number | undefined;
18
+ now: number;
19
+ maxAgeMs: number;
20
+ }
21
+ export interface RegistryClassification {
22
+ keep: RegistryEntry[];
23
+ discard: Array<{
24
+ entry: RegistryEntry;
25
+ reason: DiscardReason;
26
+ }>;
27
+ }
28
+ /**
29
+ * Classify every entry except `ownPid`'s (the caller's own, just-written
30
+ * entry — always kept, never re-evaluated against its own freshly-recorded
31
+ * data). An entry is discarded the first reason that applies, in this
32
+ * order: dead pid; alive but identity mismatched beyond
33
+ * {@link START_ID_TOLERANCE_MS} (pid reuse) — only ever checked when the
34
+ * entry actually carries a `processStartId`; older than `maxAgeMs`.
35
+ * Anything else is kept.
36
+ *
37
+ * An entry with no verifiable `processStartId` at all (written by a build
38
+ * predating this field, or a torn/partial write) is **never used for
39
+ * identity matching** (`domain/ancestry-match.ts#findAncestorEntry` already
40
+ * requires both sides to carry a start id) — but that alone is no longer
41
+ * grounds for deletion here (F4): a live, in-age entry that merely cannot be
42
+ * verified is kept, exactly like a verified one, so a sweep run by an
43
+ * unrelated sibling process can never un-register a genuinely live
44
+ * orchestrator whose own start-time read happened to fail. It still gets
45
+ * cleaned up the ordinary way once its pid dies or it ages out — dead and
46
+ * over-age entries are discarded regardless of whether they carry a
47
+ * `processStartId`.
48
+ */
49
+ export declare function classifyRegistryEntries(entries: RegistryEntry[], ownPid: number, deps: RegistryClassifyDeps): RegistryClassification;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Decide which machine-wide {@link RegistryEntry} rows a sweep should keep
3
+ * vs discard, and why. Pure, no I/O — `adapters/machine-process-registry.ts`
4
+ * drives this with real `isAlive`/`liveStartId`/`now`, and
5
+ * `adapters/kankaku-command.ts`'s `/kankaku doctor` reuses it (cheap mode,
6
+ * no fresh identity re-verification) to report registry health.
7
+ */
8
+ import { START_ID_TOLERANCE_MS } from "./ancestry-match.js";
9
+ /** Sane last-resort ceiling on an entry's age, regardless of aliveness/identity: 7 days. */
10
+ export const DEFAULT_MAX_ENTRY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
11
+ /**
12
+ * Classify every entry except `ownPid`'s (the caller's own, just-written
13
+ * entry — always kept, never re-evaluated against its own freshly-recorded
14
+ * data). An entry is discarded the first reason that applies, in this
15
+ * order: dead pid; alive but identity mismatched beyond
16
+ * {@link START_ID_TOLERANCE_MS} (pid reuse) — only ever checked when the
17
+ * entry actually carries a `processStartId`; older than `maxAgeMs`.
18
+ * Anything else is kept.
19
+ *
20
+ * An entry with no verifiable `processStartId` at all (written by a build
21
+ * predating this field, or a torn/partial write) is **never used for
22
+ * identity matching** (`domain/ancestry-match.ts#findAncestorEntry` already
23
+ * requires both sides to carry a start id) — but that alone is no longer
24
+ * grounds for deletion here (F4): a live, in-age entry that merely cannot be
25
+ * verified is kept, exactly like a verified one, so a sweep run by an
26
+ * unrelated sibling process can never un-register a genuinely live
27
+ * orchestrator whose own start-time read happened to fail. It still gets
28
+ * cleaned up the ordinary way once its pid dies or it ages out — dead and
29
+ * over-age entries are discarded regardless of whether they carry a
30
+ * `processStartId`.
31
+ */
32
+ export function classifyRegistryEntries(entries, ownPid, deps) {
33
+ const keep = [];
34
+ const discard = [];
35
+ for (const entry of entries) {
36
+ if (entry.pid === ownPid) {
37
+ keep.push(entry);
38
+ continue;
39
+ }
40
+ if (!deps.isAlive(entry.pid)) {
41
+ discard.push({ entry, reason: "dead" });
42
+ continue;
43
+ }
44
+ if (entry.processStartId !== undefined) {
45
+ const liveId = deps.liveStartId(entry.pid);
46
+ if (liveId !== undefined && Math.abs(liveId - entry.processStartId) > START_ID_TOLERANCE_MS) {
47
+ discard.push({ entry, reason: "stale-reuse" });
48
+ continue;
49
+ }
50
+ }
51
+ if (deps.now - Date.parse(entry.startedAt) > deps.maxAgeMs) {
52
+ discard.push({ entry, reason: "over-age" });
53
+ continue;
54
+ }
55
+ keep.push(entry);
56
+ }
57
+ return { keep, discard };
58
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A rule that tags a tool execution as belonging to a named segment (e.g.
3
+ * `review`) when the tool name matches `tool` and the tool's argument text
4
+ * matches `pattern`.
5
+ */
6
+ export interface SegmentRule {
7
+ tag: string;
8
+ tool: string;
9
+ pattern: RegExp;
10
+ }
@@ -0,0 +1 @@
1
+ export {};