kankaku 0.6.0 → 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.
Files changed (44) hide show
  1. package/README.md +103 -14
  2. package/dist/adapters/cached-catalog.d.ts +8 -2
  3. package/dist/adapters/cached-catalog.js +13 -3
  4. package/dist/adapters/pocketbase-catalog.d.ts +7 -5
  5. package/dist/adapters/pocketbase-catalog.js +22 -5
  6. package/dist/adapters/pocketbase-sink.d.ts +2 -1
  7. package/dist/adapters/pocketbase-sink.js +2 -1
  8. package/dist/domain/hub-entry.d.ts +12 -3
  9. package/dist/domain/hub-entry.js +14 -4
  10. package/dist/domain/task-view.d.ts +4 -0
  11. package/dist/domain/task-view.js +2 -0
  12. package/dist/domain/work-record.d.ts +18 -0
  13. package/dist/domain/work-record.js +18 -0
  14. package/dist/domain/work-target.d.ts +30 -1
  15. package/dist/domain/work-target.js +33 -11
  16. package/dist/ports/catalog.d.ts +4 -2
  17. package/package.json +1 -1
  18. package/src/adapters/cached-catalog.ts +22 -6
  19. package/src/adapters/hub-actions.ts +83 -0
  20. package/src/adapters/kankaku-command.ts +236 -244
  21. package/src/adapters/panel/kankaku-panel.ts +274 -0
  22. package/src/adapters/panel/panel-items.ts +77 -0
  23. package/src/adapters/panel/panel-lines.ts +13 -0
  24. package/src/adapters/panel/panel-theme.ts +32 -0
  25. package/src/adapters/panel/screens/about.ts +69 -0
  26. package/src/adapters/panel/screens/doctor.ts +85 -0
  27. package/src/adapters/panel/screens/export.ts +119 -0
  28. package/src/adapters/panel/screens/report.ts +141 -0
  29. package/src/adapters/panel/screens/sync.ts +126 -0
  30. package/src/adapters/panel/screens/target.ts +367 -0
  31. package/src/adapters/pi-tracker.ts +110 -6
  32. package/src/adapters/pocketbase-catalog.ts +36 -7
  33. package/src/adapters/pocketbase-sink.ts +15 -3
  34. package/src/adapters/report-views.ts +98 -0
  35. package/src/adapters/report.ts +66 -16
  36. package/src/adapters/session-target.ts +111 -4
  37. package/src/adapters/target-picker.ts +46 -1
  38. package/src/domain/hub-entry.ts +20 -5
  39. package/src/domain/panel-model.ts +267 -0
  40. package/src/domain/task-view.ts +6 -0
  41. package/src/domain/work-record.ts +23 -0
  42. package/src/domain/work-target.ts +60 -11
  43. package/src/extension.ts +19 -8
  44. package/src/ports/catalog.ts +4 -2
@@ -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,10 +33,172 @@ 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
- const HUB_COMMAND_TOKENS = ["target", "projects", "catalog", "sync", "backfill"];
199
+ const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
50
200
  const TARGET_TOKENS = ["pick", "clear"];
201
+ const TASK_TOKENS = ["pick", "clear"];
51
202
  const CATALOG_TOKENS = ["refresh"];
52
203
  const SYNC_TOKENS = ["all", "status"];
53
204
 
@@ -174,6 +325,14 @@ export interface KankakuCommandDeps {
174
325
  * a caller has not wired this — back-compat with an older embedder.
175
326
  */
176
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>;
177
336
  }
178
337
 
179
338
  export interface KankakuCommand {
@@ -232,11 +391,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
232
391
  });
233
392
 
234
393
  function showReport(ctx: ExtensionContext, report: KankakuReportData): void {
235
- if (ctx.hasUI) {
236
- pi.appendEntry<KankakuReportData>(REPORT_ENTRY_TYPE, report);
237
- return;
238
- }
239
- ctx.ui.notify(`${report.title}\n${report.lines.join("\n")}`);
394
+ appendReportEntry(pi, ctx, report);
240
395
  }
241
396
 
