kankaku-pi 1.0.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1438 -0
  3. package/dist/adapters/cached-catalog.d.ts +42 -0
  4. package/dist/adapters/cached-catalog.js +121 -0
  5. package/dist/adapters/export-writer.d.ts +13 -0
  6. package/dist/adapters/export-writer.js +28 -0
  7. package/dist/adapters/file-modes.d.ts +20 -0
  8. package/dist/adapters/file-modes.js +34 -0
  9. package/dist/adapters/hub-actions.d.ts +35 -0
  10. package/dist/adapters/hub-actions.js +70 -0
  11. package/dist/adapters/hub-credentials.d.ts +35 -0
  12. package/dist/adapters/hub-credentials.js +58 -0
  13. package/dist/adapters/jsonl-work-log.d.ts +20 -0
  14. package/dist/adapters/jsonl-work-log.js +62 -0
  15. package/dist/adapters/kankaku-dir.d.ts +38 -0
  16. package/dist/adapters/kankaku-dir.js +85 -0
  17. package/dist/adapters/lazy-jsonl-work-log.d.ts +17 -0
  18. package/dist/adapters/lazy-jsonl-work-log.js +31 -0
  19. package/dist/adapters/pocketbase-catalog.d.ts +16 -0
  20. package/dist/adapters/pocketbase-catalog.js +56 -0
  21. package/dist/adapters/pocketbase-client.d.ts +81 -0
  22. package/dist/adapters/pocketbase-client.js +148 -0
  23. package/dist/adapters/pocketbase-sink.d.ts +53 -0
  24. package/dist/adapters/pocketbase-sink.js +181 -0
  25. package/dist/adapters/project-config.d.ts +42 -0
  26. package/dist/adapters/project-config.js +108 -0
  27. package/dist/adapters/report-data.d.ts +12 -0
  28. package/dist/adapters/report-data.js +8 -0
  29. package/dist/adapters/report-views.d.ts +45 -0
  30. package/dist/adapters/report-views.js +73 -0
  31. package/dist/adapters/report.d.ts +112 -0
  32. package/dist/adapters/report.js +236 -0
  33. package/dist/adapters/sync-runner.d.ts +114 -0
  34. package/dist/adapters/sync-runner.js +273 -0
  35. package/dist/adapters/sync-state-store.d.ts +62 -0
  36. package/dist/adapters/sync-state-store.js +188 -0
  37. package/dist/config.d.ts +168 -0
  38. package/dist/config.js +392 -0
  39. package/dist/domain/ancestry-match.d.ts +49 -0
  40. package/dist/domain/ancestry-match.js +82 -0
  41. package/dist/domain/client-label.d.ts +28 -0
  42. package/dist/domain/client-label.js +44 -0
  43. package/dist/domain/day.d.ts +2 -0
  44. package/dist/domain/day.js +8 -0
  45. package/dist/domain/export.d.ts +38 -0
  46. package/dist/domain/export.js +68 -0
  47. package/dist/domain/hub-entry.d.ts +234 -0
  48. package/dist/domain/hub-entry.js +265 -0
  49. package/dist/domain/index.d.ts +19 -0
  50. package/dist/domain/index.js +19 -0
  51. package/dist/domain/intervals.d.ts +17 -0
  52. package/dist/domain/intervals.js +43 -0
  53. package/dist/domain/registry-health.d.ts +49 -0
  54. package/dist/domain/registry-health.js +58 -0
  55. package/dist/domain/segment-rule.d.ts +10 -0
  56. package/dist/domain/segment-rule.js +1 -0
  57. package/dist/domain/subagent-profile.d.ts +278 -0
  58. package/dist/domain/subagent-profile.js +418 -0
  59. package/dist/domain/sync-plan.d.ts +151 -0
  60. package/dist/domain/sync-plan.js +196 -0
  61. package/dist/domain/task-view.d.ts +117 -0
  62. package/dist/domain/task-view.js +428 -0
  63. package/dist/domain/work-record.d.ts +236 -0
  64. package/dist/domain/work-record.js +91 -0
  65. package/dist/domain/work-target.d.ts +101 -0
  66. package/dist/domain/work-target.js +149 -0
  67. package/dist/domain/work-tracker.d.ts +90 -0
  68. package/dist/domain/work-tracker.js +405 -0
  69. package/dist/hub/index.d.ts +25 -0
  70. package/dist/hub/index.js +25 -0
  71. package/dist/ports/catalog.d.ts +31 -0
  72. package/dist/ports/catalog.js +1 -0
  73. package/dist/ports/clock.d.ts +3 -0
  74. package/dist/ports/clock.js +1 -0
  75. package/dist/ports/index.d.ts +11 -0
  76. package/dist/ports/index.js +1 -0
  77. package/dist/ports/inflight-store.d.ts +15 -0
  78. package/dist/ports/inflight-store.js +1 -0
  79. package/dist/ports/process-registry.d.ts +72 -0
  80. package/dist/ports/process-registry.js +1 -0
  81. package/dist/ports/work-log.d.ts +14 -0
  82. package/dist/ports/work-log.js +1 -0
  83. package/dist/ports/work-sink.d.ts +39 -0
  84. package/dist/ports/work-sink.js +1 -0
  85. package/package.json +66 -0
  86. package/src/adapters/agent-info.ts +86 -0
  87. package/src/adapters/ancestry.ts +260 -0
  88. package/src/adapters/cached-catalog.ts +147 -0
  89. package/src/adapters/export-writer.ts +33 -0
  90. package/src/adapters/file-inflight-store.ts +115 -0
  91. package/src/adapters/file-modes.ts +35 -0
  92. package/src/adapters/hub-actions.ts +82 -0
  93. package/src/adapters/hub-credentials.ts +95 -0
  94. package/src/adapters/jsonl-work-log.ts +67 -0
  95. package/src/adapters/kankaku-command.ts +717 -0
  96. package/src/adapters/kankaku-dir.ts +102 -0
  97. package/src/adapters/lazy-file-inflight-store.ts +43 -0
  98. package/src/adapters/lazy-jsonl-work-log.ts +39 -0
  99. package/src/adapters/machine-process-registry.ts +256 -0
  100. package/src/adapters/panel/kankaku-panel.ts +419 -0
  101. package/src/adapters/panel/panel-items.ts +87 -0
  102. package/src/adapters/panel/panel-lines.ts +13 -0
  103. package/src/adapters/panel/panel-theme.ts +32 -0
  104. package/src/adapters/panel/screens/about.ts +69 -0
  105. package/src/adapters/panel/screens/doctor.ts +89 -0
  106. package/src/adapters/panel/screens/export.ts +123 -0
  107. package/src/adapters/panel/screens/report.ts +143 -0
  108. package/src/adapters/panel/screens/sync.ts +136 -0
  109. package/src/adapters/panel/screens/target.ts +384 -0
  110. package/src/adapters/pi-tracker.ts +753 -0
  111. package/src/adapters/pocketbase-catalog.ts +89 -0
  112. package/src/adapters/pocketbase-client.ts +197 -0
  113. package/src/adapters/pocketbase-sink.ts +236 -0
  114. package/src/adapters/process-identity-memo.ts +102 -0
  115. package/src/adapters/process-identity.ts +162 -0
  116. package/src/adapters/project-config.ts +116 -0
  117. package/src/adapters/report-data.ts +13 -0
  118. package/src/adapters/report-views.ts +98 -0
  119. package/src/adapters/report.ts +335 -0
  120. package/src/adapters/session-client.ts +116 -0
  121. package/src/adapters/session-dir.ts +28 -0
  122. package/src/adapters/session-target.ts +431 -0
  123. package/src/adapters/status-bar.ts +86 -0
  124. package/src/adapters/subagent-startup.ts +66 -0
  125. package/src/adapters/sync-runner.ts +340 -0
  126. package/src/adapters/sync-state-store.ts +227 -0
  127. package/src/adapters/target-picker.ts +127 -0
  128. package/src/config.ts +536 -0
  129. package/src/domain/ancestry-match.ts +84 -0
  130. package/src/domain/client-label.ts +56 -0
  131. package/src/domain/day.ts +8 -0
  132. package/src/domain/export.ts +107 -0
  133. package/src/domain/hub-entry.ts +433 -0
  134. package/src/domain/index.ts +19 -0
  135. package/src/domain/intervals.ts +53 -0
  136. package/src/domain/panel-model.ts +270 -0
  137. package/src/domain/registry-health.ts +87 -0
  138. package/src/domain/segment-rule.ts +10 -0
  139. package/src/domain/subagent-profile.ts +495 -0
  140. package/src/domain/sync-plan.ts +266 -0
  141. package/src/domain/task-view.ts +526 -0
  142. package/src/domain/work-record.ts +320 -0
  143. package/src/domain/work-target.ts +234 -0
  144. package/src/domain/work-tracker.ts +485 -0
  145. package/src/extension.ts +346 -0
  146. package/src/hub/index.ts +25 -0
  147. package/src/ports/catalog.ts +33 -0
  148. package/src/ports/clock.ts +3 -0
  149. package/src/ports/index.ts +11 -0
  150. package/src/ports/inflight-store.ts +16 -0
  151. package/src/ports/process-registry.ts +75 -0
  152. package/src/ports/work-log.ts +15 -0
  153. package/src/ports/work-sink.ts +35 -0
