kankaku 0.6.5 → 0.7.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.
@@ -2,8 +2,7 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Box, Text } from "@earendil-works/pi-tui";
4
4
  import { isValidClient } from "../domain/client-label.ts";
5
- import { exportRows, toCsv, toJson } from "../domain/export.ts";
6
- import { buildSessions, buildTasks, detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
5
+ import { detectSameProcessOverlaps, orphanSubagents, uncertainRecords } from "../domain/task-view.ts";
7
6
  import { formatWorkTargetLabel } from "../domain/work-target.ts";
8
7
  import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
9
8
  import type { SubagentProfile } from "../domain/subagent-profile.ts";
@@ -12,19 +11,9 @@ import type { SyncState } from "../domain/sync-plan.ts";
12
11
  import type { RegistryClassification } from "../domain/registry-health.ts";
13
12
  import type { Catalog } from "../ports/catalog.ts";
14
13
  import type { WorkLog } from "../ports/work-log.ts";
14
+ import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
15
+ import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
15
16
  import type { SyncSummary, SyncTrigger } from "./sync-runner.ts";
16
- import {
17
- countUncertain,
18
- formatClients,
19
- formatProjects,
20
- formatReport,
21
- formatSessions,
22
- formatTasks,
23
- localDay,
24
- summarize,
25
- summarizeByClient,
26
- summarizeByProject,
27
- } from "./report.ts";
28
17
  import type { SessionClient } from "./session-client.ts";
29
18
  import { readNonDefaultSessionDir } from "./session-dir.ts";
30
19
  import type { SessionTarget } from "./session-target.ts";
@@ -44,6 +33,167 @@ export function notifyError(ctx: ExtensionContext, error: unknown): void {
44
33
  ctx.ui.notify(`kankaku: ${message}`, "error");
45
34
  }
46
35
 
36
+ /**
37
+ * Append a {@link KankakuReportData} as a durable entry in the chat
38
+ * transcript when a UI is attached, or fall back to a one-shot notify
39
+ * otherwise. Exact body of `/kankaku`'s own `showReport` (which now
40
+ * delegates here), so the panel's "pin to chat" action
41
+ * (`screens/report.ts`, `screens/doctor.ts`) renders identically to every
42
+ * existing subcommand's report.
43
+ */
44
+ export function appendReportEntry(pi: ExtensionAPI, ctx: ExtensionContext, report: KankakuReportData): void {
45
+ if (ctx.hasUI) {
46
+ pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
47
+ return;
48
+ }
49
+ ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
50
+ }
51
+
52
+ /**
53
+ * Handle `/kankaku doctor`'s line-building: a no-network diagnostic
54
+ * (SUBAGENT-REQ-017) reporting orphan/uncertain record counts (ADR 0022)
55
+ * with why, and whether ancestor-chain detection is available on this
56
+ * platform, so silent undercount stays visible (see README "Subagents").
57
+ * Extracted from `handleDoctorCommand` so the panel's doctor screen
58
+ * (`screens/doctor.ts`) can never drift from `/kankaku doctor`'s own output.
59
+ */
60
+ export function buildDoctorLines(deps: KankakuCommandDeps, ctx: ExtensionContext): string[] {
61
+ const records = deps.log.readAll();
62
+ const orphans = orphanSubagents(records);
63
+ const uncertain = uncertainRecords(records);
64
+ const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
65
+
66
+ const lines = [
67
+ `ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
68
+ `orphan subagent record(s): ${orphans.length}` +
69
+ (orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
70
+ `uncertain record(s): ${uncertain.length}` +
71
+ (uncertain.length > 0
72
+ ? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
73
+ : ""),
74
+ ];
75
+
76
+ if (!ancestorDetectionAvailable) {
77
+ lines.push(
78
+ "on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
79
+ "an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
80
+ "session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
81
+ "the other way).",
82
+ );
83
+ }
84
+
85
+ if (deps.roleOverride) {
86
+ if (deps.childMarkerPresent) {
87
+ // R1: both signals present — the confirmed child marker always
88
+ // wins (config.ts#detectRole), so the override did not decide
89
+ // anything, whatever it said.
90
+ lines.push(
91
+ `role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
92
+ );
93
+ } else if (deps.overrideIgnoredInteractive) {
94
+ lines.push(
95
+ "role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
96
+ );
97
+ } else {
98
+ lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
99
+ }
100
+ }
101
+
102
+ // C2 item 2/3: a configured child-env marker matched but was ignored
103
+ // for this process's role because it looked interactive — a
104
+ // configured marker, unlike a built-in one, never demotes an
105
+ // interactive session. Escalated when this process also has no
106
+ // tracked ancestor at all (C2 item 3's self-check: the strongest
107
+ // signal the marker is genuinely ambient, not a real subagent
108
+ // mechanism).
109
+ if (deps.configuredMarkerIgnoredInteractive) {
110
+ lines.push(
111
+ deps.hasTrackedAncestor
112
+ ? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
113
+ : "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",
114
+ );
115
+ }
116
+
117
+ // C2 item 1: markers rejected at config load time as looking
118
+ // pi/shell/OS/npm-owned rather than genuinely child-only.
119
+ if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
120
+ for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
121
+ lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
122
+ }
123
+ }
124
+
125
+ // SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
126
+ // tool names/markers, which profile matched each subagent record, and
127
+ // any tool-name ambiguity among the active set. Omitted entirely when
128
+ // deps.subagentProfiles was not wired (back-compat).
129
+ if (deps.subagentProfiles) {
130
+ const profiles = deps.subagentProfiles;
131
+ lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
132
+
133
+ const configured = profiles.find((p) => p.id === "configured");
134
+ if (configured) {
135
+ if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
136
+ if (configured.childEnvMarkers.length > 0) {
137
+ lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
138
+ }
139
+ }
140
+
141
+ const subagentRecords = records.filter((record) => record.role === "subagent");
142
+ if (subagentRecords.length > 0) {
143
+ const counts = new Map<string, number>();
144
+ let unmatched = 0;
145
+ for (const record of subagentRecords) {
146
+ if (record.profile) {
147
+ counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
148
+ } else {
149
+ unmatched++;
150
+ }
151
+ }
152
+ const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
153
+ if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
154
+ lines.push(`profile matches: ${parts.join(", ")}`);
155
+ }
156
+
157
+ for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
158
+ lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
159
+ }
160
+ }
161
+
162
+ // SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
163
+ // never observed from any real subagent mechanism today, but flagged
164
+ // here (informational only, never changing buildTasks' own numbers) as
165
+ // the observable signature an in-process nested session would leave.
166
+ for (const overlap of detectSameProcessOverlaps(records)) {
167
+ lines.push(
168
+ `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`,
169
+ );
170
+ }
171
+
172
+ if (deps.workLogRouting?.usedFallback) {
173
+ lines.push(
174
+ `kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
175
+ "fell back to its own local worklog — this record may show as an orphan until reunited manually",
176
+ );
177
+ }
178
+
179
+ if (deps.registryHealth) {
180
+ const { keep, discard } = deps.registryHealth();
181
+ const counts = new Map<string, number>();
182
+ for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
183
+ const byReason = Array.from(counts.entries())
184
+ .map(([reason, count]) => `${reason}: ${count}`)
185
+ .join(", ");
186
+ lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
187
+ }
188
+
189
+ const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
190
+ if (sessionDir !== undefined) {
191
+ lines.push(`session dir (non-default): ${sessionDir}`);
192
+ }
193
+
194
+ return lines;
195
+ }
196
+
47
197
  const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
48
198
  /** Only offered when the hub is configured, so completions are unchanged for users without one. */
49
199
  const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
@@ -175,6 +325,14 @@ export interface KankakuCommandDeps {
175
325
  * a caller has not wired this — back-compat with an older embedder.
176
326
  */
177
327
  subagentProfiles?: SubagentProfile[];
328
+ /**
329
+ * Opens the `/kankaku` overlay panel (`adapters/panel/kankaku-panel.ts`)
330
+ * when present. `/kankaku` with no arguments prefers this over the plain
331
+ * summary report whenever a UI is attached; absent (or without a UI),
332
+ * behaviour is unchanged — the summary report, exactly as before this
333
+ * panel existed.
334
+ */
335
+ openPanel?: (ctx: ExtensionContext) => Promise<void>;
178
336
  }
179
337
 
180
338
  export interface KankakuCommand {
@@ -233,11 +391,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
233
391
  });
234
392
 
235
393
  function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
236
- if (ctx.hasUI) {
237
- pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
238
- return;
239
- }
240
- ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
394
+ appendReportEntry(pi, ctx, report);
241
395
  }
242
396
 
243
397
  /**
@@ -372,38 +526,13 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
372
526
  notifyError(ctx, new Error("hub unreachable; catalog not refreshed"));
373
527
  return;
374
528
  }
375
- showReport(ctx, {
376
- title: "catalog",
377
- lines: [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`],
378
- });
529
+ showReport(ctx, { title: "catalog", lines: formatCatalogRefreshLines(snapshot) });
379
530
  return;
