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.
@@ -3,7 +3,7 @@ import { isValidClient } from "../domain/client-label.ts";
3
3
  import { formatWorkTargetLabel } from "../domain/work-target.ts";
4
4
  import type { RegistryClassification } from "../domain/registry-health.ts";
5
5
  import type { SubagentProfile } from "../domain/subagent-profile.ts";
6
- import type { RejectedChildEnvMarker } from "../config.ts";
6
+ import type { KankakuConfig, RejectedChildEnvMarker } from "../config.ts";
7
7
  import type { OrchestratorRef, WorkRecord, WorkRecordCore, WorkRole } from "../domain/work-record.ts";
8
8
  import type { WorkTracker } from "../domain/work-tracker.ts";
9
9
  import type { Catalog } from "../ports/catalog.ts";
@@ -13,8 +13,15 @@ import { createSessionClient } from "./session-client.ts";
13
13
  import { readNonDefaultSessionDir } from "./session-dir.ts";
14
14
  import type { SessionTarget } from "./session-target.ts";
15
15
  import { createStatusBar } from "./status-bar.ts";
16
- import { notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
- import type { SyncCommandDeps } from "./kankaku-command.ts";
16
+ import { appendReportEntry, notifyError, registerKankakuCommand } from "./kankaku-command.ts";
17
+ import type { KankakuCommandDeps, SyncCommandDeps } from "./kankaku-command.ts";
18
+ import { openKankakuPanel } from "./panel/kankaku-panel.ts";
19
+ import { createAboutScreen } from "./panel/screens/about.ts";
20
+ import { createDoctorScreen } from "./panel/screens/doctor.ts";
21
+ import { createExportScreen } from "./panel/screens/export.ts";
22
+ import { createReportScreen } from "./panel/screens/report.ts";
23
+ import { createSyncScreen } from "./panel/screens/sync.ts";
24
+ import { createTargetScreen } from "./panel/screens/target.ts";
18
25
  import type { SyncTrigger } from "./sync-runner.ts";
19
26
 
20
27
  export type { KankakuReportData } from "./kankaku-command.ts";
@@ -174,11 +181,37 @@ export interface PiTrackerDeps {
174
181
  * real timer.
175
182
  */
176
183
  shutdownSyncTimeoutMs?: number;
184
+ /**
185
+ * The full active kankaku configuration (`config.ts#loadConfig`), for the
186
+ * panel's `about` screen (P3). Optional so every existing direct
187
+ * `createPiTracker` caller (e.g. `tests/pi-tracker.test.ts`) keeps
188
+ * working unchanged — falls back to {@link FALLBACK_ABOUT_CONFIG} (an
189
+ * empty configuration) when absent. `extension.ts` always supplies the
190
+ * real one.
191
+ */
192
+ config?: KankakuConfig;
193
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`), for the `about` screen. */
194
+ kankakuDir?: string;
195
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`), for the `about` screen. */
196
+ agentVersion?: string;
197
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`), for the `about` screen. */
198
+ pluginVersion?: string;
199
+ /** The hub (PocketBase) URL, when configured, for the `about` screen. */
200
+ hubUrl?: string;
177
201
  }
178
202
 
179
203
  /** Default for {@link PiTrackerDeps.shutdownSyncTimeoutMs}. */
180
204
  const DEFAULT_SHUTDOWN_SYNC_TIMEOUT_MS = 3000;
181
205
 
206
+ /** {@link PiTrackerDeps.config}'s fallback for a caller that has not wired the real one yet. */
207
+ const FALLBACK_ABOUT_CONFIG: KankakuConfig = {
208
+ dir: ".kankaku",
209
+ interactiveTools: [],
210
+ subagentProfiles: [],
211
+ segmentRules: [],
212
+ rejectedSubagentChildEnvMarkers: [],
213
+ };
214
+
182
215
  /** Default `isAlive`: probe with signal 0 — no signal is sent, only existence/permission is checked. */
183
216
  function defaultIsAlive(pid: number): boolean {
184
217
  try {
@@ -252,10 +285,19 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
252
285
  resolveIdleClient: idleDisplayLabel,
253
286
  });
254
287
 
255
- const kankakuCommand = registerKankakuCommand(pi, {
288
+ const refreshIdleStatus = (ctx: ExtensionContext) => statusBar.showIdle(ctx);
289
+
290
+ // Built as a named const (rather than inlined into `registerKankakuCommand`
291
+ // below) so `openPanel`'s doctor screen can reuse the exact same
292
+ // `KankakuCommandDeps` `buildDoctorLines` needs — the panel and
293
+ // `/kankaku doctor` can then never drift. Safe to reference `commandDeps`
294
+ // from within its own `openPanel` closure: the closure body only runs
295
+ // once `/kankaku` (no args) is actually invoked, long after this `const`
296
+ // has finished initializing.
297
+ const commandDeps: KankakuCommandDeps = {
256
298
  log,
257
299
  sessionClient,
258
- refreshIdleStatus: (ctx) => statusBar.showIdle(ctx),
300
+ refreshIdleStatus,
259
301
  writeExportFile: deps.writeExportFile,
260
302
  sessionTarget: deps.sessionTarget,
261
303
  catalog: deps.catalog,
@@ -270,7 +312,67 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
270
312
  rejectedSubagentChildEnvMarkers: deps.rejectedSubagentChildEnvMarkers,
271
313
  workLogRouting: deps.workLogRouting,
272
314
  subagentProfiles: deps.subagentProfiles,
273
- });
315
+ // The hub is configured exactly when a session target is wired (see
316
+ // PiTrackerDeps.sessionTarget's doc comment): the panel's root menu
317
+ // uses the same signal to decide whether to offer `sync` — `target`
318
+ // itself is offered either way, since the legacy `/kankaku client
319
+ // <name>` label lives on that same screen and works with no hub at
320
+ // all (see `domain/panel-model.ts#rootMenu`'s `target` row).
321
+ openPanel: (ctx) =>
322
+ openKankakuPanel(ctx, {
323
+ hubConfigured: deps.sessionTarget !== undefined,
324
+ screens: {
325
+ target: createTargetScreen({
326
+ pi,
327
+ ctx,
328
+ role,
329
+ sessionTarget: deps.sessionTarget,
330
+ sessionClient,
331
+ catalog: deps.catalog,
332
+ refreshIdleStatus,
333
+ }),
334
+ report: createReportScreen({
335
+ log,
336
+ ctx,
337
+ pi,
338
+ sessionId: () => ctx.sessionManager.getSessionId(),
339
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
340
+ }),
341
+ doctor: createDoctorScreen({
342
+ commandDeps,
343
+ ctx,
344
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
345
+ }),
346
+ about: createAboutScreen({
347
+ config: deps.config ?? FALLBACK_ABOUT_CONFIG,
348
+ kankakuDir: deps.kankakuDir ?? deps.config?.dir ?? FALLBACK_ABOUT_CONFIG.dir,
349
+ agentVersion: deps.agentVersion,
350
+ pluginVersion: deps.pluginVersion,
351
+ hubUrl: deps.hubUrl,
352
+ }),
353
+ // Present only when the hub is configured — mirrors `sync`'s own
354
+ // hub-only root-menu row (`domain/panel-model.ts#rootMenu`).
355
+ ...(deps.sync
356
+ ? {
357
+ sync: createSyncScreen({
358
+ sync: deps.sync,
359
+ catalog: deps.catalog,
360
+ ctx,
361
+ refreshIdleStatus,
362
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
363
+ }),
364
+ }
365
+ : {}),
366
+ export: createExportScreen({
367
+ log,
368
+ writeExportFile: deps.writeExportFile,
369
+ ctx,
370
+ pinReport: (report) => appendReportEntry(pi, ctx, report),
371
+ }),
372
+ },
373
+ }),
374
+ };
375
+ const kankakuCommand = registerKankakuCommand(pi, commandDeps);
274
376
 