@@ -0,0 +1,115 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isWorkRecord } from "../domain/work-record.ts";
4
+ import type { WorkRecord } from "../domain/work-record.ts";
5
+ import type { InflightStore } from "../ports/inflight-store.ts";
6
+
7
+ const INFLIGHT_DIR_NAME = "inflight";
8
+ const JSON_EXT = ".json";
9
+ const TMP_EXT = ".tmp";
10
+ /** Matches `save`'s tmp filename: `<ownerPid>.json.<writerPid>.<timestamp>.tmp`. */
11
+ const TMP_NAME_PATTERN = /\.json\.(\d+)\.\d+\.tmp$/;
12
+
13
+ function safeUnlink(filePath: string): void {
14
+ try {
15
+ unlinkSync(filePath);
16
+ } catch {
17
+ // Best effort: another process may have already removed it.
18
+ }
19
+ }
20
+
21
+ /** The writer pid embedded in a `save` tmp filename, or `undefined` when it cannot be parsed. */
22
+ function parseTmpWriterPid(entry: string): number | undefined {
23
+ const match = TMP_NAME_PATTERN.exec(entry);
24
+ if (!match) return undefined;
25
+ const pid = Number(match[1]);
26
+ return Number.isFinite(pid) ? pid : undefined;
27
+ }
28
+
29
+ /**
30
+ * {@link InflightStore} backed by one checkpoint file per process,
31
+ * `<dir>/inflight/<pid>.json`. `save` writes to a temp file then renames
32
+ * it into place so a reader never observes a partially written checkpoint.
33
+ */
34
+ export class FileInflightStore implements InflightStore {
35
+ private readonly dir: string;
36
+ private readonly pid: number;
37
+
38
+ constructor(dir: string, pid: number) {
39
+ this.dir = dir;
40
+ this.pid = pid;
41
+ }
42
+
43
+ private get inflightDir(): string {
44
+ return join(this.dir, INFLIGHT_DIR_NAME);
45
+ }
46
+
47
+ private filePathFor(pid: number): string {
48
+ return join(this.inflightDir, `${pid}${JSON_EXT}`);
49
+ }
50
+
51
+ save(record: WorkRecord): void {
52
+ mkdirSync(this.inflightDir, { recursive: true });
53
+ const target = this.filePathFor(this.pid);
54
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
55
+ writeFileSync(tmp, JSON.stringify(record));
56
+ renameSync(tmp, target);
57
+ }
58
+
59
+ clear(): void {
60
+ safeUnlink(this.filePathFor(this.pid));
61
+ }
62
+
63
+ recoverStale(isAlive: (pid: number) => boolean): WorkRecord[] {
64
+ if (!existsSync(this.inflightDir)) return [];
65
+
66
+ const recovered: WorkRecord[] = [];
67
+ const ownFileName = `${this.pid}${JSON_EXT}`;
68
+
69
+ for (const entry of readdirSync(this.inflightDir)) {
70
+ if (entry.endsWith(TMP_EXT)) {
71
+ this.sweepTmpEntry(entry, isAlive);
72
+ continue;
73
+ }
74
+
75
+ if (!entry.endsWith(JSON_EXT) || entry === ownFileName) continue;
76
+
77
+ const filePath = join(this.inflightDir, entry);
78
+ let parsed: unknown;
79
+ try {
80
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
81
+ } catch {
82
+ safeUnlink(filePath);
83
+ continue;
84
+ }
85
+
86
+ if (!isWorkRecord(parsed)) {
87
+ safeUnlink(filePath);
88
+ continue;
89
+ }
90
+
91
+ if (isAlive(parsed.pid)) continue;
92
+
93
+ recovered.push({ ...parsed, status: "interrupted" });
94
+ safeUnlink(filePath);
95
+ }
96
+
97
+ return recovered;
98
+ }
99
+
100
+ /**
101
+ * Delete a stray `save()` tmp file left behind by a writer that crashed
102
+ * between the write and the rename. Deleted when the writer pid is not
103
+ * alive, or when the filename cannot be parsed at all (nothing to check
104
+ * liveness against). A tmp file written by this very process is always
105
+ * left alone regardless of what `isAlive` reports, since a concurrent
106
+ * `save()` in this process may still be renaming it into place.
107
+ */
108
+ private sweepTmpEntry(entry: string, isAlive: (pid: number) => boolean): void {
109
+ const writerPid = parseTmpWriterPid(entry);
110
+ if (writerPid === process.pid) return;
111
+ if (writerPid === undefined || !isAlive(writerPid)) {
112
+ safeUnlink(join(this.inflightDir, entry));
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,35 @@
1
+ import { chmodSync, mkdirSync, statSync } from "node:fs";
2
+
3
+ /**
4
+ * Every file kankaku writes under `~/.kankaku` (the machine-wide registry
5
+ * `run/`, the catalog cache) carries absolute project paths, session ids, or
6
+ * hub identifiers — restricted to the owner (F4). Not applied to a
7
+ * project's own `<KANKAKU_DIR>` (`worklog.jsonl`, `inflight/`, …), which is
8
+ * project-local, frequently committed alongside, and out of scope here.
9
+ */
10
+ export const OWNER_DIR_MODE = 0o700;
11
+ export const OWNER_FILE_MODE = 0o600;
12
+
13
+ /**
14
+ * Create `dir` (recursive) with `mode`, then best-effort tighten it when it
15
+ * already existed with a looser mode — `mkdirSync`'s own `mode` option only
16
+ * applies to a directory it actually creates in this call, never to one
17
+ * that already existed (e.g. left by an older kankaku build, or a
18
+ * permissive umask). Never throws: a permission-tightening failure must
19
+ * never block the write this call exists to make.
20
+ */
21
+ export function ensureDirMode(dir: string, mode: number = OWNER_DIR_MODE): void {
22
+ mkdirSync(dir, { recursive: true, mode });
23
+ tightenMode(dir, mode);
24
+ }
25
+
26
+ /** Best-effort `chmod` down to `mode` when the current mode is looser than it; never throws. */
27
+ export function tightenMode(path: string, mode: number = OWNER_FILE_MODE): void {
28
+ try {
29
+ const current = statSync(path).mode & 0o777;
30
+ if ((current & ~mode) !== 0) chmodSync(path, mode);
31
+ } catch {
32
+ // Best effort: another process may have already removed the path, or
33
+ // this filesystem does not support POSIX modes at all.
34
+ }
35
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Shared line-building for the hub-facing `/kankaku` subcommands (`sync`,
3
+ * `sync all`, `sync status`, `backfill`, `catalog refresh`) and the panel's
4
+ * `sync`/`export` screens (`adapters/panel/screens/sync.ts`), extracted from
5
+ * `kankaku-command.ts` so the subcommands and the panel can never drift —
6
+ * see odd/tasks/kankaku-panel.md P4.
7
+ */
8
+ import type { CatalogSnapshot } from "../ports/catalog.ts";
9
+ import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
10
+
11
+ /**
12
+ * `/kankaku sync status`'s report lines: the watermark, the pending count,
13
+ * how many tasks fell outside this run's revisit window (R3), and the last
14
+ * error, when any. Exact body of `handleSyncCommand`'s `status` branch.
15
+ */
16
+ export function buildSyncStatusLines(status: SyncStatusSnapshot): string[] {
17
+ const { state, pending, staleOutsideWindow } = status;
18
+ const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
19
+ if (staleOutsideWindow > 0) {
20
+ lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
21
+ }
22
+ if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
23
+ return lines;
24
+ }
25
+
26
+ /**
27
+ * Render a {@link SyncSummary} as report lines: counts, any stop reason, the
28
+ * new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
29
+ * sync`/`sync all` and the panel's sync screen.
30
+ */
31
+ export function formatSyncSummaryLines(summary: SyncSummary): string[] {
32
+ const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
33
+
34
+ if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
35
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
36
+ if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
37
+
38
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
39
+ if (unassignedEntries.length > 0) {
40
+ lines.push("unassigned (Sin determinar):");
41
+ for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
42
+ }
43
+
44
+ if (summary.failed.length > 0) {
45
+ lines.push("failed:");
46
+ for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
47
+ }
48
+
49
+ return lines;
50
+ }
51
+
52
+ /**
53
+ * `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
54
+ * run, grouped by their old free-text label, or "no unassigned tasks" when
55
+ * none were. Exact body of `handleBackfillCommand`.
56
+ */
57
+ export function formatBackfillLines(summary: SyncSummary): string[] {
58
+ const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
59
+
60
+ const lines =
61
+ unassignedEntries.length > 0
62
+ ? [
63
+ ...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
64
+ "reassign these in the hub web app's unassigned queue",
65
+ ]
66
+ : ["no unassigned tasks"];
67
+
68
+ if (summary.error) lines.push(`stopped early: ${summary.error}`);
69
+ return lines;
70
+ }
71
+
72
+ /**
73
+ * `/kankaku catalog refresh`'s report lines: client/project counts, or an
74
+ * unreachable notice when the refresh failed (`snapshot` is `undefined`).
75
+ * The subcommand still notifies that failure as an error rather than a
76
+ * report (see `handleCatalogCommand`); the panel's sync screen shows
77
+ * whatever this returns either way.
78
+ */
79
+ export function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[] {
80
+ if (!snapshot) return ["hub unreachable; catalog not refreshed"];
81
+ return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
82
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadHubEnvCredentials, validateHubUrl } from "../config.ts";
4
+
5
+ const CREDENTIALS_FILE = join(".kankaku", "credentials.json");
6
+
7
+ export interface HubCredentials {
8
+ url: string;
9
+ email: string;
10
+ password: string;
11
+ }
12
+
13
+ interface HubCredentialsFile {
14
+ url?: string;
15
+ email?: string;
16
+ password?: string;
17
+ }
18
+
19
+ /**
20
+ * Read `<homeDir>/.kankaku/credentials.json`. Tolerates a missing file,
21
+ * malformed JSON, a non-object document, or non-string fields — all
22
+ * return `{}` rather than throwing, since this file is optional and
23
+ * hand-edited (mirrors `project-config.ts#readProjectClient`).
24
+ */
25
+ function readCredentialsFile(filePath: string): HubCredentialsFile {
26
+ if (!existsSync(filePath)) return {};
27
+
28
+ try {
29
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
31
+ const record = parsed as Record<string, unknown>;
32
+ return {
33
+ url: typeof record["url"] === "string" ? record["url"] : undefined,
34
+ email: typeof record["email"] === "string" ? record["email"] : undefined,
35
+ password: typeof record["password"] === "string" ? record["password"] : undefined,
36
+ };
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ export interface ResolveHubCredentialsDeps {
43
+ env: NodeJS.ProcessEnv;
44
+ /**
45
+ * Resolves the home directory; called lazily, inside this function, and
46
+ * defensively. A throwing provider (no `HOME`, a sandboxed environment
47
+ * without a resolvable home directory) is treated the same as "no home
48
+ * directory" rather than propagating — env-only credentials must still
49
+ * resolve, and the hub-unconfigured case must stay a no-op regardless of
50
+ * the host environment. Injectable for tests; defaults to `os.homedir` at
51
+ * the call site (extension.ts) — passed as a reference, never invoked
52
+ * there, so a throw never escapes before this function's own try/catch.
53
+ */
54
+ homeDir: () => string;
55
+ }
56
+
57
+ export interface ResolveHubCredentialsResult {
58
+ /** `undefined` when unconfigured (no url/email/password from any source) or the URL is refused. */
59
+ credentials: HubCredentials | undefined;
60
+ /** Set only when a hub URL was given but rejected by {@link validateHubUrl}; surface it once via `ctx.ui.notify`. */
61
+ invalidReason?: string;
62
+ }
63
+
64
+ /**
65
+ * Resolve hub credentials: `KANKAKU_PB_URL`/`_EMAIL`/`_PASSWORD` take
66
+ * precedence per field over `~/.kankaku/credentials.json` (so a partially
67
+ * set environment still combines with the file rather than failing
68
+ * closed). Never reads the project's own `.kankaku/config.json` — that
69
+ * file is project-local and frequently committed.
70
+ */
71
+ /** 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. */
72
+ export function safeHomeDir(homeDir: () => string): string | undefined {
73
+ try {
74
+ return homeDir();
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ export function resolveHubCredentials(deps: ResolveHubCredentialsDeps): ResolveHubCredentialsResult {
81
+ const fromEnv = loadHubEnvCredentials(deps.env);
82
+ const homeDir = safeHomeDir(deps.homeDir);
83
+ const fromFile = homeDir !== undefined ? readCredentialsFile(join(homeDir, CREDENTIALS_FILE)) : {};
84
+
85
+ const url = fromEnv.url ?? fromFile.url;
86
+ const email = fromEnv.email ?? fromFile.email;
87
+ const password = fromEnv.password ?? fromFile.password;
88
+
89
+ if (!url || !email || !password) return { credentials: undefined };
90
+
91
+ const validation = validateHubUrl(url);
92
+ if (!validation.ok) return { credentials: undefined, invalidReason: validation.reason };
93
+
94
+ return { credentials: { url, email, password } };
95
+ }
@@ -0,0 +1,67 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { dedupeById } from "../domain/task-view.ts";
4
+ import type { WorkLog } from "../ports/work-log.ts";
5
+ import type { WorkRecord } from "../domain/work-record.ts";
6
+ import { isWorkRecord } from "../domain/work-record.ts";
7
+
8
+ const LOG_FILE_NAME = "worklog.jsonl";
9
+
10
+ /**
11
+ * Append-only JSONL {@link WorkLog} backed by `<dir>/worklog.jsonl`.
12
+ * A record is written with a single `appendFileSync` call so a parent and
13
+ * its subagent children can write concurrently without interleaving lines.
14
+ */
15
+ export class JsonlWorkLog implements WorkLog {
16
+ private readonly dir: string;
17
+
18
+ constructor(dir: string) {
19
+ this.dir = dir;
20
+ }
21
+
22
+ private get filePath(): string {
23
+ return join(this.dir, LOG_FILE_NAME);
24
+ }
25
+
26
+ append(record: WorkRecord): void {
27
+ mkdirSync(this.dir, { recursive: true });
28
+ appendFileSync(this.filePath, `${JSON.stringify(record)}\n`);
29
+ }
30
+
31
+ /**
32
+ * Cheap change signal: `mtimeMs:size` of the log file, computed with a
33
+ * single `statSync` rather than reading the file. `"0:0"` when the file
34
+ * does not exist yet (before the first `append`).
35
+ */
36
+ version(): string {
37
+ try {
38
+ const stats = statSync(this.filePath);
39
+ return `${stats.mtimeMs}:${stats.size}`;
40
+ } catch {
41
+ return "0:0";
42
+ }
43
+ }
44
+
45
+ readAll(): WorkRecord[] {
46
+ if (!existsSync(this.filePath)) return [];
47
+ const content = readFileSync(this.filePath, "utf8");
48
+ const records: WorkRecord[] = [];
49
+ for (const line of content.split("\n")) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed) continue;
52
+ try {
53
+ const parsed: unknown = JSON.parse(trimmed);
54
+ if (isWorkRecord(parsed)) {
55
+ records.push(parsed);
56
+ }
57
+ // Tolerate a structurally invalid record (e.g. an incompatible
58
+ // schema or a torn write that still parses as JSON); skip it.
59
+ } catch {
60
+ // Tolerate malformed lines (e.g. a torn write); skip them.
61
+ }
62
+ }
63
+ // One id can be in the file twice (written at settle, then re-appended by
64
+ // crash recovery): every reader gets it once — see dedupeById.
65
+ return dedupeById(records);
66
+ }
67
+ }