380
531
  }
381
532
 
382
533
  notifyError(ctx, new Error(`unknown catalog subcommand: ${rest.join(" ")}`));
383
534
  }
384
535
 
385
- /** Render a {@link SyncSummary} as report lines: counts, any stop reason, the new watermark, and the unassigned breakdown. */
386
- function formatSyncSummary(summary: SyncSummary): string[] {
387
- const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
388
-
389
- if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
390
- if (summary.error) lines.push(`stopped early: ${summary.error}`);
391
- if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
392
-
393
- const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
394
- if (unassignedEntries.length > 0) {
395
- lines.push("unassigned (Sin determinar):");
396
- for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
397
- }
398
-
399
- if (summary.failed.length > 0) {
400
- lines.push("failed:");
401
- for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
402
- }
403
-
404
- return lines;
405
- }
406
-
407
536
  /** Handle `/kankaku sync [all|status]`; `rest` excludes the leading `sync` token. */
408
537
  async function handleSyncCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
409
538
  const sync = deps.sync;
@@ -413,13 +542,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
413
542
  }
414
543
 
415
544
  if (rest.length === 1 && rest[0] === "status") {
416
- const { state, pending, staleOutsideWindow } = sync.status();
417
- const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
418
- if (staleOutsideWindow > 0) {
419
- lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
420
- }
421
- if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
422
- showReport(ctx, { title: "sync status", lines });
545
+ showReport(ctx, { title: "sync status", lines: buildSyncStatusLines(sync.status()) });
423
546
  return;
424
547
  }
