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
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
|
|
@@ -685,13 +687,53 @@ the future, this is the signal that would surface it.
|
|
|
685
687
|
|
|
686
688
|
## The `/kankaku` command
|
|
687
689
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
690
|
+
`/kankaku` with no arguments, run inside pi's TUI, opens an overlay panel
|
|
691
|
+
modelled on pi's own `/settings` (the same pi-tui `SettingsList`/
|
|
692
|
+
`SelectList` widgets, the same keys) from which every kankaku view and
|
|
693
|
+
action is reachable:
|
|
694
|
+
|
|
695
|
+
- **Target** — billing client, project, hub task, and the legacy label
|
|
696
|
+
(see "Billing labels" and "Hub (PocketBase)" below). The Task row lists
|
|
697
|
+
the open/doing hub tasks of the current project; picking one links the
|
|
698
|
+
session, exactly like `/kankaku task pick` below — **session-only**,
|
|
699
|
+
never persisted (see "Linking to a hub task").
|
|
700
|
+
- **Report** — today/all totals, tasks, sessions, clients, and projects:
|
|
701
|
+
the same five views `/kankaku`'s subcommands produce.
|
|
702
|
+
- **Sync** (hub only) — status, sync now, sync all, backfill, and a
|
|
703
|
+
catalog refresh.
|
|
704
|
+
- **Export** — write today's or every task as csv/json.
|
|
705
|
+
- **Doctor** — orphan/uncertain subagent counts and ancestor-detection
|
|
706
|
+
availability.
|
|
707
|
+
- **About** — versions, the resolved `KANKAKU_DIR`, the hub URL, and every
|
|
708
|
+
env-only setting, read-only.
|
|
709
|
+
|
|
710
|
+
Keys: `↑↓` move, `Enter` open a section or select a value, `Esc` go back
|
|
711
|
+
(or close the panel at the root), `q` close from anywhere. Mouse: the
|
|
712
|
+
footer hints and list rows are clickable, but only in pi's fullscreen
|
|
713
|
+
mode — pi does not dispatch mouse events in its regular (non-fullscreen)
|
|
714
|
+
mode, so there the panel is keyboard-only.
|
|
715
|
+
|
|
716
|
+
Every subcommand below is unchanged, and is exactly what headless (print
|
|
717
|
+
or RPC) mode still uses — `/kankaku` there keeps showing today's totals
|
|
718
|
+
directly, never the panel, since there is no UI to open one in. Run
|
|
719
|
+
`/kankaku <subcommand>` (with arguments) in the TUI to skip the panel and
|
|
720
|
+
go straight to that subcommand's output, exactly as before the panel
|
|
721
|
+
existed.
|
|
722
|
+
|
|
723
|
+
Plain `/kankaku` (headless, or any subcommand below) shows today's totals
|
|
724
|
+
(work, waiting, record count) per role, plus a union-based tasks segment.
|
|
725
|
+
Each totals line also shows `cache hit NN%` when tokens were recorded: the
|
|
726
|
+
share of prompt input tokens served from the provider's prompt cache, cache
|
|
727
|
+
reads over input plus cache reads plus cache writes; the segment is
|
|
728
|
+
omitted, not shown as `0%`, when no tokens were recorded. In the
|
|
729
|
+
interactive TUI the report is appended to the chat transcript as a durable
|
|
730
|
+
card that is never sent to the LLM; without a UI (print or RPC mode) it
|
|
731
|
+
falls back to a notification. Arguments are whitespace-separated and
|
|
732
|
+
order-insensitive:
|
|
733
|
+
|
|
734
|
+
- `/kankaku` (headless only — the TUI opens the panel instead, whose
|
|
735
|
+
Report screen defaults to the same view) — today's role totals and
|
|
736
|
+
tasks segment, each with its estimated cost.
|
|
695
737
|
- `/kankaku all` — same, but across every record.
|
|
696
738
|
- `/kankaku tasks` — one line per task (time, union wall/work, cost,
|
|
697
739
|
subagent count, truncated prompt) for the **current pi session**. Add `all` for
|
|
@@ -719,6 +761,9 @@ The following are available only when a hub is configured (see "Hub
|
|
|
719
761
|
produced it. `/kankaku target pick` runs the picker again (works
|
|
720
762
|
mid-session; the new target applies to records settled afterwards).
|
|
721
763
|
`/kankaku target clear` clears the session-level target.
|
|
764
|
+
- `/kankaku task` (or `/kankaku task pick`) — link this session to an
|
|
765
|
+
open/doing hub task of the effective project. `/kankaku task clear`
|
|
766
|
+
drops the link. See "Linking to a hub task" below.
|
|
722
767
|
- `/kankaku catalog refresh` — force a catalog refresh and report the
|
|
723
768
|
client/project counts.
|
|
724
769
|
- `/kankaku projects` — one line per project (work/waiting/wall time, cost,
|
|
@@ -825,6 +870,45 @@ task view exposes it from the orchestrator record only.
|
|
|
825
870
|
The status bar shows `💼 <client> · <project>` (or just `💼 <client>` without
|
|
826
871
|
a project) in place of the legacy client label, both idle and during a run.
|
|
827
872
|
|
|
873
|
+
Mid-session, the `/kankaku` panel's Target screen (see "The `/kankaku`
|
|
874
|
+
command" above) is the interactive way to change the client or project
|
|
875
|
+
without going through `/kankaku target pick`'s picker dialog — it edits the
|
|
876
|
+
same session-level target, applies through the same `SessionTarget`, and
|
|
877
|
+
has its own "Remember" row for `<KANKAKU_DIR>/config.json`.
|
|
878
|
+
|
|
879
|
+
### Linking to a hub task
|
|
880
|
+
|
|
881
|
+
`/kankaku task` (or `/kankaku task pick`) links the current session to one
|
|
882
|
+
of the effective project's existing hub `tasks` rows: a `ctx.ui.select`
|
|
883
|
+
picker lists the project's `open`/`doing` tasks, sorted by title (colliding
|
|
884
|
+
titles are disambiguated with the task's external reference, or its id).
|
|
885
|
+
`/kankaku task clear` drops the link, keeping the rest of the session
|
|
886
|
+
target. kankaku never creates a task from pi — this only links to one
|
|
887
|
+
that already exists in the hub.
|
|
888
|
+
|
|
889
|
+
The link is **session-only**: unlike `clientId`/`projectId`, it is never
|
|
890
|
+
persisted to `<KANKAKU_DIR>/config.json`, and it is never asked for at
|
|
891
|
+
`session_start` — you always link a task explicitly, with `/kankaku task`.
|
|
892
|
+
Any target change (`/kankaku target pick`, `/kankaku target clear`, or the
|
|
893
|
+
legacy `/kankaku client <name>`) drops the linked task, since a new client
|
|
894
|
+
or project makes the old task's link meaningless. A task whose project no
|
|
895
|
+
longer matches the effective project (e.g. after a target change or a
|
|
896
|
+
reassignment in the hub) is also dropped by the domain resolver, never
|
|
897
|
+
silently linked across projects. A task already marked `done` when linked
|
|
898
|
+
keeps linking for the rest of the session — only the picker itself hides
|
|
899
|
+
`done` tasks, so you cannot accidentally pick a closed one, but finishing
|
|
900
|
+
the picked task in the hub mid-session does not break the link. Subagent
|
|
901
|
+
records never carry a linked task, exactly like `clientId`/`projectId` —
|
|
902
|
+
the task view exposes it from the orchestrator record only, and
|
|
903
|
+
`formatWorkTargetLabel` appends it to the status-bar/report label as
|
|
904
|
+
`<client> · <project> › <task title>`.
|
|
905
|
+
|
|
906
|
+
The `/kankaku` panel's Target screen's Task row is the interactive
|
|
907
|
+
equivalent of `/kankaku task pick`/`clear`: it lists the same open/doing
|
|
908
|
+
tasks, applies the link through the same `SessionTarget.setTask`, and
|
|
909
|
+
stays just as session-only — picking a task there is never persisted to
|
|
910
|
+
`config.json` either.
|
|
911
|
+
|
|
828
912
|
### Caching and offline behaviour
|
|
829
913
|
|
|
830
914
|
The catalog (clients/projects) is cached machine-wide at
|
|
@@ -905,10 +989,13 @@ web app) can move a task from one client/project to another directly in
|
|
|
905
989
|
PocketBase — for example, moving a "Sin determinar" row to its real
|
|
906
990
|
client once you have identified it. A later re-sync of that same task
|
|
907
991
|
**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
|
-
|
|
992
|
+
`client`/`project`/`task`/`legacy_client_label`; on every subsequent update
|
|
993
|
+
it sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
|
|
994
|
+
never touches assignment fields again. `task` (the linked `tasks` relation)
|
|
995
|
+
is create-only for the exact same reason: reassigning which task a row
|
|
996
|
+
belongs to in the web app is never undone by a later sync. If you need
|
|
997
|
+
kankaku itself to change a task's assignment, do it in the web app, not by
|
|
998
|
+
re-syncing.
|
|
912
999
|
|
|
913
1000
|
**Historical ("Sin determinar") records.** A record with no `clientId`, or
|
|
914
1001
|
whose `clientId` no longer resolves in the catalog, is routed to the hub's
|
|
@@ -1252,8 +1339,10 @@ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
|
|
|
1252
1339
|
is not; the compiled entry points are today consumed as a library, not a
|
|
1253
1340
|
binary.
|
|
1254
1341
|
- Linking a `task_entries` row to an existing `tasks` record (phase 3 in the
|
|
1255
|
-
hub's own data model)
|
|
1256
|
-
|
|
1342
|
+
hub's own data model) has shipped: `/kankaku task pick`/`clear` (see
|
|
1343
|
+
"Linking to a hub task" above). Creating a task from pi
|
|
1344
|
+
(`/kankaku task new`) is deliberately not included — kankaku never
|
|
1345
|
+
invents tasks; it only ever links to one already created in the manager.
|
|
1257
1346
|
- Generic subagent detection (phase 6): 6a fixed the two correctness bugs
|
|
1258
1347
|
described in "Subagents" above (a phantom-orchestrator double count; a
|
|
1259
1348
|
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;
|