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,266 @@
1
+ /**
2
+ * Decide which tasks a sync run should push, without touching the network
3
+ * or the filesystem. Pure, no I/O — see `sync-runner.ts` for the adapter
4
+ * that drives this with a real clock, `WorkLog` and `WorkSink`.
5
+ *
6
+ * A task is not final the moment it is first written: a background
7
+ * subagent can settle after its orchestrator and extend the task's union
8
+ * (see `task-view.ts`), which is exactly why every sync also revisits a
9
+ * trailing window behind the watermark instead of only pushing brand-new
10
+ * tasks (proposal §6.0).
11
+ */
12
+
13
+ import { computeCostQuality, computeSubagentLinkage } from "./hub-entry.ts";
14
+ import type { TaskView } from "./task-view.ts";
15
+
16
+ /** Persisted sync state (`<KANKAKU_DIR>/sync-state.json`). */
17
+ export interface SyncState {
18
+ /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
19
+ syncedThrough?: string;
20
+ /** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
21
+ hashes: Record<string, string>;
22
+ /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
23
+ target: string;
24
+ /** Set after a network/5xx failure stopped a run short; cleared by the next fully-successful run. */
25
+ lastError?: { message: string; at: string };
26
+ /**
27
+ * The `WorkLog`'s cheap change signal (`version()`) as of the last real
28
+ * (non-short-circuited) automatic-or-manual sync attempt. The automatic
29
+ * path (`session_start`/`agent_settled`) compares this against the
30
+ * current value to skip entirely — no `readAll()`, no network — when
31
+ * nothing has changed and the last attempt did not error. See
32
+ * `adapters/sync-runner.ts#runSync`.
33
+ */
34
+ logVersion?: string | number;
35
+ /**
36
+ * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
+ * automatic sync attempt, persisted so the automatic path's throttle
38
+ * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
41
+ */
42
+ lastRunAt?: number;
43
+ }
44
+
45
+ export interface SyncPlanOptions {
46
+ /** The configured hub URL. A state whose `target` differs triggers a full sync. */
47
+ target: string;
48
+ /** Revisit window in hours, applied behind `syncedThrough`. Defaults to 24. */
49
+ windowHours?: number;
50
+ /** Force every task to be (re-)evaluated, e.g. `/kankaku sync all`. */
51
+ full?: boolean;
52
+ }
53
+
54
+ export interface SyncPlan {
55
+ /** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
56
+ toSync: TaskView[];
57
+ /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
58
+ unchangedCount: number;
59
+ /** `true` when this run evaluated every task rather than only the revisit window. */
60
+ isFullSync: boolean;
61
+ /**
62
+ * Tasks outside this run's revisit window that were NEVER synced to this
63
+ * hub: an ordinary incremental sync does not look that far back for new
64
+ * work, so only `sync all` (or `backfill`) uploads them. A task the hub
65
+ * already holds never appears here — if it changed it is in `toSync`.
66
+ * Always empty for a full sync. Surfaced by `/kankaku sync status`.
67
+ */
68
+ staleOutsideWindow: TaskView[];
69
+ /** Already-synced rows that changed but were left for a later run by {@link MAX_CORRECTIONS_PER_RUN}. */
70
+ correctionsDeferred: number;
71
+ }
72
+
73
+ const DEFAULT_WINDOW_HOURS = 24;
74
+
75
+ /** How many already-synced, out-of-window rows one incremental run corrects at most; the rest wait for the next run (or `sync all`). */
76
+ export const MAX_CORRECTIONS_PER_RUN = 50;
77
+
78
+ function windowMs(hours: number): number {
79
+ return hours * 60 * 60 * 1000;
80
+ }
81
+
82
+ /** Stable (key-sorted) JSON serialization so field order never changes a hash. */
83
+ function stableStringify(value: unknown): string {
84
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
85
+ if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
86
+ const record = value as Record<string, unknown>;
87
+ const keys = Object.keys(record).sort();
88
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
89
+ }
90
+
91
+ /** FNV-1a 32-bit over a UTF-16 code-unit stream — not a security hash, just a cheap deterministic change fingerprint. */
92
+ function fingerprint(input: string): string {
93
+ let hash = 0x811c9dc5;
94
+ for (let i = 0; i < input.length; i++) {
95
+ hash ^= input.charCodeAt(i);
96
+ hash = Math.imul(hash, 0x01000193);
97
+ }
98
+ return (hash >>> 0).toString(16).padStart(8, "0");
99
+ }
100
+
101
+ /**
102
+ * Content hash of everything a re-sync could change: the measurement
103
+ * fields (never the assignment — a reassignment made in the web is never
104
+ * visible locally, and must never trigger a resync on its own). A task
105
+ * whose hash matches the last stored one is unchanged and can be skipped
106
+ * without a request. Includes the derived measurement-quality fields
107
+ * (`domain/hub-entry.ts`) too, not just the raw numbers they are computed
108
+ * from: a background subagent that joins *after* this task was first
109
+ * synced can turn `cost_quality`/`subagent_linkage` from `"unknown"`/
110
+ * `"unlinked"` into a better answer without any of the other numeric
111
+ * fields necessarily changing (e.g. a joined child with no cost of its own
112
+ * still flips `subagent_linkage`) — that must still trigger a resync.
113
+ * `waiting_quality` is a true constant (`domain/hub-entry.ts`'s
114
+ * `computeWaitingQuality`) and is deliberately left out: it can never
115
+ * change between two evaluations of the same task. `sessionDir` is also a
116
+ * measurement-style field (`domain/hub-entry.ts`'s `session_dir`, sent on
117
+ * both create and update) — its own change, e.g. a resume that switches to
118
+ * a non-default session directory, must trigger a resync on its own even
119
+ * when nothing else changed.
120
+ */
121
+ export function computeTaskContentHash(task: TaskView): string {
122
+ return fingerprint(
123
+ stableStringify({
124
+ endedAt: task.endedAt,
125
+ status: task.status,
126
+ wallMs: task.wallMs,
127
+ waitingMs: task.waitingMs,
128
+ workMs: task.workMs,
129
+ subagentCount: task.subagents.length,
130
+ usage: task.usage,
131
+ segments: task.segments,
132
+ costQuality: computeCostQuality(task),
133
+ subagentLinkage: computeSubagentLinkage(task),
134
+ sessionDir: task.sessionDir,
135
+ // `undefined` is dropped by the serialiser, so a task that never knew
136
+ // its reasoning effort keeps the hash it had before this field existed.
137
+ thinkingLevel: task.orchestrator.thinkingLevel,
138
+ // A task gaining a who-measured identity
139
+ // (domain/hub-entry.ts#resolveTaskIdentity) must resync, since it
140
+ // changes which agent/plugin the row is attributed to; the version
141
+ // fields are left out on purpose so a version-only bump alone does not
142
+ // force a resync. The keys are added CONDITIONALLY: `stableStringify`
143
+ // renders an `undefined` value as `"agent":undefined`, so an
144
+ // unconditional key would change every legacy task's hash and force a
145
+ // full resync right after upgrading (see the golden-hash test).
146
+ ...(task.orchestrator.agent !== undefined ? { agent: task.orchestrator.agent } : {}),
147
+ ...(task.orchestrator.plugin !== undefined ? { plugin: task.orchestrator.plugin } : {}),
148
+ }),
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Decide which tasks need a request this run.
154
+ *
155
+ * Eligibility: every task, when there is no state yet, the state's
156
+ * `target` differs from the configured hub URL, or `options.full` is set
157
+ * (a full sync); otherwise only tasks with `endedAt` after
158
+ * `syncedThrough - window`. Within the eligible set, a task is only
159
+ * included in `toSync` when its current content hash differs from the one
160
+ * stored in `state.hashes` (absent, i.e. never synced, always counts as
161
+ * different).
162
+ */
163
+ export function planSync(tasks: TaskView[], state: SyncState | undefined, options: SyncPlanOptions): SyncPlan {
164
+ const windowHours = options.windowHours ?? DEFAULT_WINDOW_HOURS;
165
+ const isFullSync = options.full === true || state === undefined || state.target !== options.target;
166
+
167
+ const sorted = [...tasks].sort((a, b) => Date.parse(a.endedAt) - Date.parse(b.endedAt));
168
+
169
+ let eligible: TaskView[];
170
+ let outsideWindow: TaskView[];
171
+ if (isFullSync) {
172
+ eligible = sorted;
173
+ outsideWindow = [];
174
+ } else {
175
+ const syncedThroughMs = state.syncedThrough ? Date.parse(state.syncedThrough) : Number.NEGATIVE_INFINITY;
176
+ const cutoff = syncedThroughMs - windowMs(windowHours);
177
+ eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
178
+ outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
179
+ }
180
+
181
+ // A state written for another hub contributes nothing, not even its
182
+ // hashes: the new hub has none of these rows, so a task that matched
183
+ // the OLD hub's hash must still be pushed. `isFullSync` alone only
184
+ // widened the window; without this gate every task looked "unchanged".
185
+ const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
186
+ const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
187
+ // A row the hub ALREADY holds is corrected wherever it sits: the window
188
+ // bounds how far back NEW work is looked for, never whether a known row
189
+ // may go stale. A child can move between tasks (task-view.ts's rescue
190
+ // join, or a crash-recovered parent appearing later), so a task can
191
+ // SHRINK — skipping it would leave its old, larger cost on the hub next
192
+ // to the row the money moved to.
193
+ // Newest first and capped: anything that invalidates many stored hashes at
194
+ // once (a new field in the content hash, a damaged state file) must drain
195
+ // over several runs, never as one burst from an automatic sync. New work
196
+ // goes FIRST and corrections last, so a correction the hub keeps failing
197
+ // (the runner stops at the first transport error) can never hold newer
198
+ // rows back; the runner's watermark is a max, so the order is safe for it.
199
+ const allCorrections = outsideWindow.filter((task) => hashes[task.id] !== undefined && changed(task)).reverse();
200
+ const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
201
+ const correctionsDeferred = allCorrections.length - knownAndChanged.length;
202
+ const toSync = [...eligible.filter(changed), ...knownAndChanged];
203
+ // R3: cheap, pure visibility into a task this incremental run's window
204
+ // will not look at and that this hub has NEVER received (no stored hash)
205
+ // — see SyncPlan's doc comment. A known row that changed is a correction
206
+ // and is handled above, not reported here.
207
+ const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
208
+
209
+ return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
210
+ }
211
+
212
+ /**
213
+ * Drop hash entries for task ids that no longer exist in `tasks` (which
214
+ * should not normally happen since `worklog.jsonl` is append-only — this
215
+ * is a defensive backstop, not the normal path). `sync-state.json`'s
216
+ * `hashes` are otherwise kept **forever** for every task this process
217
+ * still knows about, regardless of how far outside the revisit window its
218
+ * `endedAt` has fallen (G2 fix).
219
+ *
220
+ * This used to also drop a hash once its task's `endedAt` fell behind
221
+ * `syncedThrough - window` — but `planSync`'s `staleOutsideWindow` treats a
222
+ * *missing* hash exactly like "content changed" (there is no third state
223
+ * for "unchanged but I forgot"), so that window-based pruning made every
224
+ * task older than the window look permanently changed, forever, the moment
225
+ * its hash was first pruned: `/kankaku sync status` would report a
226
+ * never-shrinking "changed outside the window" count that trained users
227
+ * to ignore it (the bug this rewrite fixes).
228
+ *
229
+ * Never pruning by window instead means `hashes` grows with the total
230
+ * number of distinct tasks a directory has ever synced, not with time — an
231
+ * FNV-1a hash is 8 hex chars and a task id (a `crypto.randomUUID()`) is 36,
232
+ * so each retained entry costs roughly 50 bytes of JSON. Measured: 10,000
233
+ * entries serialize to well under 1MB (see `tests/sync-plan.test.ts`'s
234
+ * state-size-bound test) — even a directory with a decade of daily,
235
+ * multi-task-per-day history stays a small, instantly-parseable file. A
236
+ * coarser design (e.g. one rolling digest per closed day) would bound the
237
+ * file even tighter, but cannot answer "which specific task changed" —
238
+ * `staleOutsideWindow` needs exactly that, per-task precision, to stay
239
+ * useful — so it was rejected in favour of this simpler, still-cheap
240
+ * per-task scheme.
241
+ *
242
+ * No migration is needed for an existing `sync-state.json`: it already has
243
+ * exactly this shape (`Record<taskId, hash>`), just with some outside-
244
+ * window entries already missing from a build that pruned them. The first
245
+ * sync after upgrading treats each of those exactly like "never synced" —
246
+ * a real fact this process cannot know is false, since the old hash is
247
+ * genuinely gone — and reports it once via `staleOutsideWindow`; once that
248
+ * task's hash is recorded again (an ordinary `sync all`, or simply being
249
+ * observed unchanged), this function never drops it again. See
250
+ * `tests/sync-plan.test.ts`'s "first sync after upgrading" test.
251
+ *
252
+ * Accumulated in a `Map` and emitted via `Object.fromEntries` (never
253
+ * `pruned[id] = ...` on a plain object), since a task id ultimately traces
254
+ * back to free-text worklog content: a value like `__proto__` written to a
255
+ * plain object would silently no-op (the inherited accessor ignores a
256
+ * non-object assignment) instead of being kept as an own property.
257
+ */
258
+ export function pruneHashes(hashes: Record<string, string>, tasks: TaskView[]): Record<string, string> {
259
+ const knownIds = new Set(tasks.map((task) => task.id));
260
+
261
+ const pruned = new Map<string, string>();
262
+ for (const [id, hash] of Object.entries(hashes)) {
263
+ if (knownIds.has(id)) pruned.set(id, hash);
264
+ }
265
+ return Object.fromEntries(pruned);
266
+ }