425
548
 
@@ -430,7 +553,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
430
553
 
431
554
  const full = rest[0] === "all";
432
555
  const summary = await sync.run({ full });
433
- showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummary(summary) });
556
+ showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummaryLines(summary) });
434
557
  }
435
558
 
436
559
  /** Handle `/kankaku backfill`: a full sync, reported as the "Sin determinar" breakdown that needs reassigning in the web. */
@@ -442,161 +565,16 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
442
565
  }
443
566
 
444
567
  const summary = await sync.run({ full: true });
445
- const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
446
-
447
- const lines =
448
- unassignedEntries.length > 0
449
- ? [
450
- ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
451
- "reassign these in the hub web app's unassigned queue",
452
- ]
453
- : ["no unassigned tasks"];
454
-
455
- if (summary.error) lines.push(`stopped early: ${summary.error}`);
456
- showReport(ctx, { title: "backfill", lines });
568
+ showReport(ctx, { title: "backfill", lines: formatBackfillLines(summary) });
457
569
  }
458
570
 
459
571
  /**
460
- * Handle `/kankaku doctor`: a no-network diagnostic (SUBAGENT-REQ-017)
461
- * reporting orphan/uncertain record counts (ADR 0022) with why, and
462
- * whether ancestor-chain detection is available on this platform, so
463
- * silent undercount stays visible (see README "Subagents").
572
+ * Handle `/kankaku doctor`: builds the lines (shared with the panel's
573
+ * doctor screen — see {@link buildDoctorLines}) and shows them as the
574
+ * durable report.
464
575
  */
