kankaku 0.5.1 → 0.6.5

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 (89) hide show
  1. package/README.md +83 -13
  2. package/dist/adapters/cached-catalog.d.ts +42 -0
  3. package/dist/adapters/cached-catalog.js +121 -0
  4. package/dist/adapters/file-modes.d.ts +20 -0
  5. package/dist/adapters/file-modes.js +34 -0
  6. package/dist/adapters/hub-credentials.d.ts +35 -0
  7. package/dist/adapters/hub-credentials.js +58 -0
  8. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  9. package/dist/adapters/jsonl-work-log.js +62 -0
  10. package/dist/adapters/kankaku-dir.d.ts +38 -0
  11. package/dist/adapters/kankaku-dir.js +85 -0
  12. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  13. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  14. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  15. package/dist/adapters/pocketbase-catalog.js +56 -0
  16. package/dist/adapters/pocketbase-client.d.ts +81 -0
  17. package/dist/adapters/pocketbase-client.js +148 -0
  18. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  19. package/dist/adapters/pocketbase-sink.js +181 -0
  20. package/dist/adapters/sync-runner.d.ts +99 -0
  21. package/dist/adapters/sync-runner.js +242 -0
  22. package/dist/adapters/sync-state-store.d.ts +62 -0
  23. package/dist/adapters/sync-state-store.js +188 -0
  24. package/dist/config.d.ts +168 -0
  25. package/dist/config.js +392 -0
  26. package/dist/domain/ancestry-match.d.ts +49 -0
  27. package/dist/domain/ancestry-match.js +82 -0
  28. package/dist/domain/client-label.d.ts +28 -0
  29. package/dist/domain/client-label.js +44 -0
  30. package/dist/domain/day.d.ts +2 -0
  31. package/dist/domain/day.js +8 -0
  32. package/dist/domain/export.d.ts +38 -0
  33. package/dist/domain/export.js +68 -0
  34. package/dist/domain/hub-entry.d.ts +210 -0
  35. package/dist/domain/hub-entry.js +221 -0
  36. package/dist/domain/index.d.ts +19 -0
  37. package/dist/domain/index.js +19 -0
  38. package/dist/domain/intervals.d.ts +17 -0
  39. package/dist/domain/intervals.js +43 -0
  40. package/dist/domain/registry-health.d.ts +49 -0
  41. package/dist/domain/registry-health.js +58 -0
  42. package/dist/domain/segment-rule.d.ts +10 -0
  43. package/dist/domain/segment-rule.js +1 -0
  44. package/dist/domain/subagent-profile.d.ts +278 -0
  45. package/dist/domain/subagent-profile.js +418 -0
  46. package/dist/domain/sync-plan.d.ts +150 -0
  47. package/dist/domain/sync-plan.js +182 -0
  48. package/dist/domain/task-view.d.ts +117 -0
  49. package/dist/domain/task-view.js +428 -0
  50. package/dist/domain/work-record.d.ts +219 -0
  51. package/dist/domain/work-record.js +87 -0
  52. package/dist/domain/work-target.d.ts +101 -0
  53. package/dist/domain/work-target.js +149 -0
  54. package/dist/domain/work-tracker.d.ts +90 -0
  55. package/dist/domain/work-tracker.js +405 -0
  56. package/dist/hub/index.d.ts +19 -0
  57. package/dist/hub/index.js +19 -0
  58. package/dist/ports/catalog.d.ts +31 -0
  59. package/dist/ports/catalog.js +1 -0
  60. package/dist/ports/clock.d.ts +3 -0
  61. package/dist/ports/clock.js +1 -0
  62. package/dist/ports/index.d.ts +11 -0
  63. package/dist/ports/index.js +1 -0
  64. package/dist/ports/inflight-store.d.ts +15 -0
  65. package/dist/ports/inflight-store.js +1 -0
  66. package/dist/ports/process-registry.d.ts +72 -0
  67. package/dist/ports/process-registry.js +1 -0
  68. package/dist/ports/work-log.d.ts +14 -0
  69. package/dist/ports/work-log.js +1 -0
  70. package/dist/ports/work-sink.d.ts +39 -0
  71. package/dist/ports/work-sink.js +1 -0
  72. package/package.json +20 -2
  73. package/src/adapters/cached-catalog.ts +22 -6
  74. package/src/adapters/kankaku-command.ts +43 -1
  75. package/src/adapters/pi-tracker.ts +2 -0
  76. package/src/adapters/pocketbase-catalog.ts +36 -7
  77. package/src/adapters/pocketbase-sink.ts +15 -3
  78. package/src/adapters/report.ts +66 -16
  79. package/src/adapters/session-target.ts +64 -4
  80. package/src/adapters/target-picker.ts +46 -1
  81. package/src/domain/hub-entry.ts +20 -5
  82. package/src/domain/index.ts +19 -0
  83. package/src/domain/task-view.ts +6 -0
  84. package/src/domain/work-record.ts +23 -0
  85. package/src/domain/work-target.ts +60 -11
  86. package/src/extension.ts +1 -0
  87. package/src/hub/index.ts +19 -0
  88. package/src/ports/catalog.ts +4 -2
  89. package/src/ports/index.ts +11 -0