242
397
  /**
@@ -328,6 +483,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
328
483
  notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
329
484
  }
330
485
 
486
+ /**
487
+ * Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
488
+ * token. Default (no args) is `pick`. `pickTask`/`clearTask`
489
+ * (`session-target.ts`) notify their own outcome directly, so this only
490
+ * dispatches and refreshes the idle status line.
491
+ */
492
+ async function handleTaskCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
493
+ const sessionTarget = deps.sessionTarget;
494
+ if (!sessionTarget) {
495
+ notifyError(ctx, new Error("hub is not configured"));
496
+ return;
497
+ }
498
+
499
+ if (rest.length === 1 && rest[0] === "clear") {
500
+ sessionTarget.clearTask(pi);
501
+ deps.refreshIdleStatus(ctx);
502
+ showReport(ctx, { title: "task", lines: ["task link cleared for this session"] });
503
+ return;
504
+ }
505
+
506
+ if (rest.length === 0 || (rest.length === 1 && rest[0] === "pick")) {
507
+ await sessionTarget.pickTask(pi, ctx);
508
+ deps.refreshIdleStatus(ctx);
509
+ return;
510
+ }
511
+
512
+ notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
513
+ }
514
+
331
515
  /** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
332
516
  async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
333
517
  const catalog = deps.catalog;
@@ -342,38 +526,13 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
342
526
  notifyError(ctx, new Error("hub unreachable; catalog not refreshed"));
343
527
  return;
344
528
  }
345
- showReport(ctx, {
346
- title: "catalog",
347
- lines: [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`],
348
- });
529
+ showReport(ctx, { title: "catalog", lines: formatCatalogRefreshLines(snapshot) });
349
530
  return;
350
531
  }
351
532
 
352
533
  notifyError(ctx, new Error(`unknown catalog subcommand: ${rest.join(" ")}`));
353
534
  }
354
535
 
355
- /** Render a {@link SyncSummary} as report lines: counts, any stop reason, the new watermark, and the unassigned breakdown. */
356
- function formatSyncSummary(summary: SyncSummary): string[] {
357
- const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
358
-
359
- if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
360
- if (summary.error) lines.push(`stopped early: ${summary.error}`);
361
- if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
362
-
363
- const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
364
- if (unassignedEntries.length > 0) {
365
- lines.push("unassigned (Sin determinar):");
366
- for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
367
- }
368
-
369
- if (summary.failed.length > 0) {
370
- lines.push("failed:");
371
- for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
372
- }
373
-
374
- return lines;
375
- }
376
-
377
536
  /** Handle `/kankaku sync [all|status]`; `rest` excludes the leading `sync` token. */
378
537
  async function handleSyncCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
379
538
  const sync = deps.sync;
@@ -383,13 +542,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
383
542
  }
384
543
 
385
544
  if (rest.length === 1 && rest[0] === "status") {
386
- const { state, pending, staleOutsideWindow } = sync.status();
387
- const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
388
- if (staleOutsideWindow > 0) {
389
- lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
390
- }
391
- if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
392
- showReport(ctx, { title: "sync status", lines });
545
+ showReport(ctx, { title: "sync status", lines: buildSyncStatusLines(sync.status()) });
393
546
  return;
394
547
  }
395
548
 
@@ -400,7 +553,7 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
400
553
 
401
554
  const full = rest[0] === "all";
402
555
  const summary = await sync.run({ full });
403
- showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummary(summary) });
556
+ showReport(ctx, { title: full ? "sync (all)" : "sync", lines: formatSyncSummaryLines(summary) });
404
557
  }
405
558
 
406
559
  /** Handle `/kankaku backfill`: a full sync, reported as the "Sin determinar" breakdown that needs reassigning in the web. */
@@ -412,161 +565,16 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
412
565
  }
413
566
 
414
567
  const summary = await sync.run({ full: true });
415
- const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
416
-
417
- const lines =
418
- unassignedEntries.length > 0
419
- ? [
420
- ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
421
- "reassign these in the hub web app's unassigned queue",
422
- ]
423
- : ["no unassigned tasks"];
424
-
425
- if (summary.error) lines.push(`stopped early: ${summary.error}`);
426
- showReport(ctx, { title: "backfill", lines });
568
+ showReport(ctx, { title: "backfill", lines: formatBackfillLines(summary) });
427
569
  }
