kankaku 0.7.1 → 0.8.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 CHANGED
@@ -96,6 +96,10 @@ Each line in `worklog.jsonl` is one JSON object:
96
96
  "projectId": "pocketbase-record-id",
97
97
  "projectName": "Portal",
98
98
  "machine": "laptop",
99
+ "agent": "pi",
100
+ "agentVersion": "0.87.1",
101
+ "plugin": "kankaku",
102
+ "pluginVersion": "0.7.1",
99
103
  "prompt": "first 200 chars of the first prompt",
100
104
  "startedAt": "2026-09-10T16:00:00.000Z",
101
105
  "settledAt": "2026-09-10T16:04:10.000Z",
@@ -160,6 +164,19 @@ anything that wants to reconstruct the exact `pi --session-dir <dir>
160
164
  sent as `session_dir` on every sync (see "Hub (PocketBase)" > "Sync" >
161
165
  "Agent and measurement quality").
162
166
 
167
+ `agent`, `agentVersion`, `plugin` and `pluginVersion` are optional and
168
+ identify who *measured* this record, not who later syncs it: a worklog can
169
+ be synced by a process that did not write it (a standalone `kankaku` TUI
170
+ syncing pi's records; a session syncing a directory another agent also
171
+ wrote to), so this identity travels with the record instead of being
172
+ resolved fresh by whichever process happens to push it to the hub. For a
173
+ record this package writes, `agent` is always `"pi"` and `plugin` is always
174
+ `"kankaku"`; `agentVersion`/`pluginVersion` are included when known and
175
+ omitted rather than guessed. Neither field bumps `WORK_RECORD_SCHEMA` — an
176
+ older record without them stays valid and falls back to the syncing
177
+ process's own identity on create only (see "Hub (PocketBase)" > "Sync" >
178
+ "Agent and measurement quality").
179
+
163
180
  ## Task and session views
164
181
 
165
182
  Each `WorkRecord` still measures one pi process's own prompt-to-idle span.
@@ -1038,25 +1055,39 @@ in the web app, from the unassigned queue.
1038
1055
  **Agent and measurement quality.** Every `task_entries` row also carries
1039
1056
  who produced it and how well each figure was measured, so the hub can
1040
1057
  label what it has instead of silently blending incompatible numbers from
1041
- different agents: `agent` (`"pi"`), `agent_version` (pi's own version,
1042
- when it could be determined — never guessed, omitted otherwise), `plugin`
1043
- (`"kankaku"`), `plugin_version` (this package's own version),
1044
- `waiting_quality` (always `"measured"` for kankaku/pi — it always
1045
- instruments waiting time), `cost_quality` (`"measured"` when the task's
1046
- own record or any joined subagent observed a real provider cost figure on
1047
- at least one turn; `"unknown"` when none did, e.g. a subscription/OAuth
1048
- provider that reports no cost — kankaku has no token-price estimator, so
1049
- it never sends `"estimated"`), and `subagent_linkage` (`"not_applicable"`
1050
- when the task opened no subagent spans; `"linked"` when at least as many
1051
- child records were joined as spans were opened; `"unlinked"` otherwise —
1052
- a task-level approximation, since there is no per-span correlation id
1053
- today, see "Subagents" > "Limitations"). These are measurement fields, not
1054
- assignment: sent on every create *and* update, and included in the sync
1055
- content hash, so a background subagent that joins later — improving
1056
- `cost_quality`/`subagent_linkage` without changing any other number —
1057
- still triggers a resync. An older hub predating these fields simply
1058
- ignores them (PocketBase silently drops unrecognized fields on write); no
1059
- capability probing is needed.
1058
+ different agents: `agent` (`"pi"` for this package), `agent_version` (the
1059
+ agent's own version, when it could be determined — never guessed, omitted
1060
+ otherwise), `plugin` (`"kankaku"`), `plugin_version` (this package's own
1061
+ version), `waiting_quality` (always `"measured"` for kankaku/pi — it
1062
+ always instruments waiting time), `cost_quality` (`"measured"` when the
1063
+ task's own record or any joined subagent observed a real provider cost
1064
+ figure on at least one turn; `"unknown"` when none did, e.g. a
1065
+ subscription/OAuth provider that reports no cost — kankaku has no
1066
+ token-price estimator, so it never sends `"estimated"`), and
1067
+ `subagent_linkage` (`"not_applicable"` when the task opened no subagent
1068
+ spans; `"linked"` when at least as many child records were joined as spans
1069
+ were opened; `"unlinked"` otherwise — a task-level approximation, since
1070
+ there is no per-span correlation id today, see "Subagents" >
1071
+ "Limitations"). These are measurement fields, not assignment, but
1072
+ `agent`/`agent_version`/`plugin`/`plugin_version` specifically identify
1073
+ who *measured* the task, not who *syncs* it: they are taken from the
1074
+ orchestrator record's own `agent`/`agentVersion`/`plugin`/`pluginVersion`
1075
+ (see "Record schema") when it carries one, and only fall back to the
1076
+ syncing process's own identity for a legacy record written before this
1077
+ field existed. On create, `agent`/`plugin` are always sent (from the
1078
+ record or the fallback). On update, all four are sent when the record
1079
+ carries an identity, and OMITTED ENTIRELY for a legacy record — so a
1080
+ re-sync by a *different* process (a standalone `kankaku` TUI, or a
1081
+ different agent syncing a shared directory) can never overwrite a row's
1082
+ original identity with its own. `waiting_quality`/`cost_quality`/
1083
+ `subagent_linkage` are unaffected by this and are always sent on both
1084
+ create and update. All of these are included in the sync content hash
1085
+ (the record's own `agent`/`plugin`, not their versions), so a background
1086
+ subagent that joins later, or a task that first gains a who-measured
1087
+ identity — improving `cost_quality`/`subagent_linkage`/`agent` without
1088
+ changing any other number — still triggers a resync. An older hub
1089
+ predating these fields simply ignores them (PocketBase silently drops
1090
+ unrecognized fields on write); no capability probing is needed.
1060
1091
 
1061
1092
  **Session directory.** `session_dir` carries a task's non-default session
1062
1093
  directory (`TaskView.sessionDir`, see "Record schema") to the hub, so a
@@ -1329,8 +1360,16 @@ separate CLI or another agent's plugin (e.g. the `kankaku-claude` package):
1329
1360
  adapters against.
1330
1361
  - `kankaku/hub` — the pi-free adapters: the PocketBase HTTP client and
1331
1362
  catalog/sink, `runSync`, the JSONL work log, the cached catalog, hub
1332
- credentials, and related filesystem helpers. Nothing reachable from this
1333
- entry point ever imports a pi package type.
1363
+ credentials, and related filesystem helpers; also the report formatters
1364
+ and the five report view builders (`formatReport`, `summarize`,
1365
+ `buildSummaryView`, `buildTasksView`, etc.), the hub action line-builders
1366
+ (`buildSyncStatusLines`, `formatSyncSummaryLines`, ...), the export
1367
+ writer (`writeExport`) and the project config reader/writer
1368
+ (`readProjectTargetIds`, `writeProjectTargetIds`). These exist so a
1369
+ standalone CLI or TUI (e.g. a future Ink-based one) can render the exact
1370
+ same reports and hub actions as the `/kankaku` subcommands and panel,
1371
+ without reimplementing them. Nothing reachable from this entry point ever
1372
+ imports a pi package type.
1334
1373
 
1335
1374
  ```js