275
377
  /** At most one quiet auto-sync failure notification per session; never notified on success. Shared by the fire-and-forget `triggerAutoSync` and the awaited shutdown sync below. */
276
378
  let autoSyncErrorNotified = false;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The five `/kankaku` report views (summary/tasks/sessions/clients/
3
+ * projects), each as one pure `WorkRecord[] -> KankakuReportData` builder.
4
+ * Extracted from `kankaku-command.ts`'s subcommand handlers (see
5
+ * odd/tasks/kankaku-panel.md P3) so the `/kankaku` subcommands and the
6
+ * panel's report screen (`panel/screens/report.ts`) always compute the
7
+ * exact same lines — neither ever re-implements the other's logic.
8
+ */
9
+ import { buildSessions, buildTasks } from "../domain/task-view.ts";
10
+ import { exportRows, toCsv, toJson } from "../domain/export.ts";
11
+ import type { WorkRecord } from "../domain/work-record.ts";
12
+ import type { KankakuReportData } from "./kankaku-command.ts";
13
+ import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
14
+
15
+ /** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
16
+ export interface ReportViewOptions {
17
+ all: boolean;
18
+ }
19
+
20
+ /** `buildTasksView`'s own options: `all` scopes to every session instead of local-day range (tasks are never day-filtered — see `kankaku-command.ts`'s original `tasks` handler). */
21
+ export interface TasksViewOptions {
22
+ all: boolean;
23
+ sessionId: string | undefined;
24
+ }
25
+
26
+ /** The plain-text summary view (`/kankaku` with no view token): role/task totals, plus a one-line hint when uncertain records were excluded (SUBAGENT-REQ-017). */
27
+ export function buildSummaryView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
28
+ const { all } = options;
29
+ const summary = summarize(records, { all });
30
+ const lines = formatReport(summary).split(" | ");
31
+ const uncertainCount = countUncertain(records, { all });
32
+ if (uncertainCount > 0) {
33
+ lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
34
+ }
35
+ return { title: all ? "summary (all days)" : "summary (today)", lines };
36
+ }
37
+
38
+ /** `/kankaku clients [all]`: per-client totals. */
39
+ export function buildClientsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
40
+ const { all } = options;
41
+ const today = localDay(new Date().toISOString());
42
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
43
+ return { title: all ? "clients (all days)" : "clients (today)", lines: formatClients(summarizeByClient(tasks)).split("\n") };
44
+ }
45
+
46
+ /** `/kankaku projects [all]`: per-project totals. */
47
+ export function buildProjectsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
48
+ const { all } = options;
49
+ const today = localDay(new Date().toISOString());
50
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
51
+ return { title: all ? "projects (all days)" : "projects (today)", lines: formatProjects(summarizeByProject(tasks)).split("\n") };
52
+ }
53
+
54
+ /** `/kankaku sessions [all]`: per-session totals. */
55
+ export function buildSessionsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData {
56
+ const { all } = options;
57
+ const today = localDay(new Date().toISOString());
58
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
59
+ return { title: all ? "sessions (all days)" : "sessions (today)", lines: formatSessions(buildSessions(tasks)).split("\n") };
60
+ }
61
+
62
+ /**
63
+ * `/kankaku tasks [all]`: one line per task. Unlike every other view, tasks
64
+ * are never restricted by local day — `all` (or a missing `sessionId`)
65
+ * instead scopes from "this session" to "every session".
66
+ */
67
+ export function buildTasksView(records: WorkRecord[], options: TasksViewOptions): KankakuReportData {
68
+ const { all, sessionId } = options;
69
+ const scoped = all || !sessionId;
70
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
71
+ return { title: scoped ? "tasks (every session)" : "tasks (this session)", lines: formatTasks(tasks).split("\n") };
72
+ }
73
+
74
+ /** {@link buildExportContent}'s options: `format` picks csv/json, `all` includes every day instead of just today's local day (mirrors every other view except `buildTasksView`). */
75
+ export interface ExportContentOptions {
76
+ format: "csv" | "json";
77
+ all: boolean;
78
+ }
79
+
80
+ /**
81
+ * `/kankaku export [csv|json] [all]`'s file content: the flat export rows
82
+ * for today's (or every) task, rendered as csv or json, with the file name
83
+ * `/kankaku export` and the panel's export screen (`panel/screens/
84
+ * export.ts`) both use. Extracted from `kankaku-command.ts`'s
85
+ * `handleExportCommand` (see odd/tasks/kankaku-panel.md P4) so the
86
+ * subcommand and the panel never drift. `rowCount` is exposed only for the
87
+ * subcommand's "wrote N row(s) to <path>" confirmation line — the panel's
88
+ * own confirmation is simpler ("wrote <path>").
89
+ */
90
+ export function buildExportContent(records: WorkRecord[], options: ExportContentOptions): { name: string; content: string; rowCount: number } {
91
+ const { format, all } = options;
92
+ const today = localDay(new Date().toISOString());
93
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
94
+ const rows = exportRows(tasks);
95
+ const content = format === "json" ? toJson(rows) : toCsv(rows);
96
+ const name = `tasks-${all ? "all" : today}.${format}`;
97
+ return { name, content, rowCount: rows.length };
98
+ }
@@ -81,6 +81,24 @@ export interface SessionTarget {
81
81
  pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
82
82
  /** `/kankaku task clear`: drops the linked hub task, keeping the rest of the session target. A no-op when none is linked. */
83
83
  clearTask(pi: ExtensionAPI): void;
84
+ /**
85
+ * Set the linked hub task directly, bypassing the picker — the panel's
86
+ * target screen (`adapters/panel/screens/target.ts`) uses this once the
87
+ * user has already chosen a task from its own `SelectList`. Builds the
88
+ * session override from the current {@link effectiveTarget}'s candidate
89
+ * plus `hubTaskId` and appends the session entry, exactly like
90
+ * {@link pickTask}'s own persistence step. A no-op when there is no
91
+ * effective target with a project.
92
+ */
93
+ setTask(pi: ExtensionAPI, hubTaskId: string): void;
94
+ /**
95
+ * `/kankaku target remember` / the panel's "Remember" row: persists the
96
+ * current {@link effectiveTarget}'s `clientId`/`projectId` (never
97
+ * `hubTaskId` — task linking stays session-only) to the project's
98
+ * `.kankaku/config.json`. Returns `false`, without persisting anything,
99
+ * when there is no effective target.
100
+ */
101
+ rememberTarget(): boolean;
84
102
  /** Current effective target (session > project config > repoPaths), regardless of role. */
85
103
  effectiveTarget(): WorkTarget | undefined;
86
104
  /** Which source produced {@link effectiveTarget}. */
@@ -346,6 +364,22 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
346
364
  pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
347
365
  }