428
570
 
429
571
  /**
430
- * Handle `/kankaku doctor`: a no-network diagnostic (SUBAGENT-REQ-017)
431
- * reporting orphan/uncertain record counts (ADR 0022) with why, and
432
- * whether ancestor-chain detection is available on this platform, so
433
- * 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.
434
575
  */
435
576
  function handleDoctorCommand(ctx: ExtensionContext): void {
436
- const records = log.readAll();
437
- const orphans = orphanSubagents(records);
438
- const uncertain = uncertainRecords(records);
439
- const ancestorDetectionAvailable = deps.ancestorDetectionAvailable ? deps.ancestorDetectionAvailable() : process.platform !== "win32";
440
-
441
- const lines = [
442
- `ancestor-chain detection: ${ancestorDetectionAvailable ? "available" : "unavailable"}`,
443
- `orphan subagent record(s): ${orphans.length}` +
444
- (orphans.length > 0 ? " — recognised as someone's child (an env marker matched), but no orchestrator could be matched" : ""),
445
- `uncertain record(s): ${uncertain.length}` +
446
- (uncertain.length > 0
447
- ? " — no recognised child-env-marker, but a live tracked ancestor process was found; not counted as a new task, not synced"
448
- : ""),
449
- ];
450
-
451
- if (!ancestorDetectionAvailable) {
452
- lines.push(
453
- "on this platform/environment, ancestor-chain detection could not run (Windows, or a failed/unavailable ps/proc read): " +
454
- "an unmarked subagent system may be counted twice (a genuine child with no recognised marker looks like a fresh top-level " +
455
- "session). Mark it explicitly with KANKAKU_ROLE=subagent in the child's environment (or KANKAKU_ROLE=orchestrator to force " +
456
- "the other way).",
457
- );
458
- }
459
-
460
- if (deps.roleOverride) {
461
- if (deps.childMarkerPresent) {
462
- // R1: both signals present — the confirmed child marker always
463
- // wins (config.ts#detectRole), so the override did not decide
464
- // anything, whatever it said.
465
- lines.push(
466
- `role override: KANKAKU_ROLE=${deps.roleOverride} was present, but the confirmed child marker (GENTLE_PI_AGENTS_CHILD=1) takes precedence — resolved role: subagent`,
467
- );
468
- } else if (deps.overrideIgnoredInteractive) {
469
- lines.push(
470
- "role override: KANKAKU_ROLE=subagent was ignored for this interactive session (likely a leaked shell export) — resolved role: orchestrator",
471
- );
472
- } else {
473
- lines.push(`role override: KANKAKU_ROLE=${deps.roleOverride} (deciding signal for this process's role)`);
474
- }
475
- }
476
-
477
- // C2 item 2/3: a configured child-env marker matched but was ignored
478
- // for this process's role because it looked interactive — a
479
- // configured marker, unlike a built-in one, never demotes an
480
- // interactive session. Escalated when this process also has no
481
- // tracked ancestor at all (C2 item 3's self-check: the strongest
482
- // signal the marker is genuinely ambient, not a real subagent
483
- // mechanism).
484
- if (deps.configuredMarkerIgnoredInteractive) {
485
- lines.push(
486
- deps.hasTrackedAncestor
487
- ? "configured marker: a KANKAKU_SUBAGENT_CHILD_ENV marker was present but ignored for this interactive session — resolved role: orchestrator"
488
- : "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",
489
- );
490
- }
491
-
492
- // C2 item 1: markers rejected at config load time as looking
493
- // pi/shell/OS/npm-owned rather than genuinely child-only.
494
- if (deps.rejectedSubagentChildEnvMarkers && deps.rejectedSubagentChildEnvMarkers.length > 0) {
495
- for (const rejected of deps.rejectedSubagentChildEnvMarkers) {
496
- lines.push(`rejected KANKAKU_SUBAGENT_CHILD_ENV marker "${rejected.name}": ${rejected.reason}`);
497
- }
498
- }
499
-
500
- // SUBAGENT-REQ-001/002/003/005/017 (6b): active profiles, any configured
501
- // tool names/markers, which profile matched each subagent record, and
502
- // any tool-name ambiguity among the active set. Omitted entirely when
503
- // deps.subagentProfiles was not wired (back-compat).
504
- if (deps.subagentProfiles) {
505
- const profiles = deps.subagentProfiles;
506
- lines.push(`subagent profiles active: ${profiles.map((p) => p.id).join(", ")}`);
507
-
508
- const configured = profiles.find((p) => p.id === "configured");
509
- if (configured) {
510
- if (configured.toolNames.length > 0) lines.push(`configured subagent tools: ${configured.toolNames.join(", ")}`);
511
- if (configured.childEnvMarkers.length > 0) {
512
- lines.push(`configured child-env markers: ${configured.childEnvMarkers.map((m) => (m.value !== undefined ? `${m.name}=${m.value}` : m.name)).join(", ")}`);
513
- }
514
- }
515
-
516
- const subagentRecords = records.filter((record) => record.role === "subagent");
517
- if (subagentRecords.length > 0) {
518
- const counts = new Map<string, number>();
519
- let unmatched = 0;
520
- for (const record of subagentRecords) {
521
- if (record.profile) {
522
- counts.set(record.profile, (counts.get(record.profile) ?? 0) + 1);
523
- } else {
524
- unmatched++;
525
- }
526
- }
527
- const parts = profiles.filter((p) => counts.has(p.id)).map((p) => `${p.id}: ${counts.get(p.id)}`);
528
- if (unmatched > 0) parts.push(`unmatched: ${unmatched}`);
529
- lines.push(`profile matches: ${parts.join(", ")}`);
530
- }
531
-
532
- for (const { toolName, profileIds } of findAmbiguousToolNames(profiles)) {
533
- lines.push(`ambiguous tool name "${toolName}": registered by ${profileIds.join(", ")} — never guessed, resolved by child-env marker or left uncertain`);
534
- }
535
- }
536
-
537
- // SUBAGENT-REQ-015 (6c): same-pid overlapping orchestrator records —
538
- // never observed from any real subagent mechanism today, but flagged
539
- // here (informational only, never changing buildTasks' own numbers) as
540
- // the observable signature an in-process nested session would leave.
541
- for (const overlap of detectSameProcessOverlaps(records)) {
542
- lines.push(
543
- `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`,
544
- );
545
- }
546
-
547
- if (deps.workLogRouting?.usedFallback) {
548
- lines.push(
549
- `kankaku: this subagent could not write to its orchestrator's directory (${deps.workLogRouting.parentDir}); ` +
550
- "fell back to its own local worklog — this record may show as an orphan until reunited manually",
551
- );
552
- }
553
-
554
- if (deps.registryHealth) {
555
- const { keep, discard } = deps.registryHealth();
556
- const counts = new Map<string, number>();
557
- for (const { reason } of discard) counts.set(reason, (counts.get(reason) ?? 0) + 1);
558
- const byReason = Array.from(counts.entries())
559
- .map(([reason, count]) => `${reason}: ${count}`)
560
- .join(", ");
561
- lines.push(`registry (~/.kankaku/run): ${keep.length} entrie(s) trusted${discard.length > 0 ? `, ${discard.length} discarded (${byReason})` : ""}`);
562
- }
563
-
564
- const sessionDir = readNonDefaultSessionDir(ctx.sessionManager);
565
- if (sessionDir !== undefined) {
566
- lines.push(`session dir (non-default): ${sessionDir}`);
567
- }
568
-
569
- showReport(ctx, { title: "doctor", lines });
577
+ showReport(ctx, { title: "doctor", lines: buildDoctorLines(deps, ctx) });
570
578
  }
