kankaku 0.6.0 → 0.6.5
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 +49 -8
- 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/kankaku-command.ts +43 -1
- package/src/adapters/pi-tracker.ts +2 -0
- package/src/adapters/pocketbase-catalog.ts +36 -7
- package/src/adapters/pocketbase-sink.ts +15 -3
- package/src/adapters/report.ts +66 -16
- package/src/adapters/session-target.ts +64 -4
- package/src/adapters/target-picker.ts +46 -1
- package/src/domain/hub-entry.ts +20 -5
- 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 +1 -0
- package/src/ports/catalog.ts +4 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
3
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
4
4
|
import type { Clock } from "../ports/clock.ts";
|
|
5
5
|
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
6
6
|
import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.ts";
|
|
@@ -16,6 +16,10 @@ function isProjectArray(value: unknown): value is Project[] {
|
|
|
16
16
|
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as Project).id === "string");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function isHubTaskArray(value: unknown): value is HubTask[] {
|
|
20
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as HubTask).id === "string");
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
|
|
20
24
|
if (!value || typeof value !== "object") return false;
|
|
21
25
|
const record = value as Record<string, unknown>;
|
|
@@ -23,7 +27,8 @@ function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
|
|
|
23
27
|
typeof record["fetchedAt"] === "number" &&
|
|
24
28
|
typeof record["url"] === "string" &&
|
|
25
29
|
isClientArray(record["clients"]) &&
|
|
26
|
-
isProjectArray(record["projects"])
|
|
30
|
+
isProjectArray(record["projects"]) &&
|
|
31
|
+
(record["tasks"] === undefined || isHubTaskArray(record["tasks"]))
|
|
27
32
|
);
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -35,8 +40,13 @@ export interface CachedCatalogDeps {
|
|
|
35
40
|
clock: Clock;
|
|
36
41
|
/** Cache TTL in ms. Defaults to 6 hours. */
|
|
37
42
|
ttlMs?: number;
|
|
38
|
-
/**
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
|
|
45
|
+
* `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
|
|
46
|
+
* on the real PocketBase fetcher) so a test double that only cares about
|
|
47
|
+
* clients/projects keeps compiling unchanged.
|
|
48
|
+
*/
|
|
49
|
+
fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks?: HubTask[] }>;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/**
|
|
@@ -73,8 +83,14 @@ export class CachedCatalog implements Catalog {
|
|
|
73
83
|
|
|
74
84
|
async refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined> {
|
|
75
85
|
try {
|
|
76
|
-
const { clients, projects } = await this.deps.fetchCatalog(signal);
|
|
77
|
-
const snapshot: CatalogSnapshot = {
|
|
86
|
+
const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
|
|
87
|
+
const snapshot: CatalogSnapshot = {
|
|
88
|
+
fetchedAt: this.deps.clock.now(),
|
|
89
|
+
url: this.deps.url,
|
|
90
|
+
clients,
|
|
91
|
+
projects,
|
|
92
|
+
...(tasks !== undefined ? { tasks } : {}),
|
|
93
|
+
};
|
|
78
94
|
this.writeDisk(snapshot);
|
|
79
95
|
this.memo = snapshot;
|
|
80
96
|
this.memoized = true;
|
|
@@ -46,8 +46,9 @@ export function notifyError(ctx: ExtensionContext, error: unknown): void {
|
|
|
46
46
|
|
|
47
47
|
const COMMAND_TOKENS = ["all", "tasks", "sessions", "client", "clients", "export", "doctor"];
|
|
48
48
|
/** Only offered when the hub is configured, so completions are unchanged for users without one. */
|
|
49
|
-
const HUB_COMMAND_TOKENS = ["target", "projects", "catalog", "sync", "backfill"];
|
|
49
|
+
const HUB_COMMAND_TOKENS = ["target", "task", "projects", "catalog", "sync", "backfill"];
|
|
50
50
|
const TARGET_TOKENS = ["pick", "clear"];
|
|
51
|
+
const TASK_TOKENS = ["pick", "clear"];
|
|
51
52
|
const CATALOG_TOKENS = ["refresh"];
|
|
52
53
|
const SYNC_TOKENS = ["all", "status"];
|
|
53
54
|
|
|
@@ -328,6 +329,35 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
328
329
|
notifyError(ctx, new Error(`unknown target subcommand: ${rest.join(" ")}`));
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Handle `/kankaku task [pick|clear]`; `rest` excludes the leading `task`
|
|
334
|
+
* token. Default (no args) is `pick`. `pickTask`/`clearTask`
|
|
335
|
+
* (`session-target.ts`) notify their own outcome directly, so this only
|
|
336
|
+
* dispatches and refreshes the idle status line.
|
|
337
|
+
*/
|
|
338
|
+
async function handleTaskCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
|
|
339
|
+
const sessionTarget = deps.sessionTarget;
|
|
340
|
+
if (!sessionTarget) {
|
|
341
|
+
notifyError(ctx, new Error("hub is not configured"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (rest.length === 1 && rest[0] === "clear") {
|
|
346
|
+
sessionTarget.clearTask(pi);
|
|
347
|
+
deps.refreshIdleStatus(ctx);
|
|
348
|
+
showReport(ctx, { title: "task", lines: ["task link cleared for this session"] });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (rest.length === 0 || (rest.length === 1 && rest[0] === "pick")) {
|
|
353
|
+
await sessionTarget.pickTask(pi, ctx);
|
|
354
|
+
deps.refreshIdleStatus(ctx);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
notifyError(ctx, new Error(`unknown task subcommand: ${rest.join(" ")}`));
|
|
359
|
+
}
|
|
360
|
+
|
|
331
361
|
/** Handle `/kankaku catalog refresh`; `rest` excludes the leading `catalog` token. */
|
|
332
362
|
async function handleCatalogCommand(rest: string[], ctx: ExtensionContext): Promise<void> {
|
|
333
363
|
const catalog = deps.catalog;
|
|
@@ -598,6 +628,8 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
598
628
|
"'doctor' to report orphan/uncertain subagent counts and ancestor-detection availability (no network). " +
|
|
599
629
|
"When a hub (PocketBase) is configured: 'target' to show the effective client/project and its source, " +
|
|
600
630
|
"'target pick' to run the picker again, 'target clear' to clear the session target, " +
|
|
631
|
+
"'task' (or 'task pick') to link this session to an open/doing hub task of the effective project, " +
|
|
632
|
+
"'task clear' to drop the link, " +
|
|
601
633
|
"'catalog refresh' to force a catalog refresh, 'projects' for per-project totals today ('projects all' for every day), " +
|
|
602
634
|
"'sync' to push pending tasks to the hub ('sync all' for a full re-evaluation, 'sync status' for the watermark/pending count/last error), " +
|
|
603
635
|
"'backfill' to run a full sync and report how many tasks went to Sin determinar, grouped by their old label. " +
|
|
@@ -615,6 +647,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
615
647
|
const prefix = targetMatch[1] ?? "";
|
|
616
648
|
return TARGET_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
|
|
617
649
|
}
|
|
650
|
+
const taskMatch = /^task\s+(\S*)$/.exec(argumentPrefix);
|
|
651
|
+
if (taskMatch) {
|
|
652
|
+
const prefix = taskMatch[1] ?? "";
|
|
653
|
+
return TASK_TOKENS.filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value }));
|
|
654
|
+
}
|
|
618
655
|
const catalogMatch = /^catalog\s+(\S*)$/.exec(argumentPrefix);
|
|
619
656
|
if (catalogMatch) {
|
|
620
657
|
const prefix = catalogMatch[1] ?? "";
|
|
@@ -647,6 +684,11 @@ export function registerKankakuCommand(pi: ExtensionAPI, deps: KankakuCommandDep
|
|
|
647
684
|
return;
|
|
648
685
|
}
|
|
649
686
|
|
|
687
|
+
if (tokens[0] === "task") {
|
|
688
|
+
await handleTaskCommand(tokens.slice(1), ctx);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
650
692
|
if (tokens[0] === "catalog") {
|
|
651
693
|
await handleCatalogCommand(tokens.slice(1), ctx);
|
|
652
694
|
return;
|
|
@@ -386,6 +386,8 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
|
|
|
386
386
|
clientName: target.clientName,
|
|
387
387
|
...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
|
|
388
388
|
...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
|
|
389
|
+
...(target.hubTaskId !== undefined ? { hubTaskId: target.hubTaskId } : {}),
|
|
390
|
+
...(target.hubTaskTitle !== undefined ? { hubTaskTitle: target.hubTaskTitle } : {}),
|
|
389
391
|
}
|
|
390
392
|
: {}),
|
|
391
393
|
...(deps.machine !== undefined ? { machine: deps.machine } : {}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
2
|
import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
|
|
3
3
|
|
|
4
4
|
interface ClientRecord extends PocketBaseRecord {
|
|
@@ -16,6 +16,15 @@ interface ProjectRecord extends PocketBaseRecord {
|
|
|
16
16
|
active?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
interface TaskRecord extends PocketBaseRecord {
|
|
20
|
+
title?: string;
|
|
21
|
+
project: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
external_ref?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TASK_STATUSES = new Set(["open", "doing", "done"]);
|
|
27
|
+
|
|
19
28
|
function mapClient(record: ClientRecord): Client {
|
|
20
29
|
return {
|
|
21
30
|
id: record.id,
|
|
@@ -37,24 +46,44 @@ function mapProject(record: ProjectRecord): Project {
|
|
|
37
46
|
};
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
/** `open` on any unrecognised or missing status, rather than dropping the task. */
|
|
50
|
+
function mapTaskStatus(status: string | undefined): HubTask["status"] {
|
|
51
|
+
return status !== undefined && TASK_STATUSES.has(status) ? (status as HubTask["status"]) : "open";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mapTask(record: TaskRecord): HubTask {
|
|
55
|
+
return {
|
|
56
|
+
id: record.id,
|
|
57
|
+
title: record.title ?? "",
|
|
58
|
+
projectId: record.project,
|
|
59
|
+
status: mapTaskStatus(record.status),
|
|
60
|
+
...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
40
64
|
/**
|
|
41
65
|
* Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
|
|
42
|
-
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients
|
|
43
|
-
* `projects` record (no `active` filter,
|
|
44
|
-
* "deleted" from "inactive"; see
|
|
45
|
-
* `
|
|
66
|
+
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
|
|
67
|
+
* `projects` and `tasks` record in parallel (no `active`/`status` filter,
|
|
68
|
+
* so the domain resolver can tell "deleted" from "inactive"/"done"; see
|
|
69
|
+
* `domain/work-target.ts`), clients/projects sorted by name and tasks by
|
|
70
|
+
* title. `signal`, when given, bounds every fetch (see
|
|
46
71
|
* `pocketbase-client.ts#request`).
|
|
47
72
|
*/
|
|
48
|
-
export function createPocketBaseCatalogFetcher(
|
|
73
|
+
export function createPocketBaseCatalogFetcher(
|
|
74
|
+
client: PocketBaseClient,
|
|
75
|
+
): (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks: HubTask[] }> {
|
|
49
76
|
return async (signal?: AbortSignal) => {
|
|
50
|
-
const [clientRecords, projectRecords] = await Promise.all([
|
|
77
|
+
const [clientRecords, projectRecords, taskRecords] = await Promise.all([
|
|
51
78
|
client.list<ClientRecord>("clients", { sort: "name" }, signal),
|
|
52
79
|
client.list<ProjectRecord>("projects", { sort: "name" }, signal),
|
|
80
|
+
client.list<TaskRecord>("tasks", { sort: "title" }, signal),
|
|
53
81
|
]);
|
|
54
82
|
|
|
55
83
|
return {
|
|
56
84
|
clients: clientRecords.map(mapClient),
|
|
57
85
|
projects: projectRecords.map(mapProject),
|
|
86
|
+
tasks: taskRecords.map(mapTask),
|
|
58
87
|
};
|
|
59
88
|
};
|
|
60
89
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { buildTaskEntryCreatePayload, buildTaskEntryUpdatePayload, buildWorkRecordPayload, resolveTaskAssignment, taskWorkRecords } from "../domain/hub-entry.ts";
|
|
15
15
|
import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
|
|
16
16
|
import type { TaskView } from "../domain/task-view.ts";
|
|
17
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
17
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
18
18
|
import type { PushOutcome, PushTaskResult, WorkSink } from "../ports/work-sink.ts";
|
|
19
19
|
import type { PocketBaseClient, PocketBaseRecord } from "./pocketbase-client.ts";
|
|
20
20
|
import { PocketBaseError } from "./pocketbase-client.ts";
|
|
@@ -24,6 +24,7 @@ export interface PocketBaseSinkDeps {
|
|
|
24
24
|
/** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
|
|
25
25
|
clients: Client[];
|
|
26
26
|
projects: Project[];
|
|
27
|
+
tasks: HubTask[];
|
|
27
28
|
machine: string;
|
|
28
29
|
promptMode: PromptPrivacyMode;
|
|
29
30
|
/** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
|
|
@@ -105,10 +106,21 @@ export class PocketBaseSink implements WorkSink {
|
|
|
105
106
|
return found.get(value);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
private get entryContext(): {
|
|
109
|
+
private get entryContext(): {
|
|
110
|
+
clients: Client[];
|
|
111
|
+
projects: Project[];
|
|
112
|
+
tasks: HubTask[];
|
|
113
|
+
machine: string;
|
|
114
|
+
promptMode: PromptPrivacyMode;
|
|
115
|
+
agent: string;
|
|
116
|
+
agentVersion?: string;
|
|
117
|
+
plugin: string;
|
|
118
|
+
pluginVersion?: string;
|
|
119
|
+
} {
|
|
109
120
|
return {
|
|
110
121
|
clients: this.deps.clients,
|
|
111
122
|
projects: this.deps.projects,
|
|
123
|
+
tasks: this.deps.tasks,
|
|
112
124
|
machine: this.deps.machine,
|
|
113
125
|
promptMode: this.deps.promptMode,
|
|
114
126
|
agent: this.deps.agent ?? "pi",
|
|
@@ -177,7 +189,7 @@ export class PocketBaseSink implements WorkSink {
|
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
private async pushOne(task: TaskView, existingId: string | undefined): Promise<PushOutcome> {
|
|
180
|
-
const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
|
|
192
|
+
const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
|
|
181
193
|
const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
|
|
182
194
|
|
|
183
195
|
try {
|
package/src/adapters/report.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildTasks, uncertainRecords } from "../domain/task-view.ts";
|
|
2
2
|
import type { SessionView, TaskView } from "../domain/task-view.ts";
|
|
3
3
|
import { localDay } from "../domain/day.ts";
|
|
4
|
-
import { finiteOrZero } from "../domain/work-record.ts";
|
|
4
|
+
import { cacheHitRatio, finiteOrZero } from "../domain/work-record.ts";
|
|
5
5
|
import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
|
|
6
6
|
|
|
7
7
|
export { localDay } from "../domain/day.ts";
|
|
@@ -13,6 +13,12 @@ export interface RoleTotals {
|
|
|
13
13
|
count: number;
|
|
14
14
|
/** Estimated cost in USD, as priced by pi's model table. */
|
|
15
15
|
cost: number;
|
|
16
|
+
/** Prompt input tokens, summed across every record of this role. */
|
|
17
|
+
input: number;
|
|
18
|
+
/** Prompt-cache read tokens, summed across every record of this role. */
|
|
19
|
+
cacheRead: number;
|
|
20
|
+
/** Prompt-cache write tokens, summed across every record of this role. */
|
|
21
|
+
cacheWrite: number;
|
|
16
22
|
/** Per-tag total milliseconds summed across every record of this role. */
|
|
17
23
|
segments: Record<string, number>;
|
|
18
24
|
}
|
|
@@ -23,6 +29,12 @@ export interface TaskTotals {
|
|
|
23
29
|
workMs: number;
|
|
24
30
|
/** Estimated cost in USD, orchestrator and subagents combined. */
|
|
25
31
|
cost: number;
|
|
32
|
+
/** Prompt input tokens, orchestrator and subagents combined. */
|
|
33
|
+
input: number;
|
|
34
|
+
/** Prompt-cache read tokens, orchestrator and subagents combined. */
|
|
35
|
+
cacheRead: number;
|
|
36
|
+
/** Prompt-cache write tokens, orchestrator and subagents combined. */
|
|
37
|
+
cacheWrite: number;
|
|
26
38
|
/** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
|
|
27
39
|
segments: Record<string, number>;
|
|
28
40
|
}
|
|
@@ -39,7 +51,7 @@ export interface SummarizeOptions {
|
|
|
39
51
|
const ROLES: WorkRole[] = ["orchestrator", "subagent"];
|
|
40
52
|
|
|
41
53
|
function emptyTotals(): RoleTotals {
|
|
42
|
-
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, segments: {} };
|
|
54
|
+
return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
/**
|
|
@@ -67,7 +79,7 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
67
79
|
const summary: Summary = {
|
|
68
80
|
orchestrator: emptyTotals(),
|
|
69
81
|
subagent: emptyTotals(),
|
|
70
|
-
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, segments: {} },
|
|
82
|
+
tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
|
|
71
83
|
};
|
|
72
84
|
// Accumulated in Maps (see `addSegments`) and only converted to the
|
|
73
85
|
// returned plain objects at the very end, via `Object.fromEntries`.
|
|
@@ -82,6 +94,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
82
94
|
totals.wallMs += record.wallMs;
|
|
83
95
|
totals.count += 1;
|
|
84
96
|
totals.cost += finiteOrZero(record.usage.cost);
|
|
97
|
+
totals.input += finiteOrZero(record.usage.input);
|
|
98
|
+
totals.cacheRead += finiteOrZero(record.usage.cacheRead);
|
|
99
|
+
totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
|
|
85
100
|
addSegments(segmentsByRole[record.role], record.segments);
|
|
86
101
|
}
|
|
87
102
|
|
|
@@ -91,6 +106,9 @@ export function summarize(records: WorkRecord[], options: SummarizeOptions): Sum
|
|
|
91
106
|
summary.tasks.wallMs += task.wallMs;
|
|
92
107
|
summary.tasks.workMs += task.workMs;
|
|
93
108
|
summary.tasks.cost += task.usage.cost;
|
|
109
|
+
summary.tasks.input += finiteOrZero(task.usage.input);
|
|
110
|
+
summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
111
|
+
summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
94
112
|
addSegments(taskSegments, task.segments);
|
|
95
113
|
}
|
|
96
114
|
|
|
@@ -131,6 +149,12 @@ function formatTime(iso: string): string {
|
|
|
131
149
|
return `${hours}:${minutes}`;
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
/** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
|
|
153
|
+
function formatCacheHit(usage: { input: number; cacheRead: number; cacheWrite: number }): string | undefined {
|
|
154
|
+
const ratio = cacheHitRatio(usage);
|
|
155
|
+
return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
/** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
|
|
135
159
|
function formatSegmentTags(segments: Record<string, number>): string | undefined {
|
|
136
160
|
const tags = Object.keys(segments)
|
|
@@ -144,10 +168,14 @@ function formatSegmentTags(segments: Record<string, number>): string | undefined
|
|
|
144
168
|
export function formatReport(summary: Summary): string {
|
|
145
169
|
const lines = ROLES.map((role) => {
|
|
146
170
|
const totals = summary[role];
|
|
147
|
-
|
|
171
|
+
const cacheHit = formatCacheHit(totals);
|
|
172
|
+
const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
|
|
173
|
+
return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
|
|
148
174
|
});
|
|
175
|
+
const tasksCacheHit = formatCacheHit(summary.tasks);
|
|
176
|
+
const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
|
|
149
177
|
lines.push(
|
|
150
|
-
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}`,
|
|
178
|
+
`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`,
|
|
151
179
|
);
|
|
152
180
|
const segmentTags = formatSegmentTags(summary.tasks.segments);
|
|
153
181
|
if (segmentTags !== undefined) {
|
|
@@ -173,7 +201,9 @@ export function formatTasks(tasks: TaskView[]): string {
|
|
|
173
201
|
const segmentTags = formatSegmentTags(task.segments);
|
|
174
202
|
const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
|
|
175
203
|
const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
|
|
176
|
-
|
|
204
|
+
const cacheHit = formatCacheHit(task.usage);
|
|
205
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
206
|
+
return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
|
|
177
207
|
})
|
|
178
208
|
.join("\n");
|
|
179
209
|
}
|
|
@@ -184,6 +214,12 @@ export interface ClientTotals {
|
|
|
184
214
|
workMs: number;
|
|
185
215
|
/** Estimated cost in USD, summed across this client's tasks. */
|
|
186
216
|
cost: number;
|
|
217
|
+
/** Prompt input tokens, summed across this client's tasks. */
|
|
218
|
+
input: number;
|
|
219
|
+
/** Prompt-cache read tokens, summed across this client's tasks. */
|
|
220
|
+
cacheRead: number;
|
|
221
|
+
/** Prompt-cache write tokens, summed across this client's tasks. */
|
|
222
|
+
cacheWrite: number;
|
|
187
223
|
count: number;
|
|
188
224
|
}
|
|
189
225
|
|
|
@@ -201,11 +237,14 @@ export function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>
|
|
|
201
237
|
const totals = new Map<string, ClientTotals>();
|
|
202
238
|
for (const task of tasks) {
|
|
203
239
|
const key = task.client ?? NO_CLIENT;
|
|
204
|
-
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
|
|
240
|
+
const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
205
241
|
entry.wallMs += task.wallMs;
|
|
206
242
|
entry.waitingMs += task.waitingMs;
|
|
207
243
|
entry.workMs += task.workMs;
|
|
208
244
|
entry.cost += finiteOrZero(task.usage.cost);
|
|
245
|
+
entry.input += finiteOrZero(task.usage.input);
|
|
246
|
+
entry.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
247
|
+
entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
209
248
|
entry.count += 1;
|
|
210
249
|
totals.set(key, entry);
|
|
211
250
|
}
|
|
@@ -220,6 +259,12 @@ export interface ProjectTotals {
|
|
|
220
259
|
workMs: number;
|
|
221
260
|
/** Estimated cost in USD, summed across this project's tasks. */
|
|
222
261
|
cost: number;
|
|
262
|
+
/** Prompt input tokens, summed across this project's tasks. */
|
|
263
|
+
input: number;
|
|
264
|
+
/** Prompt-cache read tokens, summed across this project's tasks. */
|
|
265
|
+
cacheRead: number;
|
|
266
|
+
/** Prompt-cache write tokens, summed across this project's tasks. */
|
|
267
|
+
cacheWrite: number;
|
|
223
268
|
count: number;
|
|
224
269
|
}
|
|
225
270
|
|
|
@@ -237,11 +282,14 @@ export function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals
|
|
|
237
282
|
for (const task of tasks) {
|
|
238
283
|
const key = task.projectId ?? NO_PROJECT;
|
|
239
284
|
const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
|
|
240
|
-
const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, count: 0 };
|
|
285
|
+
const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
|
|
241
286
|
entry.wallMs += task.wallMs;
|
|
242
287
|
entry.waitingMs += task.waitingMs;
|
|
243
288
|
entry.workMs += task.workMs;
|
|
244
289
|
entry.cost += finiteOrZero(task.usage.cost);
|
|
290
|
+
entry.input += finiteOrZero(task.usage.input);
|
|
291
|
+
entry.cacheRead += finiteOrZero(task.usage.cacheRead);
|
|
292
|
+
entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
|
|
245
293
|
entry.count += 1;
|
|
246
294
|
totals.set(key, entry);
|
|
247
295
|
}
|
|
@@ -253,10 +301,11 @@ export function formatProjects(totals: Map<string, ProjectTotals>): string {
|
|
|
253
301
|
if (totals.size === 0) return "no projects";
|
|
254
302
|
return Array.from(totals.values())
|
|
255
303
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
256
|
-
.map(
|
|
257
|
-
(t)
|
|
258
|
-
|
|
259
|
-
|
|
304
|
+
.map((t) => {
|
|
305
|
+
const cacheHit = formatCacheHit(t);
|
|
306
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
307
|
+
return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
|
|
308
|
+
})
|
|
260
309
|
.join("\n");
|
|
261
310
|
}
|
|
262
311
|
|
|
@@ -265,10 +314,11 @@ export function formatClients(totals: Map<string, ClientTotals>): string {
|
|
|
265
314
|
if (totals.size === 0) return "no clients";
|
|
266
315
|
return Array.from(totals.entries())
|
|
267
316
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
268
|
-
.map(
|
|
269
|
-
(
|
|
270
|
-
|
|
271
|
-
|
|
317
|
+
.map(([client, t]) => {
|
|
318
|
+
const cacheHit = formatCacheHit(t);
|
|
319
|
+
const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
|
|
320
|
+
return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
|
|
321
|
+
})
|
|
272
322
|
.join("\n");
|
|
273
323
|
}
|
|
274
324
|
|
|
@@ -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,16 @@ 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;
|
|
67
84
|
/** Current effective target (session > project config > repoPaths), regardless of role. */
|
|
68
85
|
effectiveTarget(): WorkTarget | undefined;
|
|
69
86
|
/** Which source produced {@link effectiveTarget}. */
|
|
@@ -81,7 +98,11 @@ function candidateFrom(target: WorkTarget): WorkTargetCandidate {
|
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
function entryDataFrom(ids: WorkTargetCandidate): KankakuTargetEntryData {
|
|
84
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
clientId: ids.clientId,
|
|
103
|
+
...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}),
|
|
104
|
+
...(ids.hubTaskId !== undefined ? { hubTaskId: ids.hubTaskId } : {}),
|
|
105
|
+
};
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
/**
|
|
@@ -113,7 +134,11 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
113
134
|
if (data?.skipped === true) {
|
|
114
135
|
sessionOverride = "skipped";
|
|
115
136
|
} else if (typeof data?.clientId === "string") {
|
|
116
|
-
sessionOverride = {
|
|
137
|
+
sessionOverride = {
|
|
138
|
+
clientId: data.clientId,
|
|
139
|
+
...(typeof data.projectId === "string" ? { projectId: data.projectId } : {}),
|
|
140
|
+
...(typeof data.hubTaskId === "string" ? { hubTaskId: data.hubTaskId } : {}),
|
|
141
|
+
};
|
|
117
142
|
} else {
|
|
118
143
|
sessionOverride = undefined;
|
|
119
144
|
}
|
|
@@ -131,6 +156,7 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
131
156
|
cwd: cwd(),
|
|
132
157
|
clients: snapshot?.clients ?? [],
|
|
133
158
|
projects: snapshot?.projects ?? [],
|
|
159
|
+
tasks: snapshot?.tasks ?? [],
|
|
134
160
|
});
|
|
135
161
|
}
|
|
136
162
|
|
|
@@ -320,5 +346,39 @@ export function createSessionTarget(deps: SessionTargetDeps): SessionTarget {
|
|
|
320
346
|
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, {});
|
|
321
347
|
}
|
|
322
348
|
|
|
323
|
-
|
|
349
|
+
async function pickTask(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
350
|
+
if (deps.role !== "orchestrator" || !ctx.hasUI) return;
|
|
351
|
+
|
|
352
|
+
const target = effectiveTarget();
|
|
353
|
+
if (!target || target.projectId === undefined) {
|
|
354
|
+
ctx.ui.notify("kankaku: Pick a client and project first (/kankaku target pick)", "warning");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const tasks = deps.catalog.read()?.tasks ?? [];
|
|
359
|
+
const result = await pickHubTask(ctx, tasks, target.projectId);
|
|
360
|
+
|
|
361
|
+
if (result.kind === "empty") {
|
|
362
|
+
ctx.ui.notify(`kankaku: No open tasks for ${target.projectName ?? target.projectId} in the hub`, "warning");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (result.kind === "skipped") return;
|
|
366
|
+
|
|
367
|
+
const ids: WorkTargetCandidate = { ...candidateFrom(target), hubTaskId: result.task.id };
|
|
368
|
+
sessionOverride = ids;
|
|
369
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(ids));
|
|
370
|
+
|
|
371
|
+
const updated = effectiveTarget();
|
|
372
|
+
if (updated) ctx.ui.notify(`kankaku: task set to ${formatWorkTargetLabel(updated)}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function clearTask(pi: ExtensionAPI): void {
|
|
376
|
+
if (sessionOverride === undefined || sessionOverride === "skipped") return;
|
|
377
|
+
if (sessionOverride.hubTaskId === undefined) return;
|
|
378
|
+
const { hubTaskId: _hubTaskId, ...rest } = sessionOverride;
|
|
379
|
+
sessionOverride = rest;
|
|
380
|
+
pi.appendEntry<KankakuTargetEntryData>(TARGET_ENTRY_TYPE, entryDataFrom(rest));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { restore, ensurePicked, pick, setExplicit, clear, pickTask, clearTask, effectiveTarget, effectiveSource, runTarget, idleTarget, endRun };
|
|
324
384
|
}
|
|
@@ -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
|
+
}
|