348
366
 
367
+ function setTask(pi: ExtensionAPI, hubTaskId: string): void {
368
+ const target = effectiveTarget();
369
+ if (!target || target.projectId === undefined) return;
370
+
371
+ const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId };
372
+ sessionOverride = ids;
373
+ pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
374
+ }
375
+
376
+ function rememberTarget(): boolean {
377
+ const target = effectiveTarget();
378
+ if (!target) return false;
379
+ deps.persistProjectConfig(candidateFrom(target));
380
+ return true;
381
+ }
382
+
349
383
  async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
350
384
  if (deps.role !== "orchestrator" || !ctx.hasUI) return;
351
385
 
@@ -364,9 +398,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
364
398
  }
365
399
  if (result.kind === "skipped") return;
366
400
 
367
- const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId: result.task.id };
368
- sessionOverride = ids;
369
- pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
401
+ setTask(pi, result.task.id);
370
402
 
371
403
  const updated = effectiveTarget();
372
404
  if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
@@ -380,5 +412,20 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
380
412
  pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
381
413
  }
382
414
 
383
- return { restore, ensurePicked, pick, setExplicit, clear, pickTask, clearTask, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
415
+ return {
416
+ restore,
417
+ ensurePicked,
418
+ pick,
419
+ setExplicit,
420
+ clear,
421
+ pickTask,
422
+ clearTask,
423
+ setTask,
424
+ rememberTarget,
425
+ effectiveTarget,
426
+ effectiveSource,
427
+ runTarget,
428
+ idleTarget,
429
+ endRun,
430
+ };
384
431
  }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Pure model for the `/kankaku` panel (see odd/tasks/kankaku-panel.md): the
