kankaku 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +67 -19
  2. package/dist/adapters/cached-catalog.d.ts +36 -0
  3. package/dist/adapters/cached-catalog.js +111 -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 +14 -0
  15. package/dist/adapters/pocketbase-catalog.js +39 -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 +52 -0
  19. package/dist/adapters/pocketbase-sink.js +180 -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 +201 -0
  35. package/dist/domain/hub-entry.js +211 -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 +113 -0
  49. package/dist/domain/task-view.js +426 -0
  50. package/dist/domain/work-record.d.ts +201 -0
  51. package/dist/domain/work-record.js +69 -0
  52. package/dist/domain/work-target.d.ts +72 -0
  53. package/dist/domain/work-target.js +127 -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 +29 -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/session-target.ts +86 -24
  74. package/src/domain/index.ts +19 -0
  75. package/src/hub/index.ts +19 -0
  76. package/src/ports/index.ts +11 -0
package/README.md CHANGED
@@ -804,7 +804,9 @@ On `session_start`, for the orchestrator role with a UI available:
804
804
  sorted by name, plus "— skip —"), then for the project (active projects
805
805
  of that client, plus "(no project)" and "— skip —"). Declining at either
806
806
  step — "— skip —" or dismissing the dialog — cancels the whole pick and
807
- is remembered for the session.
807
+ is remembered for the session. The picker shows the freshly refreshed
808
+ catalog when the hub answered within the deadline described in "Caching
809
+ and offline behaviour" below; otherwise it falls back to the cache.
808
810
 
809
811
  After a pick, kankaku asks whether to remember it for this repository; a
810
812
  "yes" merges `clientId`/`projectId` into `<KANKAKU_DIR>/config.json`.
@@ -826,19 +828,36 @@ a project) in place of the legacy client label, both idle and during a run.
826
828
  ### Caching and offline behaviour
827
829
 
828
830
  The catalog (clients/projects) is cached machine-wide at
