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.
Files changed (44) hide show
  1. package/README.md +103 -14
  2. package/dist/adapters/cached-catalog.d.ts +8 -2
  3. package/dist/adapters/cached-catalog.js +13 -3
  4. package/dist/adapters/pocketbase-catalog.d.ts +7 -5
  5. package/dist/adapters/pocketbase-catalog.js +22 -5
  6. package/dist/adapters/pocketbase-sink.d.ts +2 -1
  7. package/dist/adapters/pocketbase-sink.js +2 -1
  8. package/dist/domain/hub-entry.d.ts +12 -3
  9. package/dist/domain/hub-entry.js +14 -4
  10. package/dist/domain/task-view.d.ts +4 -0
  11. package/dist/domain/task-view.js +2 -0
  12. package/dist/domain/work-record.d.ts +18 -0
  13. package/dist/domain/work-record.js +18 -0
  14. package/dist/domain/work-target.d.ts +30 -1
  15. package/dist/domain/work-target.js +33 -11
  16. package/dist/ports/catalog.d.ts +4 -2
  17. package/package.json +1 -1
  18. package/src/adapters/cached-catalog.ts +22 -6
  19. package/src/adapters/hub-actions.ts +83 -0
  20. package/src/adapters/kankaku-command.ts +236 -244
  21. package/src/adapters/panel/kankaku-panel.ts +274 -0
  22. package/src/adapters/panel/panel-items.ts +77 -0
  23. package/src/adapters/panel/panel-lines.ts +13 -0
  24. package/src/adapters/panel/panel-theme.ts +32 -0
  25. package/src/adapters/panel/screens/about.ts +69 -0
  26. package/src/adapters/panel/screens/doctor.ts +85 -0
  27. package/src/adapters/panel/screens/export.ts +119 -0
  28. package/src/adapters/panel/screens/report.ts +141 -0
  29. package/src/adapters/panel/screens/sync.ts +126 -0
  30. package/src/adapters/panel/screens/target.ts +367 -0
  31. package/src/adapters/pi-tracker.ts +110 -6
  32. package/src/adapters/pocketbase-catalog.ts +36 -7
  33. package/src/adapters/pocketbase-sink.ts +15 -3
  34. package/src/adapters/report-views.ts +98 -0
  35. package/src/adapters/report.ts +66 -16
  36. package/src/adapters/session-target.ts +111 -4
  37. package/src/adapters/target-picker.ts +46 -1
  38. package/src/domain/hub-entry.ts +20 -5
  39. package/src/domain/panel-model.ts +267 -0
  40. package/src/domain/task-view.ts +6 -0
  41. package/src/domain/work-record.ts +23 -0
  42. package/src/domain/work-target.ts +60 -11
  43. package/src/extension.ts +19 -8
  44. package/src/ports/catalog.ts +4 -2
@@ -26,7 +26,18 @@ function findUsableProject(projects, projectId, clientId) {
26
26
  return undefined;
27
27
  return project;
28
28
  }
29
- function buildTarget(client, project) {
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
- return buildTarget(client, project);
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
- /** `<clientName>` or `<clientName> · <projectName>` — the display label used by the status bar and the "remember" prompt. */
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
- return target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
147
+ const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
148
+ return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
127
149
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -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
- /** Fetch a fresh `{ clients, projects }` pair, e.g. `createPocketBaseCatalogFetcher(...)`. */
39
- fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[] }>;
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 = { fetchedAt: this.deps.clock.now(), url: this.deps.url, clients, projects };
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;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared line-building for the hub-facing `/kankaku` subcommands (`sync`,
3
+ * `sync all`, `sync status`, `backfill`, `catalog refresh`) and the panel's
4
+ * `sync`/`export` screens (`adapters/panel/screens/sync.ts`), extracted from
5
+ * `kankaku-command.ts` so the subcommands and the panel can never drift —
6
+ * see odd/tasks/kankaku-panel.md P4.
7
+ */
8
+ import type { CatalogSnapshot } from "../ports/catalog.ts";
9
+ import type { SyncCommandDeps } from "./kankaku-command.ts";
10
+ import type { SyncSummary } from "./sync-runner.ts";
11
+
12
+ /**
13
+ * `/kankaku sync status`'s report lines: the watermark, the pending count,
14
+ * how many tasks fell outside this run's revisit window (R3), and the last
15
+ * error, when any. Exact body of `handleSyncCommand`'s `status` branch.
16
+ */
17
+ export function buildSyncStatusLines(status: ReturnType<SyncCommandDeps["status"]>): string[] {
18
+ const { state, pending, staleOutsideWindow } = status;
19
+ const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
20
+ if (staleOutsideWindow > 0) {
21
+ lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
22
+ }
23
+ if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
24
+ return lines;
25
+ }
26
+
27
+ /**
28
+ * Render a {@link SyncSummary} as report lines: counts, any stop reason, the
29
+ * new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
30
+ * sync`/`sync all` and the panel's sync screen.
31
+ */
32
+ export function formatSyncSummaryLines(summary: SyncSummary): string[] {
33
+ const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
34
+
35
+ if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
36
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
37
+ if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
38
+
39
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
40
+ if (unassignedEntries.length > 0) {
41
+ lines.push("unassigned (Sin determinar):");
42
+ for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
43
+ }
44
+
45
+ if (summary.failed.length > 0) {
46
+ lines.push("failed:");
47
+ for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
48
+ }
49
+
50
+ return lines;
51
+ }
52
+
53
+ /**
54
+ * `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
55
+ * run, grouped by their old free-text label, or "no unassigned tasks" when
56
+ * none were. Exact body of `handleBackfillCommand`.
57
+ */
58
+ export function formatBackfillLines(summary: SyncSummary): string[] {
59
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
60
+
61
+ const lines =
62
+ unassignedEntries.length > 0
63
+ ? [
64
+ ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
65
+ "reassign these in the hub web app's unassigned queue",
66
+ ]
67
+ : ["no unassigned tasks"];
68
+
69
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
70
+ return lines;
71
+ }
72
+
73
+ /**
74
+ * `/kankaku catalog refresh`'s report lines: client/project counts, or an
75
+ * unreachable notice when the refresh failed (`snapshot` is `undefined`).
76
+ * The subcommand still notifies that failure as an error rather than a
77
+ * report (see `handleCatalogCommand`); the panel's sync screen shows
78
+ * whatever this returns either way.
79
+ */
80
+ export function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[] {
81
+ if (!snapshot) return ["hub unreachable; catalog not refreshed"];
82
+ return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
83
+ }