3
+ * set of screens, the root menu, an immutable navigation stack, the
4
+ * title/footer-hint text every adapter screen renders from, and pure row
5
+ * builders (e.g. {@link buildTargetRows}) that turn kankaku state into row
6
+ * models for a screen's `SettingsList`. No I/O, no pi imports — see
7
+ * AGENTS.md "Architecture (hexagonal)".
8
+ */
9
+
10
+ import type { WorkTarget } from "./work-target.ts";
11
+
12
+ export type PanelScreenId = "root" | "target" | "report" | "sync" | "export" | "doctor" | "about";
13
+
14
+ /** One row of the root menu (`rootMenu`): a section the panel can navigate to. */
15
+ export interface PanelMenuItem {
16
+ id: PanelScreenId;
17
+ label: string;
18
+ description: string;
19
+ /** Offered only when the hub (PocketBase) is configured; see {@link rootMenu}. */
20
+ hubOnly: boolean;
21
+ }
22
+
23
+ const ROOT_MENU_ITEMS: PanelMenuItem[] = [
24
+ // Not hub-only: the legacy billing label (`/kankaku client <name>`) is
25
+ // set from this same screen and works with no hub configured at all.
26
+ { id: "target", label: "Target", description: "Billing client, project, hub task, legacy label", hubOnly: false },
27
+ { id: "report", label: "Report", description: "Today/all totals, tasks, sessions, clients, projects", hubOnly: false },
28
+ { id: "sync", label: "Sync", description: "Status, sync now, sync all, backfill, catalog refresh", hubOnly: true },
29
+ { id: "export", label: "Export", description: "Write today's or every task as csv/json", hubOnly: false },
30
+ { id: "doctor", label: "Doctor", description: "Orphan/uncertain subagent counts, ancestor detection", hubOnly: false },
31
+ { id: "about", label: "About", description: "Versions, KANKAKU_DIR, env-only config", hubOnly: false },
32
+ ];
33
+
34
+ /**
35
+ * The root menu's rows, in a fixed order. `hubOnly` rows (only `sync`;
36
+ * `target` is not hub-only — see `ROOT_MENU_ITEMS`'s comment) are omitted
37
+ * entirely when the hub is not configured, so the panel offers exactly what
38
+ * `/kankaku` itself would today.
39
+ */
40
+ export function rootMenu(options: { hubConfigured: boolean }): PanelMenuItem[] {
41
+ return ROOT_MENU_ITEMS.filter((item) => options.hubConfigured || !item.hubOnly);
42
+ }
43
+
44
+ /** Immutable navigation stack: `stack[0]` is always `"root"`. */
45
+ export interface PanelNav {
46
+ stack: PanelScreenId[];
47
+ }
48
+
49
+ /** A fresh navigation stack, positioned at the root screen. */
50
+ export function navRoot(): PanelNav {
51
+ return { stack: ["root"] };
52
+ }
53
+
54
+ /** Push a screen onto the stack, returning a new {@link PanelNav}; the input is never mutated. */
55
+ export function navPush(nav: PanelNav, id: PanelScreenId): PanelNav {
56
+ return { stack: [...nav.stack, id] };
57
+ }
58
+
59
+ /**
60
+ * Pop the current screen, returning a new {@link PanelNav} and whether the
61
+ * panel should close. At the root, there is nothing left to pop: the stack
62
+ * is returned unchanged and `closed` is `true` — the caller closes the
63
+ * overlay instead of navigating.
64
+ */
65
+ export function navBack(nav: PanelNav): { nav: PanelNav; closed: boolean } {
66
+ if (nav.stack.length <= 1) {
67
+ return { nav, closed: true };
68
+ }
69
+ return { nav: { stack: nav.stack.slice(0, -1) }, closed: false };
70
+ }
71
+
72
+ /** The screen currently on top of the stack. */
73
+ export function navCurrent(nav: PanelNav): PanelScreenId {
74
+ return nav.stack[nav.stack.length - 1] ?? "root";
75
+ }
76
+
77
+ const SCREEN_TITLES: Record<Exclude<PanelScreenId, "root">, string> = {
78
+ target: "Target",
79
+ report: "Report",
80
+ sync: "Sync",
81
+ export: "Export",
82
+ doctor: "Doctor",
83
+ about: "About",
84
+ };
85
+
86
+ /** `kankaku` at root, `kankaku · <Screen>` on every other screen. */
87
+ export function panelTitle(screen: PanelScreenId): string {
88
+ if (screen === "root") return "kankaku";
89
+ return `kankaku · ${SCREEN_TITLES[screen]}`;
90
+ }
91
+
92
+ /** One clickable/keyboard hint shown in the panel's footer. */
93
+ export interface PanelHint {
94
+ key: string;
95
+ label: string;
96
+ }
97
+
98
+ /**
99
+ * The footer hint row for a screen: navigation hints, an optional search
100
+ * hint when the current body supports it, and how Escape/`q` behave — back
101
+ * at root closes the panel outright, so root shows only `esc close`; every
102
+ * other screen shows both `esc back` and `q close`.
103
+ */
104
+ export function footerHints(screen: PanelScreenId, options: { searchable: boolean }): PanelHint[] {
105
+ const hints: PanelHint[] = [{ key: "↑↓", label: "move" }];
106
+
107
+ if (screen === "root") {
108
+ hints.push({ key: "enter", label: "open" });
109
+ if (options.searchable) hints.push({ key: "/", label: "search" });
110
+ hints.push({ key: "esc", label: "close" });
111
+ return hints;
112
+ }
113
+
114
+ hints.push({ key: "enter", label: "select" });
115
+ if (options.searchable) hints.push({ key: "/", label: "search" });
116
+ hints.push({ key: "esc", label: "back" }, { key: "q", label: "close" });
117
+ return hints;
118
+ }
119
+
120
+ /** One row of a `SettingsList`-backed screen (e.g. {@link buildTargetRows}'s output). */
121
+ export interface PanelRow {
122
+ id: string;
123
+ label: string;
124
+ value: string;
125
+ description?: string;
126
+ }
127
+
128
+ const NONE_VALUE = "— none —";
129
+
130
+ /** Pure input for {@link buildTargetRows}. */
131
+ export interface TargetRowsInput {
132
+ /** Whether the hub (PocketBase) is configured; see `rootMenu`'s `target` row, which is offered either way. */
133
+ hubConfigured: boolean;
134
+ /** The current effective hub target, if any (`SessionTarget#effectiveTarget()`). */
135
+ target?: WorkTarget;
136
+ /** Which source produced {@link target} (already formatted by the caller as `"session" | "config" | "repoPaths"`), or `undefined` for none. */
137
+ source?: string;
138
+ /** The current effective legacy billing label (`SessionClient#effectiveClient()`), if any. */
139
+ legacyLabel?: string;
140
+ /** Which source produced {@link legacyLabel} (already formatted by the caller), or `undefined` for none. */
141
+ legacySource?: string;
142
+ }
143
+
144
+ /**
145
+ * Build the target screen's rows from kankaku's current billing-target
146
+ * state. Pure: the caller (`adapters/panel/screens/target.ts`) resolves
147
+ * `target`/`source`/`legacyLabel`/`legacySource` from `SessionTarget`/
148
+ * `SessionClient` and formats each source name as a plain string, so this
149
+ * function never depends on their concrete types.
150
+ *
151
+ * `client`/`project`/`task`/`source`/`remember` are included only when
152
+ * {@link TargetRowsInput.hubConfigured} is `true` — the panel's `target`
153
+ * screen is offered either way (see `rootMenu`), but those rows only make
154
+ * sense once a hub exists to resolve them against. `legacy` is always
155
+ * included: the legacy billing label works with no hub at all.
156
+ */
157
+ export function buildTargetRows(input: TargetRowsInput): PanelRow[] {
158
+ const rows: PanelRow[] = [];
159
+ const target = input.target;
160
+ const hasClient = target !== undefined;
161
+ const hasProject = target !== undefined && target.projectId !== undefined;
162
+
163
+ if (input.hubConfigured) {
164
+ rows.push({
165
+ id: "client",
166
+ label: "Client",
167
+ value: hasClient ? `${target.clientName} (${target.clientCode})` : NONE_VALUE,
168
+ });
169
+
170
+ rows.push({
171
+ id: "project",
172
+ label: "Project",
173
+ value: hasProject ? (target.projectName ?? NONE_VALUE) : NONE_VALUE,
174
+ ...(hasClient ? {} : { description: "pick a client first" }),
175
+ });
176
+
177
+ rows.push({
178
+ id: "task",
179
+ label: "Task",
180
+ value: hasProject && target.hubTaskTitle !== undefined ? target.hubTaskTitle : NONE_VALUE,
181
+ ...(hasProject ? {} : { description: "pick a project first" }),
182
+ });
183
+
184
+ rows.push({
185
+ id: "source",
186
+ label: "Source",
187
+ value: input.source ?? "none",
188
+ });
189
+
190
+ rows.push({
191
+ id: "remember",
192
+ label: "Remember",
193
+ value: "save to config.json",
194
+ description: "Saves the client and project (never the linked task) to this repository's .kankaku/config.json.",
195
+ });
196
+ }
197
+
198
+ rows.push({
199
+ id: "legacy",
200
+ label: "Legacy label",
201
+ value: input.legacyLabel ?? NONE_VALUE,
202
+ description: `Source: ${input.legacySource ?? "none"}`,
203
+ });
204
+
205
+ return rows;
206
+ }
207
+
208
+ /**
209
+ * Pure input for {@link buildAboutRows}. Every env-only value is passed in
210
+ * already extracted from `KankakuConfig` (see `config.ts`) rather than the
211
+ * config object itself: `KankakuConfig` lives outside `src/domain/`, and
212
+ * this module must import nothing but `src/domain/`/`src/ports/` (see
213
+ * AGENTS.md "Architecture (hexagonal)").
214
+ */
215
+ export interface AboutRowsInput {
216
+ /** kankaku's own version (`adapters/agent-info.ts#resolvePluginVersion`). */
217
+ pluginVersion?: string;
218
+ /** pi's version (`adapters/agent-info.ts#resolveAgentVersion`). */
219
+ agentVersion?: string;
220
+ /** This session's resolved kankaku directory (`adapters/kankaku-dir.ts#resolveKankakuDir`). */
221
+ kankakuDir: string;
222
+ /** The hub (PocketBase) URL, when configured. */
223
+ hubUrl?: string;
224
+ /** `KankakuConfig.interactiveTools`. */
225
+ interactiveTools: string[];
226
+ /** `KankakuConfig.segmentRules.length`. */
227
+ segmentRuleCount: number;
228
+ /** `KankakuConfig.subagentProfiles`' ids. */
229
+ subagentProfileNames: string[];
230
+ /** `KankakuConfig.client`. */
231
+ client?: string;
232
+ }
233
+
234
+ /**
235
+ * Build the panel's `about` screen rows: versions, the resolved directory,
236
+ * the hub URL, and every env-only setting (`KANKAKU_INTERACTIVE_TOOLS`,
237
+ * `KANKAKU_SEGMENTS`, `KANKAKU_SUBAGENT_TOOLS`/`KANKAKU_SUBAGENT_CHILD_ENV`,
238
+ * `KANKAKU_CLIENT`) with its env var name as the row's description — every
239
+ * row is read-only (see `screens/about.ts`).
240
+ */
241
+ export function buildAboutRows(input: AboutRowsInput): PanelRow[] {
242
+ return [
243
+ { id: "kankaku", label: "kankaku", value: input.pluginVersion ?? "unknown" },
244
+ { id: "pi", label: "pi", value: input.agentVersion ?? "unknown" },
245
+ { id: "directory", label: "Directory", value: input.kankakuDir },
246
+ { id: "hub", label: "Hub", value: input.hubUrl ?? "not configured" },
247
+ {
248
+ id: "interactive-tools",
249
+ label: "Interactive tools",
250
+ value: input.interactiveTools.length > 0 ? input.interactiveTools.join(", ") : NONE_VALUE,
251
+ description: "KANKAKU_INTERACTIVE_TOOLS",
252
+ },
253
+ {
254
+ id: "segments",
255
+ label: "Segments",
256
+ value: `${input.segmentRuleCount} rule(s)`,
257
+ description: "KANKAKU_SEGMENTS",
258
+ },
259
+ {
260
+ id: "subagent-profiles",
261
+ label: "Subagent profiles",
262
+ value: input.subagentProfileNames.length > 0 ? input.subagentProfileNames.join(", ") : NONE_VALUE,
263
+ description: "KANKAKU_SUBAGENT_TOOLS / KANKAKU_SUBAGENT_CHILD_ENV",
264
+ },
265
+ { id: "client", label: "Client", value: input.client ?? NONE_VALUE, description: "KANKAKU_CLIENT" },
266
+ ];
267
+ }
package/src/extension.ts CHANGED
@@ -184,6 +184,18 @@ export default function kankaku(pi: ExtensionAPI): void {
184
184
  let hubConfigError: string | undefined;
185
185
  let sync: SyncCommandDeps | undefined;
186
186
  let autoSyncEnabled: boolean | undefined;
187
+ let hubUrl: string | undefined;
188
+
189
+ // Resolved once, here (never on a hot path): see README "Hub
190
+ // (PocketBase)" > "Agent and measurement quality" and the panel's `about`
191
+ // screen (`adapters/panel/screens/about.ts`). Both degrade to `undefined`
192
+ // on any failure rather than guessing. Unlike the sync sink below (which
193
+ // needs them only when the hub is configured), the `about` screen shows
194
+ // these regardless — so they are resolved unconditionally here.
195
+ const agentVersion = resolveAgentVersion();
196
+ // extension.ts lives at <package root>/src/extension.ts.
197
+ const pluginPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
198
+ const pluginVersion = resolvePluginVersion(pluginPackageRoot);
187
199
 
188
200
  // `homeDir` is passed as a reference, never invoked here: any failure
189
201
  // resolving it (no HOME, a sandbox) must not fail extension load for
@@ -194,6 +206,7 @@ export default function kankaku(pi: ExtensionAPI): void {
194
206
 
195
207
  if (hub.credentials) {
196
208
  const credentials = hub.credentials;
209
+ hubUrl = credentials.url;
197
210
  const client = new PocketBaseClient({ url: credentials.url, email: credentials.email, password: credentials.password });
198
211
  // Same defensive resolution as above; falls back to the OS temp dir
199
212
  // when no home directory is available so an env-only hub configuration
@@ -226,14 +239,6 @@ export default function kankaku(pi: ExtensionAPI): void {
226
239
  const catalogRef = catalog;
227
240
  const machineName = machine;
228
241
 
229
- // Resolved once, here (never on a hot path): see README "Hub
230
- // (PocketBase)" > "Agent and measurement quality". Both degrade to
231
- // `undefined` on any failure rather than guessing.
232
- const agentVersion = resolveAgentVersion();
233
- // extension.ts lives at <package root>/src/extension.ts.
234
- const pluginPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
235
- const pluginVersion = resolvePluginVersion(pluginPackageRoot);
236
-
237
242
  const runOnce = (options?: { full?: boolean; trigger?: SyncTrigger }) => {
238
243
  const snapshot = catalogRef.read();
239
244
  const sink = new PocketBaseSink({
@@ -309,6 +314,11 @@ export default function kankaku(pi: ExtensionAPI): void {
309
314
  ...(hubConfigError !== undefined ? { hubConfigError } : {}),
310
315
  ...(sync !== undefined ? { sync } : {}),
311
316
  ...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
317
+ config,
318
+ kankakuDir: resolvedDir,
319
+ ...(agentVersion !== undefined ? { agentVersion } : {}),
320
+ ...(pluginVersion !== undefined ? { pluginVersion } : {}),
321
+ ...(hubUrl !== undefined ? { hubUrl } : {}),
312
322
  ...(roleOverride !== undefined ? { roleOverride } : {}),
313
323
  ...(childMarkerPresent ? { childMarkerPresent } : {}),
314
324
  ...(profile !== undefined ? { profile } : {}),