kankaku 0.7.1 → 0.8.2
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 +60 -21
- package/dist/adapters/export-writer.d.ts +13 -0
- package/dist/adapters/export-writer.js +28 -0
- package/dist/adapters/hub-actions.d.ts +35 -0
- package/dist/adapters/hub-actions.js +70 -0
- package/dist/adapters/project-config.d.ts +42 -0
- package/dist/adapters/project-config.js +108 -0
- package/dist/adapters/report-data.d.ts +12 -0
- package/dist/adapters/report-data.js +8 -0
- package/dist/adapters/report-views.d.ts +45 -0
- package/dist/adapters/report-views.js +73 -0
- package/dist/adapters/report.d.ts +112 -0
- package/dist/adapters/report.js +236 -0
- package/dist/adapters/sync-runner.d.ts +21 -6
- package/dist/adapters/sync-runner.js +40 -9
- package/dist/domain/hub-entry.d.ts +26 -2
- package/dist/domain/hub-entry.js +50 -6
- package/dist/domain/sync-plan.d.ts +6 -5
- package/dist/domain/sync-plan.js +21 -7
- package/dist/domain/work-record.d.ts +17 -0
- package/dist/domain/work-record.js +5 -1
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +6 -0
- package/package.json +1 -1
- package/src/adapters/hub-actions.ts +2 -3
- package/src/adapters/kankaku-command.ts +5 -9
- package/src/adapters/pi-tracker.ts +8 -0
- package/src/adapters/report-data.ts +13 -0
- package/src/adapters/report-views.ts +1 -1
- package/src/adapters/sync-runner.ts +54 -15
- package/src/domain/hub-entry.ts +87 -8
- package/src/domain/sync-plan.ts +25 -10
- package/src/domain/work-record.ts +22 -1
- package/src/hub/index.ts +6 -0
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` (
|
|
1042
|
-
when it could be determined — never guessed, omitted
|
|
1043
|
-
(`"kankaku"`), `plugin_version` (this package's own
|
|
1044
|
-
`waiting_quality` (always `"measured"` for kankaku/pi — it
|
|
1045
|
-
instruments waiting time), `cost_quality` (`"measured"` when the
|
|
1046
|
-
own record or any joined subagent observed a real provider cost
|
|
1047
|
-
at least one turn; `"unknown"` when none did, e.g. a
|
|
1048
|
-
provider that reports no cost — kankaku has no
|
|
1049
|
-
it never sends `"estimated"`), and
|
|
1050
|
-
when the task opened no subagent
|
|
1051
|
-
child records were joined as spans
|
|
1052
|
-
a task-level approximation, since
|
|
1053
|
-
today, see "Subagents" >
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
|
1333
|
-
|
|
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
|
+
}
|