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.
- package/README.md +103 -14
- package/dist/adapters/cached-catalog.d.ts +8 -2
- package/dist/adapters/cached-catalog.js +13 -3
- package/dist/adapters/pocketbase-catalog.d.ts +7 -5
- package/dist/adapters/pocketbase-catalog.js +22 -5
- package/dist/adapters/pocketbase-sink.d.ts +2 -1
- package/dist/adapters/pocketbase-sink.js +2 -1
- package/dist/domain/hub-entry.d.ts +12 -3
- package/dist/domain/hub-entry.js +14 -4
- package/dist/domain/task-view.d.ts +4 -0
- package/dist/domain/task-view.js +2 -0
- package/dist/domain/work-record.d.ts +18 -0
- package/dist/domain/work-record.js +18 -0
- package/dist/domain/work-target.d.ts +30 -1
- package/dist/domain/work-target.js +33 -11
- package/dist/ports/catalog.d.ts +4 -2
- package/package.json +1 -1
- package/src/adapters/cached-catalog.ts +22 -6
- package/src/adapters/hub-actions.ts +83 -0
- package/src/adapters/kankaku-command.ts +236 -244
- 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 +110 -6
- package/src/adapters/pocketbase-catalog.ts +36 -7
- package/src/adapters/pocketbase-sink.ts +15 -3
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +66 -16
- package/src/adapters/session-target.ts +111 -4
- package/src/adapters/target-picker.ts +46 -1
- package/src/domain/hub-entry.ts +20 -5
- package/src/domain/panel-model.ts +267 -0
- package/src/domain/task-view.ts +6 -0
- package/src/domain/work-record.ts +23 -0
- package/src/domain/work-target.ts +60 -11
- package/src/extension.ts +19 -8
- package/src/ports/catalog.ts +4 -2
|
@@ -3,7 +3,7 @@ import { formatWorkTargetLabel, resolveWorkTarget, resolveWorkTargetSource } fro
|
|
|
3
3
|
import type { WorkTarget, WorkTargetCandidate, WorkTargetSessionOverride, WorkTargetSourceName } from "../domain/work-target.ts";
|
|
4
4
|
import type { WorkRole } from "../domain/work-record.ts";
|
|
5
5
|
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
6
|
-
import { pickTarget } from "./target-picker.ts";
|
|
6
|
+
import { pickHubTask, pickTarget } from "./target-picker.ts";
|
|
7
7
|
|
|
8
8
|
/** Persisted as a `kankaku-target` custom session entry so the session-level target survives a reload. */
|
|
9
9
|
export interface KankakuTargetEntryData {
|
|
@@ -11,6 +11,13 @@ export interface KankakuTargetEntryData {
|
|
|
11
11
|
projectId?: string;
|
|
12
12
|
/** `true` when the user explicitly declined the picker; distinct from "no entry yet". */
|
|
13
13
|
skipped?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A hub task picked for this session (`/kankaku task pick`). Session-only:
|
|
16
|
+
* never written to the project's `.kankaku/config.json`, and dropped by
|
|
17
|
+
* any target change (`pick`/`setExplicit`/`clear`/`ensurePicked`'s
|
|
18
|
+
* silent resolution) since those all build a fresh candidate without it.
|
|
19
|
+
*/
|
|
20
|
+
hubTaskId?: string;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export const TARGET_ENTRY_TYPE = "kankaku-target";
|
|
@@ -64,6 +71,34 @@ export interface SessionTarget {
|
|
|
64
71
|
setExplicit(pi: ExtensionAPI, ids: WorkTargetCandidate): void;
|
|
65
72
|
/** Clear the session-level override; resolution falls back to the project config file / `repo_paths`. */
|
|
66
73
|
clear(pi: ExtensionAPI): void;
|
|
74
|
+
/**
|
|
75
|
+
* `/kankaku task pick`: shows the hub-task picker for the effective
|
|
76
|
+
* project's open/doing tasks and links the pick to the session (never
|
|
77
|
+
* persisted to the project config file). Notifies when there is no
|
|
78
|
+
* effective client/project yet, or the project has no open/doing task.
|
|
79
|
+
* A no-op unless `role === "orchestrator"` and `ctx.hasUI`.
|
|
80
|
+
*/
|
|
81
|
+
pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void>;
|
|
82
|
+
/** `/kankaku task clear`: drops the linked hub task, keeping the rest of the session target. A no-op when none is linked. */
|
|
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;
|
|
67
102
|
/** Current effective target (session > project config > repoPaths), regardless of role. */
|
|
68
103
|
effectiveTarget(): WorkTarget | undefined;
|
|
69
104
|
/** Which source produced {@link effectiveTarget}. */
|
|
@@ -81,7 +116,11 @@ function candidateFrom(target: WorkTarget): WorkTargetCandidate {
|
|
|
81
116
|
}
|
|
82
117
|
|
|
83
118
|
function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
|
|
84
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
clientId: ids.clientId,
|
|
121
|
+
...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}),
|
|
122
|
+
...(ids.hubTaskId !== undefined ? { hubTaskId: ids.hubTaskId } : {}),
|
|
123
|
+
};
|
|
85
124
|
}
|
|
86
125
|
|
|
87
126
|
/**
|
|
@@ -113,7 +152,11 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
113
152
|
if (data?.skipped === true) {
|
|
114
153
|
sessionOverride = "skipped";
|
|
115
154
|
} else if (typeof data?.clientId === "string") {
|
|
116
|
-
sessionOverride = {
|
|
155
|
+
sessionOverride = {
|
|
156
|
+
clientId: data.clientId,
|
|
157
|
+
...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}),
|
|
158
|
+
...(typeof data.hubTaskId === "string" ? { hubTaskId: data.hubTaskId } : {}),
|
|
159
|
+
};
|
|
117
160
|
} else {
|
|
118
161
|
sessionOverride = undefined;
|
|
119
162
|
}
|
|
@@ -131,6 +174,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
131
174
|
cwd: cwd(),
|
|
132
175
|
clients: snapshot?.clients ?? [],
|
|
133
176
|
projects: snapshot?.projects ?? [],
|
|
177
|
+
tasks: snapshot?.tasks ?? [],
|
|
134
178
|
});
|
|
135
179
|
}
|
|
136
180
|
|
|
@@ -320,5 +364,68 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
320
364
|
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
|
|
321
365
|
}
|
|
322
366
|
|
|
323
|
-
|
|
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
|
+
|
|
383
|
+
async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
384
|
+
if (deps.role !== "orchestrator" || !ctx.hasUI) return;
|
|
385
|
+
|
|
386
|
+
const target = effectiveTarget();
|
|
387
|
+
if (!target || target.projectId === undefined) {
|
|
388
|
+
ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const tasks = deps.catalog.read()?.tasks ?? [];
|
|
393
|
+
const result = await pickHubTask(ctx, tasks, target.projectId);
|
|
394
|
+
|
|
395
|
+
if (result.kind === "empty") {
|
|
396
|
+
ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (result.kind === "skipped") return;
|
|
400
|
+
|
|
401
|
+
setTask(pi, result.task.id);
|
|
402
|
+
|
|
403
|
+
const updated = effectiveTarget();
|
|
404
|
+
if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function clearTask(pi: ExtensionAPI): void {
|
|
408
|
+
if (sessionOverride === undefined || sessionOverride === "skipped") return;
|
|
409
|
+
if (sessionOverride.hubTaskId === undefined) return;
|
|
410
|
+
const { hubTaskId: _hubTaskId, ...rest } = sessionOverride;
|
|
411
|
+
sessionOverride = rest;
|
|
412
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
|
|
413
|
+
}
|
|
414
|
+
|
|
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
|
+
};
|
|
324
431
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { Client, Project, WorkTarget } from "../domain/work-target.ts";
|
|
2
|
+
import type { Client, HubTask, Project, WorkTarget } from "../domain/work-target.ts";
|
|
3
3
|
|
|
4
4
|
const SKIP_OPTION = "— skip —";
|
|
5
5
|
const NO_PROJECT_OPTION = "(no project)";
|
|
@@ -80,3 +80,48 @@ export async function pickTarget(ctx: ExtensionContext, catalog: PickerCatalog):
|
|
|
80
80
|
|
|
81
81
|
return { kind: "picked", target };
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
export type PickHubTaskResult = { kind: "picked"; task: HubTask } | { kind: "skipped" } | { kind: "empty" };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build `label -> task` options for {@link pickHubTask}, sorted by title.
|
|
88
|
+
* When two tasks share the same title, disambiguate every colliding label
|
|
89
|
+
* with the task's `externalRef` when it has one, or its bare id otherwise —
|
|
90
|
+
* unlike {@link labelOptions}, a hub task has no `code`, and every task
|
|
91
|
+
* needs a disambiguator, not just the ones lucky enough to have one.
|
|
92
|
+
*/
|
|
93
|
+
function labelTaskOptions(tasks: HubTask[]): LabeledOption<HubTask>[] {
|
|
94
|
+
const sorted = [...tasks].sort((a, b) => a.title.localeCompare(b.title));
|
|
95
|
+
const titleCounts = new Map<string, number>();
|
|
96
|
+
for (const task of sorted) {
|
|
97
|
+
titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return sorted.map((task) => {
|
|
101
|
+
const collides = (titleCounts.get(task.title) ?? 0) > 1;
|
|
102
|
+
const label = collides ? `${task.title} (${task.externalRef ?? task.id})` : task.title;
|
|
103
|
+
return { label, item: task };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the hub-task picker (`ctx.ui.select`) for `projectId`'s open/doing
|
|
109
|
+
* tasks. Pure UI interaction: no network, no persistence — the caller
|
|
110
|
+
* (`session-target.ts#pickTask`) decides what to do with the result.
|
|
111
|
+
* `{ kind: "empty" }` is returned without ever prompting when the project
|
|
112
|
+
* has no open/doing task, so the caller can tell "nothing to pick from"
|
|
113
|
+
* apart from "the user skipped".
|
|
114
|
+
*/
|
|
115
|
+
export async function pickHubTask(ctx: ExtensionContext, tasks: HubTask[], projectId: string): Promise<PickHubTaskResult> {
|
|
116
|
+
const pickable = tasks.filter((task) => task.status !== "done" && task.projectId === projectId);
|
|
117
|
+
if (pickable.length === 0) return { kind: "empty" };
|
|
118
|
+
|
|
119
|
+
const taskOptions = labelTaskOptions(pickable);
|
|
120
|
+
const choice = await ctx.ui.select("kankaku — task", [...taskOptions.map((option) => option.label), SKIP_OPTION]);
|
|
121
|
+
if (choice === undefined || choice === SKIP_OPTION) return { kind: "skipped" };
|
|
122
|
+
|
|
123
|
+
const task = taskOptions.find((option) => option.label === choice)?.item;
|
|
124
|
+
if (!task) return { kind: "skipped" };
|
|
125
|
+
|
|
126
|
+
return { kind: "picked", task };
|
|
127
|
+
}
|
package/src/domain/hub-entry.ts
CHANGED
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
import type { TaskView } from "./task-view.ts";
|
|
14
14
|
import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
|
|
15
15
|
import { finiteOrZero } from "./work-record.ts";
|
|
16
|
-
import type { Client, Project } from "./work-target.ts";
|
|
16
|
+
import type { Client, HubTask, Project } from "./work-target.ts";
|
|
17
17
|
|
|
18
18
|
export type PromptPrivacyMode = "none" | "truncated" | "full";
|
|
19
19
|
|
|
20
20
|
export interface HubEntryContext {
|
|
21
21
|
clients: Client[];
|
|
22
22
|
projects: Project[];
|
|
23
|
+
/** The hub's known tasks, to validate a task's linked `hubTaskId` against. See {@link resolveTaskAssignment}. */
|
|
24
|
+
tasks: HubTask[];
|
|
23
25
|
/** This machine's hostname or `KANKAKU_MACHINE`. */
|
|
24
26
|
machine: string;
|
|
25
27
|
/** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
|
|
@@ -39,6 +41,8 @@ export interface TaskAssignment {
|
|
|
39
41
|
clientId: string;
|
|
40
42
|
/** `projects` relation id, or `""`. */
|
|
41
43
|
projectId: string;
|
|
44
|
+
/** `tasks` relation id, or `""` when no usable hub task could be resolved. See {@link resolveTaskAssignment}. */
|
|
45
|
+
hubTaskId: string;
|
|
42
46
|
/** Only set (non-empty) for a task routed to the unassigned client. */
|
|
43
47
|
legacyClientLabel: string;
|
|
44
48
|
/** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
|
|
@@ -46,7 +50,8 @@ export interface TaskAssignment {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
49
|
-
* Resolve which client/project a task's `task_entries` row should
|
|
53
|
+
* Resolve which client/project/hub-task a task's `task_entries` row should
|
|
54
|
+
* link to.
|
|
50
55
|
*
|
|
51
56
|
* - A task whose `clientId` still exists in `clients` links to that client
|
|
52
57
|
* (regardless of its `active` flag — this is a historical fact, not a
|
|
@@ -57,16 +62,25 @@ export interface TaskAssignment {
|
|
|
57
62
|
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
58
63
|
* label (or its `clientName` when the label itself is absent) as
|
|
59
64
|
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
65
|
+
* - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
|
|
66
|
+
* the resolved `projectId` (non-empty); otherwise it is `""` — including
|
|
67
|
+
* whenever the client fell back to unassigned, since there is then no
|
|
68
|
+
* resolved project for a task to belong to.
|
|
60
69
|
*/
|
|
61
|
-
export function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[]): TaskAssignment {
|
|
70
|
+
export function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[], tasks: HubTask[]): TaskAssignment {
|
|
62
71
|
const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
|
|
63
72
|
|
|
64
73
|
if (client) {
|
|
65
74
|
const project =
|
|
66
75
|
task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
|
|
76
|
+
const hubTask =
|
|
77
|
+
project !== undefined && task.hubTaskId !== undefined
|
|
78
|
+
? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
|
|
79
|
+
: undefined;
|
|
67
80
|
return {
|
|
68
81
|
clientId: client.id,
|
|
69
82
|
projectId: project ? project.id : "",
|
|
83
|
+
hubTaskId: hubTask ? hubTask.id : "",
|
|
70
84
|
legacyClientLabel: "",
|
|
71
85
|
routedToUnassigned: false,
|
|
72
86
|
};
|
|
@@ -76,6 +90,7 @@ export function resolveTaskAssignment(task: TaskView, clients: Client[], project
|
|
|
76
90
|
return {
|
|
77
91
|
clientId: unassigned ? unassigned.id : "",
|
|
78
92
|
projectId: "",
|
|
93
|
+
hubTaskId: "",
|
|
79
94
|
legacyClientLabel: task.client ?? task.clientName ?? "",
|
|
80
95
|
routedToUnassigned: true,
|
|
81
96
|
};
|
|
@@ -208,12 +223,12 @@ export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project"
|
|
|
208
223
|
|
|
209
224
|
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
210
225
|
export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload {
|
|
211
|
-
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects);
|
|
226
|
+
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
|
|
212
227
|
return {
|
|
213
228
|
task_id: task.id,
|
|
214
229
|
client: assignment.clientId,
|
|
215
230
|
project: assignment.projectId,
|
|
216
|
-
task:
|
|
231
|
+
task: assignment.hubTaskId,
|
|
217
232
|
started_at: toPbDate(task.startedAt),
|
|
218
233
|
ended_at: toPbDate(task.endedAt),
|
|
219
234
|
wall_ms: task.wallMs,
|
|
@@ -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/domain/task-view.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface TaskView {
|
|
|
36
36
|
projectId?: string;
|
|
37
37
|
/** Hub project display name, from the orchestrator record only. */
|
|
38
38
|
projectName?: string;
|
|
39
|
+
/** Hub task record id, from the orchestrator record only. See `domain/work-target.ts#HubTask`. */
|
|
40
|
+
hubTaskId?: string;
|
|
41
|
+
/** Hub task title, from the orchestrator record only. */
|
|
42
|
+
hubTaskTitle?: string;
|
|
39
43
|
/**
|
|
40
44
|
* Per-tag total milliseconds across the orchestrator and every subagent,
|
|
41
45
|
* summed rather than unioned: unlike `wallMs`, segment intervals are not
|
|
@@ -368,6 +372,8 @@ function buildTaskView(orchestrator: WorkRecord, subagents: WorkRecord[]): TaskV
|
|
|
368
372
|
...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
|
|
369
373
|
...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
|
|
370
374
|
...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
|
|
375
|
+
...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
|
|
376
|
+
...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
|
|
371
377
|
project: orchestrator.project,
|
|
372
378
|
prompt: orchestrator.prompt,
|
|
373
379
|
startedAt: orchestrator.startedAt,
|
|
@@ -168,6 +168,10 @@ export interface WorkRecordMetadata {
|
|
|
168
168
|
projectId?: string;
|
|
169
169
|
/** Hub project display name, denormalised alongside `projectId`. */
|
|
170
170
|
projectName?: string;
|
|
171
|
+
/** Hub `tasks` record id linked for this session (`/kankaku task pick`), when one is active. See `domain/work-target.ts#HubTask`. */
|
|
172
|
+
hubTaskId?: string;
|
|
173
|
+
/** Hub task title, denormalised alongside `hubTaskId`. */
|
|
174
|
+
hubTaskTitle?: string;
|
|
171
175
|
/** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
|
|
172
176
|
machine?: string;
|
|
173
177
|
/**
|
|
@@ -210,6 +214,23 @@ export function finiteOrZero(value: unknown): number {
|
|
|
210
214
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Share of prompt input tokens served from the provider's prompt cache:
|
|
219
|
+
* `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
|
|
220
|
+
* the provider's `input_tokens`, which already excludes cached tokens, so
|
|
221
|
+
* the three fields are disjoint and this sum is the true denominator.
|
|
222
|
+
* Returns `undefined` when the denominator is `0` (nothing to compute a
|
|
223
|
+
* ratio from) rather than `0`, so callers never render a misleading `0%`.
|
|
224
|
+
* Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
|
|
225
|
+
*/
|
|
226
|
+
export function cacheHitRatio(usage: { input: number; cacheRead: number; cacheWrite: number }): number | undefined {
|
|
227
|
+
const input = finiteOrZero(usage.input);
|
|
228
|
+
const cacheRead = finiteOrZero(usage.cacheRead);
|
|
229
|
+
const cacheWrite = finiteOrZero(usage.cacheWrite);
|
|
230
|
+
const denominator = input + cacheRead + cacheWrite;
|
|
231
|
+
return denominator === 0 ? undefined : cacheRead / denominator;
|
|
232
|
+
}
|
|
233
|
+
|
|
213
234
|
const ROLES = new Set<WorkRole>(["orchestrator", "subagent"]);
|
|
214
235
|
const STATUSES = new Set<WorkStatus>(["completed", "aborted", "interrupted"]);
|
|
215
236
|
|
|
@@ -267,6 +288,8 @@ export function isWorkRecord(value: unknown): value is WorkRecord {
|
|
|
267
288
|
(record["clientName"] === undefined || typeof record["clientName"] === "string") &&
|
|
268
289
|
(record["projectId"] === undefined || typeof record["projectId"] === "string") &&
|
|
269
290
|
(record["projectName"] === undefined || typeof record["projectName"] === "string") &&
|
|
291
|
+
(record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
|
|
292
|
+
(record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
|
|
270
293
|
(record["machine"] === undefined || typeof record["machine"] === "string") &&
|
|
271
294
|
(record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
|
|
272
295
|
(record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
|