1336
1375
  import { runSync } from "kankaku/hub";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Write `content` to `<dir>/export/<name>` (creating the `export`
3
+ * subdirectory as needed, overwriting any existing file with the same
4
+ * name) and return the absolute path it was written to.
5
+ */
6
+ export declare function writeExport(dir: string, name: string, content: string): string;
7
+ /** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
8
+ export declare class LazyExportWriter {
9
+ private readonly dirOrRelative;
10
+ private readonly fallbackCwd;
11
+ constructor(dirOrRelative: string, fallbackCwd?: () => string);
12
+ write(name: string, content: string): string;
13
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { resolveKankakuDir } from "./kankaku-dir.js";
4
+ const EXPORT_SUBDIR = "export";
5
+ /**
6
+ * Write `content` to `<dir>/export/<name>` (creating the `export`
7
+ * subdirectory as needed, overwriting any existing file with the same
8
+ * name) and return the absolute path it was written to.
9
+ */
10
+ export function writeExport(dir, name, content) {
11
+ const exportDir = join(dir, EXPORT_SUBDIR);
12
+ mkdirSync(exportDir, { recursive: true });
13
+ const filePath = resolve(join(exportDir, name));
14
+ writeFileSync(filePath, content);
15
+ return filePath;
16
+ }
17
+ /** Writes exports under a kankaku dir resolved lazily against `fallbackCwd()` at call time. */
18
+ export class LazyExportWriter {
19
+ dirOrRelative;
20
+ fallbackCwd;
21
+ constructor(dirOrRelative, fallbackCwd = () => process.cwd()) {
22
+ this.dirOrRelative = dirOrRelative;
23
+ this.fallbackCwd = fallbackCwd;
24
+ }
25
+ write(name, content) {
26
+ return writeExport(resolveKankakuDir(this.dirOrRelative, this.fallbackCwd()), name, content);
27
+ }
28
+ }
@@ -0,0 +1,35 @@
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 { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
10
+ /**
11
+ * `/kankaku sync status`'s report lines: the watermark, the pending count,
12
+ * how many tasks fell outside this run's revisit window (R3), and the last
13
+ * error, when any. Exact body of `handleSyncCommand`'s `status` branch.
14
+ */
15
+ export declare function buildSyncStatusLines(status: SyncStatusSnapshot): string[];
16
+ /**
17
+ * Render a {@link SyncSummary} as report lines: counts, any stop reason, the
18
+ * new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
19
+ * sync`/`sync all` and the panel's sync screen.
20
+ */
21
+ export declare function formatSyncSummaryLines(summary: SyncSummary): string[];
22
+ /**
23
+ * `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
24
+ * run, grouped by their old free-text label, or "no unassigned tasks" when
25
+ * none were. Exact body of `handleBackfillCommand`.
26
+ */
27
+ export declare function formatBackfillLines(summary: SyncSummary): string[];
28
+ /**
29
+ * `/kankaku catalog refresh`'s report lines: client/project counts, or an
30
+ * unreachable notice when the refresh failed (`snapshot` is `undefined`).
31
+ * The subcommand still notifies that failure as an error rather than a
32
+ * report (see `handleCatalogCommand`); the panel's sync screen shows
33
+ * whatever this returns either way.
34
+ */
35
+ export declare function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `/kankaku sync status`'s report lines: the watermark, the pending count,
3
+ * how many tasks fell outside this run's revisit window (R3), and the last
4
+ * error, when any. Exact body of `handleSyncCommand`'s `status` branch.
5
+ */
6
+ export function buildSyncStatusLines(status) {
7
+ const { state, pending, staleOutsideWindow } = status;
8
+ const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
9
+ if (staleOutsideWindow > 0) {
10
+ lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
11
+ }
12
+ if (state?.lastError)
13
+ lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
14
+ return lines;
15
+ }
16
+ /**
17
+ * Render a {@link SyncSummary} as report lines: counts, any stop reason, the
18
+ * new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
19
+ * sync`/`sync all` and the panel's sync screen.
20
+ */
21
+ export function formatSyncSummaryLines(summary) {
22
+ const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
23
+ if (summary.locked)
24
+ lines.push("another sync is already in progress; nothing was attempted");
25
+ if (summary.error)
26
+ lines.push(`stopped early: ${summary.error}`);
27
+ if (summary.syncedThrough)
28
+ lines.push(`synced through ${summary.syncedThrough}`);
29
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
30
+ if (unassignedEntries.length > 0) {
31
+ lines.push("unassigned (Sin determinar):");
32
+ for (const [label, count] of unassignedEntries)
33
+ lines.push(` ${label}: ${count}`);
34
+ }
35
+ if (summary.failed.length > 0) {
36
+ lines.push("failed:");
37
+ for (const entry of summary.failed)
38
+ lines.push(` ${entry.id}: ${entry.reason}`);
39
+ }
40
+ return lines;
41
+ }
42
+ /**
43
+ * `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
44
+ * run, grouped by their old free-text label, or "no unassigned tasks" when
45
+ * none were. Exact body of `handleBackfillCommand`.
46
+ */
47
+ export function formatBackfillLines(summary) {
48
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
49
+ const lines = unassignedEntries.length > 0
50
+ ? [
51
+ ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
52
+ "reassign these in the hub web app's unassigned queue",
53
+ ]
54
+ : ["no unassigned tasks"];
55
+ if (summary.error)
56
+ lines.push(`stopped early: ${summary.error}`);
57
+ return lines;
58
+ }
59
+ /**
60
+ * `/kankaku catalog refresh`'s report lines: client/project counts, or an
61
+ * unreachable notice when the refresh failed (`snapshot` is `undefined`).
62
+ * The subcommand still notifies that failure as an error rather than a
63
+ * report (see `handleCatalogCommand`); the panel's sync screen shows
64
+ * whatever this returns either way.
65
+ */
66
+ export function formatCatalogRefreshLines(snapshot) {
67
+ if (!snapshot)
68
+ return ["hub unreachable; catalog not refreshed"];
69
+ return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
70
+ }
@@ -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/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/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
+ }