829
- `~/.kankaku/catalog.json` with a 6-hour TTL. On startup: a fresh cache is
830
- used as-is; a stale cache is used immediately while a refresh happens in
831
- the background; when there is no cache at all, one refresh is awaited
832
- (bounded by the hub client's own request timeout, 3s by default) before
833
- falling back. If the hub is unreachable and there is no cache, kankaku
834
- notifies once (`kankaku: hub unreachable, using local labels`) and
835
- continues exactly as it would without a hub configured. `/kankaku catalog
836
- refresh` forces a refresh on demand. The cache file is always written
837
- owner-only (`0600`); if kankaku is the first thing to ever create
838
- `~/.kankaku` itself (no project has put its own `.kankaku` there), the
839
- directory is created owner-only (`0700`) too — but an already-existing
840
- `~/.kankaku` is never chmod'd, since it may be a project's own kankaku
841
- directory (see "The registry" below for the same rule applied to `run/`).
831
+ `~/.kankaku/catalog.json`. On `session_start`, for the orchestrator role
832
+ with a UI available, kankaku always starts a background refresh when a
833
+ cache already exists — regardless of the cache's age — so a client or
834
+ project created in the hub minutes ago shows up without waiting for a TTL
835
+ to expire (the 6-hour TTL and `isStale()` still exist and still gate other
836
+ callers, but session start no longer depends on them). If the target
837
+ resolves silently from the project config file or `repo_paths` against
838
+ the cached snapshot, `ensurePicked` returns immediately without waiting
839
+ for that refresh at all; it keeps running in the background and
840
+ `catalog.read()` reflects it once it lands, exactly as before. Only when
841
+ the picker is actually about to be shown does kankaku wait for the
842
+ in-flight refresh, bounded by a short deadline (1.5s by default,
843
+ `pickerRefreshDeadlineMs`): if the hub answers in time, the picker offers
844
+ the fresh clients/projects; otherwise (or if the refresh fails) it falls
845
+ back to the cached snapshot silently, and the refresh keeps running
846
+ in the background rather than being aborted. `/kankaku target pick` (the
847
+ explicit re-pick command) follows the same wait-then-fall-back rule. When
848
+ there is no cache at all, one refresh is still awaited (bounded by the hub
849
+ client's own request timeout, 3s by default) before falling back — this
850
+ path is unchanged. If the hub is unreachable and there is no cache,
851
+ kankaku notifies once (`kankaku: hub unreachable, using local labels`) and
852
+ continues exactly as it would without a hub configured; a background
853
+ refresh that merely fails once a cache already exists is silent, with no
854
+ notification. `/kankaku catalog refresh` still forces a refresh on demand
855
+ independently of any of this. The cache file is always written owner-only
856
+ (`0600`); if kankaku is the first thing to ever create `~/.kankaku` itself
857
+ (no project has put its own `.kankaku` there), the directory is created
858
+ owner-only (`0700`) too — but an already-existing `~/.kankaku` is never
859
+ chmod'd, since it may be a project's own kankaku directory (see "The
860
+ registry" below for the same rule applied to `run/`).
842
861
 
843
862
  ### Privacy (catalog)
844
863
 
@@ -1179,6 +1198,34 @@ Columns (in this order for CSV; the same fields for JSON):
1179
1198
  are never throttled, and none of this applies to a manual `/kankaku
1180
1199
  sync`, `sync all`, or `backfill`.
1181
1200
 
1201
+ ## Using kankaku as a library
1202
+
1203
+ Besides the pi extension, `kankaku` publishes three compiled, pi-free entry
1204
+ points for a plain Node consumer — no pi, no TypeScript loader — such as a
1205
+ separate CLI or another agent's plugin (e.g. the `kankaku-claude` package):
1206
+
1207
+ - `kankaku/domain` — the pure domain layer: `WorkTracker`, `buildTasks`,
1208
+ `unionMs`, and the rest of `src/domain/`.
1209
+ - `kankaku/ports` — the port interfaces only (`Clock`, `WorkLog`, `Catalog`,
1210
+ `WorkSink`, `ProcessRegistry`, `InflightStore`), for writing your own
1211
+ adapters against.
1212
+ - `kankaku/hub` — the pi-free adapters: the PocketBase HTTP client and
1213
+ catalog/sink, `runSync`, the JSONL work log, the cached catalog, hub
1214
+ credentials, and related filesystem helpers. Nothing reachable from this
1215
+ entry point ever imports a pi package type.
1216
+
1217
+ ```js
1218
+ import { runSync } from "kankaku/hub";
1219
+ import { buildTasks } from "kankaku/domain";
1220
+ ```
1221
+
1222
+ Each entry point is compiled ahead of time (`npm run build`, part of `npm
1223
+ run check`) to `dist/<domain|ports|hub>/index.{js,d.ts}` and resolved
1224
+ through `package.json`'s `exports` map, so importing it never needs type
1225
+ stripping or a `.ts` loader. `kankaku/src/*` is how pi itself loads
1226
+ `./src/extension.ts` and is not a public API — its shape can change without
1227
+ notice; import only `kankaku/domain`, `kankaku/ports`, or `kankaku/hub`.
1228
+
1182
1229
  ## Limitations
1183
1230
 
1184
1231
  - A prompt shown by a tool that does not go through `ctx.ui` and is not
@@ -1198,11 +1245,12 @@ Columns (in this order for CSV; the same fields for JSON):
1198
1245
  (PocketBase)" above is phase 1; sync itself ("Hub (PocketBase)" > "Sync")
1199
1246
  is phase 2 — both already shipped.
1200
1247
  - A standalone CLI entry point (`npx kankaku sync`, for a cron/launchd job
1201
- outside of any pi session) is deliberately not included yet: Node refuses
1202
- type stripping for a `.ts` file under `node_modules`, so a bin script
1203
- needs a build step this package does not have yet. `sync-runner.ts` and
1204
- its adapters are already decoupled from pi so that build step is the only
1205
- missing piece.
1248
+ outside of any pi session) is deliberately not included yet. The build
1249
+ step it needs now exists — `sync-runner.ts` and its adapters are
1250
+ published pi-free and pre-compiled as `kankaku/hub` (see "Using kankaku
1251
+ as a library") — but a `bin` script that wires that up as a runnable CLI
1252
+ is not; the compiled entry points are today consumed as a library, not a
1253
+ binary.
1206
1254
  - Linking a `task_entries` row to an existing `tasks` record (phase 3 in the
1207
1255
  hub's own data model) — kankaku never invents tasks; it would only ever
1208
1256
  link to one created in the manager.
@@ -0,0 +1,36 @@
1
+ import type { Client, 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
+ /** Fetch a fresh `{ clients, projects }` pair, e.g. `createPocketBaseCatalogFetcher(...)`. */
13
+ fetchCatalog: (signal?: AbortSignal) => Promise<{
14
+ clients: Client[];
15
+ projects: Project[];
16
+ }>;
17
+ }
18
+ /**
19
+ * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
20
+ * last known snapshot (memoised after the first disk read so repeated
21
+ * calls in one process never re-stat/re-parse); `refresh()` fetches, caches
22
+ * to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
23
+ * and never throws — a failed refresh resolves `undefined` and leaves the
24
+ * previous snapshot (if any) untouched.
25
+ */
26
+ export declare class CachedCatalog implements Catalog {
27
+ private readonly deps;
28
+ private memo;
29
+ private memoized;
30
+ constructor(deps: CachedCatalogDeps);
31
+ read(): CatalogSnapshot | undefined;
32
+ isStale(): boolean;
33
+ refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined>;
34
+ private readDisk;
35
+ private writeDisk;
36
+ }
@@ -0,0 +1,111 @@
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 isCatalogSnapshot(value) {
13
+ if (!value || typeof value !== "object")
14
+ return false;
15
+ const record = value;
16
+ return (typeof record["fetchedAt"] === "number" &&
17
+ typeof record["url"] === "string" &&
18
+ isClientArray(record["clients"]) &&
19
+ isProjectArray(record["projects"]));
20
+ }
21
+ /**
22
+ * Disk-backed {@link Catalog}: `read()` is a synchronous, cheap read of the
23
+ * last known snapshot (memoised after the first disk read so repeated
24
+ * calls in one process never re-stat/re-parse); `refresh()` fetches, caches
25
+ * to disk atomically (tmp + rename, mirroring `file-inflight-store.ts`),
26
+ * and never throws — a failed refresh resolves `undefined` and leaves the
27
+ * previous snapshot (if any) untouched.
28
+ */
29
+ export class CachedCatalog {
30
+ deps;
31
+ memo;
32
+ memoized = false;
33
+ constructor(deps) {
34
+ this.deps = deps;
35
+ }
36
+ read() {
37
+ if (!this.memoized) {
38
+ this.memo = this.readDisk();
39
+ this.memoized = true;
40
+ }
41
+ return this.memo;
42
+ }
43
+ isStale() {
44
+ const snapshot = this.read();
45
+ if (!snapshot)
46
+ return true;
47
+ const ttl = this.deps.ttlMs ?? DEFAULT_TTL_MS;
48
+ return this.deps.clock.now() - snapshot.fetchedAt > ttl;
49
+ }
50
+ async refresh(signal) {
51
+ try {
52
+ const { clients, projects } = await this.deps.fetchCatalog(signal);
53
+ const snapshot = { fetchedAt: this.deps.clock.now(), url: this.deps.url, clients, projects };
54
+ this.writeDisk(snapshot);
55
+ this.memo = snapshot;
56
+ this.memoized = true;
57
+ return snapshot;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ readDisk() {
64
+ try {
65
+ if (!existsSync(this.deps.filePath))
66
+ return undefined;
67
+ const parsed = JSON.parse(readFileSync(this.deps.filePath, "utf8"));
68
+ if (!isCatalogSnapshot(parsed))
69
+ return undefined;
70
+ if (parsed.url !== this.deps.url)
71
+ return undefined;
72
+ return parsed;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ writeDisk(snapshot) {
79
+ try {
80
+ // This cache lives under `~/.kankaku` (machine-wide, not a project's
81
+ // own KANKAKU_DIR) — but `~/.kankaku` itself is never mode-tightened
82
+ // here (R2): when pi runs with cwd === $HOME, a project's own default
83
+ // KANKAKU_DIR (`.kankaku`, relative) resolves to this exact same
84
+ // path, and a project's kankaku dir must never be tightened
85
+ // (AGENTS.md). Only what this package exclusively owns is touched:
86
+ // an EXISTING directory is never chmod'd — `mkdirSync`'s own `mode`
87
+ // option only ever applies to a directory this call actually
88
+ // creates, never one that already existed (Node skips the mkdir
89
+ // syscall for an existing path segment entirely, mode and all), so
90
+ // passing `mode` here is safe even though this path may be a
91
+ // project's own dir (G3). When this call IS the first writer (no
92
+ // `~/.kankaku` yet at all, e.g. a machine with the hub configured but
93
+ // no project ever run from $HOME), it is created owner-only
94
+ // (`OWNER_DIR_MODE`, 0700) rather than left at the umask default. The
95
+ // cache file itself is always written fresh via tmp+rename with
96
+ // OWNER_FILE_MODE below, which already guarantees 0600 on every
97
+ // write regardless of whatever mode an older file at this path (or
98
+ // an older kankaku build) left behind — a rename replaces the whole
99
+ // inode, so a stale looser mode can never survive a write.
100
+ mkdirSync(dirname(this.deps.filePath), { recursive: true, mode: OWNER_DIR_MODE });
101
+ const tmp = `${this.deps.filePath}.${process.pid}.${Date.now()}.tmp`;
102
+ // Owner-only: this file names every client/project the machine's user has touched.
103
+ writeFileSync(tmp, JSON.stringify(snapshot), { mode: OWNER_FILE_MODE });
104
+ renameSync(tmp, this.deps.filePath);
105
+ }
106
+ catch {
107
+ // Best-effort cache write: a failure here must not fail the refresh
108
+ // itself, since the in-memory snapshot is still usable this process.
109
+ }
110
+ }
111
+ }
@@ -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;
@@ -0,0 +1,85 @@
1
+ import { mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ /**
4
+ * Resolve the kankaku data directory: an absolute `dirOrRelative` is used
5
+ * as-is, a relative one is joined against `cwd`. Shared by every lazy
6
+ * adapter so the rule lives in exactly one place.
7
+ */
8
+ export function resolveKankakuDir(dirOrRelative, cwd) {
9
+ return isAbsolute(dirOrRelative) ? dirOrRelative : join(cwd, dirOrRelative);
10
+ }
11
+ /** Matches this probe's own marker filename: `.kankaku-write-probe.<pid>.<timestamp>.tmp`. */
12
+ const PROBE_NAME_PATTERN = /^\.kankaku-write-probe\.\d+\.(\d+)\.tmp$/;
13
+ /** A marker older than this is assumed abandoned (its writer was SIGKILLed between the write and its own unlink) — R4. */
14
+ const PROBE_STALE_MS = 60_000;
15
+ /**
16
+ * Best-effort removal of a stale writability-probe marker (R4): a probe
17
+ * that gets SIGKILLed between its `writeFileSync` and its own `unlinkSync`
18
+ * leaves `.kankaku-write-probe.<pid>.<timestamp>.tmp` behind forever —
19
+ * nothing else in `dir` ever looks at it again otherwise. Runs
20
+ * opportunistically every time the probe itself runs (mirrors
21
+ * `machine-process-registry.ts`'s own-write-time sweep pattern), so no
22
+ * separate cleanup process is needed. Only removes a marker whose
23
+ * filename-embedded timestamp (not the file's mtime, so this stays
24
+ * deterministic and easy to test) is older than a minute — never a fresh
25
+ * one, including this call's own marker (written after this sweep runs) or
26
+ * a concurrent process's own in-flight probe.
27
+ */
28
+ function sweepStaleWriteProbes(dir, now) {
29
+ let names;
30
+ try {
31
+ names = readdirSync(dir);
32
+ }
33
+ catch {
34
+ return; // dir just created and already empty, or unreadable — nothing to sweep.
35
+ }
36
+ for (const name of names) {
37
+ const match = PROBE_NAME_PATTERN.exec(name);
38
+ if (!match)
39
+ continue;
40
+ const timestamp = Number(match[1]);
41
+ if (!Number.isFinite(timestamp) || now - timestamp <= PROBE_STALE_MS)
42
+ continue;
43
+ try {
44
+ unlinkSync(join(dir, name));
45
+ }
46
+ catch {
47
+ // Best effort: already gone, or a concurrent sweep got there first.
48
+ }
49
+ }
50
+ }
51
+ /** `mkdirSync` + a temp marker file write/unlink: proves both creatability and write permission, not just existence (a read-only *existing* directory would otherwise pass a bare `mkdirSync` check silently, since recursive mkdir on an existing dir never fails). Also sweeps any stale marker left behind by an earlier, SIGKILLed probe (R4) before writing its own. */
52
+ function defaultWritabilityProbe(dir) {
53
+ mkdirSync(dir, { recursive: true });
54
+ sweepStaleWriteProbes(dir, Date.now());
55
+ const marker = join(dir, `.kankaku-write-probe.${process.pid}.${Date.now()}.tmp`);
56
+ writeFileSync(marker, "");
57
+ unlinkSync(marker);
58
+ }
59
+ /**
60
+ * Resolve the target directory for a write that would normally go to
61
+ * `candidateDir` (F1: routing a verified subagent's work log/inflight
62
+ * checkpoints to its orchestrator's kankaku directory instead of its own
63
+ * cwd-relative one — ADR 0023's rewrite): `candidateDir` when it can be
64
+ * proven writable — created if it does not exist yet — `fallbackDir` (the
65
+ * process's own local directory) otherwise, so a parent directory that no
66
+ * longer exists, or that this process lacks permission to write to,
67
+ * degrades to a safe, local fallback instead of throwing and losing the
68
+ * record entirely. `usedFallback` lets the caller surface this (`/kankaku
69
+ * doctor`) so a human can notice and reunite the record manually — the
70
+ * append-only log can never be rewritten to fix it after the fact. Never
71
+ * throws: `fallbackDir` itself is never probed here — its own writability
72
+ * is handled the ordinary way, by whichever adapter eventually writes to
73
+ * it, exactly as it always has been for a process that never needed to
74
+ * route anywhere.
75
+ */
76
+ export function resolveWritableTarget(candidateDir, fallbackDir, deps = {}) {
77
+ const probe = deps.probe ?? defaultWritabilityProbe;
78
+ try {
79
+ probe(candidateDir);
80
+ return { dir: candidateDir, usedFallback: false };
81
+ }
82
+ catch {
83
+ return { dir: fallbackDir, usedFallback: true };
84
+ }
85
+ }
@@ -0,0 +1,17 @@
1
+ import type { WorkRecord } from "../domain/work-record.ts";
2
+ import type { WorkLog } from "../ports/work-log.ts";
3
+ /**
4
+ * {@link WorkLog} that resolves a relative log directory lazily: against the
5
+ * project of the first appended record, or against `fallbackCwd()` when a
6
+ * read happens before any record was written in this process.
7
+ */
8
+ export declare class LazyJsonlWorkLog implements WorkLog {
9
+ private readonly dirOrRelative;
10
+ private readonly fallbackCwd;
11
+ private resolved;
12
+ constructor(dirOrRelative: string, fallbackCwd?: () => string);
13
+ private resolveFor;
14
+ append(record: WorkRecord): void;
15
+ readAll(): WorkRecord[];
16
+ version(): string;
17
+ }