package/README.md CHANGED
@@ -114,7 +114,9 @@ remains valid.
114
114
  `clientId`, `clientName`, `projectId`, `projectName` and `machine` are only
115
115
  present once a hub is configured (see "Hub (PocketBase)"); every report and
116
116
  export written before this feature, or by a user without a hub, is
117
- unaffected.
117
+ unaffected. `hubTaskId`/`hubTaskTitle` are set alongside them only when a
118
+ hub task is linked to the session (`/kankaku task pick`) — see "Linking to
119
+ a hub task" below.
118
120
 
119
121
  `sessionDir` is present only when pi's session manager reports a
120
122
  *non-default* session directory (`--session-dir`, or a resumed session
@@ -686,7 +688,11 @@ the future, this is the signal that would surface it.
686
688
  ## The `/kankaku` command
687
689
 
688
690
  Run `/kankaku` inside pi to see today's totals (work, waiting, record count)
689
- per role, plus a union-based tasks segment. In the interactive TUI the report
691
+ per role, plus a union-based tasks segment. Each totals line also shows
692
+ `cache hit NN%` when tokens were recorded: the share of prompt input tokens
693
+ served from the provider's prompt cache, cache reads over input plus cache
694
+ reads plus cache writes; the segment is omitted, not shown as `0%`, when no
695
+ tokens were recorded. In the interactive TUI the report
690
696
  is appended to the chat transcript as a durable card that is never sent to
691
697
  the LLM; without a UI (print or RPC mode) it falls back to a notification.
692
698
  Arguments are whitespace-separated and order-insensitive:
@@ -719,6 +725,9 @@ The following are available only when a hub is configured (see "Hub
719
725
  produced it. `/kankaku target pick` runs the picker again (works
720
726
  mid-session; the new target applies to records settled afterwards).
721
727
  `/kankaku target clear` clears the session-level target.
728
+ - `/kankaku task` (or `/kankaku task pick`) — link this session to an
729
+ open/doing hub task of the effective project. `/kankaku task clear`
730
+ drops the link. See "Linking to a hub task" below.
722
731
  - `/kankaku catalog refresh` — force a catalog refresh and report the
723
732
  client/project counts.
724
733
  - `/kankaku projects` — one line per project (work/waiting/wall time, cost,
@@ -825,6 +834,33 @@ task view exposes it from the orchestrator record only.
825
834
  The status bar shows `💼 <client> · <project>` (or just `💼 <client>` without
826
835
  a project) in place of the legacy client label, both idle and during a run.
827
836
 
837
+ ### Linking to a hub task
838
+
839
+ `/kankaku task` (or `/kankaku task pick`) links the current session to one
840
+ of the effective project's existing hub `tasks` rows: a `ctx.ui.select`
841
+ picker lists the project's `open`/`doing` tasks, sorted by title (colliding
842
+ titles are disambiguated with the task's external reference, or its id).
843
+ `/kankaku task clear` drops the link, keeping the rest of the session
844
+ target. kankaku never creates a task from pi — this only links to one
845
+ that already exists in the hub.
846
+
847
+ The link is **session-only**: unlike `clientId`/`projectId`, it is never
848
+ persisted to `<KANKAKU_DIR>/config.json`, and it is never asked for at
849
+ `session_start` — you always link a task explicitly, with `/kankaku task`.
850
+ Any target change (`/kankaku target pick`, `/kankaku target clear`, or the
851
+ legacy `/kankaku client <name>`) drops the linked task, since a new client
852
+ or project makes the old task's link meaningless. A task whose project no
853
+ longer matches the effective project (e.g. after a target change or a
854
+ reassignment in the hub) is also dropped by the domain resolver, never
855
+ silently linked across projects. A task already marked `done` when linked
856
+ keeps linking for the rest of the session — only the picker itself hides
857
+ `done` tasks, so you cannot accidentally pick a closed one, but finishing
858
+ the picked task in the hub mid-session does not break the link. Subagent
859
+ records never carry a linked task, exactly like `clientId`/`projectId` —
860
+ the task view exposes it from the orchestrator record only, and
861
+ `formatWorkTargetLabel` appends it to the status-bar/report label as
862
+ `<client> · <project> › <task title>`.
863
+
828
864
  ### Caching and offline behaviour
829
865
 
830
866
  The catalog (clients/projects) is cached machine-wide at
@@ -905,10 +941,13 @@ web app) can move a task from one client/project to another directly in
905
941
  PocketBase — for example, moving a "Sin determinar" row to its real
906
942
  client once you have identified it. A later re-sync of that same task
907
943
  **must never undo that**: on create kankaku sends the full row, including
908
- `client`/`project`/`legacy_client_label`; on every subsequent update it
909
- sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and never
910
- touches assignment fields again. If you need kankaku itself to change a
911
- task's assignment, do it in the web app, not by re-syncing.
944
+ `client`/`project`/`task`/`legacy_client_label`; on every subsequent update
945
+ it sends measurement fields only (`wall_ms`, `cost`, `status`, ...) and
946
+ never touches assignment fields again. `task` (the linked `tasks` relation)
947
+ is create-only for the exact same reason: reassigning which task a row
948
+ belongs to in the web app is never undone by a later sync. If you need
949
+ kankaku itself to change a task's assignment, do it in the web app, not by
950
+ re-syncing.
912
951
 
913
952
  **Historical ("Sin determinar") records.** A record with no `clientId`, or
914
953
  whose `clientId` no longer resolves in the catalog, is routed to the hub's
@@ -1198,6 +1237,34 @@ Columns (in this order for CSV; the same fields for JSON):
1198
1237
  are never throttled, and none of this applies to a manual `/kankaku
1199
1238
  sync`, `sync all`, or `backfill`.
1200
1239
 
1240
+ ## Using kankaku as a library
1241
+
1242
+ Besides the pi extension, `kankaku` publishes three compiled, pi-free entry
1243
+ points for a plain Node consumer — no pi, no TypeScript loader — such as a
1244
+ separate CLI or another agent's plugin (e.g. the `kankaku-claude` package):
1245
+
1246
+ - `kankaku/domain` — the pure domain layer: `WorkTracker`, `buildTasks`,
1247
+ `unionMs`, and the rest of `src/domain/`.
1248
+ - `kankaku/ports` — the port interfaces only (`Clock`, `WorkLog`, `Catalog`,
1249
+ `WorkSink`, `ProcessRegistry`, `InflightStore`), for writing your own
1250
+ adapters against.
1251
+ - `kankaku/hub` — the pi-free adapters: the PocketBase HTTP client and
1252
+ catalog/sink, `runSync`, the JSONL work log, the cached catalog, hub
1253
+ credentials, and related filesystem helpers. Nothing reachable from this
1254
+ entry point ever imports a pi package type.
1255
+
1256
+ ```js
1257
+ import { runSync } from "kankaku/hub";
1258
+ import { buildTasks } from "kankaku/domain";
1259
+ ```
1260
+
1261
+ Each entry point is compiled ahead of time (`npm run build`, part of `npm
1262
+ run check`) to `dist/<domain|ports|hub>/index.{js,d.ts}` and resolved
1263
+ through `package.json`'s `exports` map, so importing it never needs type
1264
+ stripping or a `.ts` loader. `kankaku/src/*` is how pi itself loads
1265
+ `./src/extension.ts` and is not a public API — its shape can change without
1266
+ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
1267
+
1201
1268
  ## Limitations
1202
1269
 
1203
1270
  - A prompt shown by a tool that does not go through `ctx.ui` and is not
@@ -1217,14 +1284,17 @@ Columns (in this order for CSV; the same fields for JSON):
1217
1284
  (PocketBase)" above is phase 1; sync itself ("Hub (PocketBase)" > "Sync")
1218
1285
  is phase 2 — both already shipped.
1219
1286
  - A standalone CLI entry point (`npx kankaku sync`, for a cron/launchd job
1220
- outside of any pi session) is deliberately not included yet: Node refuses
1221
- type stripping for a `.ts` file under `node_modules`, so a bin script
1222
- needs a build step this package does not have yet. `sync-runner.ts` and
1223
- its adapters are already decoupled from pi so that build step is the only
1224
- missing piece.
1287
+ outside of any pi session) is deliberately not included yet. The build
1288
+ step it needs now exists — `sync-runner.ts` and its adapters are
1289
+ published pi-free and pre-compiled as `kankaku/hub` (see "Using kankaku
1290
+ as a library") — but a `bin` script that wires that up as a runnable CLI
1291
+ is not; the compiled entry points are today consumed as a library, not a
1292
+ binary.
1225
1293
  - Linking a `task_entries` row to an existing `tasks` record (phase 3 in the
1226
- hub's own data model) — kankaku never invents tasks; it would only ever
1227
- link to one created in the manager.
1294
+ hub's own data model) has shipped: `/kankaku task pick`/`clear` (see
1295
+ "Linking to a hub task" above). Creating a task from pi
1296
+ (`/kankaku task new`) is deliberately not included — kankaku never
1297
+ invents tasks; it only ever links to one already created in the manager.
1228
1298
  - Generic subagent detection (phase 6): 6a fixed the two correctness bugs
1229
1299
  described in "Subagents" above (a phantom-orchestrator double count; a
1230
1300
  gentle-pi cross-worktree child's work going missing). 6b added the
@@ -0,0 +1,42 @@
1
+ import type { Client, HubTask, Project } from "../domain/work-target.ts";
2
+ import type { Clock } from "../ports/clock.ts";
3
+ import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
4
+ export interface CachedCatalogDeps {
5
+ /** Absolute path to the cache file, e.g. `~/.kankaku/catalog.json`. */
6
+ filePath: string;
7
+ /** The configured hub URL; a cache written for a different URL is ignored. */
8
+ url: string;
9
+ clock: Clock;
10
+ /** Cache TTL in ms. Defaults to 6 hours. */
11
+ ttlMs?: number;
12
+ /**
13
+ * Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
14
+ * `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
15
+ * on the real PocketBase fetcher) so a test double that only cares about
16
+ * clients/projects keeps compiling unchanged.
17
+ */
18
+ fetchCatalog: (signal?: AbortSignal) => Promise<{
19
+ clients: Client[];
20
+ projects: Project[];
21
+ tasks?: HubTask[];
22
+ }>;
23
+ }
24
+ /**
25
+ * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
26
+ * last known snapshot (memoised after the first disk read so repeated
27
+ * calls in one process never re-stat/re-parse); `refresh()` fetches, caches
28
+ * to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
29
+ * and never throws — a failed refresh resolves `undefined` and leaves the
30
+ * previous snapshot (if any) untouched.
31
+ */
32
+ export declare class CachedCatalog implements Catalog {
33
+ private readonly deps;
34
+ private memo;
35
+ private memoized;
36
+ constructor(deps: CachedCatalogDeps);
37
+ read(): CatalogSnapshot | undefined;
38
+ isStale(): boolean;
39
+ refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
40
+ private readDisk;
41
+ private writeDisk;
42
+ }
@@ -0,0 +1,121 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.js";
4
+ /** Six hours in milliseconds — clients and projects change rarely. */
5
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
6
+ function isClientArray(value) {
7
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
8
+ }
9
+ function isProjectArray(value) {
10
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
11
+ }
12
+ function isHubTaskArray(value) {
13
+ return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof item.id === "string");
14
+ }
15
+ function isCatalogSnapshot(value) {
16
+ if (!value || typeof value !== "object")
17
+ return false;
18
+ const record = value;
19
+ return (typeof record["fetchedAt"] === "number" &&
20
+ typeof record["url"] === "string" &&
21
+ isClientArray(record["clients"]) &&
22
+ isProjectArray(record["projects"]) &&
23
+ (record["tasks"] === undefined || isHubTaskArray(record["tasks"])));
24
+ }
25
+ /**
26
+ * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
27
+ * last known snapshot (memoised after the first disk read so repeated
28
+ * calls in one process never re-stat/re-parse); `refresh()` fetches, caches
29
+ * to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
30
+ * and never throws — a failed refresh resolves `undefined` and leaves the
31
+ * previous snapshot (if any) untouched.
32
+ */
33
+ export class CachedCatalog {
34
+ deps;
35
+ memo;
36
+ memoized = false;
37
+ constructor(deps) {
38
+ this.deps = deps;
39
+ }
40
+ read() {
41
+ if (!this.memoized) {
42
+ this.memo = this.readDisk();
43
+ this.memoized = true;
44
+ }
45
+ return this.memo;
46
+ }
47
+ isStale() {
48
+ const snapshot = this.read();
49
+ if (!snapshot)
50
+ return true;
51
+ const ttl = this.deps.ttlMs ?? DEFAULT_TTL_MS;
52
+ return this.deps.clock.now() - snapshot.fetchedAt > ttl;
53
+ }
54
+ async refresh(signal) {
55
+ try {
56
+ const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
57
+ const snapshot = {
58
+ fetchedAt: this.deps.clock.now(),
59
+ url: this.deps.url,
60
+ clients,
61
+ projects,
62
+ ...(tasks !== undefined ? { tasks } : {}),
63
+ };
64
+ this.writeDisk(snapshot);
65
+ this.memo = snapshot;
66
+ this.memoized = true;
67
+ return snapshot;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ readDisk() {
74
+ try {
75
+ if (!existsSync(this.deps.filePath))
76
+ return undefined;
77
+ const parsed = JSON.parse(readFileSync(this.deps.filePath, "utf8"));
78
+ if (!isCatalogSnapshot(parsed))
79
+ return undefined;
80
+ if (parsed.url !== this.deps.url)
81
+ return undefined;
82
+ return parsed;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ writeDisk(snapshot) {
89
+ try {
90
+ // This cache lives under `~/.kankaku` (machine-wide, not a project's
91
+ // own KANKAKU_DIR) — but `~/.kankaku` itself is never mode-tightened
92
+ // here (R2): when pi runs with cwd === $HOME, a project's own default
93
+ // KANKAKU_DIR (`.kankaku`, relative) resolves to this exact same
94
+ // path, and a project's kankaku dir must never be tightened
95
+ // (AGENTS.md). Only what this package exclusively owns is touched:
96
+ // an EXISTING directory is never chmod'd — `mkdirSync`'s own `mode`
97
+ // option only ever applies to a directory this call actually
98
+ // creates, never one that already existed (Node skips the mkdir
99
+ // syscall for an existing path segment entirely, mode and all), so
100
+ // passing `mode` here is safe even though this path may be a
101
+ // project's own dir (G3). When this call IS the first writer (no
102
+ // `~/.kankaku` yet at all, e.g. a machine with the hub configured but
103
+ // no project ever run from $HOME), it is created owner-only
104
+ // (`OWNER_DIR_MODE`, 0700) rather than left at the umask default. The
105
+ // cache file itself is always written fresh via tmp+rename with
106
+ // OWNER_FILE_MODE below, which already guarantees 0600 on every
107
+ // write regardless of whatever mode an older file at this path (or
108
+ // an older kankaku build) left behind — a rename replaces the whole
109
+ // inode, so a stale looser mode can never survive a write.
110
+ mkdirSync(dirname(this.deps.filePath), { recursive: true, mode: OWNER_DIR_MODE });
111
+ const tmp = `${this.deps.filePath}.${process.pid}.${Date.now()}.tmp`;
112
+ // Owner-only: this file names every client/project the machine's user has touched.
113
+ writeFileSync(tmp, JSON.stringify(snapshot), { mode: OWNER_FILE_MODE });
114
+ renameSync(tmp, this.deps.filePath);
115
+ }
116
+ catch {
117
+ // Best-effort cache write: a failure here must not fail the refresh
118
+ // itself, since the in-memory snapshot is still usable this process.
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Every file kankaku writes under `~/.kankaku` (the machine-wide registry
3
+ * `run/`, the catalog cache) carries absolute project paths, session ids, or
4
+ * hub identifiers — restricted to the owner (F4). Not applied to a
5
+ * project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
6
+ * project-local, frequently committed alongside, and out of scope here.
7
+ */
8
+ export declare const OWNER_DIR_MODE = 448;
9
+ export declare const OWNER_FILE_MODE = 384;
10
+ /**
11
+ * Create `dir` (recursive) with `mode`, then best-effort tighten it when it
12
+ * already existed with a looser mode — `mkdirSync`'s own `mode` option only
13
+ * applies to a directory it actually creates in this call, never to one
14
+ * that already existed (e.g. left by an older kankaku build, or a
15
+ * permissive umask). Never throws: a permission-tightening failure must
16
+ * never block the write this call exists to make.
17
+ */
18
+ export declare function ensureDirMode(dir: string, mode?: number): void;
19
+ /** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
20
+ export declare function tightenMode(path: string, mode?: number): void;
@@ -0,0 +1,34 @@
1
+ import { chmodSync, mkdirSync, statSync } from "node:fs";
2
+ /**
3
+ * Every file kankaku writes under `~/.kankaku` (the machine-wide registry
4
+ * `run/`, the catalog cache) carries absolute project paths, session ids, or
5
+ * hub identifiers — restricted to the owner (F4). Not applied to a
6
+ * project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
7
+ * project-local, frequently committed alongside, and out of scope here.
8
+ */
9
+ export const OWNER_DIR_MODE = 0o700;
10
+ export const OWNER_FILE_MODE = 0o600;
11
+ /**
12
+ * Create `dir` (recursive) with `mode`, then best-effort tighten it when it
13
+ * already existed with a looser mode — `mkdirSync`'s own `mode` option only
14
+ * applies to a directory it actually creates in this call, never to one
15
+ * that already existed (e.g. left by an older kankaku build, or a
16
+ * permissive umask). Never throws: a permission-tightening failure must
17
+ * never block the write this call exists to make.
18
+ */
19
+ export function ensureDirMode(dir, mode = OWNER_DIR_MODE) {
20
+ mkdirSync(dir, { recursive: true, mode });
21
+ tightenMode(dir, mode);
22
+ }
23
+ /** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
24
+ export function tightenMode(path, mode = OWNER_FILE_MODE) {
25
+ try {
26
+ const current = statSync(path).mode & 0o777;
27
+ if ((current & ~mode) !== 0)
28
+ chmodSync(path, mode);
29
+ }
30
+ catch {
31
+ // Best effort: another process may have already removed the path, or
32
+ // this filesystem does not support POSIX modes at all.
33
+ }
34
+ }
@@ -0,0 +1,35 @@
1
+ export interface HubCredentials {
2
+ url: string;
3
+ email: string;
4
+ password: string;
5
+ }
6
+ export interface ResolveHubCredentialsDeps {
7
+ env: NodeJS.ProcessEnv;
8
+ /**
9
+ * Resolves the home directory; called lazily, inside this function, and
10
+ * defensively. A throwing provider (no `HOME`, a sandboxed environment
11
+ * without a resolvable home directory) is treated the same as "no home
12
+ * directory" rather than propagating — env-only credentials must still
13
+ * resolve, and the hub-unconfigured case must stay a no-op regardless of
14
+ * the host environment. Injectable for tests; defaults to `os.homedir` at
15
+ * the call site (extension.ts) — passed as a reference, never invoked
16
+ * there, so a throw never escapes before this function's own try/catch.
17
+ */
18
+ homeDir: () => string;
19
+ }
20
+ export interface ResolveHubCredentialsResult {
21
+ /** `undefined` when unconfigured (no url/email/password from any source) or the URL is refused. */
22
+ credentials: HubCredentials | undefined;
23
+ /** Set only when a hub URL was given but rejected by {@link validateHubUrl}; surface it once via `ctx.ui.notify`. */
24
+ invalidReason?: string;
25
+ }
26
+ /**
27
+ * Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
28
+ * precedence per field over `~/.kankaku/credentials.json` (so a partially
29
+ * set environment still combines with the file rather than failing
30
+ * closed). Never reads the project's own `.kankaku/config.json` — that
31
+ * file is project-local and frequently committed.
32
+ */
33
+ /** Resolve `homeDir()` defensively: any failure (no `HOME`, a sandboxed environment) yields `undefined` instead of throwing. Exported so callers with their own homedir-dependent path (e.g. `extension.ts`'s catalog cache) can share the same guard. */
34
+ export declare function safeHomeDir(homeDir: () => string): string | undefined;
35
+ export declare function resolveHubCredentials(deps: ResolveHubCredentialsDeps): ResolveHubCredentialsResult;
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadHubEnvCredentials, validateHubUrl } from "../config.js";
4
+ const CREDENTIALS_FILE = join(".kankaku", "credentials.json");
5
+ /**
6
+ * Read `<homeDir>/.kankaku/credentials.json`. Tolerates a missing file,
7
+ * malformed JSON, a non-object document, or non-string fields — all
8
+ * return `{}` rather than throwing, since this file is optional and
9
+ * hand-edited (mirrors `project-config.ts#readProjectClient`).
10
+ */
11
+ function readCredentialsFile(filePath) {
12
+ if (!existsSync(filePath))
13
+ return {};
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
16
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
17
+ return {};
18
+ const record = parsed;
19
+ return {
20
+ url: typeof record["url"] === "string" ? record["url"] : undefined,
21
+ email: typeof record["email"] === "string" ? record["email"] : undefined,
22
+ password: typeof record["password"] === "string" ? record["password"] : undefined,
23
+ };
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
29
+ /**
30
+ * Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
31
+ * precedence per field over `~/.kankaku/credentials.json` (so a partially
32
+ * set environment still combines with the file rather than failing
33
+ * closed). Never reads the project's own `.kankaku/config.json` — that
34
+ * file is project-local and frequently committed.
35
+ */
36
+ /** Resolve `homeDir()` defensively: any failure (no `HOME`, a sandboxed environment) yields `undefined` instead of throwing. Exported so callers with their own homedir-dependent path (e.g. `extension.ts`'s catalog cache) can share the same guard. */
37
+ export function safeHomeDir(homeDir) {
38
+ try {
39
+ return homeDir();
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ export function resolveHubCredentials(deps) {
46
+ const fromEnv = loadHubEnvCredentials(deps.env);
47
+ const homeDir = safeHomeDir(deps.homeDir);
48
+ const fromFile = homeDir !== undefined ? readCredentialsFile(join(homeDir, CREDENTIALS_FILE)) : {};
49
+ const url = fromEnv.url ?? fromFile.url;
50
+ const email = fromEnv.email ?? fromFile.email;
51
+ const password = fromEnv.password ?? fromFile.password;
52
+ if (!url || !email || !password)
53
+ return { credentials: undefined };
54
+ const validation = validateHubUrl(url);
55
+ if (!validation.ok)
56
+ return { credentials: undefined, invalidReason: validation.reason };
57
+ return { credentials: { url, email, password } };
58
+ }
@@ -0,0 +1,20 @@
1
+ import type { WorkLog } from "../ports/work-log.ts";
2
+ import type { WorkRecord } from "../domain/work-record.ts";
3
+ /**
4
+ * Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
5
+ * A record is written with a single `appendFileSync` call so a parent and
6
+ * its subagent children can write concurrently without interleaving lines.
7
+ */
8
+ export declare class JsonlWorkLog implements WorkLog {
9
+ private readonly dir;
10
+ constructor(dir: string);
11
+ private get filePath();
12
+ append(record: WorkRecord): void;
13
+ /**
14
+ * Cheap change signal: `mtimeMs:size` of the log file, computed with a
15
+ * single `statSync` rather than reading the file. `"0:0"` when the file
16
+ * does not exist yet (before the first `append`).
17
+ */
18
+ version(): string;
19
+ readAll(): WorkRecord[];
20
+ }
@@ -0,0 +1,62 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { dedupeById } from "../domain/task-view.js";
4
+ import { isWorkRecord } from "../domain/work-record.js";
5
+ const LOG_FILE_NAME = "worklog.jsonl";
6
+ /**
7
+ * Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
8
+ * A record is written with a single `appendFileSync` call so a parent and
9
+ * its subagent children can write concurrently without interleaving lines.
10
+ */
11
+ export class JsonlWorkLog {
12
+ dir;
13
+ constructor(dir) {
14
+ this.dir = dir;
15
+ }
16
+ get filePath() {
17
+ return join(this.dir, LOG_FILE_NAME);
18
+ }
19
+ append(record) {
20
+ mkdirSync(this.dir, { recursive: true });
21
+ appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
22
+ }
23
+ /**
24
+ * Cheap change signal: `mtimeMs:size` of the log file, computed with a
25
+ * single `statSync` rather than reading the file. `"0:0"` when the file
26
+ * does not exist yet (before the first `append`).
27
+ */
28
+ version() {
29
+ try {
30
+ const stats = statSync(this.filePath);
31
+ return `${stats.mtimeMs}:${stats.size}`;
32
+ }
33
+ catch {
34
+ return "0:0";
35
+ }
36
+ }
37
+ readAll() {
38
+ if (!existsSync(this.filePath))
39
+ return [];
40
+ const content = readFileSync(this.filePath, "utf8");
41
+ const records = [];
42
+ for (const line of content.split("\n")) {
43
+ const trimmed = line.trim();
44
+ if (!trimmed)
45
+ continue;
46
+ try {
47
+ const parsed = JSON.parse(trimmed);
48
+ if (isWorkRecord(parsed)) {
49
+ records.push(parsed);
50
+ }
51
+ // Tolerate a structurally invalid record (e.g. an incompatible
52
+ // schema or a torn write that still parses as JSON); skip it.
53
+ }
54
+ catch {
55
+ // Tolerate malformed lines (e.g. a torn write); skip them.
56
+ }
57
+ }
58
+ // One id can be in the file twice (written at settle, then re-appended by
59
+ // crash recovery): every reader gets it once — see dedupeById.
60
+ return dedupeById(records);
61
+ }
62
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Resolve the kankaku data directory: an absolute `dirOrRelative` is used
3
+ * as-is, a relative one is joined against `cwd`. Shared by every lazy
4
+ * adapter so the rule lives in exactly one place.
5
+ */
6
+ export declare function resolveKankakuDir(dirOrRelative: string, cwd: string): string;
7
+ export interface WritableTargetResult {
8
+ dir: string;
9
+ /** `true` when `candidateDir` failed its writability probe and `dir` is `fallbackDir` instead. */
10
+ usedFallback: boolean;
11
+ }
12
+ export interface ResolveWritableTargetDeps {
13
+ /**
14
+ * Proves this process can actually write to `dir` — not merely that it
15
+ * exists — and throws on any failure. Injectable for tests (never touch
16
+ * real disk to simulate an unwritable directory); defaults to
17
+ * {@link defaultWritabilityProbe}.
18
+ */
19
+ probe?: (dir: string) => void;
20
+ }
21
+ /**
22
+ * Resolve the target directory for a write that would normally go to
23
+ * `candidateDir` (F1: routing a verified subagent's work log/inflight
24
+ * checkpoints to its orchestrator's kankaku directory instead of its own
25
+ * cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
26
+ * proven writable — created if it does not exist yet — `fallbackDir` (the
27
+ * process's own local directory) otherwise, so a parent directory that no
28
+ * longer exists, or that this process lacks permission to write to,
29
+ * degrades to a safe, local fallback instead of throwing and losing the
30
+ * record entirely. `usedFallback` lets the caller surface this (`/kankaku
31
+ * doctor`) so a human can notice and reunite the record manually — the
32
+ * append-only log can never be rewritten to fix it after the fact. Never
33
+ * throws: `fallbackDir` itself is never probed here — its own writability
34
+ * is handled the ordinary way, by whichever adapter eventually writes to
35
+ * it, exactly as it always has been for a process that never needed to
36
+ * route anywhere.
37
+ */
38
+ export declare function resolveWritableTarget(candidateDir: string, fallbackDir: string, deps?: ResolveWritableTargetDeps): WritableTargetResult;