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.
- package/README.md +59 -11
- package/package.json +1 -1
- package/src/adapters/hub-actions.ts +83 -0
- package/src/adapters/kankaku-command.ts +193 -243
- package/src/adapters/panel/kankaku-panel.ts +274 -0
- package/src/adapters/panel/panel-items.ts +77 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +85 -0
- package/src/adapters/panel/screens/export.ts +119 -0
- package/src/adapters/panel/screens/report.ts +141 -0
- package/src/adapters/panel/screens/sync.ts +126 -0
- package/src/adapters/panel/screens/target.ts +367 -0
- package/src/adapters/pi-tracker.ts +108 -6
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/session-target.ts +51 -4
- package/src/domain/panel-model.ts +267 -0
- package/src/extension.ts +18 -8
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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 } : {}),
|