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,717 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Box, Text } from "@earendil-works/pi-tui";
4
+ import { isValidClient } from "../domain/client-label.ts";
5
+ import { detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
6
+ import { formatWorkTargetLabel } from "../domain/work-target.ts";
7
+ import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
8
+ import type { SubagentProfile } from "../domain/subagent-profile.ts";
9
+ import type { RejectedChildEnvMarker } from "../config.ts";
10
+ import type { RegistryClassification } from "../domain/registry-health.ts";
11
+ import type { Catalog } from "../ports/catalog.ts";
12
+ import type { WorkLog } from "../ports/work-log.ts";
13
+ import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
14
+ import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
15
+ import type { SyncStatusSnapshot, SyncSummary, SyncTrigger } from "./sync-runner.ts";
16
+ import type { SessionClient } from "./session-client.ts";
17
+ import { readNonDefaultSessionDir } from "./session-dir.ts";
18
+ import type { SessionTarget } from "./session-target.ts";
19
+ import type { KankakuReportData } from "./report-data.ts";
20
+
21
+ export type { KankakuReportData } from "./report-data.ts";
22
+
23
+ const REPORT_ENTRY_TYPE = "kankaku-report";
24
+
25
+ /** Notify the user of an error through the UI, when one is available. */
26
+ export function notifyError(ctx: ExtensionContext, error: unknown): void {
27
+ if (!ctx.hasUI) return;
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ ctx.ui.notify(`kankaku: ${message}`, "error");
30
+ }
31
+
32
+ /**
33
+ * Append a {@link KankakuReportData} as a durable entry in the chat
34
+ * transcript when a UI is attached, or fall back to a one-shot notify
35
+ * otherwise. Exact body of `/kankaku`'s own `showReport` (which now
36
+ * delegates here), so the panel's "pin to chat" action
37
+ * (`screens/report.ts`, `screens/doctor.ts`) renders identically to every
38
+ * existing subcommand's report.
39
+ */
40
+ export function appendReportEntry(pi: ExtensionAPI, ctx: ExtensionContext, report: KankakuReportData): void {
41
+ if (ctx.hasUI) {
42
+ pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
43
+ return;
44
+ }
45
+ ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
46
+ }
47
+
48
+ /**
49
+ * Handle `/kankaku doctor`'s line-building: a no-network diagnostic
50
+ * (SUBAGENT-REQ-017) reporting orphan/uncertain record counts (ADR 0022)
51
+ * with why, and whether ancestor-chain detection is available on this
52
+ * platform, so silent undercount stays visible (see README "Subagents").
53
+ * Extracted from `handleDoctorCommand` so the panel's doctor screen
54
+ * (`screens/doctor.ts`) can never drift from `/kankaku doctor`'s own output.
55
+ */
56
+ export function buildDoctorLines(deps: KankakuCommandDeps, ctx: ExtensionContext): string[] {
57
+ const records = deps.log.readAll();
58
+ const orphans = orphanSubagents(records);
59
+ const uncertain = uncertainRecords(records);
60
+ const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
61
+
62
+ const lines = [
63
+ `ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
64
+ `orphan subagent record(s): ${orphans.length}` +
65
+ (orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
66
+ `uncertain record(s): ${uncertain.length}` +
67
+ (uncertain.length > 0
68
+ ? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
69
+ : ""),
70
+ ];
71
+
72
+ if (!ancestorDetectionAvailable) {
73
+ lines.push(
74
+ "on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
75
+ "an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
76
+ "session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
77
+ "the other way).",
78
+ );
79
+ }
80
+
81
+ if (deps.roleOverride) {
82
+ if (deps.childMarkerPresent) {
83
+ // R1: both signals present — the confirmed child marker always
84
+ // wins (config.ts#detectRole), so the override did not decide
85
+ // anything, whatever it said.
86
+ lines.push(
87
+ `role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
88
+ );
89
+ } else if (deps.overrideIgnoredInteractive) {
90
+ lines.push(
91
+ "role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
92
+ );
93
+ } else {
94
+ lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
95
+ }
96
+ }
97
+
98
+ // C2 item 2/3: a configured child-env marker matched but was ignored
99
+ // for this process's role because it looked interactive — a
100
+ // configured marker, unlike a built-in one, never demotes an
101
+ // interactive session. Escalated when this process also has no
102
+ // tracked ancestor at all (C2 item 3's self-check: the strongest
103
+ // signal the marker is genuinely ambient, not a real subagent
104
+ // mechanism).
105
+ if (deps.configuredMarkerIgnoredInteractive) {
106
+ lines.push(
107
+ deps.hasTrackedAncestor
108
+ ? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
109
+ : "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present on this interactive, TOP-LEVEL session (no tracked ancestor) — the marker is likely ambient (set on every process of its kind, not just a subagent's child), not a real subagent mechanism; resolved role: orchestrator",
110
+ );
111
+ }
112
+
113
+ // C2 item 1: markers rejected at config load time as looking
114
+ // pi/shell/OS/npm-owned rather than genuinely child-only.
115
+ if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
116
+ for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
117
+ lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
118
+ }
119
+ }
120
+
121
+ // SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
122
+ // tool names/markers, which profile matched each subagent record, and
123
+ // any tool-name ambiguity among the active set. Omitted entirely when
124
+ // deps.subagentProfiles was not wired (back-compat).
125
+ if (deps.subagentProfiles) {
126
+ const profiles = deps.subagentProfiles;
127
+ lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
128
+
129
+ const configured = profiles.find((p) => p.id === "configured");
130
+ if (configured) {
131
+ if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
132
+ if (configured.childEnvMarkers.length > 0) {
133
+ lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
134
+ }
135
+ }
136
+
137
+ const subagentRecords = records.filter((record) => record.role === "subagent");
138
+ if (subagentRecords.length > 0) {
139
+ const counts = new Map<string, number>();
140
+ let unmatched = 0;
141
+ for (const record of subagentRecords) {
142
+ if (record.profile) {
143
+ counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
144
+ } else {
145
+ unmatched++;
146
+ }
147
+ }
148
+ const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
149
+ if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
150
+ lines.push(`profile matches: ${parts.join(", ")}`);
151
+ }
152
+
153
+ for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
154
+ lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
155
+ }
156
+ }
157
+
158
+ // SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
159
+ // never observed from any real subagent mechanism today, but flagged
160
+ // here (informational only, never changing buildTasks' own numbers) as
161
+ // the observable signature an in-process nested session would leave.
162
+ for (const overlap of detectSameProcessOverlaps(records)) {
163
+ lines.push(
164
+ `likely in-process nesting: pid ${overlap.pid} has ${overlap.recordIds.length} overlapping orchestrator records (${overlap.recordIds.join(", ")}) — union of their wall time is ${overlap.unionedWallMs}ms`,
165
+ );
166
+ }
167
+
168
+ if (deps.workLogRouting?.usedFallback) {
169
+ lines.push(
170
+ `kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
171
+ "fell back to its own local worklog — this record may show as an orphan until reunited manually",
172
+ );
173
+ }
174
+
175
+ if (deps.registryHealth) {
176
+ const { keep, discard } = deps.registryHealth();
177
+ const counts = new Map<string, number>();
178
+ for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
179
+ const byReason = Array.from(counts.entries())
180
+ .map(([reason, count]) => `${reason}: ${count}`)
181
+ .join(", ");
182
+ lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
183
+ }
184
+
185
+ const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
186
+ if (sessionDir !== undefined) {
187
+ lines.push(`session dir (non-default): ${sessionDir}`);
188
+ }
189
+
190
+ return lines;
191
+ }
192
+
193
+ const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
194
+ /** Only offered when the hub is configured, so completions are unchanged for users without one. */
195
+ const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
196
+ const TARGET_TOKENS = ["pick", "clear"];
197
+ const TASK_TOKENS = ["pick", "clear"];
198
+ const CATALOG_TOKENS = ["refresh"];
199
+ const SYNC_TOKENS = ["all", "status"];
200
+
201
+ /** Drives `/kankaku sync [all|status]` and `/kankaku backfill`. Present only when the hub is configured. */
202
+ export interface SyncCommandDeps {
203
+ /**
204
+ * Run one sync pass; `full: true` re-evaluates every task (`/kankaku
205
+ * sync all`, `/kankaku backfill`). `trigger`, left unset here (a manual
206
+ * command), marks the automatic `session_start`/`agent_settled` path
207
+ * (`pi-tracker.ts`) so its version short-circuit and throttle never
208
+ * apply to a manual sync. Never throws.
209
+ */
210
+ run: (options?: { full?: boolean; trigger?: SyncTrigger }) => Promise<SyncSummary>;
211
+ /**
212
+ * `/kankaku sync status`: the persisted state, a locally-computed pending
213
+ * count, and (R3) how many tasks changed since their last sync but fall
214
+ * outside this run's revisit window — needs `sync all`. No network.
215
+ */
216
+ status: () => SyncStatusSnapshot;
217
+ }
218
+
219
+ export interface KankakuCommandDeps {
220
+ log: WorkLog;
221
+ sessionClient: SessionClient;
222
+ /** Refresh the idle status line, e.g. after `/kankaku client` changes the session client. */
223
+ refreshIdleStatus: (ctx: ExtensionContext) => void;
224
+ /**
225
+ * Write an export file (name, content) under the kankaku dir and return
226
+ * its absolute path. `/kankaku export` notifies an error when this is not
227
+ * configured.
228
+ */
229
+ writeExportFile?: (name: string, content: string) => string;
230
+ /**
231
+ * Present only when the hub (PocketBase) is configured. Drives `/kankaku
232
+ * target [pick|clear]` and makes `/kankaku client <name>` validate
233
+ * against the catalog instead of accepting free text.
234
+ */
235
+ sessionTarget?: SessionTarget;
236
+ /** Present only when the hub is configured. Drives `/kankaku catalog refresh` and the hub-aware `/kankaku client <name>`. */
237
+ catalog?: Catalog;
238
+ /** Present only when the hub is configured. Drives `/kankaku sync [all|status]` and `/kankaku backfill`. */
239
+ sync?: SyncCommandDeps;
240
+ /**
241
+ * Read-only machine-wide process-registry health snapshot (see
242
+ * `adapters/machine-process-registry.ts#health`), for `/kankaku doctor`
243
+ * (SUBAGENT-REQ-017): how many entries would be kept vs discarded, and
244
+ * why. Absent entirely when the registry is unavailable for some reason
245
+ * kankaku itself could not construct (never expected in practice, since
246
+ * `MachineProcessRegistry` always degrades gracefully on its own) —
247
+ * doctor then simply omits that section rather than guessing.
248
+ */
249
+ registryHealth?: () => RegistryClassification;
250
+ /**
251
+ * Whether the OS ancestor-chain mechanism itself is actually usable right
252
+ * now (F2): `false` on a platform with no supported mechanism (Windows)
253
+ * *or* when the mechanism is available but a fresh attempt still fails
254
+ * (`ps`/`/proc` missing, timing out, or producing unreadable output) —
255
+ * both collapse to the same "could not check" state, distinct from
256
+ * "checked, no tracked ancestor found". Never folded into
257
+ * `WorkRecord.roleConfidence`: an unprovable ancestor never demotes a
258
+ * process to `uncertain` (see `config.ts#detectRole`'s doc comment) — this
259
+ * is purely a visibility signal for the doctor's own report. Falls back
260
+ * to a bare `process.platform !== "win32"` check when not provided
261
+ * (back-compat with a caller that has not wired the real, ps/proc-aware
262
+ * check yet).
263
+ */
264
+ ancestorDetectionAvailable?: () => boolean;
265
+ /**
266
+ * `KANKAKU_ROLE`, when it held a recognised value for this process (F3's
267
+ * explicit escape hatch) — reported by doctor as the deciding signal for
268
+ * this process's role, UNLESS `childMarkerPresent` or
269
+ * `overrideIgnoredInteractive` below says otherwise (R1): the override no
270
+ * longer beats every other detection signal unconditionally.
271
+ */
272
+ roleOverride?: "orchestrator" | "subagent";
273
+ /**
274
+ * Whether `GENTLE_PI_AGENTS_CHILD=1` (the confirmed child marker) was
275
+ * also present on this process (R1) — when both it and `roleOverride`
276
+ * are set, the marker always wins (`config.ts#detectRole`'s precedence),
277
+ * so doctor flags the contradiction with the resolved outcome instead of
278
+ * claiming the override decided anything.
279
+ */
280
+ childMarkerPresent?: boolean;
281
+ /**
282
+ * Set when `KANKAKU_ROLE=subagent` was present, with no confirmed child
283
+ * marker, but was ignored because this process looked interactive (R1) —
284
+ * doctor reports the resolved outcome (orchestrator) instead of claiming
285
+ * the override decided this process's role.
286
+ */
287
+ overrideIgnoredInteractive?: boolean;
288
+ /**
289
+ * C2 (CRITICAL fix): set when a USER-CONFIGURED child-env marker
290
+ * (`KANKAKU_SUBAGENT_CHILD_ENV`) matched, but was ignored because this
291
+ * process looked interactive — a configured marker, unlike a built-in
292
+ * one, never demotes an interactive session. Doctor escalates the
293
+ * wording when `hasTrackedAncestor` is also `false` (C2 item 3's
294
+ * self-check: the strongest signal the marker is genuinely ambient).
295
+ */
296
+ configuredMarkerIgnoredInteractive?: boolean;
297
+ /** C2 item 3: whether a live tracked ancestor was found for this process — see `configuredMarkerIgnoredInteractive` above. */
298
+ hasTrackedAncestor?: boolean;
299
+ /**
300
+ * C2 (CRITICAL fix, item 1): every `KANKAKU_SUBAGENT_CHILD_ENV` marker
301
+ * `config.ts#validateSubagentChildEnvMarkers` rejected as looking
302
+ * pi/shell/OS/npm-owned rather than genuinely child-only.
303
+ */
304
+ rejectedSubagentChildEnvMarkers?: RejectedChildEnvMarker[];
305
+ /**
306
+ * Set only when this process is itself a subagent whose work log/inflight
307
+ * checkpoints were routed to its orchestrator's kankaku directory (F1, ADR
308
+ * 0023's rewrite): `usedFallback: true` means the orchestrator's
309
+ * directory could not be written to (gone, or no permission) and this
310
+ * process fell back to its own local directory instead — surfaced here so
311
+ * a human can notice and go reunite the record manually, since the
312
+ * append-only log can never be rewritten to fix it after the fact.
313
+ */
314
+ workLogRouting?: { usedFallback: boolean; parentDir: string };
315
+ /**
316
+ * The full active {@link SubagentProfile} set (`config.ts#loadConfig`'s
317
+ * `subagentProfiles`), for `/kankaku doctor` (SUBAGENT-REQ-001/002/003/005/017):
318
+ * which profiles are active, any configured tool names/child-env markers,
319
+ * which profile matched each subagent record, and any tool-name ambiguity
320
+ * among the active set. Omitted entirely (no profile section at all) when
321
+ * a caller has not wired this — back-compat with an older embedder.
322
+ */
323
+ subagentProfiles?: SubagentProfile[];
324
+ /**
325
+ * Opens the `/kankaku` overlay panel (`adapters/panel/kankaku-panel.ts`)
326
+ * when present. `/kankaku` with no arguments prefers this over the plain
327
+ * summary report whenever a UI is attached; absent (or without a UI),
328
+ * behaviour is unchanged — the summary report, exactly as before this
329
+ * panel existed.
330
+ */
331
+ openPanel?: (ctx: ExtensionContext) => Promise<void>;
332
+ }
333
+
334
+ export interface KankakuCommand {
335
+ /** Drop the cached client-name list so the next completion re-reads the log. */
336
+ invalidateClientNames(): void;
337
+ }
338
+
339
+ /**
340
+ * Registers the `/kankaku` command (report/tasks/sessions/clients/export/
341
+ * client), its argument completions, and the durable report entry renderer.
342
+ */
343
+ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDeps): KankakuCommand {
344
+ const { log, sessionClient } = deps;
345
+
346
+ /**
347
+ * Cached, sorted, de-duplicated client names for `/kankaku client <prefix>`
348
+ * autocomplete, so pressing a key does not re-read the whole worklog.
349
+ * Invalidated whenever this process appends a record, and — when `log`
350
+ * exposes the optional `version()` signal — whenever that signal changes,
351
+ * so a change from another process is picked up too.
352
+ */
353
+ let clientNamesCache: string[] | undefined;
354
+ let clientNamesCacheVersion: string | number | undefined;
355
+
356
+ function invalidateClientNames(): void {
357
+ clientNamesCache = undefined;
358
+ }
359
+
360
+ function clientNames(): string[] {
361
+ const currentVersion = log.version?.();
362
+ const versionUnchanged = log.version === undefined || currentVersion === clientNamesCacheVersion;
363
+ if (clientNamesCache !== undefined && versionUnchanged) {
364
+ return clientNamesCache;
365
+ }
366
+ const names = Array.from(
367
+ new Set(
368
+ log
369
+ .readAll()
370
+ .map((record) => record.client)
371
+ .filter((client): client is string => typeof client === "string"),
372
+ ),
373
+ ).sort();
374
+ clientNamesCache = names;
375
+ clientNamesCacheVersion = currentVersion;
376
+ return names;
377
+ }
378
+
379
+ pi.registerEntryRenderer<KankakuReportData>(REPORT_ENTRY_TYPE, (entry, _options, theme) => {
380
+ const data = entry.data ?? { title: "kankaku", lines: [] };
381
+ const box = new Box(1, 0, (text) => theme.bg("customMessageBg", text));
382
+ box.addChild(new Text(`${theme.fg("accent", "kankaku")} ${data.title}`, 0, 0));
383
+ for (const line of data.lines) {
384
+ box.addChild(new Text(line, 0, 0));
385
+ }
386
+ return box;
387
+ });
388
+
389
+ function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
390
+ appendReportEntry(pi, ctx, report);
391
+ }
392
+
393
+ /**
394
+ * Handle `/kankaku client [<name> | --clear]`; `rest` excludes the
395
+ * leading `client` token. When the hub is configured, setting a name
396
+ * (not `--clear` or empty) is the legacy compatibility path: it
397
+ * validates against the catalog (case-insensitive exact match of a
398
+ * client `code` or `name`) and sets the session hub target with no
399
+ * project, instead of the free-text legacy client. `--clear` and the
400
+ * no-argument report stay on the legacy client for both cases.
401
+ */
402
+ function handleClientCommand(rest: string[], ctx: ExtensionContext): void {
403
+ if (rest.length === 1 && rest[0] === "--clear") {
404
+ sessionClient.set(pi, undefined);
405
+ deps.refreshIdleStatus(ctx);
406
+ showReport(ctx, { title: "client", lines: ["client label cleared for this session"] });
407
+ return;
408
+ }
409
+
410
+ if (rest.length === 0) {
411
+ const client = sessionClient.effectiveClient();
412
+ const source = sessionClient.effectiveSource();
413
+ const line = client !== undefined ? `client: ${client} (from ${source})` : "client: none";
414
+ showReport(ctx, { title: "client", lines: [line] });
415
+ return;
416
+ }
417
+
418
+ const name = rest.join(" ");
419
+
420
+ if (deps.sessionTarget) {
421
+ const clients = (deps.catalog?.read()?.clients ?? []).filter((client) => client.active && !client.unassigned);
422
+ const lowerName = name.toLowerCase();
423
+ const match = clients.find((client) => client.code.toLowerCase() === lowerName || client.name.toLowerCase() === lowerName);
424
+ if (!match) {
425
+ const validCodes = clients
426
+ .map((client) => client.code)
427
+ .sort()
428
+ .join(", ");
429
+ notifyError(ctx, new Error(`unknown client: ${name}${validCodes ? ` (valid: ${validCodes})` : ""}`));
430
+ return;
431
+ }
432
+ deps.sessionTarget.setExplicit(pi, { clientId: match.id });
433
+ deps.refreshIdleStatus(ctx);
434
+ showReport(ctx, { title: "client", lines: [`client set to ${match.name} (${match.code})`] });
435
+ return;
436
+ }
437
+
438
+ if (!isValidClient(name)) {
439
+ notifyError(ctx, new Error(`invalid client name: ${name}`));
440
+ return;
441
+ }
442
+ sessionClient.set(pi, name);
443
+ deps.refreshIdleStatus(ctx);
444
+ showReport(ctx, { title: "client", lines: [`client set to ${name}`] });
445
+ }
446
+
447
+ /** Handle `/kankaku target [pick|clear]`; `rest` excludes the leading `target` token. */
448
+ async function handleTargetCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
449
+ const sessionTarget = deps.sessionTarget;
450
+ if (!sessionTarget) {
451
+ notifyError(ctx, new Error("hub is not configured"));
452
+ return;
453
+ }
454
+
455
+ if (rest.length === 1 && rest[0] === "clear") {
456
+ sessionTarget.clear(pi);
457
+ deps.refreshIdleStatus(ctx);
458
+ showReport(ctx, { title: "target", lines: ["target cleared for this session"] });
459
+ return;
460
+ }
461
+
462
+ if (rest.length === 1 && rest[0] === "pick") {
463
+ await sessionTarget.pick(pi, ctx);
464
+ deps.refreshIdleStatus(ctx);
465
+ const target = sessionTarget.effectiveTarget();
466
+ const line = target ? `target set to ${formatWorkTargetLabel(target)}` : "target skipped";
467
+ showReport(ctx, { title: "target", lines: [line] });
468
+ return;
469
+ }
470
+
471
+ if (rest.length === 0) {
472
+ const target = sessionTarget.effectiveTarget();
473
+ const source = sessionTarget.effectiveSource();
474
+ const line = target !== undefined ? `target: ${formatWorkTargetLabel(target)} (from ${source})` : "target: none";
475
+ showReport(ctx, { title: "target", lines: [line] });
476
+ return;
477
+ }
478
+
479
+ notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
480
+ }
481
+
482
+ /**
483
+ * Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
484
+ * token. Default (no args) is `pick`. `pickTask`/`clearTask`
485
+ * (`session-target.ts`) notify their own outcome directly, so this only
486
+ * dispatches and refreshes the idle status line.
487
+ */
488
+ async function handleTaskCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
489
+ const sessionTarget = deps.sessionTarget;
490
+ if (!sessionTarget) {
491
+ notifyError(ctx, new Error("hub is not configured"));
492
+ return;
493
+ }
494
+
495
+ if (rest.length === 1 && rest[0] === "clear") {
496
+ sessionTarget.clearTask(pi);
497
+ deps.refreshIdleStatus(ctx);
498
+ showReport(ctx, { title: "task", lines: ["task link cleared for this session"] });
499
+ return;
500
+ }
501
+
502
+ if (rest.length === 0 || (rest.length === 1 && rest[0] === "pick")) {
503
+ await sessionTarget.pickTask(pi, ctx);
504
+ deps.refreshIdleStatus(ctx);
505
+ return;
506
+ }
507
+
508
+ notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
509
+ }
510
+
511
+ /** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
512
+ async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
513
+ const catalog = deps.catalog;
514
+ if (!catalog) {
515
+ notifyError(ctx, new Error("hub is not configured"));
516
+ return;
517
+ }
518
+
519
+ if (rest.length === 1 && rest[0] === "refresh") {
520
+ const snapshot = await catalog.refresh();
521
+ if (!snapshot) {
522
+ notifyError(ctx, new Error("hub unreachable; catalog not refreshed"));
523
+ return;
524
+ }
525
+ showReport(ctx, { title: "catalog", lines: formatCatalogRefreshLines(snapshot) });
526
+ return;
527
+ }
528
+
529
+ notifyError(ctx, new Error(`unknown catalog subcommand: ${rest.join(" ")}`));
530
+ }
531
+
532
+ /** Handle `/kankaku sync [all|status]`; `rest` excludes the leading `sync` token. */
533
+ async function handleSyncCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
534
+ const sync = deps.sync;
535
+ if (!sync) {
536
+ notifyError(ctx, new Error("hub is not configured"));
537
+ return;
538
+ }
539
+
540
+ if (rest.length === 1 && rest[0] === "status") {
541
+ showReport(ctx, { title: "sync status", lines: buildSyncStatusLines(sync.status()) });
542
+ return;
543
+ }
544
+
545
+ if (rest.length > 0 && !(rest.length === 1 && rest[0] === "all")) {
546
+ notifyError(ctx, new Error(`unknown sync subcommand: ${rest.join(" ")}`));
547
+ return;
548
+ }
549
+
550
+ const full = rest[0] === "all";
551
+ const summary = await sync.run({ full });
552
+ showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummaryLines(summary) });
553
+ }
554
+
555
+ /** Handle `/kankaku backfill`: a full sync, reported as the "Sin determinar" breakdown that needs reassigning in the web. */
556
+ async function handleBackfillCommand(ctx: ExtensionContext): Promise<void> {
557
+ const sync = deps.sync;
558
+ if (!sync) {
559
+ notifyError(ctx, new Error("hub is not configured"));
560
+ return;
561
+ }
562
+
563
+ const summary = await sync.run({ full: true });
564
+ showReport(ctx, { title: "backfill", lines: formatBackfillLines(summary) });
565
+ }
566
+
567
+ /**
568
+ * Handle `/kankaku doctor`: builds the lines (shared with the panel's
569
+ * doctor screen — see {@link buildDoctorLines}) and shows them as the
570
+ * durable report.
571
+ */
572
+ function handleDoctorCommand(ctx: ExtensionContext): void {
573
+ showReport(ctx, { title: "doctor", lines: buildDoctorLines(deps, ctx) });
574
+ }
575
+
576
+ /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
577
+ function handleExportCommand(rest: string[], ctx: ExtensionContext): void {
578
+ if (!deps.writeExportFile) {
579
+ notifyError(ctx, new Error("export is not configured"));
580
+ return;
581
+ }
582
+
583
+ const all = rest.includes("all");
584
+ const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
585
+ const { name, content, rowCount } = buildExportContent(log.readAll(), { format, all });
586
+ const path = deps.writeExportFile(name, content);
587
+ showReport(ctx, { title: "export", lines: [`wrote ${rowCount} row(s) to ${path}`] });
588
+ }
589
+
590
+ pi.registerCommand("kankaku", {
591
+ description:
592
+ "Show kankaku work-time totals for today. Args (any order): 'all' for every record, " +
593
+ "'tasks' for this session's tasks ('tasks all' for every session), 'sessions' for today's sessions, " +
594
+ "'client <name>' to set the session billing client, 'client' to show the effective one and its source, " +
595
+ "'client --clear' to clear it, 'clients' for per-client totals today ('clients all' for every day), " +
596
+ "'export [csv|json] [all]' to write today's (or every) task as a file. " +
597
+ "'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
598
+ "When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
599
+ "'target pick' to run the picker again, 'target clear' to clear the session target, " +
600
+ "'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
601
+ "'task clear' to drop the link, " +
602
+ "'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
603
+ "'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
604
+ "'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
605
+ "With a hub configured, 'client <name>' instead validates against the catalog (code or name) and sets the target.",
606
+ getArgumentCompletions: (argumentPrefix: string): AutocompleteItem[] => {
607
+ const clientMatch = /^client\s+(\S*)$/.exec(argumentPrefix);
608
+ if (clientMatch) {
609
+ const prefix = clientMatch[1] ?? "";
610
+ return clientNames()
611
+ .filter((name) => name.startsWith(prefix))
612
+ .map((name) => ({ value: name, label: name }));
613
+ }
614
+ const targetMatch = /^target\s+(\S*)$/.exec(argumentPrefix);
615
+ if (targetMatch) {
616
+ const prefix = targetMatch[1] ?? "";
617
+ return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
618
+ }
619
+ const taskMatch = /^task\s+(\S*)$/.exec(argumentPrefix);
620
+ if (taskMatch) {
621
+ const prefix = taskMatch[1] ?? "";
622
+ return TASK_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
623
+ }
624
+ const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
625
+ if (catalogMatch) {
626
+ const prefix = catalogMatch[1] ?? "";
627
+ return CATALOG_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
628
+ }
629
+ const syncMatch = /^sync\s+(\S*)$/.exec(argumentPrefix);
630
+ if (syncMatch) {
631
+ const prefix = syncMatch[1] ?? "";
632
+ return SYNC_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
633
+ }
634
+ const tokens = deps.sessionTarget ? [...COMMAND_TOKENS, ...HUB_COMMAND_TOKENS] : COMMAND_TOKENS;
635
+ return tokens.filter((value) => value.startsWith(argumentPrefix)).map((value) => ({ value, label: value }));
636
+ },
637
+ handler: async (args, ctx) => {
638
+ try {
639
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
640
+
641
+ if (tokens[0] === "client") {
642
+ handleClientCommand(tokens.slice(1), ctx);
643
+ return;
644
+ }
645
+
646
+ if (tokens[0] === "export") {
647
+ handleExportCommand(tokens.slice(1), ctx);
648
+ return;
649
+ }
650
+
651
+ if (tokens[0] === "target") {
652
+ await handleTargetCommand(tokens.slice(1), ctx);
653
+ return;
654
+ }
655
+
656
+ if (tokens[0] === "task") {
657
+ await handleTaskCommand(tokens.slice(1), ctx);
658
+ return;
659
+ }
660
+
661
+ if (tokens[0] === "catalog") {
662
+ await handleCatalogCommand(tokens.slice(1), ctx);
663
+ return;
664
+ }
665
+
666
+ if (tokens[0] === "sync") {
667
+ await handleSyncCommand(tokens.slice(1), ctx);
668
+ return;
669
+ }
670
+
671
+ if (tokens[0] === "backfill") {
672
+ await handleBackfillCommand(ctx);
673
+ return;
674
+ }
675
+
676
+ if (tokens[0] === "doctor") {
677
+ handleDoctorCommand(ctx);
678
+ return;
679
+ }
680
+
681
+ if (tokens.length === 0 && ctx.hasUI && deps.openPanel) {
682
+ await deps.openPanel(ctx);
683
+ return;
684
+ }
685
+
686
+ const all = tokens.includes("all");
687
+ const records = log.readAll();
688
+
689
+ if (tokens.includes("clients")) {
690
+ showReport(ctx, buildClientsView(records, { all }));
691
+ return;
692
+ }
693
+
694
+ if (tokens.includes("projects")) {
695
+ showReport(ctx, buildProjectsView(records, { all }));
696
+ return;
697
+ }
698
+
699
+ if (tokens.includes("tasks")) {
700
+ showReport(ctx, buildTasksView(records, { all, sessionId: ctx.sessionManager.getSessionId() }));
701
+ return;
702
+ }
703
+
704
+ if (tokens.includes("sessions")) {
705
+ showReport(ctx, buildSessionsView(records, { all }));
706
+ return;
707
+ }
708
+
709
+ showReport(ctx, buildSummaryView(records, { all }));
710
+ } catch (error) {
711
+ notifyError(ctx, error);
712
+ }
713
+ },
714
+ });
715
+
716
+ return { invalidateClientNames };
717
+ }