kankaku-pi 1.0.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,42 @@
1
+ import type { WorkTargetCandidate } from "../domain/work-target.ts";
2
+ /**
3
+ * Read the project's default billing client from `<dir>/config.json`
4
+ * (`{ "client": "acme" }`), the lowest-precedence source in
5
+ * `domain/client-label.ts#resolveClient`. `dir` is the kankaku dir (same
6
+ * directory as the work log).
7
+ *
8
+ * Tolerates a missing file, malformed JSON, a non-object document, or a
9
+ * `client` field that is not a string — all return `undefined` rather than
10
+ * throwing, since this file is optional and hand-edited.
11
+ */
12
+ export declare function readProjectClient(dir: string): string | undefined;
13
+ /** Reads the project client from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
14
+ export declare class LazyProjectClientSource {
15
+ private readonly dirOrRelative;
16
+ private readonly fallbackCwd;
17
+ constructor(dirOrRelative: string, fallbackCwd?: () => string);
18
+ read(): string | undefined;
19
+ }
20
+ /**
21
+ * Read `clientId`/`projectId` from `<dir>/config.json`, the lowest-precedence
22
+ * source in `domain/work-target.ts#resolveWorkTarget`. Tolerates the same
23
+ * failure modes as {@link readProjectClient}. `undefined` when `clientId`
24
+ * is absent or not a string (a `projectId` without a `clientId` is not a
25
+ * valid candidate); a non-string `projectId` is dropped, keeping `clientId`.
26
+ */
27
+ export declare function readProjectTargetIds(dir: string): WorkTargetCandidate | undefined;
28
+ /**
29
+ * Merge `clientId`/`projectId` into `<dir>/config.json`, preserving every
30
+ * other existing key (including the legacy `client` label). Writes
31
+ * atomically (tmp + rename), mirroring `file-inflight-store.ts`. A missing
32
+ * or malformed existing file is treated as `{}` rather than failing.
33
+ */
34
+ export declare function writeProjectTargetIds(dir: string, ids: WorkTargetCandidate): void;
35
+ /** Reads target ids from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
36
+ export declare class LazyProjectTargetSource {
37
+ private readonly dirOrRelative;
38
+ private readonly fallbackCwd;
39
+ constructor(dirOrRelative: string, fallbackCwd?: () => string);
40
+ read(): WorkTargetCandidate | undefined;
41
+ write(ids: WorkTargetCandidate): void;
42
+ }
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveKankakuDir } from "./kankaku-dir.js";
4
+ const CONFIG_FILE_NAME = "config.json";
5
+ /**
6
+ * Read the project's default billing client from `<dir>/config.json`
7
+ * (`{ "client": "acme" }`), the lowest-precedence source in
8
+ * `domain/client-label.ts#resolveClient`. `dir` is the kankaku dir (same
9
+ * directory as the work log).
10
+ *
11
+ * Tolerates a missing file, malformed JSON, a non-object document, or a
12
+ * `client` field that is not a string — all return `undefined` rather than
13
+ * throwing, since this file is optional and hand-edited.
14
+ */
15
+ export function readProjectClient(dir) {
16
+ const filePath = join(dir, CONFIG_FILE_NAME);
17
+ if (!existsSync(filePath))
18
+ return undefined;
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
21
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
22
+ return undefined;
23
+ const client = parsed["client"];
24
+ return typeof client === "string" ? client : undefined;
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ /** Reads the project client from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
31
+ export class LazyProjectClientSource {
32
+ dirOrRelative;
33
+ fallbackCwd;
34
+ constructor(dirOrRelative, fallbackCwd = () => process.cwd()) {
35
+ this.dirOrRelative = dirOrRelative;
36
+ this.fallbackCwd = fallbackCwd;
37
+ }
38
+ read() {
39
+ return readProjectClient(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
40
+ }
41
+ }
42
+ /**
43
+ * Read `clientId`/`projectId` from `<dir>/config.json`, the lowest-precedence
44
+ * source in `domain/work-target.ts#resolveWorkTarget`. Tolerates the same
45
+ * failure modes as {@link readProjectClient}. `undefined` when `clientId`
46
+ * is absent or not a string (a `projectId` without a `clientId` is not a
47
+ * valid candidate); a non-string `projectId` is dropped, keeping `clientId`.
48
+ */
49
+ export function readProjectTargetIds(dir) {
50
+ const filePath = join(dir, CONFIG_FILE_NAME);
51
+ if (!existsSync(filePath))
52
+ return undefined;
53
+ try {
54
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
55
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
56
+ return undefined;
57
+ const record = parsed;
58
+ const clientId = record["clientId"];
59
+ if (typeof clientId !== "string")
60
+ return undefined;
61
+ const projectId = record["projectId"];
62
+ return typeof projectId === "string" ? { clientId, projectId } : { clientId };
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /**
69
+ * Merge `clientId`/`projectId` into `<dir>/config.json`, preserving every
70
+ * other existing key (including the legacy `client` label). Writes
71
+ * atomically (tmp + rename), mirroring `file-inflight-store.ts`. A missing
72
+ * or malformed existing file is treated as `{}` rather than failing.
73
+ */
74
+ export function writeProjectTargetIds(dir, ids) {
75
+ const filePath = join(dir, CONFIG_FILE_NAME);
76
+ let existing = {};
77
+ if (existsSync(filePath)) {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
80
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
81
+ existing = parsed;
82
+ }
83
+ }
84
+ catch {
85
+ existing = {};
86
+ }
87
+ }
88
+ const merged = { ...existing, clientId: ids.clientId, ...(ids.projectId !== undefined ? { projectId: ids.projectId } : {}) };
89
+ mkdirSync(dir, { recursive: true });
90
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(merged, null, 2));
92
+ renameSync(tmp, filePath);
93
+ }
94
+ /** Reads target ids from a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
95
+ export class LazyProjectTargetSource {
96
+ dirOrRelative;
97
+ fallbackCwd;
98
+ constructor(dirOrRelative, fallbackCwd = () => process.cwd()) {
99
+ this.dirOrRelative = dirOrRelative;
100
+ this.fallbackCwd = fallbackCwd;
101
+ }
102
+ read() {
103
+ return readProjectTargetIds(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()));
104
+ }
105
+ write(ids) {
106
+ writeProjectTargetIds(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), ids);
107
+ }
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
3
+ * report view and the panel's report screen produce. Extracted out of
4
+ * `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
5
+ * be published through `kankaku-pi/hub` without pulling in anything that
6
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
7
+ */
8
+ /** Durable report rendered inside the chat transcript; never sent to the LLM. */
9
+ export interface KankakuReportData {
10
+ title: string;
11
+ lines: string[];
12
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
3
+ * report view and the panel's report screen produce. Extracted out of
4
+ * `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
5
+ * be published through `kankaku-pi/hub` without pulling in anything that
6
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
7
+ */
8
+ export {};
@@ -0,0 +1,45 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { KankakuReportData } from "./report-data.ts";
3
+ /** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
4
+ export interface ReportViewOptions {
5
+ all: boolean;
6
+ }
7
+ /** `buildTasksView`'s own options: `all` scopes to every session instead of local-day range (tasks are never day-filtered — see `kankaku-command.ts`'s original `tasks` handler). */
8
+ export interface TasksViewOptions {
9
+ all: boolean;
10
+ sessionId: string | undefined;
11
+ }
12
+ /** The plain-text summary view (`/kankaku` with no view token): role/task totals, plus a one-line hint when uncertain records were excluded (SUBAGENT-REQ-017). */
13
+ export declare function buildSummaryView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData;
14
+ /** `/kankaku clients [all]`: per-client totals. */
15
+ export declare function buildClientsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData;
16
+ /** `/kankaku projects [all]`: per-project totals. */
17
+ export declare function buildProjectsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData;
18
+ /** `/kankaku sessions [all]`: per-session totals. */
19
+ export declare function buildSessionsView(records: WorkRecord[], options: ReportViewOptions): KankakuReportData;
20
+ /**
21
+ * `/kankaku tasks [all]`: one line per task. Unlike every other view, tasks
22
+ * are never restricted by local day — `all` (or a missing `sessionId`)
23
+ * instead scopes from "this session" to "every session".
24
+ */
25
+ export declare function buildTasksView(records: WorkRecord[], options: TasksViewOptions): KankakuReportData;
26
+ /** {@link buildExportContent}'s options: `format` picks csv/json, `all` includes every day instead of just today's local day (mirrors every other view except `buildTasksView`). */
27
+ export interface ExportContentOptions {
28
+ format: "csv" | "json";
29
+ all: boolean;
30
+ }
31
+ /**
32
+ * `/kankaku export [csv|json] [all]`'s file content: the flat export rows
33
+ * for today's (or every) task, rendered as csv or json, with the file name
34
+ * `/kankaku export` and the panel's export screen (`panel/screens/
35
+ * export.ts`) both use. Extracted from `kankaku-command.ts`'s
36
+ * `handleExportCommand` (see odd/tasks/kankaku-panel.md P4) so the
37
+ * subcommand and the panel never drift. `rowCount` is exposed only for the
38
+ * subcommand's "wrote N row(s) to <path>" confirmation line — the panel's
39
+ * own confirmation is simpler ("wrote <path>").
40
+ */
41
+ export declare function buildExportContent(records: WorkRecord[], options: ExportContentOptions): {
42
+ name: string;
43
+ content: string;
44
+ rowCount: number;
45
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The five `/kankaku` report views (summary/tasks/sessions/clients/
3
+ * projects), each as one pure `WorkRecord[] -> KankakuReportData` builder.
4
+ * Extracted from `kankaku-command.ts`'s subcommand handlers (see
5
+ * odd/tasks/kankaku-panel.md P3) so the `/kankaku` subcommands and the
6
+ * panel's report screen (`panel/screens/report.ts`) always compute the
7
+ * exact same lines — neither ever re-implements the other's logic.
8
+ */
9
+ import { buildSessions, buildTasks } from "../domain/task-view.js";
10
+ import { exportRows, toCsv, toJson } from "../domain/export.js";
11
+ import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.js";
12
+ /** The plain-text summary view (`/kankaku` with no view token): role/task totals, plus a one-line hint when uncertain records were excluded (SUBAGENT-REQ-017). */
13
+ export function buildSummaryView(records, options) {
14
+ const { all } = options;
15
+ const summary = summarize(records, { all });
16
+ const lines = formatReport(summary).split(" | ");
17
+ const uncertainCount = countUncertain(records, { all });
18
+ if (uncertainCount > 0) {
19
+ lines.push(`kankaku: ${uncertainCount} uncertain record(s) excluded from tasks — run /kankaku doctor`);
20
+ }
21
+ return { title: all ? "summary (all days)" : "summary (today)", lines };
22
+ }
23
+ /** `/kankaku clients [all]`: per-client totals. */
24
+ export function buildClientsView(records, options) {
25
+ const { all } = options;
26
+ const today = localDay(new Date().toISOString());
27
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
28
+ return { title: all ? "clients (all days)" : "clients (today)", lines: formatClients(summarizeByClient(tasks)).split("\n") };
29
+ }
30
+ /** `/kankaku projects [all]`: per-project totals. */
31
+ export function buildProjectsView(records, options) {
32
+ const { all } = options;
33
+ const today = localDay(new Date().toISOString());
34
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
35
+ return { title: all ? "projects (all days)" : "projects (today)", lines: formatProjects(summarizeByProject(tasks)).split("\n") };
36
+ }
37
+ /** `/kankaku sessions [all]`: per-session totals. */
38
+ export function buildSessionsView(records, options) {
39
+ const { all } = options;
40
+ const today = localDay(new Date().toISOString());
41
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
42
+ return { title: all ? "sessions (all days)" : "sessions (today)", lines: formatSessions(buildSessions(tasks)).split("\n") };
43
+ }
44
+ /**
45
+ * `/kankaku tasks [all]`: one line per task. Unlike every other view, tasks
46
+ * are never restricted by local day — `all` (or a missing `sessionId`)
47
+ * instead scopes from "this session" to "every session".
48
+ */
49
+ export function buildTasksView(records, options) {
50
+ const { all, sessionId } = options;
51
+ const scoped = all || !sessionId;
52
+ const tasks = buildTasks(records).filter((task) => scoped || task.sessionId === sessionId);
53
+ return { title: scoped ? "tasks (every session)" : "tasks (this session)", lines: formatTasks(tasks).split("\n") };
54
+ }
55
+ /**
56
+ * `/kankaku export [csv|json] [all]`'s file content: the flat export rows
57
+ * for today's (or every) task, rendered as csv or json, with the file name
58
+ * `/kankaku export` and the panel's export screen (`panel/screens/
59
+ * export.ts`) both use. Extracted from `kankaku-command.ts`'s
60
+ * `handleExportCommand` (see odd/tasks/kankaku-panel.md P4) so the
61
+ * subcommand and the panel never drift. `rowCount` is exposed only for the
62
+ * subcommand's "wrote N row(s) to <path>" confirmation line — the panel's
63
+ * own confirmation is simpler ("wrote <path>").
64
+ */
65
+ export function buildExportContent(records, options) {
66
+ const { format, all } = options;
67
+ const today = localDay(new Date().toISOString());
68
+ const tasks = buildTasks(records).filter((task) => all || localDay(task.startedAt) === today);
69
+ const rows = exportRows(tasks);
70
+ const content = format === "json" ? toJson(rows) : toCsv(rows);
71
+ const name = `tasks-${all ? "all" : today}.${format}`;
72
+ return { name, content, rowCount: rows.length };
73
+ }
@@ -0,0 +1,112 @@
1
+ import type { SessionView, TaskView } from "../domain/task-view.ts";
2
+ import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
3
+ export { localDay } from "../domain/day.ts";
4
+ export interface RoleTotals {
5
+ workMs: number;
6
+ waitingMs: number;
7
+ wallMs: number;
8
+ count: number;
9
+ /** Estimated cost in USD, as priced by pi's model table. */
10
+ cost: number;
11
+ /** Prompt input tokens, summed across every record of this role. */
12
+ input: number;
13
+ /** Prompt-cache read tokens, summed across every record of this role. */
14
+ cacheRead: number;
15
+ /** Prompt-cache write tokens, summed across every record of this role. */
16
+ cacheWrite: number;
17
+ /** Per-tag total milliseconds summed across every record of this role. */
18
+ segments: Record<string, number>;
19
+ }
20
+ export interface TaskTotals {
21
+ count: number;
22
+ wallMs: number;
23
+ workMs: number;
24
+ /** Estimated cost in USD, orchestrator and subagents combined. */
25
+ cost: number;
26
+ /** Prompt input tokens, orchestrator and subagents combined. */
27
+ input: number;
28
+ /** Prompt-cache read tokens, orchestrator and subagents combined. */
29
+ cacheRead: number;
30
+ /** Prompt-cache write tokens, orchestrator and subagents combined. */
31
+ cacheWrite: number;
32
+ /** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
33
+ segments: Record<string, number>;
34
+ }
35
+ export type Summary = Record<WorkRole, RoleTotals> & {
36
+ tasks: TaskTotals;
37
+ };
38
+ export interface SummarizeOptions {
39
+ /** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
40
+ day?: string;
41
+ /** Include every record regardless of day. */
42
+ all?: boolean;
43
+ }
44
+ /**
45
+ * Aggregate work records by role, restricted to one local day unless `all`
46
+ * is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
47
+ * over one orchestrator run and its subagents) for tasks whose `startedAt`
48
+ * falls on the same day.
49
+ */
50
+ export declare function summarize(records: WorkRecord[], options: SummarizeOptions): Summary;
51
+ /**
52
+ * Count of `uncertain` records (ADR 0022) within the same day scope
53
+ * `summarize` uses, so the `/kankaku` report can surface a one-line hint
54
+ * when some records are silently excluded from the task count
55
+ * (SUBAGENT-REQ-017): an undercount must never be silent.
56
+ */
57
+ export declare function countUncertain(records: WorkRecord[], options: SummarizeOptions): number;
58
+ /** Render a short, human-readable summary for the `/kankaku` command. */
59
+ export declare function formatReport(summary: Summary): string;
60
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
61
+ export declare function formatTasks(tasks: TaskView[]): string;
62
+ export interface ClientTotals {
63
+ wallMs: number;
64
+ waitingMs: number;
65
+ workMs: number;
66
+ /** Estimated cost in USD, summed across this client's tasks. */
67
+ cost: number;
68
+ /** Prompt input tokens, summed across this client's tasks. */
69
+ input: number;
70
+ /** Prompt-cache read tokens, summed across this client's tasks. */
71
+ cacheRead: number;
72
+ /** Prompt-cache write tokens, summed across this client's tasks. */
73
+ cacheWrite: number;
74
+ count: number;
75
+ }
76
+ /**
77
+ * Aggregate tasks by billing client (see `domain/client-label.ts`), summing
78
+ * work/waiting/wall time, cost, and task count. Tasks without a `client`
79
+ * are grouped under `"(none)"`. Returned as a `Map` rather than a plain
80
+ * object so an attacker-controlled client name can never repoint a
81
+ * prototype property.
82
+ */
83
+ export declare function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>;
84
+ export interface ProjectTotals {
85
+ /** Display name: the project's `projectName`, or `"(no project)"` for the ungrouped bucket. */
86
+ name: string;
87
+ wallMs: number;
88
+ waitingMs: number;
89
+ workMs: number;
90
+ /** Estimated cost in USD, summed across this project's tasks. */
91
+ cost: number;
92
+ /** Prompt input tokens, summed across this project's tasks. */
93
+ input: number;
94
+ /** Prompt-cache read tokens, summed across this project's tasks. */
95
+ cacheRead: number;
96
+ /** Prompt-cache write tokens, summed across this project's tasks. */
97
+ cacheWrite: number;
98
+ count: number;
99
+ }
100
+ /**
101
+ * Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
102
+ * `projectId` (so two projects that happen to share a display name are
103
+ * never merged) with the name denormalised alongside for display. Tasks
104
+ * without a `projectId` are grouped under `"(no project)"`.
105
+ */
106
+ export declare function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals>;
107
+ /** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
108
+ export declare function formatProjects(totals: Map<string, ProjectTotals>): string;
109
+ /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
110
+ export declare function formatClients(totals: Map<string, ClientTotals>): string;
111
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
112
+ export declare function formatSessions(sessions: SessionView[]): string;
@@ -0,0 +1,236 @@
1
+ import { buildTasks, uncertainRecords } from "../domain/task-view.js";
2
+ import { localDay } from "../domain/day.js";
3
+ import { cacheHitRatio, finiteOrZero } from "../domain/work-record.js";
4
+ export { localDay } from "../domain/day.js";
5
+ const ROLES = ["orchestrator", "subagent"];
6
+ function emptyTotals() {
7
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
8
+ }
9
+ /**
10
+ * Add per-tag milliseconds from `segments` (missing on older records) into
11
+ * `into`, a `Map` rather than a plain object so a tag from a hand-edited
12
+ * worklog line named `__proto__` or `constructor` accumulates as a normal
13
+ * entry instead of silently reading (and arithmetically corrupting) an
14
+ * inherited `Object.prototype` value.
15
+ */
16
+ function addSegments(into, segments) {
17
+ for (const [tag, ms] of Object.entries(segments ?? {})) {
18
+ into.set(tag, (into.get(tag) ?? 0) + ms);
19
+ }
20
+ }
21
+ /**
22
+ * Aggregate work records by role, restricted to one local day unless `all`
23
+ * is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
24
+ * over one orchestrator run and its subagents) for tasks whose `startedAt`
25
+ * falls on the same day.
26
+ */
27
+ export function summarize(records, options) {
28
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
29
+ const summary = {
30
+ orchestrator: emptyTotals(),
31
+ subagent: emptyTotals(),
32
+ tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
33
+ };
34
+ // Accumulated in Maps (see `addSegments`) and only converted to the
35
+ // returned plain objects at the very end, via `Object.fromEntries`.
36
+ const segmentsByRole = { orchestrator: new Map(), subagent: new Map() };
37
+ const taskSegments = new Map();
38
+ for (const record of records) {
39
+ if (targetDay !== undefined && localDay(record.startedAt) !== targetDay)
40
+ continue;
41
+ const totals = summary[record.role];
42
+ totals.workMs += record.workMs;
43
+ totals.waitingMs += record.waitingMs;
44
+ totals.wallMs += record.wallMs;
45
+ totals.count += 1;
46
+ totals.cost += finiteOrZero(record.usage.cost);
47
+ totals.input += finiteOrZero(record.usage.input);
48
+ totals.cacheRead += finiteOrZero(record.usage.cacheRead);
49
+ totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
50
+ addSegments(segmentsByRole[record.role], record.segments);
51
+ }
52
+ const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
53
+ for (const task of tasks) {
54
+ summary.tasks.count += 1;
55
+ summary.tasks.wallMs += task.wallMs;
56
+ summary.tasks.workMs += task.workMs;
57
+ summary.tasks.cost += task.usage.cost;
58
+ summary.tasks.input += finiteOrZero(task.usage.input);
59
+ summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
60
+ summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
61
+ addSegments(taskSegments, task.segments);
62
+ }
63
+ summary.orchestrator.segments = Object.fromEntries(segmentsByRole.orchestrator);
64
+ summary.subagent.segments = Object.fromEntries(segmentsByRole.subagent);
65
+ summary.tasks.segments = Object.fromEntries(taskSegments);
66
+ return summary;
67
+ }
68
+ /**
69
+ * Count of `uncertain` records (ADR 0022) within the same day scope
70
+ * `summarize` uses, so the `/kankaku` report can surface a one-line hint
71
+ * when some records are silently excluded from the task count
72
+ * (SUBAGENT-REQ-017): an undercount must never be silent.
73
+ */
74
+ export function countUncertain(records, options) {
75
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
76
+ return uncertainRecords(records).filter((record) => targetDay === undefined || localDay(record.startedAt) === targetDay).length;
77
+ }
78
+ function formatMinutes(ms) {
79
+ const totalSeconds = Math.round(ms / 1000);
80
+ const minutes = Math.floor(totalSeconds / 60);
81
+ const seconds = totalSeconds % 60;
82
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
83
+ }
84
+ /** Estimated USD cost with two decimals, e.g. `$1.23`. */
85
+ function formatCost(cost) {
86
+ return `$${cost.toFixed(2)}`;
87
+ }
88
+ function formatTime(iso) {
89
+ const date = new Date(iso);
90
+ const hours = String(date.getHours()).padStart(2, "0");
91
+ const minutes = String(date.getMinutes()).padStart(2, "0");
92
+ return `${hours}:${minutes}`;
93
+ }
94
+ /** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
95
+ function formatCacheHit(usage) {
96
+ const ratio = cacheHitRatio(usage);
97
+ return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
98
+ }
99
+ /** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
100
+ function formatSegmentTags(segments) {
101
+ const tags = Object.keys(segments)
102
+ .filter((tag) => segments[tag] > 0)
103
+ .sort();
104
+ if (tags.length === 0)
105
+ return undefined;
106
+ return tags.map((tag) => `${tag} ${formatMinutes(segments[tag])}`).join(", ");
107
+ }
108
+ /** Render a short, human-readable summary for the `/kankaku` command. */
109
+ export function formatReport(summary) {
110
+ const lines = ROLES.map((role) => {
111
+ const totals = summary[role];
112
+ const cacheHit = formatCacheHit(totals);
113
+ const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
114
+ return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
115
+ });
116
+ const tasksCacheHit = formatCacheHit(summary.tasks);
117
+ const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
118
+ lines.push(`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`);
119
+ const segmentTags = formatSegmentTags(summary.tasks.segments);
120
+ if (segmentTags !== undefined) {
121
+ lines.push(`segments: ${segmentTags}`);
122
+ }
123
+ return lines.join(" | ");
124
+ }
125
+ /** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
126
+ const PROMPT_DISPLAY_LIMIT = 60;
127
+ /** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
128
+ function truncateWithEllipsis(text, limit) {
129
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
130
+ }
131
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
132
+ export function formatTasks(tasks) {
133
+ if (tasks.length === 0)
134
+ return "no tasks";
135
+ return tasks
136
+ .map((task) => {
137
+ const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
138
+ const segmentTags = formatSegmentTags(task.segments);
139
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
140
+ const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
141
+ const cacheHit = formatCacheHit(task.usage);
142
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
143
+ return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
144
+ })
145
+ .join("\n");
146
+ }
147
+ /** Client name under which tasks without a resolved client are grouped. */
148
+ const NO_CLIENT = "(none)";
149
+ /**
150
+ * Aggregate tasks by billing client (see `domain/client-label.ts`), summing
151
+ * work/waiting/wall time, cost, and task count. Tasks without a `client`
152
+ * are grouped under `"(none)"`. Returned as a `Map` rather than a plain
153
+ * object so an attacker-controlled client name can never repoint a
154
+ * prototype property.
155
+ */
156
+ export function summarizeByClient(tasks) {
157
+ const totals = new Map();
158
+ for (const task of tasks) {
159
+ const key = task.client ?? NO_CLIENT;
160
+ const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
161
+ entry.wallMs += task.wallMs;
162
+ entry.waitingMs += task.waitingMs;
163
+ entry.workMs += task.workMs;
164
+ entry.cost += finiteOrZero(task.usage.cost);
165
+ entry.input += finiteOrZero(task.usage.input);
166
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
167
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
168
+ entry.count += 1;
169
+ totals.set(key, entry);
170
+ }
171
+ return totals;
172
+ }
173
+ /** Key (and display name) under which tasks without a resolved project are grouped. */
174
+ const NO_PROJECT = "(no project)";
175
+ /**
176
+ * Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
177
+ * `projectId` (so two projects that happen to share a display name are
178
+ * never merged) with the name denormalised alongside for display. Tasks
179
+ * without a `projectId` are grouped under `"(no project)"`.
180
+ */
181
+ export function summarizeByProject(tasks) {
182
+ const totals = new Map();
183
+ for (const task of tasks) {
184
+ const key = task.projectId ?? NO_PROJECT;
185
+ const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
186
+ const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
187
+ entry.wallMs += task.wallMs;
188
+ entry.waitingMs += task.waitingMs;
189
+ entry.workMs += task.workMs;
190
+ entry.cost += finiteOrZero(task.usage.cost);
191
+ entry.input += finiteOrZero(task.usage.input);
192
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
193
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
194
+ entry.count += 1;
195
+ totals.set(key, entry);
196
+ }
197
+ return totals;
198
+ }
199
+ /** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
200
+ export function formatProjects(totals) {
201
+ if (totals.size === 0)
202
+ return "no projects";
203
+ return Array.from(totals.values())
204
+ .sort((a, b) => a.name.localeCompare(b.name))
205
+ .map((t) => {
206
+ const cacheHit = formatCacheHit(t);
207
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
208
+ return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
209
+ })
210
+ .join("\n");
211
+ }
212
+ /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
213
+ export function formatClients(totals) {
214
+ if (totals.size === 0)
215
+ return "no clients";
216
+ return Array.from(totals.entries())
217
+ .sort(([a], [b]) => a.localeCompare(b))
218
+ .map(([client, t]) => {
219
+ const cacheHit = formatCacheHit(t);
220
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
221
+ return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
222
+ })
223
+ .join("\n");
224
+ }
225
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
226
+ export function formatSessions(sessions) {
227
+ if (sessions.length === 0)
228
+ return "no sessions";
229
+ return sessions
230
+ .map((session) => {
231
+ const segmentTags = formatSegmentTags(session.segments);
232
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
233
+ return `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)}${segmentPart} tasks ${session.tasks.length}`;
234
+ })
235
+ .join("\n");
236
+ }