kankaku-tui 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 soyunninja
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # kankaku-tui
2
+
3
+ A standalone terminal app, `kankaku`, that reads every project's
4
+ `.kankaku/worklog.jsonl` under a configurable list of roots and shows the
5
+ day across projects — the one view the [kankaku](https://kankaku.io) pi
6
+ panel cannot give, since it only ever sees the one project pi is running
7
+ in. Built with [Ink](https://github.com/vadimdemedes/ink) on Node 24. Four
8
+ screens — Dashboard, Tasks, Catalog and Sync — share one tab bar, and each
9
+ has a plain-text subcommand for scripts and cron.
10
+
11
+ ## Install
12
+
13
+ Until the next kankaku release, this package depends on the sibling
14
+ `kankaku` checkout via `file:../kankaku`, so both repos must sit next to
15
+ each other on disk. Run `npm install` inside `kankaku-tui/`.
16
+
17
+ Later, once published: `npm install -g kankaku-tui`. For now, run it from
18
+ this repo with `npm run dev`.
19
+
20
+ ## Configuration
21
+
22
+ `~/.kankaku/tui.json`:
23
+
24
+ ```json
25
+ {
26
+ "roots": ["/absolute/path/to/workspace", "~/another-workspace"]
27
+ }
28
+ ```
29
+
30
+ Each root is either a project itself (it has its own `.kankaku/worklog.jsonl`)
31
+ or a directory containing one or more projects as direct subdirectories.
32
+ `~` expands to the home directory. Missing or malformed config falls back
33
+ to the current working directory as the only root.
34
+
35
+ ### Hub credentials (Catalog and Sync)
36
+
37
+ The Catalog and Sync screens (and their subcommands) talk to the same
38
+ PocketBase hub kankaku itself syncs to, through kankaku's own
39
+ `resolveHubCredentials`: `~/.kankaku/credentials.json`
40
+
41
+ ```json
42
+ {
43
+ "url": "https://your-hub.example.com",
44
+ "email": "you@example.com",
45
+ "password": "…"
46
+ }
47
+ ```
48
+
49
+ or the environment (env takes precedence per field over the file):
50
+
51
+ - `KANKAKU_PB_URL`, `KANKAKU_PB_EMAIL`, `KANKAKU_PB_PASSWORD`
52
+ - `KANKAKU_SYNC_WINDOW_HOURS` — revisit window for `sync`/`sync status` (default 24)
53
+ - `KANKAKU_SYNC_PROMPT` — `none` (default), `truncated` or `full`
54
+ - `KANKAKU_SYNC_RECORDS` — set to `0` to skip uploading individual `work_records`
55
+ - `KANKAKU_MACHINE` — overrides the reported hostname
56
+
57
+ Without credentials, the Catalog and Sync screens show a one-line note
58
+ instead of a list; `kankaku catalog` and `kankaku sync status` print the
59
+ same note and exit 0 (no network attempted); `kankaku catalog refresh` and
60
+ `kankaku sync`/`kankaku sync all` print an error and exit 1.
61
+
62
+ Every sync uploaded from here is stamped `plugin: kankaku-tui`; a task's
63
+ `agent` comes from its own orchestrator record when it carries one (see
64
+ kankaku's `hub-entry.ts`), else falls back to `agent: unknown` — this app
65
+ never guesses which coding agent produced someone else's worklog.
66
+
67
+ ## Usage
68
+
69
+ - `kankaku` — opens the interactive TUI on the Dashboard screen.
70
+ - `kankaku today [--roots a,b]` — today's work per project, plain text.
71
+ - `kankaku tasks [--all]` — every task's line (kankaku's own `formatTasks`),
72
+ grouped under a `== <project> ==` header per project; restricted to
73
+ today unless `--all`.
74
+ - `kankaku catalog [refresh]` — without `refresh`, reports the locally
75
+ cached client/project counts (no network); `refresh` fetches a fresh
76
+ snapshot from the hub and caches it to `~/.kankaku/catalog.json`.
77
+ - `kankaku sync [status|all] [--project <dir>]` — `status` reports the
78
+ pending count and last sync per project, no network; with no argument,
79
+ syncs the pending window; `all` does a full resync. Defaults to every
80
+ discovered project, sequentially; `--project <dir>` restricts to one.
81
+
82
+ `--roots` (on `today`/`tasks`) overrides the configured roots for that run.
83
+
84
+ `--theme <name>` picks one of the three built-in colour presets for the
85
+ interactive TUI; `KANKAKU_TUI_THEME=<name>` does the same through the
86
+ environment (the flag wins when both are given). The valid names are
87
+ `gentleman-sexy` (the default), `gentleman-cute` and `gentle` — resolved
88
+ hex values copied from [gentle-pi](https://github.com/Gentleman-Programming/gentle-pi)'s
89
+ own themes (MIT), so this TUI matches the owner's pi panel instead of an
90
+ unrelated default. An unknown name prints a usage error listing the valid
91
+ names and exits 1 without opening the TUI.
92
+
93
+ ## Screens
94
+
95
+ One visual system drives all four screens: a left sidebar for navigation,
96
+ titled bordered panels, aligned tables with a highlighted selection, text
97
+ bars and sparklines, a header line and a footer of key hints — all driven
98
+ by a single theme of colour roles (`src/ui/theme.ts`, see `--theme` above
99
+ for the three built-in presets). The app runs fullscreen, in the
100
+ terminal's alternate screen buffer: the frame fills the whole terminal
101
+ height, resizing live with the terminal. The sidebar sits beside the
102
+ screen at 100+ terminal columns, stacks full-width above it at 70-99
103
+ columns, and collapses to a one-line tab strip below 70 columns; a
104
+ selected row or card is always marked with a visible `›`, never colour
105
+ alone. The sidebar itself shows which zone has focus: its border switches
106
+ to the active border colour and the active item gets a full-row highlight
107
+ when it has focus, dropping back to a plain `›` marker with no highlight
108
+ once focus moves to the screen's own content.
109
+
110
+ Every panel in the main area is sized to a fixed height derived from the
111
+ terminal's own height, so it never grows with its content and shifts the
112
+ rest of the screen — a long value (e.g. the Tasks screen's full prompt)
113
+ is wrapped and, if it still doesn't fit the panel's fixed height, clipped
114
+ with a trailing `… N more lines` note instead of silently overflowing or
115
+ pushing the header out of view.
116
+
117
+ Any list that can grow past the available height (the Tasks table, the
118
+ Catalog Clients/Projects lists, the Dashboard Projects table, the Sync
119
+ card grid) scrolls instead of overflowing the terminal: the viewport
120
+ follows the current selection, and a `↑ N more` / `↓ N more` line marks
121
+ rows hidden above or below it.
122
+
123
+ Dashboard is the app's home screen: a Today card (work/wait/cost/tasks/
124
+ cache hit — it shows today's numbers, hence its own title), a Last 7 days
125
+ card (work and cost sparklines with weekday labels), a Projects table
126
+ (work, cost and a share bar per project), a Hub card (pending/stale, last
127
+ sync time, catalog summary) and a Quick actions panel (`c` refresh the
128
+ catalog, `s` sync every project, `S` full-sync every project, `r` reload):
129
+
130
+ ```
131
+ >_ kankaku 0.1.0 hub ● kankaku.soyun.ninja · synced 08:20
132
+ ┌──────────────┐ ╭─[ Today ]────────────────────╮ ╭─[ Last 7 days ]──────────────────╮
133
+ │ › Dashboard │ │ work 1h 42m │ │ work ▂▅▇▃▁▆█ cost ▁▃▆▂▁▅█ │
134
+ │ Tasks │ │ wait 6m cost $9.83 │ │ mon tue wed thu fri sat sun │
135
+ │ Catalog │ │ tasks 12 cache hit 68%│ ╰──────────────────────────────────╯
136
+ │ Sync │ ╰──────────────────────────────╯ ╭─[ Hub ]──────────────────────────╮
137
+ │ │ ╭─[ Projects ]────────────────────────────╮ │ pending 1 · stale 0 │
138
+ │ │ │ project work cost share │ │ last sync ok 08:20 │
139
+ │ │ │ kankaku 1h 02m $6.49 ████████░░ │ │ catalog 9 clients · │
140
+ │ │ │ kankaku-tui 31m $2.10 █████░░░░░ │ │ 17 projects │
141
+ │ │ │ kankaku-hub 9m $1.24 ██░░░░░░░░ │ ╰─────────────────────────╯
142
+ │ │ ╰─────────────────────────────────────────╯
143
+ ├──────────────┤
144
+ │ roots 1 │
145
+ │ projects 3 │
146
+ └──────────────┘
147
+ ↑↓ move enter open r refresh 1-4 screens q quit
148
+ ```
149
+
150
+ The Quick actions panel sits below the Hub card in wide mode (100+
151
+ columns), or right after the Projects table in stacked mode (70-99
152
+ columns):
153
+
154
+ ```
155
+ ╭─[ Quick actions ]────────────────╮
156
+ │ c refresh catalog │
157
+ │ s sync all projects │
158
+ │ S full sync all │
159
+ │ r reload │
160
+ │ catalog: 9 clients · 17 projects │
161
+ ╰──────────────────────────────────╯
162
+ ```
163
+
164
+ The bottom line is the status line: empty until the first action runs,
165
+ `… <label>` while one is running, its result message once it settles
166
+ (e.g. the catalog refresh above, or a sync summary), `error: <message>`
167
+ if it failed, or `hub not configured (~/.kankaku/credentials.json)` when
168
+ the hub has no credentials — in which case `c`/`s`/`S` do nothing.
169
+
170
+ - **Tasks** — a table (time, project, work, cost, prompt) with a
171
+ highlighted row on the left, and a `[ Task ]` detail panel on the right
172
+ showing the selected row's full prompt, client, project, hub task,
173
+ wall/work/wait time, cost, cache hit and subagent count.
174
+ - **Catalog** — `[ Clients ]` on the left; the selected client's
175
+ `[ Projects ]`, with open/doing hub task counts, on the right. The
176
+ Clients panel header shows the cache's age and a `(stale)` flag.
177
+ - **Sync** — one card per project in a wrapping grid; the selected card is
178
+ highlighted, and each action's result line shows inside its card while
179
+ it runs and once it settles.
180
+
181
+ ## Keys (TUI)
182
+
183
+ The app has two focus zones — the sidebar and the active screen's own main
184
+ content — and one of them always has focus (`domain/nav-model.ts`'s
185
+ `NavState.focus`, starting on the sidebar). `1`-`4` switch the Dashboard/
186
+ Tasks/Catalog/Sync tab bar and `q` quits from anywhere, in either zone; every
187
+ other key belongs to whichever zone currently has focus, so a screen's own
188
+ list never moves by accident while you are still picking a screen.
189
+
190
+ - **Sidebar focused** (the app's own starting state) — `↑`/`↓` move
191
+ between screens, and the screen switches as you move, so you see each
192
+ one before committing to it. `enter`, `→` or `Tab` focus the main zone
193
+ (the screen you last landed on).
194
+ - **Main zone focused** — the active screen's own keys work as below.
195
+ `←` or `Tab` return focus to the sidebar. `esc` also returns to the
196
+ sidebar, unless the screen consumes it first: on Tasks with a project
197
+ filter set (from Dashboard's `enter`), the first `esc` clears the filter
198
+ and the next `esc` returns to the sidebar.
199
+
200
+ The focused zone is visible in the frame: the sidebar's active-item marker
201
+ is in the accent colour when the sidebar is focused and muted otherwise,
202
+ the focused screen's primary panel gets the accent border, and the footer
203
+ key hints change — the sidebar's own hints while it is focused, the
204
+ screen's hints plus `← menu` while the main zone is focused.
205
+
206
+ The app fills the whole terminal; every scrolling list (Tasks, Catalog's
207
+ Clients/Projects, Dashboard's Projects, Sync's cards) additionally takes
208
+ `PageUp`/`PageDown` to move a full window at a time and `Home`/`End` to
209
+ jump to the first/last row.
210
+
211
+ - **Dashboard** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the
212
+ Projects selection, `enter` opens the selected project in Tasks
213
+ (filtered to it), `r` refresh; the Quick actions panel additionally
214
+ takes `c` (refresh catalog), `s` (sync all projects) and `S` (full sync
215
+ all) — one at a time, ignored while another is running.
216
+ - **Tasks** — `a` toggle today/all, `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End`
217
+ move the selection, `r` refresh, `esc` clears a project filter set from
218
+ Dashboard.
219
+ - **Catalog** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the client
220
+ selection, `r` refresh from the hub.
221
+ - **Sync** — `↑`/`↓`/`PageUp`/`PageDown`/`Home`/`End` move the selection,
222
+ `s` sync the selected project, `f` full-sync the selected project, `S`
223
+ sync every project. Each action's summary shows inline in its card
224
+ while it runs and once it settles.
225
+
226
+ The TUI never writes to disk on its own — Dashboard, Tasks and read-only
227
+ Catalog views write nothing at all; Catalog's `refresh`, Sync's
228
+ `s`/`f`/`S` and Dashboard's Quick actions `c`/`s`/`S` write only through
229
+ kankaku's own adapters (`CachedCatalog`, `SyncStateStore`, the hub
230
+ itself), exactly as kankaku's own sync paths do.
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Reads the `version` field from `<root>/package.json`; `"0.0.0"` when it is missing. */
5
+ export function readAppVersion(root) {
6
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
7
+ return pkg.version ?? "0.0.0";
8
+ }
9
+ /** This package's own version, read from its `package.json` next to `dist/adapters/app-info.js` (or `src/adapters/app-info.ts` under `tsx`) — used by the header bar's `>_ kankaku <version>` label. */
10
+ export function readOwnVersion() {
11
+ const root = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
12
+ return readAppVersion(root);
13
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Shared hub adapter: credentials, catalog and per-project sync, used by
3
+ * both the Catalog/Sync screens and their `kankaku catalog`/`kankaku sync`
4
+ * subcommands. Mirrors kankaku-claude's `sync-cli.ts#syncConfigured` (see
5
+ * `odd/tasks/screens-tasks-catalog-sync.md`), but scoped to one explicit
6
+ * `ProjectRef` per call rather than the process's own `cwd`, since one TUI
7
+ * process can sync several projects.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { hostname as osHostname } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { CachedCatalog, JsonlWorkLog, PocketBaseClient, PocketBaseSink, SyncStateStore, computeSyncStatus, createPocketBaseCatalogFetcher, resolveHubCredentials, runSync, safeHomeDir, } from "kankaku/hub";
14
+ const HUB_UNCONFIGURED_REASON = "hub credentials are not configured (KANKAKU_PB_URL/_EMAIL/_PASSWORD or ~/.kankaku/credentials.json)";
15
+ /** Resolve hub credentials via kankaku's own `resolveHubCredentials`, collapsing its result into one pass/fail outcome with a display reason for the Catalog/Sync screens and subcommands. */
16
+ export function resolveHub(deps) {
17
+ const { credentials, invalidReason } = resolveHubCredentials(deps);
18
+ if (invalidReason)
19
+ return { ok: false, reason: `invalid hub URL: ${invalidReason}` };
20
+ if (!credentials)
21
+ return { ok: false, reason: HUB_UNCONFIGURED_REASON };
22
+ return { ok: true, credentials };
23
+ }
24
+ /** Build the disk-backed `CachedCatalog` (`<homeDir>/.kankaku/catalog.json`) for `credentials`, mirroring kankaku-claude's `syncConfigured`. */
25
+ export function createCatalog(credentials, deps) {
26
+ const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
27
+ const home = safeHomeDir(deps.homeDir) ?? deps.homeDir();
28
+ return new CachedCatalog({
29
+ filePath: join(home, ".kankaku", "catalog.json"),
30
+ url: credentials.url,
31
+ clock: { now: deps.now },
32
+ fetchCatalog: createPocketBaseCatalogFetcher(client),
33
+ });
34
+ }
35
+ /** Refresh `catalog` against the hub and return the new snapshot (`undefined` on failure — see `CachedCatalog.refresh`). */
36
+ export function refreshCatalog(catalog) {
37
+ return catalog.refresh();
38
+ }
39
+ /** `KANKAKU_SYNC_WINDOW_HOURS`, parsed the same way as kankaku's own sync paths; defaults to 24. */
40
+ function windowHours(env) {
41
+ const value = Number(env.KANKAKU_SYNC_WINDOW_HOURS);
42
+ return env.KANKAKU_SYNC_WINDOW_HOURS && Number.isFinite(value) && value > 0 ? value : 24;
43
+ }
44
+ /** `KANKAKU_SYNC_PROMPT`, defaulting to `"none"`. */
45
+ function promptMode(env) {
46
+ const value = env.KANKAKU_SYNC_PROMPT;
47
+ return value === "truncated" || value === "full" ? value : "none";
48
+ }
49
+ /** This package's own `version`, read from its `package.json` next to `dist/adapters/hub.js` (or `src/adapters/hub.ts` under `tsx`). */
50
+ function packageVersion() {
51
+ const root = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
52
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
53
+ return pkg.version;
54
+ }
55
+ /** Read `<project.dir>/.kankaku` as a no-network sync status snapshot: pending count, tasks stale outside the revisit window, and the persisted `SyncState` (if any). Never touches any project other than `project`. */
56
+ export function computeProjectSyncStatus(project, credentials, env) {
57
+ const dir = join(project.dir, ".kankaku");
58
+ const log = new JsonlWorkLog(dir);
59
+ const stateStore = new SyncStateStore({ dir, pid: process.pid });
60
+ return computeSyncStatus(log, stateStore, credentials.url, windowHours(env));
61
+ }
62
+ /**
63
+ * Sync one project against the hub: mirrors kankaku-claude's
64
+ * `sync-cli.ts#syncConfigured` (`JsonlWorkLog`, `SyncStateStore`,
65
+ * `PocketBaseSink` fed the refreshed catalog snapshot including `tasks`,
66
+ * `runSync`), but stamped with this TUI's own agent/plugin identity —
67
+ * `agent: "unknown"` (kankaku's `hub-entry.ts` prefers the record's own
68
+ * `agent` when it carries one, so this fallback only ever reaches a
69
+ * never-labelled legacy row) and `plugin: "kankaku-tui"`. Never touches
70
+ * any project other than `project`.
71
+ */
72
+ export async function syncProject(project, credentials, options, deps) {
73
+ const dir = join(project.dir, ".kankaku");
74
+ const log = new JsonlWorkLog(dir);
75
+ const stateStore = new SyncStateStore({ dir, pid: process.pid, now: deps.now });
76
+ const catalog = createCatalog(credentials, deps);
77
+ // On an offline hub the cached snapshot (if any) remains usable.
78
+ await catalog.refresh();
79
+ const snapshot = catalog.read();
80
+ const pluginVersion = packageVersion();
81
+ const sink = {
82
+ push: async (tasks) => {
83
+ if (tasks.length === 0)
84
+ return [];
85
+ const client = new PocketBaseClient({ ...credentials, ...(deps.fetch ? { fetch: deps.fetch } : {}) });
86
+ return new PocketBaseSink({
87
+ client,
88
+ clients: snapshot?.clients ?? [],
89
+ projects: snapshot?.projects ?? [],
90
+ tasks: snapshot?.tasks ?? [],
91
+ machine: deps.env.KANKAKU_MACHINE || (deps.hostname ?? osHostname)(),
92
+ promptMode: promptMode(deps.env),
93
+ syncRecords: deps.env.KANKAKU_SYNC_RECORDS !== "0",
94
+ agent: "unknown",
95
+ plugin: "kankaku-tui",
96
+ pluginVersion,
97
+ }).push(tasks);
98
+ },
99
+ };
100
+ return runSync({ log, sink, stateStore, clock: { now: deps.now }, target: credentials.url, windowHours: windowHours(deps.env) }, options);
101
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ function hasWorklog(dir) {
4
+ return existsSync(join(dir, ".kankaku", "worklog.jsonl"));
5
+ }
6
+ function childDirs(root) {
7
+ try {
8
+ return readdirSync(root)
9
+ .map((entry) => join(root, entry))
10
+ .filter((entry) => {
11
+ try {
12
+ return statSync(entry).isDirectory();
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ });
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
23
+ /**
24
+ * Discover projects under `roots`: a root that itself has
25
+ * `.kankaku/worklog.jsonl` is a project; otherwise each direct child
26
+ * directory with one is a project. Deduped by `dir`, sorted by `name`.
27
+ * Never throws on an unreadable or missing root.
28
+ */
29
+ export function discoverProjects(roots) {
30
+ const byDir = new Map();
31
+ for (const root of roots) {
32
+ if (hasWorklog(root)) {
33
+ byDir.set(root, { name: basename(root), dir: root });
34
+ continue;
35
+ }
36
+ for (const child of childDirs(root)) {
37
+ if (hasWorklog(child)) {
38
+ byDir.set(child, { name: basename(child), dir: child });
39
+ }
40
+ }
41
+ }
42
+ return Array.from(byDir.values()).sort((a, b) => a.name.localeCompare(b.name));
43
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ function expandHome(path, homeDir) {
4
+ if (path === "~")
5
+ return homeDir;
6
+ if (path.startsWith("~/"))
7
+ return join(homeDir, path.slice(2));
8
+ return path;
9
+ }
10
+ function isStringArray(value) {
11
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
12
+ }
13
+ /**
14
+ * Read `<homeDir>/.kankaku/tui.json` (`{ "roots": [...] }`, `~` expanded
15
+ * against `homeDir`). A missing or malformed file falls back to `cwd` as
16
+ * the only root, so the app always has something to discover projects
17
+ * under.
18
+ */
19
+ export function readTuiConfig(homeDir, cwd) {
20
+ const configPath = join(homeDir, ".kankaku", "tui.json");
21
+ if (!existsSync(configPath)) {
22
+ return { roots: [cwd] };
23
+ }
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
26
+ if (!parsed || typeof parsed !== "object" || !isStringArray(parsed["roots"])) {
27
+ return { roots: [cwd] };
28
+ }
29
+ const roots = parsed.roots.map((root) => expandHome(root, homeDir));
30
+ return { roots };
31
+ }
32
+ catch {
33
+ return { roots: [cwd] };
34
+ }
35
+ }
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path";
2
+ import { JsonlWorkLog } from "kankaku/hub";
3
+ /**
4
+ * Read every {@link WorkRecord} appended under `<project.dir>/.kankaku`,
5
+ * via kankaku's own `JsonlWorkLog` (which reads `<dir>/worklog.jsonl`).
6
+ */
7
+ export function readProjectRecords(project) {
8
+ return new JsonlWorkLog(join(project.dir, ".kankaku")).readAll();
9
+ }