571
579
 
572
580
  /** Handle `/kankaku export [csv|json] [all]`; `rest` excludes the leading `export` token. Default format is csv. */
@@ -578,14 +586,9 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
578
586
 
579
587
  const all = rest.includes("all");
580
588
  const format: "csv" | "json" = rest.includes("json") ? "json" : "csv";
581
- const records = log.readAll();
582
- const today = localDay(new Date().toISOString());
583
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
584
- const rows = exportRows(tasks);
585
- const content = format === "json" ? toJson(rows) : toCsv(rows);
586
- const name = `tasks-${all ? "all" : today}.${format}`;
589
+ const { name, content, rowCount } = buildExportContent(log.readAll(), { format, all });
587
590
  const path = deps.writeExportFile(name, content);
588
- showReport(ctx, { title: "export", lines: [`wrote ${rows.length} row(s) to ${path}`] });
591
+ showReport(ctx, { title: "export", lines: [`wrote ${rowCount} row(s) to ${path}`] });
589
592
  }
590
593
 
591
594
  pi.registerCommand("kankaku", {
@@ -598,6 +601,8 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
598
601
  "'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
599
602
  "When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
600
603
  "'target pick' to run the picker again, 'target clear' to clear the session target, " +
604
+ "'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
605
+ "'task clear' to drop the link, " +
601
606
  "'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
602
607
  "'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
603
608
  "'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
@@ -615,6 +620,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
615
620
  const prefix = targetMatch[1] ?? "";
616
621
  return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
617
622
  }
623
+ const taskMatch = /^task\s+(\S*)$/.exec(argumentPrefix);
624
+ if (taskMatch) {
625
+ const prefix = taskMatch[1] ?? "";
626
+ return TASK_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
627
+ }
618
628
  const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
619
629
  if (catalogMatch) {
620
630
  const prefix = catalogMatch[1] ?? "";
@@ -647,6 +657,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
647
657
  return;
648
658
  }
649
659
 
660
+ if (tokens[0] === "task") {
661
+ await handleTaskCommand(tokens.slice(1), ctx);
662
+ return;
663
+ }
664
+
650
665
  if (tokens[0] === "catalog") {
651
666
  await handleCatalogCommand(tokens.slice(1), ctx);
652
667
  return;
@@ -667,58 +682,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
667
682
  return;
668
683
  }
669
684
 
685
+ if (tokens.length === 0 && ctx.hasUI && deps.openPanel) {
686
+ await deps.openPanel(ctx);
687
+ return;
688
+ }
689
+
670
690
  const all = tokens.includes("all");
671
691
  const records = log.readAll();
672
- const today = localDay(new Date().toISOString());
673
692
 
674
693
  if (tokens.includes("clients")) {
675
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
676
- showReport(ctx, {
677
- title: all ? "clients (all days)" : "clients (today)",
678
- lines: formatClients(summarizeByClient(tasks)).split("\n"),
679
- });
694
+ showReport(ctx, buildClientsView(records, { all }));
680
695
  return;
681
696
  }
682
697
 
683
698
  if (tokens.includes("projects")) {
684
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
685
- showReport(ctx, {
686
- title: all ? "projects (all days)" : "projects (today)",
687
- lines: formatProjects(summarizeByProject(tasks)).split("\n"),
688
- });
699
+ showReport(ctx, buildProjectsView(records, { all }));
689
700
  return;
690
701
  }
691
702
 
692
703
  if (tokens.includes("tasks")) {
693
- const sessionId = ctx.sessionManager.getSessionId();
694
- const scoped = all || !sessionId;
695
- const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
696
- showReport(ctx, {
697
- title: scoped ? "tasks (every session)" : "tasks (this session)",
698
- lines: formatTasks(tasks).split("\n"),
699
- });
704
+ showReport(ctx, buildTasksView(records, { all, sessionId: ctx.sessionManager.getSessionId() }));
700
705
  return;
701
706
  }
702
707
 
703
708
  if (tokens.includes("sessions")) {
704
- const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
705
- showReport(ctx, {
706
- title: all ? "sessions (all days)" : "sessions (today)",
707
- lines: formatSessions(buildSessions(tasks)).split("\n"),
708
- });
709
+ showReport(ctx, buildSessionsView(records, { all }));
709
710
  return;
710
711
  }
711
712
 
712
- const summary = summarize(records, { all });
713
- const lines = formatReport(summary).split(" | ");
714
- const uncertainCount = countUncertain(records, { all });
715
- if (uncertainCount > 0) {
716
- lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
717
- }
718
- showReport(ctx, {
719
- title: all ? "summary (all days)" : "summary (today)",
720
- lines,
721
- });
713
+ showReport(ctx, buildSummaryView(records, { all }));
722
714
  } catch (error) {
723
715
  notifyError(ctx, error);
724
716
  }