465
576
  function handleDoctorCommand(ctx: ExtensionContext): void {
466
- const records = log.readAll();
467
- const orphans = orphanSubagents(records);
468
- const uncertain = uncertainRecords(records);
469
- const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
470
-
471
- const lines = [
472
- `ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
473
- `orphan subagent record(s): ${orphans.length}` +
474
- (orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
475
- `uncertain record(s): ${uncertain.length}` +
476
- (uncertain.length > 0
477
- ? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
478
- : ""),
479
- ];
480
-
481
- if (!ancestorDetectionAvailable) {
482
- lines.push(
483
- "on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
484
- "an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
485
- "session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
486
- "the other way).",
487
- );
488
- }
489
-
490
- if (deps.roleOverride) {
491
- if (deps.childMarkerPresent) {
492
- // R1: both signals present — the confirmed child marker always
493
- // wins (config.ts#detectRole), so the override did not decide
494
- // anything, whatever it said.
495
- lines.push(
496
- `role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
497
- );
498
- } else if (deps.overrideIgnoredInteractive) {
499
- lines.push(
500
- "role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
501
- );
502
- } else {
503
- lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
504
- }
505
- }
506
-
507
- // C2 item 2/3: a configured child-env marker matched but was ignored
508
- // for this process's role because it looked interactive — a
509
- // configured marker, unlike a built-in one, never demotes an
510
- // interactive session. Escalated when this process also has no
511
- // tracked ancestor at all (C2 item 3's self-check: the strongest
512
- // signal the marker is genuinely ambient, not a real subagent
513
- // mechanism).
514
- if (deps.configuredMarkerIgnoredInteractive) {
515
- lines.push(
516
- deps.hasTrackedAncestor
517
- ? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
518
- : "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",
519
- );
520
- }
521
-
522
- // C2 item 1: markers rejected at config load time as looking
523
- // pi/shell/OS/npm-owned rather than genuinely child-only.
524
- if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
525
- for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
526
- lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
527
- }
528
- }
529
-
530
- // SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
531
- // tool names/markers, which profile matched each subagent record, and
532
- // any tool-name ambiguity among the active set. Omitted entirely when
533
- // deps.subagentProfiles was not wired (back-compat).
534
- if (deps.subagentProfiles) {
535
- const profiles = deps.subagentProfiles;
536
- lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
537
-
538
- const configured = profiles.find((p) => p.id === "configured");
539
- if (configured) {
540
- if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
541
- if (configured.childEnvMarkers.length > 0) {
542
- lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
543
- }
544
- }
545
-
546
- const subagentRecords = records.filter((record) => record.role === "subagent");
547
- if (subagentRecords.length > 0) {
548
- const counts = new Map<string, number>();
549
- let unmatched = 0;
550
- for (const record of subagentRecords) {
551
- if (record.profile) {
552
- counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
553
- } else {
554
- unmatched++;
555
- }
556
- }
557
- const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
558
- if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
559
- lines.push(`profile matches: ${parts.join(", ")}`);
560
- }
561
-
562
- for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
563
- lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
564
- }
565
- }
566
-
567
- // SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
568
- // never observed from any real subagent mechanism today, but flagged
569
- // here (informational only, never changing buildTasks' own numbers) as
570
- // the observable signature an in-process nested session would leave.
571
- for (const overlap of detectSameProcessOverlaps(records)) {
572
- lines.push(
573
- `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`,
574
- );
575
- }
576
-
577
- if (deps.workLogRouting?.usedFallback) {
578
- lines.push(
579
- `kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
580
- "fell back to its own local worklog — this record may show as an orphan until reunited manually",
581
- );
582
- }
583
-
584
- if (deps.registryHealth) {
585
- const { keep, discard } = deps.registryHealth();
586
- const counts = new Map<string, number>();
587
- for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
588
- const byReason = Array.from(counts.entries())
589
- .map(([reason, count]) => `${reason}: ${count}`)
590
- .join(", ");
591
- lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
592
- }
593
-
594
- const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
595
- if (sessionDir !== undefined) {
596
- lines.push(`session dir (non-default): ${sessionDir}`);
597
- }
598
-
599
- showReport(ctx, { title: "doctor", lines });
577
+ showReport(ctx, { title: "doctor", lines: buildDoctorLines(deps, ctx) });
600
578
  }
601
579
 
602
580
  /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
@@ -608,14 +586,9 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
608
586
 
609
587
  const all = rest.includes("all");
610
588
  const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
611
- const records = log.readAll();
612
- const today = localDay(new Date().toISOString());
613
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
614
- const rows = exportRows(tasks);
615
- const content = format === "json" ? toJson(rows) : toCsv(rows);
616
- const name = `tasks-${all ? "all" : today}.${format}`;
589
+ const { name, content, rowCount } = buildExportContent(log.readAll(), { format, all });
617
590
  const path = deps.writeExportFile(name, content);
618
- showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
591
+ showReport(ctx, { title: "export", lines: [`wrote ${rowCount} row(s) to ${path}`] });
619
592
  }
620
593
 
621
594
  pi.registerCommand("kankaku", {
@@ -709,58 +682,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
709
682
  return;
710
683
  }
711
684
 
685
+ if (tokens.length === 0 && ctx.hasUI && deps.openPanel) {
686
+ await deps.openPanel(ctx);
687
+ return;
688
+ }
689
+
712
690
  const all = tokens.includes("all");
713
691
  const records = log.readAll();
714
- const today = localDay(new Date().toISOString());
715
692
 
716
693
  if (tokens.includes("clients")) {
717
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
718
- showReport(ctx, {
719
- title: all ? "clients (all days)" : "clients (today)",
720
- lines: formatClients(summarizeByClient(tasks)).split("\n"),
721
- });
694
+ showReport(ctx, buildClientsView(records, { all }));
722
695
  return;
723
696
  }
724
697
 
725
698
  if (tokens.includes("projects")) {
726
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
727
- showReport(ctx, {
728
- title: all ? "projects (all days)" : "projects (today)",
729
- lines: formatProjects(summarizeByProject(tasks)).split("\n"),
730
- });
699
+ showReport(ctx, buildProjectsView(records, { all }));
731
700
  return;
732
701
  }
733
702
 
734
703
  if (tokens.includes("tasks")) {
735
- const sessionId = ctx.sessionManager.getSessionId();
736
- const scoped = all || !sessionId;
737
- const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
738
- showReport(ctx, {
739
- title: scoped ? "tasks (every session)" : "tasks (this session)",
740
- lines: formatTasks(tasks).split("\n"),
741
- });
704
+ showReport(ctx, buildTasksView(records, { all, sessionId: ctx.sessionManager.getSessionId() }));
742
705
  return;
743
706
  }
744
707
 
745
708
  if (tokens.includes("sessions")) {
746
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
747
- showReport(ctx, {
748
- title: all ? "sessions (all days)" : "sessions (today)",
749
- lines: formatSessions(buildSessions(tasks)).split("\n"),
750
- });
709
+ showReport(ctx, buildSessionsView(records, { all }));
751
710
  return;
752
711
  }
753
712
 
754
- const summary = summarize(records, { all });
755
- const lines = formatReport(summary).split(" | ");
756
- const uncertainCount = countUncertain(records, { all });
757
- if (uncertainCount > 0) {
758
- lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
759
- }
760
- showReport(ctx, {
761
- title: all ? "summary (all days)" : "summary (today)",
762
- lines,
763
- });
713
+ showReport(ctx, buildSummaryView(records, { all }));
764
714
  } catch (error) {
765
715
  notifyError(ctx, error);
766
716
  }