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
package/README.md
CHANGED
|
@@ -114,7 +114,9 @@ remains valid.
|
|
|
114
114
|
`clientId`, `clientName`, `projectId`, `projectName` and `machine` are only
|
|
115
115
|
present once a hub is configured (see "Hub (PocketBase)"); every report and
|
|
116
116
|
export written before this feature, or by a user without a hub, is
|
|
117
|
-
unaffected.
|
|
117
|
+
unaffected. `hubTaskId`/`hubTaskTitle` are set alongside them only when a
|
|
118
|
+
hub task is linked to the session (`/kankaku task pick`) — see "Linking to
|
|
119
|
+
a hub task" below.
|
|
118
120
|
|
|
119
121
|
`sessionDir` is present only when pi's session manager reports a
|
|
120
122
|
*non-default* session directory (`--session-dir`, or a resumed session
|
|
@@ -686,7 +688,11 @@ the future, this is the signal that would surface it.
|
|
|
686
688
|
## The `/kankaku` command
|
|
687
689
|
|
|
688
690
|
Run `/kankaku` inside pi to see today's totals (work, waiting, record count)
|
|
689
|
-
per role, plus a union-based tasks segment.
|
|
691
|
+
per role, plus a union-based tasks segment. Each totals line also shows
|
|
692
|
+
`cache hit NN%` when tokens were recorded: the share of prompt input tokens
|
|
693
|
+
served from the provider's prompt cache, cache reads over input plus cache
|
|
694
|
+
reads plus cache writes; the segment is omitted, not shown as `0%`, when no
|
|
695
|
+
tokens were recorded. In the interactive TUI the report
|
|
690
696
|
is appended to the chat transcript as a durable card that is never sent to
|
|
691
697
|
the LLM; without a UI (print or RPC mode) it falls back to a notification.
|
|
692
698
|
Arguments are whitespace-separated and order-insensitive:
|
|
@@ -719,6 +725,9 @@ The following are available only when a hub is configured (see "Hub
|
|
|
719
725
|
produced it. `/kankaku target pick` runs the picker again (works
|
|
720
726
|
mid-session; the new target applies to records settled afterwards).
|
|
721
727
|
`/kankaku target clear` clears the session-level target.
|
|
728
|
+
- `/kankaku task` (or `/kankaku task pick`) — link this session to an
|
|
729
|
+
open/doing hub task of the effective project. `/kankaku task clear`
|
|
730
|
+
drops the link. See "Linking to a hub task" below.
|
|
722
731
|
- `/kankaku catalog refresh` — force a catalog refresh and report the
|
|
723
732
|
client/project counts.
|
|
724
733
|
- `/kankaku projects` — one line per project (work/waiting/wall time, cost,
|
|
@@ -825,6 +834,33 @@ task view exposes it from the orchestrator record only.
|
|
|
825
834
|
The status bar shows `💼 <client> · <project>` (or just `💼 <client>` without
|
|
826
835
|
a project) in place of the legacy client label, both idle and during a run.
|
|
827
836
|
|
|
837
|
+
### Linking to a hub task
|
|
838
|
+
|
|
839
|
+
`/kankaku task` (or `/kankaku task pick`) links the current session to one
|
|
840
|
+
of the effective project's existing hub `tasks` rows: a `ctx.ui.select`
|
|
841
|
+
picker lists the project's `open`/`doing` tasks, sorted by title (colliding
|
|
842
|
+
titles are disambiguated with the task's external reference, or its id).
|
|
843
|
+
`/kankaku task clear` drops the link, keeping the rest of the session
|
|
844
|
+
target. kankaku never creates a task from pi — this only links to one
|
|
845
|
+
that already exists in the hub.
|
|
846
|
+
|
|
847
|
+
The link is **session-only**: unlike `clientId`/`projectId`, it is never
|
|
848
|
+
persisted to `<KANKAKU_DIR>/config.json`, and it is never asked for at
|
|
849
|
+
`session_start` — you always link a task explicitly, with `/kankaku task`.
|
|
850
|
+
Any target change (`/kankaku target pick`, `/kankaku target clear`, or the
|
|
851
|
+
legacy `/kankaku client <name>`) drops the linked task, since a new client
|
|
852
|
+
or project makes the old task's link meaningless. A task whose project no
|
|
853
|
+
longer matches the effective project (e.g. after a target change or a
|
|
854
|
+
reassignment in the hub) is also dropped by the domain resolver, never
|
|
855
|
+
silently linked across projects. A task already marked `done` when linked
|
|
856
|
+
keeps linking for the rest of the session — only the picker itself hides
|
|
857
|
+
`done` tasks, so you cannot accidentally pick a closed one, but finishing
|
|
858
|
+
the picked task in the hub mid-session does not break the link. Subagent
|
|
859
|
+
records never carry a linked task, exactly like `clientId`/`projectId` —
|
|
860
|
+
the task view exposes it from the orchestrator record only, and
|
|
861
|
+
`formatWorkTargetLabel` appends it to the status-bar/report label as
|
|
862
|
+
`<client> · <project> › <task title>`.
|
|
863
|
+
|
|
828
864
|
### Caching and offline behaviour
|
|
829
865
|
|
|
830
866
|
The catalog (clients/projects) is cached machine-wide at
|
|
@@ -905,10 +941,13 @@ web app) can move a task from one client/project to another directly in
|
|
|
905
941
|
PocketBase — for example, moving a "Sin determinar" row to its real
|
|
906
942
|
client once you have identified it. A later re-sync of that same task
|
|
907
943
|
**must never undo that**: on create kankaku sends the full row, including
|
|
908
|
-
`client`/`project`/`legacy_client_label`; on every subsequent update
|
|
909
|
-
sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
|
|
910
|
-
touches assignment fields again.
|
|
911
|
-
|
|
944
|
+
`client`/`project`/`task`/`legacy_client_label`; on every subsequent update
|
|
945
|
+
it sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
|
|
946
|
+
never touches assignment fields again. `task` (the linked `tasks` relation)
|
|
947
|
+
is create-only for the exact same reason: reassigning which task a row
|
|
948
|
+
belongs to in the web app is never undone by a later sync. If you need
|
|
949
|
+
kankaku itself to change a task's assignment, do it in the web app, not by
|
|
950
|
+
re-syncing.
|
|
912
951
|
|
|
913
952
|
**Historical ("Sin determinar") records.** A record with no `clientId`, or
|
|
914
953
|
whose `clientId` no longer resolves in the catalog, is routed to the hub's
|
|
@@ -1252,8 +1291,10 @@ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
|
|
|
1252
1291
|
is not; the compiled entry points are today consumed as a library, not a
|
|
1253
1292
|
binary.
|
|
1254
1293
|
- Linking a `task_entries` row to an existing `tasks` record (phase 3 in the
|
|
1255
|
-
hub's own data model)
|
|
1256
|
-
|
|
1294
|
+
hub's own data model) has shipped: `/kankaku task pick`/`clear` (see
|
|
1295
|
+
"Linking to a hub task" above). Creating a task from pi
|
|
1296
|
+
(`/kankaku task new`) is deliberately not included — kankaku never
|
|
1297
|
+
invents tasks; it only ever links to one already created in the manager.
|
|
1257
1298
|
- Generic subagent detection (phase 6): 6a fixed the two correctness bugs
|
|
1258
1299
|
described in "Subagents" above (a phantom-orchestrator double count; a
|
|
1259
1300
|
gentle-pi cross-worktree child's work going missing). 6b added the
|
|
@@ -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 { Clock } from "../ports/clock.ts";
|
|
3
3
|
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
4
4
|
export interface CachedCatalogDeps {
|
|
@@ -9,10 +9,16 @@ export interface CachedCatalogDeps {
|
|
|
9
9
|
clock: Clock;
|
|
10
10
|
/** Cache TTL in ms. Defaults to 6 hours. */
|
|
11
11
|
ttlMs?: number;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
|
|
14
|
+
* `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
|
|
15
|
+
* on the real PocketBase fetcher) so a test double that only cares about
|
|
16
|
+
* clients/projects keeps compiling unchanged.
|
|
17
|
+
*/
|
|
13
18
|
fetchCatalog: (signal?: AbortSignal) => Promise<{
|
|
14
19
|
clients: Client[];
|
|
15
20
|
projects: Project[];
|
|
21
|
+
tasks?: HubTask[];
|
|
16
22
|
}>;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
@@ -9,6 +9,9 @@ function isClientArray(value) {
|
|
|
9
9
|
function isProjectArray(value) {
|
|
10
10
|
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
|
|
11
11
|
}
|
|
12
|
+
function isHubTaskArray(value) {
|
|
13
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
|
|
14
|
+
}
|
|
12
15
|
function isCatalogSnapshot(value) {
|
|
13
16
|
if (!value || typeof value !== "object")
|
|
14
17
|
return false;
|
|
@@ -16,7 +19,8 @@ function isCatalogSnapshot(value) {
|
|
|
16
19
|
return (typeof record["fetchedAt"] === "number" &&
|
|
17
20
|
typeof record["url"] === "string" &&
|
|
18
21
|
isClientArray(record["clients"]) &&
|
|
19
|
-
isProjectArray(record["projects"])
|
|
22
|
+
isProjectArray(record["projects"]) &&
|
|
23
|
+
(record["tasks"] === undefined || isHubTaskArray(record["tasks"])));
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
22
26
|
* Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
|
|
@@ -49,8 +53,14 @@ export class CachedCatalog {
|
|
|
49
53
|
}
|
|
50
54
|
async refresh(signal) {
|
|
51
55
|
try {
|
|
52
|
-
const { clients, projects } = await this.deps.fetchCatalog(signal);
|
|
53
|
-
const snapshot = {
|
|
56
|
+
const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
|
|
57
|
+
const snapshot = {
|
|
58
|
+
fetchedAt: this.deps.clock.now(),
|
|
59
|
+
url: this.deps.url,
|
|
60
|
+
clients,
|
|
61
|
+
projects,
|
|
62
|
+
...(tasks !== undefined ? { tasks } : {}),
|
|
63
|
+
};
|
|
54
64
|
this.writeDisk(snapshot);
|
|
55
65
|
this.memo = snapshot;
|
|
56
66
|
this.memoized = true;
|
|
@@ -1,14 +1,16 @@
|
|
|
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 } from "./pocketbase-client.ts";
|
|
3
3
|
/**
|
|
4
4
|
* Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
|
|
5
|
-
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients
|
|
6
|
-
* `projects` record (no `active` filter,
|
|
7
|
-
* "deleted" from "inactive"; see
|
|
8
|
-
* `
|
|
5
|
+
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
|
|
6
|
+
* `projects` and `tasks` record in parallel (no `active`/`status` filter,
|
|
7
|
+
* so the domain resolver can tell "deleted" from "inactive"/"done"; see
|
|
8
|
+
* `domain/work-target.ts`), clients/projects sorted by name and tasks by
|
|
9
|
+
* title. `signal`, when given, bounds every fetch (see
|
|
9
10
|
* `pocketbase-client.ts#request`).
|
|
10
11
|
*/
|
|
11
12
|
export declare function createPocketBaseCatalogFetcher(client: PocketBaseClient): (signal?: AbortSignal) => Promise<{
|
|
12
13
|
clients: Client[];
|
|
13
14
|
projects: Project[];
|
|
15
|
+
tasks: HubTask[];
|
|
14
16
|
}>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const TASK_STATUSES = new Set(["open", "doing", "done"]);
|
|
1
2
|
function mapClient(record) {
|
|
2
3
|
return {
|
|
3
4
|
id: record.id,
|
|
@@ -17,23 +18,39 @@ function mapProject(record) {
|
|
|
17
18
|
active: record.active === true,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
21
|
+
/** `open` on any unrecognised or missing status, rather than dropping the task. */
|
|
22
|
+
function mapTaskStatus(status) {
|
|
23
|
+
return status !== undefined && TASK_STATUSES.has(status) ? status : "open";
|
|
24
|
+
}
|
|
25
|
+
function mapTask(record) {
|
|
26
|
+
return {
|
|
27
|
+
id: record.id,
|
|
28
|
+
title: record.title ?? "",
|
|
29
|
+
projectId: record.project,
|
|
30
|
+
status: mapTaskStatus(record.status),
|
|
31
|
+
...(typeof record.external_ref === "string" && record.external_ref !== "" ? { externalRef: record.external_ref } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
20
34
|
/**
|
|
21
35
|
* Build the `fetchCatalog` function {@link CachedCatalog} (`cached-catalog.ts`)
|
|
22
|
-
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients
|
|
23
|
-
* `projects` record (no `active` filter,
|
|
24
|
-
* "deleted" from "inactive"; see
|
|
25
|
-
* `
|
|
36
|
+
* needs, backed by a {@link PocketBaseClient}. Fetches every `clients`,
|
|
37
|
+
* `projects` and `tasks` record in parallel (no `active`/`status` filter,
|
|
38
|
+
* so the domain resolver can tell "deleted" from "inactive"/"done"; see
|
|
39
|
+
* `domain/work-target.ts`), clients/projects sorted by name and tasks by
|
|
40
|
+
* title. `signal`, when given, bounds every fetch (see
|
|
26
41
|
* `pocketbase-client.ts#request`).
|
|
27
42
|
*/
|
|
28
43
|
export function createPocketBaseCatalogFetcher(client) {
|
|
29
44
|
return async (signal) => {
|
|
30
|
-
const [clientRecords, projectRecords] = await Promise.all([
|
|
45
|
+
const [clientRecords, projectRecords, taskRecords] = await Promise.all([
|
|
31
46
|
client.list("clients", { sort: "name" }, signal),
|
|
32
47
|
client.list("projects", { sort: "name" }, signal),
|
|
48
|
+
client.list("tasks", { sort: "title" }, signal),
|
|
33
49
|
]);
|
|
34
50
|
return {
|
|
35
51
|
clients: clientRecords.map(mapClient),
|
|
36
52
|
projects: projectRecords.map(mapProject),
|
|
53
|
+
tasks: taskRecords.map(mapTask),
|
|
37
54
|
};
|
|
38
55
|
};
|
|
39
56
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { PromptPrivacyMode } from "../domain/hub-entry.ts";
|
|
14
14
|
import type { TaskView } from "../domain/task-view.ts";
|
|
15
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
15
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
16
16
|
import type { PushTaskResult, WorkSink } from "../ports/work-sink.ts";
|
|
17
17
|
import type { PocketBaseClient } from "./pocketbase-client.ts";
|
|
18
18
|
export interface PocketBaseSinkDeps {
|
|
@@ -20,6 +20,7 @@ export interface PocketBaseSinkDeps {
|
|
|
20
20
|
/** Catalog snapshot to resolve assignment against; see `domain/hub-entry.ts#resolveTaskAssignment`. */
|
|
21
21
|
clients: Client[];
|
|
22
22
|
projects: Project[];
|
|
23
|
+
tasks: HubTask[];
|
|
23
24
|
machine: string;
|
|
24
25
|
promptMode: PromptPrivacyMode;
|
|
25
26
|
/** Coding agent that produced these rows; see `domain/hub-entry.ts#HubEntryContext.agent`. Defaults to `"pi"`. */
|
|
@@ -76,6 +76,7 @@ export class PocketBaseSink {
|
|
|
76
76
|
return {
|
|
77
77
|
clients: this.deps.clients,
|
|
78
78
|
projects: this.deps.projects,
|
|
79
|
+
tasks: this.deps.tasks,
|
|
79
80
|
machine: this.deps.machine,
|
|
80
81
|
promptMode: this.deps.promptMode,
|
|
81
82
|
agent: this.deps.agent ?? "pi",
|
|
@@ -139,7 +140,7 @@ export class PocketBaseSink {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
async pushOne(task, existingId) {
|
|
142
|
-
const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects);
|
|
143
|
+
const assignment = resolveTaskAssignment(task, this.deps.clients, this.deps.projects, this.deps.tasks);
|
|
143
144
|
const pushAssignment = { unassigned: assignment.routedToUnassigned, ...(assignment.legacyClientLabel ? { legacyLabel: assignment.legacyClientLabel } : {}) };
|
|
144
145
|
try {
|
|
145
146
|
const { id, created } = await this.upsertTaskEntry(task, existingId);
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { TaskView } from "./task-view.ts";
|
|
13
13
|
import type { WorkRecord, WorkRole, WorkStatus } from "./work-record.ts";
|
|
14
|
-
import type { Client, Project } from "./work-target.ts";
|
|
14
|
+
import type { Client, HubTask, Project } from "./work-target.ts";
|
|
15
15
|
export type PromptPrivacyMode = "none" | "truncated" | "full";
|
|
16
16
|
export interface HubEntryContext {
|
|
17
17
|
clients: Client[];
|
|
18
18
|
projects: Project[];
|
|
19
|
+
/** The hub's known tasks, to validate a task's linked `hubTaskId` against. See {@link resolveTaskAssignment}. */
|
|
20
|
+
tasks: HubTask[];
|
|
19
21
|
/** This machine's hostname or `KANKAKU_MACHINE`. */
|
|
20
22
|
machine: string;
|
|
21
23
|
/** `KANKAKU_SYNC_PROMPT`; see {@link applyPromptPrivacy}. */
|
|
@@ -34,13 +36,16 @@ export interface TaskAssignment {
|
|
|
34
36
|
clientId: string;
|
|
35
37
|
/** `projects` relation id, or `""`. */
|
|
36
38
|
projectId: string;
|
|
39
|
+
/** `tasks` relation id, or `""` when no usable hub task could be resolved. See {@link resolveTaskAssignment}. */
|
|
40
|
+
hubTaskId: string;
|
|
37
41
|
/** Only set (non-empty) for a task routed to the unassigned client. */
|
|
38
42
|
legacyClientLabel: string;
|
|
39
43
|
/** `true` when this task was routed to the catalog's unassigned ("Sin determinar") client. */
|
|
40
44
|
routedToUnassigned: boolean;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
|
-
* Resolve which client/project a task's `task_entries` row should
|
|
47
|
+
* Resolve which client/project/hub-task a task's `task_entries` row should
|
|
48
|
+
* link to.
|
|
44
49
|
*
|
|
45
50
|
* - A task whose `clientId` still exists in `clients` links to that client
|
|
46
51
|
* (regardless of its `active` flag — this is a historical fact, not a
|
|
@@ -51,8 +56,12 @@ export interface TaskAssignment {
|
|
|
51
56
|
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
52
57
|
* label (or its `clientName` when the label itself is absent) as
|
|
53
58
|
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
59
|
+
* - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
|
|
60
|
+
* the resolved `projectId` (non-empty); otherwise it is `""` — including
|
|
61
|
+
* whenever the client fell back to unassigned, since there is then no
|
|
62
|
+
* resolved project for a task to belong to.
|
|
54
63
|
*/
|
|
55
|
-
export declare function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[]): TaskAssignment;
|
|
64
|
+
export declare function resolveTaskAssignment(task: TaskView, clients: Client[], projects: Project[], tasks: HubTask[]): TaskAssignment;
|
|
56
65
|
/**
|
|
57
66
|
* Privacy transform for a prompt about to leave the machine
|
|
58
67
|
* (`KANKAKU_SYNC_PROMPT`, default `none`): `none` omits it entirely,
|
package/dist/domain/hub-entry.js
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { finiteOrZero } from "./work-record.js";
|
|
13
13
|
/**
|
|
14
|
-
* Resolve which client/project a task's `task_entries` row should
|
|
14
|
+
* Resolve which client/project/hub-task a task's `task_entries` row should
|
|
15
|
+
* link to.
|
|
15
16
|
*
|
|
16
17
|
* - A task whose `clientId` still exists in `clients` links to that client
|
|
17
18
|
* (regardless of its `active` flag — this is a historical fact, not a
|
|
@@ -22,14 +23,22 @@ import { finiteOrZero } from "./work-record.js";
|
|
|
22
23
|
* `unassigned: true`), carrying forward the record's free-text `client`
|
|
23
24
|
* label (or its `clientName` when the label itself is absent) as
|
|
24
25
|
* `legacyClientLabel` — the historical backfill rule (proposal §5.3).
|
|
26
|
+
* - `hubTaskId` is kept only when it still exists in `tasks` AND belongs to
|
|
27
|
+
* the resolved `projectId` (non-empty); otherwise it is `""` — including
|
|
28
|
+
* whenever the client fell back to unassigned, since there is then no
|
|
29
|
+
* resolved project for a task to belong to.
|
|
25
30
|
*/
|
|
26
|
-
export function resolveTaskAssignment(task, clients, projects) {
|
|
31
|
+
export function resolveTaskAssignment(task, clients, projects, tasks) {
|
|
27
32
|
const client = task.clientId !== undefined ? clients.find((candidate) => candidate.id === task.clientId) : undefined;
|
|
28
33
|
if (client) {
|
|
29
34
|
const project = task.projectId !== undefined ? projects.find((candidate) => candidate.id === task.projectId && candidate.clientId === client.id) : undefined;
|
|
35
|
+
const hubTask = project !== undefined && task.hubTaskId !== undefined
|
|
36
|
+
? tasks.find((candidate) => candidate.id === task.hubTaskId && candidate.projectId === project.id)
|
|
37
|
+
: undefined;
|
|
30
38
|
return {
|
|
31
39
|
clientId: client.id,
|
|
32
40
|
projectId: project ? project.id : "",
|
|
41
|
+
hubTaskId: hubTask ? hubTask.id : "",
|
|
33
42
|
legacyClientLabel: "",
|
|
34
43
|
routedToUnassigned: false,
|
|
35
44
|
};
|
|
@@ -38,6 +47,7 @@ export function resolveTaskAssignment(task, clients, projects) {
|
|
|
38
47
|
return {
|
|
39
48
|
clientId: unassigned ? unassigned.id : "",
|
|
40
49
|
projectId: "",
|
|
50
|
+
hubTaskId: "",
|
|
41
51
|
legacyClientLabel: task.client ?? task.clientName ?? "",
|
|
42
52
|
routedToUnassigned: true,
|
|
43
53
|
};
|
|
@@ -118,12 +128,12 @@ export function computeSubagentLinkage(task) {
|
|
|
118
128
|
}
|
|
119
129
|
/** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
|
|
120
130
|
export function buildTaskEntryCreatePayload(task, ctx) {
|
|
121
|
-
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects);
|
|
131
|
+
const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
|
|
122
132
|
return {
|
|
123
133
|
task_id: task.id,
|
|
124
134
|
client: assignment.clientId,
|
|
125
135
|
project: assignment.projectId,
|
|
126
|
-
task:
|
|
136
|
+
task: assignment.hubTaskId,
|
|
127
137
|
started_at: toPbDate(task.startedAt),
|
|
128
138
|
ended_at: toPbDate(task.endedAt),
|
|
129
139
|
wall_ms: task.wallMs,
|
|
@@ -33,6 +33,10 @@ export interface TaskView {
|
|
|
33
33
|
projectId?: string;
|
|
34
34
|
/** Hub project display name, from the orchestrator record only. */
|
|
35
35
|
projectName?: string;
|
|
36
|
+
/** Hub task record id, from the orchestrator record only. See `domain/work-target.ts#HubTask`. */
|
|
37
|
+
hubTaskId?: string;
|
|
38
|
+
/** Hub task title, from the orchestrator record only. */
|
|
39
|
+
hubTaskTitle?: string;
|
|
36
40
|
/**
|
|
37
41
|
* Per-tag total milliseconds across the orchestrator and every subagent,
|
|
38
42
|
* summed rather than unioned: unlike `wallMs`, segment intervals are not
|
package/dist/domain/task-view.js
CHANGED
|
@@ -295,6 +295,8 @@ function buildTaskView(orchestrator, subagents) {
|
|
|
295
295
|
...(orchestrator.clientName !== undefined ? { clientName: orchestrator.clientName } : {}),
|
|
296
296
|
...(orchestrator.projectId !== undefined ? { projectId: orchestrator.projectId } : {}),
|
|
297
297
|
...(orchestrator.projectName !== undefined ? { projectName: orchestrator.projectName } : {}),
|
|
298
|
+
...(orchestrator.hubTaskId !== undefined ? { hubTaskId: orchestrator.hubTaskId } : {}),
|
|
299
|
+
...(orchestrator.hubTaskTitle !== undefined ? { hubTaskTitle: orchestrator.hubTaskTitle } : {}),
|
|
298
300
|
project: orchestrator.project,
|
|
299
301
|
prompt: orchestrator.prompt,
|
|
300
302
|
startedAt: orchestrator.startedAt,
|
|
@@ -159,6 +159,10 @@ export interface WorkRecordMetadata {
|
|
|
159
159
|
projectId?: string;
|
|
160
160
|
/** Hub project display name, denormalised alongside `projectId`. */
|
|
161
161
|
projectName?: string;
|
|
162
|
+
/** Hub `tasks` record id linked for this session (`/kankaku task pick`), when one is active. See `domain/work-target.ts#HubTask`. */
|
|
163
|
+
hubTaskId?: string;
|
|
164
|
+
/** Hub task title, denormalised alongside `hubTaskId`. */
|
|
165
|
+
hubTaskTitle?: string;
|
|
162
166
|
/** This machine's hostname, or `KANKAKU_MACHINE`, set only when the hub is configured. */
|
|
163
167
|
machine?: string;
|
|
164
168
|
/**
|
|
@@ -193,6 +197,20 @@ export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
|
|
|
193
197
|
export declare function emptyUsage(): UsageTotals;
|
|
194
198
|
/** A finite number, or `0` for `undefined`/`NaN`/`Infinity`/non-numbers. */
|
|
195
199
|
export declare function finiteOrZero(value: unknown): number;
|
|
200
|
+
/**
|
|
201
|
+
* Share of prompt input tokens served from the provider's prompt cache:
|
|
202
|
+
* `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
|
|
203
|
+
* the provider's `input_tokens`, which already excludes cached tokens, so
|
|
204
|
+
* the three fields are disjoint and this sum is the true denominator.
|
|
205
|
+
* Returns `undefined` when the denominator is `0` (nothing to compute a
|
|
206
|
+
* ratio from) rather than `0`, so callers never render a misleading `0%`.
|
|
207
|
+
* Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
|
|
208
|
+
*/
|
|
209
|
+
export declare function cacheHitRatio(usage: {
|
|
210
|
+
input: number;
|
|
211
|
+
cacheRead: number;
|
|
212
|
+
cacheWrite: number;
|
|
213
|
+
}): number | undefined;
|
|
196
214
|
/**
|
|
197
215
|
* Runtime guard for a {@link WorkRecord} read back from disk. `readAll`
|
|
198
216
|
* skips lines that parse as JSON but fail this check, so a torn write or a
|
|
@@ -9,6 +9,22 @@ export function emptyUsage() {
|
|
|
9
9
|
export function finiteOrZero(value) {
|
|
10
10
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Share of prompt input tokens served from the provider's prompt cache:
|
|
14
|
+
* `cacheRead / (input + cacheRead + cacheWrite)`. pi's `usage.input` maps to
|
|
15
|
+
* the provider's `input_tokens`, which already excludes cached tokens, so
|
|
16
|
+
* the three fields are disjoint and this sum is the true denominator.
|
|
17
|
+
* Returns `undefined` when the denominator is `0` (nothing to compute a
|
|
18
|
+
* ratio from) rather than `0`, so callers never render a misleading `0%`.
|
|
19
|
+
* Non-finite fields count as `0`, mirroring {@link finiteOrZero}.
|
|
20
|
+
*/
|
|
21
|
+
export function cacheHitRatio(usage) {
|
|
22
|
+
const input = finiteOrZero(usage.input);
|
|
23
|
+
const cacheRead = finiteOrZero(usage.cacheRead);
|
|
24
|
+
const cacheWrite = finiteOrZero(usage.cacheWrite);
|
|
25
|
+
const denominator = input + cacheRead + cacheWrite;
|
|
26
|
+
return denominator === 0 ? undefined : cacheRead / denominator;
|
|
27
|
+
}
|
|
12
28
|
const ROLES = new Set(["orchestrator", "subagent"]);
|
|
13
29
|
const STATUSES = new Set(["completed", "aborted", "interrupted"]);
|
|
14
30
|
/** A finite, non-negative number: durations such as `wallMs` can never be negative. */
|
|
@@ -61,6 +77,8 @@ export function isWorkRecord(value) {
|
|
|
61
77
|
(record["clientName"] === undefined || typeof record["clientName"] === "string") &&
|
|
62
78
|
(record["projectId"] === undefined || typeof record["projectId"] === "string") &&
|
|
63
79
|
(record["projectName"] === undefined || typeof record["projectName"] === "string") &&
|
|
80
|
+
(record["hubTaskId"] === undefined || typeof record["hubTaskId"] === "string") &&
|
|
81
|
+
(record["hubTaskTitle"] === undefined || typeof record["hubTaskTitle"] === "string") &&
|
|
64
82
|
(record["machine"] === undefined || typeof record["machine"] === "string") &&
|
|
65
83
|
(record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
|
|
66
84
|
(record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
|
|
@@ -29,6 +29,18 @@ export interface Project {
|
|
|
29
29
|
repoPaths: string[];
|
|
30
30
|
active: boolean;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* A hub `tasks` row: something kankaku can link a session's `task_entries`
|
|
34
|
+
* row to (`task_entries.task`), never create. See `domain/hub-entry.ts` for
|
|
35
|
+
* the create-only assignment rule this feeds.
|
|
36
|
+
*/
|
|
37
|
+
export interface HubTask {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
projectId: string;
|
|
41
|
+
status: "open" | "doing" | "done";
|
|
42
|
+
externalRef?: string;
|
|
43
|
+
}
|
|
32
44
|
export interface WorkTarget {
|
|
33
45
|
clientId: string;
|
|
34
46
|
clientCode: string;
|
|
@@ -36,11 +48,22 @@ export interface WorkTarget {
|
|
|
36
48
|
projectId?: string;
|
|
37
49
|
projectCode?: string;
|
|
38
50
|
projectName?: string;
|
|
51
|
+
/** Hub `tasks` record id, kept only when it belongs to `projectId`. See {@link resolveWorkTarget}. */
|
|
52
|
+
hubTaskId?: string;
|
|
53
|
+
/** Denormalised alongside `hubTaskId` for display. */
|
|
54
|
+
hubTaskTitle?: string;
|
|
39
55
|
}
|
|
40
56
|
/** ids picked from the session, or from the project's `.kankaku/config.json`. */
|
|
41
57
|
export interface WorkTargetCandidate {
|
|
42
58
|
clientId: string;
|
|
43
59
|
projectId?: string;
|
|
60
|
+
/**
|
|
61
|
+
* A hub task the user picked for this session (`/kankaku task pick`).
|
|
62
|
+
* Only ever set on the session-level candidate — the project config file
|
|
63
|
+
* and `repoPaths` matching never carry one (task linking is session-only,
|
|
64
|
+
* see AGENTS.md).
|
|
65
|
+
*/
|
|
66
|
+
hubTaskId?: string;
|
|
44
67
|
}
|
|
45
68
|
/**
|
|
46
69
|
* The session-level override: `undefined` when no session entry exists yet
|
|
@@ -57,6 +80,8 @@ export interface ResolveWorkTargetInput {
|
|
|
57
80
|
cwd: string;
|
|
58
81
|
clients: Client[];
|
|
59
82
|
projects: Project[];
|
|
83
|
+
/** The hub's known tasks, to validate a candidate's `hubTaskId` against. Defaults to `[]` so existing callers compile unchanged. */
|
|
84
|
+
tasks?: HubTask[];
|
|
60
85
|
}
|
|
61
86
|
export type WorkTargetSourceName = "session" | "project" | "repoPaths";
|
|
62
87
|
/** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
|
|
@@ -68,5 +93,9 @@ export declare function resolveWorkTargetSource(input: ResolveWorkTargetInput):
|
|
|
68
93
|
* declined), returning `undefined` rather than falling through.
|
|
69
94
|
*/
|
|
70
95
|
export declare function resolveWorkTarget(input: ResolveWorkTargetInput): WorkTarget | undefined;
|
|
71
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
|
|
98
|
+
* appended as `... › <hubTaskTitle>` — the display label used by the
|
|
99
|
+
* status bar and the "remember" prompt.
|
|
100
|
+
*/
|
|
72
101
|
export declare function formatWorkTargetLabel(target: WorkTarget): string;
|
|
@@ -26,7 +26,18 @@ function findUsableProject(projects, projectId, clientId) {
|
|
|
26
26
|
return undefined;
|
|
27
27
|
return project;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A hub task usable as a link target: exists and belongs to `projectId`.
|
|
31
|
+
* Any {@link HubTask.status} is accepted — a task marked `done` mid-session
|
|
32
|
+
* keeps linking — only the project match matters here.
|
|
33
|
+
*/
|
|
34
|
+
function findUsableTask(tasks, taskId, projectId) {
|
|
35
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
36
|
+
if (!task || task.projectId !== projectId)
|
|
37
|
+
return undefined;
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
function buildTarget(client, project, task) {
|
|
30
41
|
return {
|
|
31
42
|
clientId: client.id,
|
|
32
43
|
clientCode: client.code,
|
|
@@ -38,6 +49,7 @@ function buildTarget(client, project) {
|
|
|
38
49
|
projectName: project.name,
|
|
39
50
|
}
|
|
40
51
|
: {}),
|
|
52
|
+
...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
|
|
41
53
|
};
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
@@ -45,14 +57,17 @@ function buildTarget(client, project) {
|
|
|
45
57
|
* file) into a target, or `undefined` when the client id does not resolve
|
|
46
58
|
* to a usable client. A projectId that does not resolve to a usable
|
|
47
59
|
* project of that client is dropped — the client-only target is still
|
|
48
|
-
* returned — rather than failing the whole candidate.
|
|
60
|
+
* returned — rather than failing the whole candidate. A `hubTaskId` is
|
|
61
|
+
* kept only when it resolves to a task of the resolved project; a target
|
|
62
|
+
* with no project never carries one.
|
|
49
63
|
*/
|
|
50
|
-
function resolveCandidate(candidate, clients, projects) {
|
|
64
|
+
function resolveCandidate(candidate, clients, projects, tasks) {
|
|
51
65
|
const client = findUsableClient(clients, candidate.clientId);
|
|
52
66
|
if (!client)
|
|
53
67
|
return undefined;
|
|
54
68
|
const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
|
|
55
|
-
|
|
69
|
+
const task = project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
|
|
70
|
+
return buildTarget(client, project, task);
|
|
56
71
|
}
|
|
57
72
|
/** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
|
|
58
73
|
function matchByRepoPath(cwd, projects) {
|
|
@@ -82,11 +97,12 @@ function isCwdWithin(cwd, repoPath) {
|
|
|
82
97
|
/** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
|
|
83
98
|
export function resolveWorkTargetSource(input) {
|
|
84
99
|
const { session } = input;
|
|
100
|
+
const tasks = input.tasks ?? [];
|
|
85
101
|
if (session === "skipped")
|
|
86
102
|
return undefined;
|
|
87
|
-
if (session !== undefined && resolveCandidate(session, input.clients, input.projects) !== undefined)
|
|
103
|
+
if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined)
|
|
88
104
|
return "session";
|
|
89
|
-
if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects) !== undefined)
|
|
105
|
+
if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined)
|
|
90
106
|
return "project";
|
|
91
107
|
const matched = matchByRepoPath(input.cwd, input.projects);
|
|
92
108
|
if (matched && findUsableClient(input.clients, matched.clientId))
|
|
@@ -101,15 +117,16 @@ export function resolveWorkTargetSource(input) {
|
|
|
101
117
|
*/
|
|
102
118
|
export function resolveWorkTarget(input) {
|
|
103
119
|
const { session } = input;
|
|
120
|
+
const tasks = input.tasks ?? [];
|
|
104
121
|
if (session === "skipped")
|
|
105
122
|
return undefined;
|
|
106
123
|
if (session !== undefined) {
|
|
107
|
-
const resolved = resolveCandidate(session, input.clients, input.projects);
|
|
124
|
+
const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
|
|
108
125
|
if (resolved)
|
|
109
126
|
return resolved;
|
|
110
127
|
}
|
|
111
128
|
if (input.project !== undefined) {
|
|
112
|
-
const resolved = resolveCandidate(input.project, input.clients, input.projects);
|
|
129
|
+
const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
|
|
113
130
|
if (resolved)
|
|
114
131
|
return resolved;
|
|
115
132
|
}
|
|
@@ -117,11 +134,16 @@ export function resolveWorkTarget(input) {
|
|
|
117
134
|
if (matched) {
|
|
118
135
|
const client = findUsableClient(input.clients, matched.clientId);
|
|
119
136
|
if (client)
|
|
120
|
-
return buildTarget(client, matched);
|
|
137
|
+
return buildTarget(client, matched, undefined);
|
|
121
138
|
}
|
|
122
139
|
return undefined;
|
|
123
140
|
}
|
|
124
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
|
|
143
|
+
* appended as `... › <hubTaskTitle>` — the display label used by the
|
|
144
|
+
* status bar and the "remember" prompt.
|
|
145
|
+
*/
|
|
125
146
|
export function formatWorkTargetLabel(target) {
|
|
126
|
-
|
|
147
|
+
const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
|
|
148
|
+
return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
|
|
127
149
|
}
|
package/dist/ports/catalog.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
2
|
-
/** A cached read of the hub's clients/projects, plus when and against which hub URL it was fetched. */
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
|
+
/** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
|
|
3
3
|
export interface CatalogSnapshot {
|
|
4
4
|
fetchedAt: number;
|
|
5
5
|
url: string;
|
|
6
6
|
clients: Client[];
|
|
7
7
|
projects: Project[];
|
|
8
|
+
/** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
|
|
9
|
+
tasks?: HubTask[];
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Read-only access to the hub catalog (clients/projects). `read()` is a
|
package/package.json
CHANGED