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,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,53 @@
1
+ export interface Interval {
2
+ start: number;
3
+ end: number;
4
+ }
5
+
6
+ /**
7
+ * Restrict every interval to `[windowStart, windowEnd]` and drop any that
8
+ * become empty (or invalid) after clamping.
9
+ */
10
+ export function clampIntervals(intervals: Interval[], windowStart: number, windowEnd: number): Interval[] {
11
+ return intervals
12
+ .map((interval) => ({
13
+ start: Math.max(interval.start, windowStart),
14
+ end: Math.min(interval.end, windowEnd),
15
+ }))
16
+ .filter((interval) => interval.end > interval.start);
17
+ }
18
+
19
+ /**
20
+ * Total duration covered by the union of the given intervals — never the
21
+ * sum, so overlapping (e.g. parallel subagent) spans are not double-counted.
22
+ */
23
+ export function unionMs(intervals: Array<{ start: number; end: number }>): number {
24
+ const sorted = [...intervals].sort((a, b) => a.start - b.start);
25
+
26
+ let total = 0;
27
+ let currentStart: number | undefined;
28
+ let currentEnd: number | undefined;
29
+
30
+ for (const interval of sorted) {
31
+ if (interval.end <= interval.start) continue;
32
+
33
+ if (currentStart === undefined || currentEnd === undefined) {
34
+ currentStart = interval.start;
35
+ currentEnd = interval.end;
36
+ continue;
37
+ }
38
+
39
+ if (interval.start <= currentEnd) {
40
+ currentEnd = Math.max(currentEnd, interval.end);
41
+ } else {
42
+ total += currentEnd - currentStart;
43
+ currentStart = interval.start;
44
+ currentEnd = interval.end;
45
+ }
46
+ }
47
+
48
+ if (currentStart !== undefined && currentEnd !== undefined) {
49
+ total += currentEnd - currentStart;
50
+ }
51
+
52
+ return total;
53
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Pure model for the `/kankaku` panel (see odd/tasks/kankaku-panel.md): the
3
+ * set of screens, the root menu, an immutable navigation stack, the
4
+ * title/footer-hint text every adapter screen renders from, and pure row
5
+ * builders (e.g. {@link buildTargetRows}) that turn kankaku state into row
6
+ * models for a screen's `SettingsList`. No I/O, no pi imports — see
7
+ * AGENTS.md "Architecture (hexagonal)".
8
+ */
9
+
10
+ import type { WorkTarget } from "./work-target.ts";
11
+
12
+ export type PanelScreenId = "root" | "target" | "report" | "sync" | "export" | "doctor" | "about";
13
+
14
+ /** One row of the root menu (`rootMenu`): a section the panel can navigate to. */
15
+ export interface PanelMenuItem {
16
+ id: PanelScreenId;
17
+ label: string;
18
+ description: string;
19
+ /** Offered only when the hub (PocketBase) is configured; see {@link rootMenu}. */
20
+ hubOnly: boolean;
21
+ }
22
+
23
+ const ROOT_MENU_ITEMS: PanelMenuItem[] = [
24
+ // Not hub-only: the legacy billing label (`/kankaku client <name>`) is
25
+ // set from this same screen and works with no hub configured at all.
26
+ { id: "target", label: "Target", description: "Billing client, project, hub task, legacy label", hubOnly: false },
27
+ { id: "report", label: "Report", description: "Today/all totals, tasks, sessions, clients, projects", hubOnly: false },
28
+ { id: "sync", label: "Sync", description: "Status, sync now, sync all, backfill, catalog refresh", hubOnly: true },
29
+ { id: "export", label: "Export", description: "Write today's or every task as csv/json", hubOnly: false },
30
+ { id: "doctor", label: "Doctor", description: "Orphan/uncertain subagent counts, ancestor detection", hubOnly: false },
31
+ { id: "about", label: "About", description: "Versions, KANKAKU_DIR, env-only config", hubOnly: false },
32
+ ];
33
+
34
+ /**
35
+ * The root menu's rows, in a fixed order. `hubOnly` rows (only `sync`;
36
+ * `target` is not hub-only — see `ROOT_MENU_ITEMS`'s comment) are omitted
37
+ * entirely when the hub is not configured, so the panel offers exactly what
38
+ * `/kankaku` itself would today.
39
+ */
40
+ export function rootMenu(options: { hubConfigured: boolean }): PanelMenuItem[] {
41
+ return ROOT_MENU_ITEMS.filter((item) => options.hubConfigured || !item.hubOnly);
42
+ }
43
+
44
+ /** Immutable navigation stack: `stack[0]` is always `"root"`. */
45
+ export interface PanelNav {
46
+ stack: PanelScreenId[];
47
+ }
48
+
49
+ /** A fresh navigation stack, positioned at the root screen. */
50
+ export function navRoot(): PanelNav {
51
+ return { stack: ["root"] };
52
+ }
53
+
54
+ /** Push a screen onto the stack, returning a new {@link PanelNav}; the input is never mutated. */
55
+ export function navPush(nav: PanelNav, id: PanelScreenId): PanelNav {
56
+ return { stack: [...nav.stack, id] };
57
+ }
58
+
59
+ /**
60
+ * Pop the current screen, returning a new {@link PanelNav} and whether the
61
+ * panel should close. At the root, there is nothing left to pop: the stack
62
+ * is returned unchanged and `closed` is `true` — the caller closes the
63
+ * overlay instead of navigating.
64
+ */
65
+ export function navBack(nav: PanelNav): { nav: PanelNav; closed: boolean } {
66
+ if (nav.stack.length <= 1) {
67
+ return { nav, closed: true };
68
+ }
69
+ return { nav: { stack: nav.stack.slice(0, -1) }, closed: false };
70
+ }
71
+
72
+ /** The screen currently on top of the stack. */
73
+ export function navCurrent(nav: PanelNav): PanelScreenId {
74
+ return nav.stack[nav.stack.length - 1] ?? "root";
75
+ }
76
+
77
+ const SCREEN_TITLES: Record<Exclude<PanelScreenId, "root">, string> = {
78
+ target: "Target",
79
+ report: "Report",
80
+ sync: "Sync",
81
+ export: "Export",
82
+ doctor: "Doctor",
83
+ about: "About",
84
+ };
85
+
86
+ /** The prompt glyph that opens every panel title, the owner's mark for kankaku. */
87
+ export const PANEL_TITLE_PREFIX = ">_";
88
+
89
+ /** `>_ kankaku` at root, `>_ kankaku · <Screen>` on every other screen. */
90
+ export function panelTitle(screen: PanelScreenId): string {
91
+ if (screen === "root") return `${PANEL_TITLE_PREFIX} kankaku`;
92
+ return `${PANEL_TITLE_PREFIX} kankaku · ${SCREEN_TITLES[screen]}`;
93
+ }
94
+
95
+ /** One clickable/keyboard hint shown in the panel's footer. */
96
+ export interface PanelHint {
97
+ key: string;
98
+ label: string;
99
+ }
100
+
101
+ /**
102
+ * The footer hint row for a screen: navigation hints, an optional search
103
+ * hint when the current body supports it, and how Escape/left arrow/`q`
104
+ * behave — back at root closes the panel outright, so root shows only
105
+ * `esc close`; every other screen shows both `esc/← back` and `q close`.
106
+ */
107
+ export function footerHints(screen: PanelScreenId, options: { searchable: boolean }): PanelHint[] {
108
+ const hints: PanelHint[] = [{ key: "↑↓", label: "move" }];
109
+
110
+ if (screen === "root") {
111
+ hints.push({ key: "enter", label: "open" });
112
+ if (options.searchable) hints.push({ key: "/", label: "search" });
113
+ hints.push({ key: "esc", label: "close" });
114
+ return hints;
115
+ }
116
+
117
+ hints.push({ key: "enter", label: "select" });
118
+ if (options.searchable) hints.push({ key: "/", label: "search" });
119
+ hints.push({ key: "esc/←", label: "back" }, { key: "q", label: "close" });
120
+ return hints;
121
+ }
122
+
123
+ /** One row of a `SettingsList`-backed screen (e.g. {@link buildTargetRows}'s output). */
124
+ export interface PanelRow {
125
+ id: string;
126
+ label: string;
127
+ value: string;
128
+ description?: string;
129
+ }
130
+
131
+ const NONE_VALUE = "— none —";
132
+
133
+ /** Pure input for {@link buildTargetRows}. */
134
+ export interface TargetRowsInput {
135
+ /** Whether the hub (PocketBase) is configured; see `rootMenu`'s `target` row, which is offered either way. */
136
+ hubConfigured: boolean;
137
+ /** The current effective hub target, if any (`SessionTarget#effectiveTarget()`). */
138
+ target?: WorkTarget;
139
+ /** Which source produced {@link target} (already formatted by the caller as `"session" | "config" | "repoPaths"`), or `undefined` for none. */
140
+ source?: string;
141
+ /** The current effective legacy billing label (`SessionClient#effectiveClient()`), if any. */
142
+ legacyLabel?: string;
143
+ /** Which source produced {@link legacyLabel} (already formatted by the caller), or `undefined` for none. */
144
+ legacySource?: string;
145
+ }
146
+
147
+ /**
148
+ * Build the target screen's rows from kankaku's current billing-target
149
+ * state. Pure: the caller (`adapters/panel/screens/target.ts`) resolves
150
+ * `target`/`source`/`legacyLabel`/`legacySource` from `SessionTarget`/
151
+ * `SessionClient` and formats each source name as a plain string, so this
152
+ * function never depends on their concrete types.
153
+ *
154
+ * `client`/`project`/`task`/`source`/`remember` are included only when
155
+ * {@link TargetRowsInput.hubConfigured} is `true` — the panel's `target`
156
+ * screen is offered either way (see `rootMenu`), but those rows only make
157
+ * sense once a hub exists to resolve them against. `legacy` is always
158
+ * included: the legacy billing label works with no hub at all.
159
+ */
160
+ export function buildTargetRows(input: TargetRowsInput): PanelRow[] {
161
+ const rows: PanelRow[] = [];
162
+ const target = input.target;
163
+ const hasClient = target !== undefined;
164
+ const hasProject = target !== undefined && target.projectId !== undefined;
165
+
166
+ if (input.hubConfigured) {
167
+ rows.push({
168
+ id: "client",
169
+ label: "Client",
170
+ value: hasClient ? `${target.clientName} (${target.clientCode})` : NONE_VALUE,
171
+ });
172
+
173
+ rows.push({
174
+ id: "project",
175
+ label: "Project",
176
+ value: hasProject ? (target.projectName ?? NONE_VALUE) : NONE_VALUE,
177
+ ...(hasClient ? {} : { description: "pick a client first" }),
178
+ });
179
+
180
+ rows.push({
181
+ id: "task",
182
+ label: "Task",
183
+ value: hasProject && target.hubTaskTitle !== undefined ? target.hubTaskTitle : NONE_VALUE,
184
+ ...(hasProject ? {} : { description: "pick a project first" }),
185
+ });
186
+
187
+ rows.push({
188
+ id: "source",
189
+ label: "Source",
190
+ value: input.source ?? "none",
191
+ });
192
+
193
+ rows.push({
194
+ id: "remember",
195
+ label: "Remember",
196
+ value: "save to config.json",
197
+ description: "Saves the client and project (never the linked task) to this repository's .kankaku/config.json.",
198
+ });
199
+ }
200
+
201
+ rows.push({
202
+ id: "legacy",
203
+ label: "Legacy label",
204
+ value: input.legacyLabel ?? NONE_VALUE,
205
+ description: `Source: ${input.legacySource ?? "none"}`,
206
+ });
207
+
208
+ return rows;
209
+ }
210
+
211
+ /**
212
+ * Pure input for {@link buildAboutRows}. Every env-only value is passed in
213
+ * already extracted from `KankakuConfig` (see `config.ts`) rather than the
214
+ * config object itself: `KankakuConfig` lives outside `src/domain/`, and
215
+ * this module must import nothing but `src/domain/`/`src/ports/` (see
216
+ * AGENTS.md "Architecture (hexagonal)").
217
+ */
218
+ export interface AboutRowsInput {
219
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`). */
220
+ pluginVersion?: string;
221
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`). */
222
+ agentVersion?: string;
223
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`). */
224
+ kankakuDir: string;
225
+ /** The hub (PocketBase) URL, when configured. */
226
+ hubUrl?: string;
227
+ /** `KankakuConfig.interactiveTools`. */
228
+ interactiveTools: string[];
229
+ /** `KankakuConfig.segmentRules.length`. */
230
+ segmentRuleCount: number;
231
+ /** `KankakuConfig.subagentProfiles`' ids. */
232
+ subagentProfileNames: string[];
233
+ /** `KankakuConfig.client`. */
234
+ client?: string;
235
+ }
236
+
237
+ /**
238
+ * Build the panel's `about` screen rows: versions, the resolved directory,
239
+ * the hub URL, and every env-only setting (`KANKAKU_INTERACTIVE_TOOLS`,
240
+ * `KANKAKU_SEGMENTS`, `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV`,
241
+ * `KANKAKU_CLIENT`) with its env var name as the row's description — every
242
+ * row is read-only (see `screens/about.ts`).
243
+ */
244
+ export function buildAboutRows(input: AboutRowsInput): PanelRow[] {
245
+ return [
246
+ { id: "kankaku", label: "kankaku", value: input.pluginVersion ?? "unknown" },
247
+ { id: "pi", label: "pi", value: input.agentVersion ?? "unknown" },
248
+ { id: "directory", label: "Directory", value: input.kankakuDir },
249
+ { id: "hub", label: "Hub", value: input.hubUrl ?? "not configured" },
250
+ {
251
+ id: "interactive-tools",
252
+ label: "Interactive tools",
253
+ value: input.interactiveTools.length > 0 ? input.interactiveTools.join(", ") : NONE_VALUE,
254
+ description: "KANKAKU_INTERACTIVE_TOOLS",
255
+ },
256
+ {
257
+ id: "segments",
258
+ label: "Segments",
259
+ value: `${input.segmentRuleCount} rule(s)`,
260
+ description: "KANKAKU_SEGMENTS",
261
+ },
262
+ {
263
+ id: "subagent-profiles",
264
+ label: "Subagent profiles",
265
+ value: input.subagentProfileNames.length > 0 ? input.subagentProfileNames.join(", ") : NONE_VALUE,
266
+ description: "KANKAKU_SUBAGENT_TOOLS / KANKAKU_SUBAGENT_CHILD_ENV",
267
+ },
268
+ { id: "client", label: "Client", value: input.client ?? NONE_VALUE, description: "KANKAKU_CLIENT" },
269
+ ];
270
+ }
@@ -0,0 +1,87 @@
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
+
9
+ import { START_ID_TOLERANCE_MS } from "./ancestry-match.ts";
10
+ import type { RegistryEntry } from "../ports/process-registry.ts";
11
+
12
+ /** Sane last-resort ceiling on an entry's age, regardless of aliveness/identity: 7 days. */
13
+ export const DEFAULT_MAX_ENTRY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
14
+
15
+ export type DiscardReason =
16
+ | "dead"
17
+ /** 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. */
18
+ | "stale-reuse"
19
+ | "over-age";
20
+
21
+ export interface RegistryClassifyDeps {
22
+ isAlive: (pid: number) => boolean;
23
+ /** Live start identity for a pid, from the same ancestry snapshot the caller already took. `undefined` means "unknown" — never treated as evidence of reuse. */
24
+ liveStartId: (pid: number) => number | undefined;
25
+ now: number;
26
+ maxAgeMs: number;
27
+ }
28
+
29
+ export interface RegistryClassification {
30
+ keep: RegistryEntry[];
31
+ discard: Array<{ entry: RegistryEntry; reason: DiscardReason }>;
32
+ }
33
+
34
+ /**
35
+ * Classify every entry except `ownPid`'s (the caller's own, just-written
36
+ * entry — always kept, never re-evaluated against its own freshly-recorded
37
+ * data). An entry is discarded the first reason that applies, in this
38
+ * order: dead pid; alive but identity mismatched beyond
39
+ * {@link START_ID_TOLERANCE_MS} (pid reuse) — only ever checked when the
40
+ * entry actually carries a `processStartId`; older than `maxAgeMs`.
41
+ * Anything else is kept.
42
+ *
43
+ * An entry with no verifiable `processStartId` at all (written by a build
44
+ * predating this field, or a torn/partial write) is **never used for
45
+ * identity matching** (`domain/ancestry-match.ts#findAncestorEntry` already
46
+ * requires both sides to carry a start id) — but that alone is no longer
47
+ * grounds for deletion here (F4): a live, in-age entry that merely cannot be
48
+ * verified is kept, exactly like a verified one, so a sweep run by an
49
+ * unrelated sibling process can never un-register a genuinely live
50
+ * orchestrator whose own start-time read happened to fail. It still gets
51
+ * cleaned up the ordinary way once its pid dies or it ages out — dead and
52
+ * over-age entries are discarded regardless of whether they carry a
53
+ * `processStartId`.
54
+ */
55
+ export function classifyRegistryEntries(entries: RegistryEntry[], ownPid: number, deps: RegistryClassifyDeps): RegistryClassification {
56
+ const keep: RegistryEntry[] = [];
57
+ const discard: RegistryClassification["discard"] = [];
58
+
59
+ for (const entry of entries) {
60
+ if (entry.pid === ownPid) {
61
+ keep.push(entry);
62
+ continue;
63
+ }
64
+
65
+ if (!deps.isAlive(entry.pid)) {
66
+ discard.push({ entry, reason: "dead" });
67
+ continue;
68
+ }
69
+
70
+ if (entry.processStartId !== undefined) {
71
+ const liveId = deps.liveStartId(entry.pid);
72
+ if (liveId !== undefined && Math.abs(liveId - entry.processStartId) > START_ID_TOLERANCE_MS) {
73
+ discard.push({ entry, reason: "stale-reuse" });
74
+ continue;
75
+ }
76
+ }
77
+
78
+ if (deps.now - Date.parse(entry.startedAt) > deps.maxAgeMs) {
79
+ discard.push({ entry, reason: "over-age" });
80
+ continue;
81
+ }
82
+
83
+ keep.push(entry);
84
+ }
85
+
86
+ return { keep, discard };
87
+ }
@@ -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
+ }