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
@@ -0,0 +1,182 @@
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
+ import { computeCostQuality, computeSubagentLinkage } from "./hub-entry.js";
13
+ const DEFAULT_WINDOW_HOURS = 24;
14
+ /** How many already-synced, out-of-window rows one incremental run corrects at most; the rest wait for the next run (or `sync all`). */
15
+ export const MAX_CORRECTIONS_PER_RUN = 50;
16
+ function windowMs(hours) {
17
+ return hours * 60 * 60 * 1000;
18
+ }
19
+ /** Stable (key-sorted) JSON serialization so field order never changes a hash. */
20
+ function stableStringify(value) {
21
+ if (value === null || typeof value !== "object")
22
+ return JSON.stringify(value);
23
+ if (Array.isArray(value))
24
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
25
+ const record = value;
26
+ const keys = Object.keys(record).sort();
27
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
28
+ }
29
+ /** FNV-1a 32-bit over a UTF-16 code-unit stream — not a security hash, just a cheap deterministic change fingerprint. */
30
+ function fingerprint(input) {
31
+ let hash = 0x811c9dc5;
32
+ for (let i = 0; i < input.length; i++) {
33
+ hash ^= input.charCodeAt(i);
34
+ hash = Math.imul(hash, 0x01000193);
35
+ }
36
+ return (hash >>> 0).toString(16).padStart(8, "0");
37
+ }
38
+ /**
39
+ * Content hash of everything a re-sync could change: the measurement
40
+ * fields (never the assignment — a reassignment made in the web is never
41
+ * visible locally, and must never trigger a resync on its own). A task
42
+ * whose hash matches the last stored one is unchanged and can be skipped
43
+ * without a request. Includes the derived measurement-quality fields
44
+ * (`domain/hub-entry.ts`) too, not just the raw numbers they are computed
45
+ * from: a background subagent that joins *after* this task was first
46
+ * synced can turn `cost_quality`/`subagent_linkage` from `"unknown"`/
47
+ * `"unlinked"` into a better answer without any of the other numeric
48
+ * fields necessarily changing (e.g. a joined child with no cost of its own
49
+ * still flips `subagent_linkage`) — that must still trigger a resync.
50
+ * `waiting_quality` is a true constant (`domain/hub-entry.ts`'s
51
+ * `computeWaitingQuality`) and is deliberately left out: it can never
52
+ * change between two evaluations of the same task. `sessionDir` is also a
53
+ * measurement-style field (`domain/hub-entry.ts`'s `session_dir`, sent on
54
+ * both create and update) — its own change, e.g. a resume that switches to
55
+ * a non-default session directory, must trigger a resync on its own even
56
+ * when nothing else changed.
57
+ */
58
+ export function computeTaskContentHash(task) {
59
+ return fingerprint(stableStringify({
60
+ endedAt: task.endedAt,
61
+ status: task.status,
62
+ wallMs: task.wallMs,
63
+ waitingMs: task.waitingMs,
64
+ workMs: task.workMs,
65
+ subagentCount: task.subagents.length,
66
+ usage: task.usage,
67
+ segments: task.segments,
68
+ costQuality: computeCostQuality(task),
69
+ subagentLinkage: computeSubagentLinkage(task),
70
+ sessionDir: task.sessionDir,
71
+ // `undefined` is dropped by the serialiser, so a task that never knew
72
+ // its reasoning effort keeps the hash it had before this field existed.
73
+ thinkingLevel: task.orchestrator.thinkingLevel,
74
+ }));
75
+ }
76
+ /**
77
+ * Decide which tasks need a request this run.
78
+ *
79
+ * Eligibility: every task, when there is no state yet, the state's
80
+ * `target` differs from the configured hub URL, or `options.full` is set
81
+ * (a full sync); otherwise only tasks with `endedAt` after
82
+ * `syncedThrough - window`. Within the eligible set, a task is only
83
+ * included in `toSync` when its current content hash differs from the one
84
+ * stored in `state.hashes` (absent, i.e. never synced, always counts as
85
+ * different).
86
+ */
87
+ export function planSync(tasks, state, options) {
88
+ const windowHours = options.windowHours ?? DEFAULT_WINDOW_HOURS;
89
+ const isFullSync = options.full === true || state === undefined || state.target !== options.target;
90
+ const sorted = [...tasks].sort((a, b) => Date.parse(a.endedAt) - Date.parse(b.endedAt));
91
+ let eligible;
92
+ let outsideWindow;
93
+ if (isFullSync) {
94
+ eligible = sorted;
95
+ outsideWindow = [];
96
+ }
97
+ else {
98
+ const syncedThroughMs = state.syncedThrough ? Date.parse(state.syncedThrough) : Number.NEGATIVE_INFINITY;
99
+ const cutoff = syncedThroughMs - windowMs(windowHours);
100
+ eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
101
+ outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
102
+ }
103
+ const hashes = state?.hashes ?? {};
104
+ const changed = (task) => hashes[task.id] !== computeTaskContentHash(task);
105
+ // A row the hub ALREADY holds is corrected wherever it sits: the window
106
+ // bounds how far back NEW work is looked for, never whether a known row
107
+ // may go stale. A child can move between tasks (task-view.ts's rescue
108
+ // join, or a crash-recovered parent appearing later), so a task can
109
+ // SHRINK — skipping it would leave its old, larger cost on the hub next
110
+ // to the row the money moved to.
111
+ // Newest first and capped: anything that invalidates many stored hashes at
112
+ // once (a new field in the content hash, a damaged state file) must drain
113
+ // over several runs, never as one burst from an automatic sync. New work
114
+ // goes FIRST and corrections last, so a correction the hub keeps failing
115
+ // (the runner stops at the first transport error) can never hold newer
116
+ // rows back; the runner's watermark is a max, so the order is safe for it.
117
+ const allCorrections = outsideWindow.filter((task) => hashes[task.id] !== undefined && changed(task)).reverse();
118
+ const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
119
+ const correctionsDeferred = allCorrections.length - knownAndChanged.length;
120
+ const toSync = [...eligible.filter(changed), ...knownAndChanged];
121
+ // R3: cheap, pure visibility into a task that changed but that this
122
+ // incremental run's window will not re-evaluate — see SyncPlan's doc
123
+ // comment. No extra work: `outsideWindow` is already computed above,
124
+ // this just re-applies the same hash-mismatch check to it.
125
+ const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
126
+ return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
127
+ }
128
+ /**
129
+ * Drop hash entries for task ids that no longer exist in `tasks` (which
130
+ * should not normally happen since `worklog.jsonl` is append-only — this
131
+ * is a defensive backstop, not the normal path). `sync-state.json`'s
132
+ * `hashes` are otherwise kept **forever** for every task this process
133
+ * still knows about, regardless of how far outside the revisit window its
134
+ * `endedAt` has fallen (G2 fix).
135
+ *
136
+ * This used to also drop a hash once its task's `endedAt` fell behind
137
+ * `syncedThrough - window` — but `planSync`'s `staleOutsideWindow` treats a
138
+ * *missing* hash exactly like "content changed" (there is no third state
139
+ * for "unchanged but I forgot"), so that window-based pruning made every
140
+ * task older than the window look permanently changed, forever, the moment
141
+ * its hash was first pruned: `/kankaku sync status` would report a
142
+ * never-shrinking "changed outside the window" count that training taught
143
+ * users to ignore (the bug this rewrite fixes).
144
+ *
145
+ * Never pruning by window instead means `hashes` grows with the total
146
+ * number of distinct tasks a directory has ever synced, not with time — an
147
+ * FNV-1a hash is 8 hex chars and a task id (a `crypto.randomUUID()`) is 36,
148
+ * so each retained entry costs roughly 50 bytes of JSON. Measured: 10,000
149
+ * entries serialize to well under 1MB (see `tests/sync-plan.test.ts`'s
150
+ * state-size-bound test) — even a directory with a decade of daily,
151
+ * multi-task-per-day history stays a small, instantly-parseable file. A
152
+ * coarser design (e.g. one rolling digest per closed day) would bound the
153
+ * file even tighter, but cannot answer "which specific task changed" —
154
+ * `staleOutsideWindow` needs exactly that, per-task precision, to stay
155
+ * useful — so it was rejected in favour of this simpler, still-cheap
156
+ * per-task scheme.
157
+ *
158
+ * No migration is needed for an existing `sync-state.json`: it already has
159
+ * exactly this shape (`Record<taskId, hash>`), just with some outside-
160
+ * window entries already missing from a build that pruned them. The first
161
+ * sync after upgrading treats each of those exactly like "never synced" —
162
+ * a real fact this process cannot know is false, since the old hash is
163
+ * genuinely gone — and reports it once via `staleOutsideWindow`; once that
164
+ * task's hash is recorded again (an ordinary `sync all`, or simply being
165
+ * observed unchanged), this function never drops it again. See
166
+ * `tests/sync-plan.test.ts`'s "first sync after upgrading" test.
167
+ *
168
+ * Accumulated in a `Map` and emitted via `Object.fromEntries` (never
169
+ * `pruned[id] = ...` on a plain object), since a task id ultimately traces
170
+ * back to free-text worklog content: a value like `__proto__` written to a
171
+ * plain object would silently no-op (the inherited accessor ignores a
172
+ * non-object assignment) instead of being kept as an own property.
173
+ */
174
+ export function pruneHashes(hashes, tasks) {
175
+ const knownIds = new Set(tasks.map((task) => task.id));
176
+ const pruned = new Map();
177
+ for (const [id, hash] of Object.entries(hashes)) {
178
+ if (knownIds.has(id))
179
+ pruned.set(id, hash);
180
+ }
181
+ return Object.fromEntries(pruned);
182
+ }
@@ -0,0 +1,113 @@
1
+ import type { UsageTotals, WorkRecord, WorkStatus } from "./work-record.ts";
2
+ /**
3
+ * One orchestrator run plus every subagent it spawned, with `wallMs`
4
+ * recomputed as the union of the orchestrator's span and each child's span
5
+ * — never their sum — because background children keep running in parallel
6
+ * after the orchestrator settles.
7
+ */
8
+ export interface TaskView {
9
+ id: string;
10
+ sessionId?: string;
11
+ project: string;
12
+ prompt: string;
13
+ startedAt: string;
14
+ endedAt: string;
15
+ wallMs: number;
16
+ waitingMs: number;
17
+ workMs: number;
18
+ status: WorkStatus;
19
+ orchestrator: WorkRecord;
20
+ subagents: WorkRecord[];
21
+ usage: UsageTotals;
22
+ /** Who this task is billed to, from the orchestrator record only — subagent children do not carry their own. */
23
+ client?: string;
24
+ /** pi's session display name, from the orchestrator record. */
25
+ sessionName?: string;
26
+ /** Non-default session directory, from the orchestrator record. See `WorkRecordMetadata.sessionDir`. Local-only today — no hub field yet. */
27
+ sessionDir?: string;
28
+ /** Hub client record id, from the orchestrator record only. See `domain/work-target.ts`. */
29
+ clientId?: string;
30
+ /** Hub client display name, from the orchestrator record only. */
31
+ clientName?: string;
32
+ /** Hub project record id, from the orchestrator record only. */
33
+ projectId?: string;
34
+ /** Hub project display name, from the orchestrator record only. */
35
+ projectName?: string;
36
+ /**
37
+ * Per-tag total milliseconds across the orchestrator and every subagent,
38
+ * summed rather than unioned: unlike `wallMs`, segment intervals are not
39
+ * persisted on disk, so once a record settles its per-tag total is all
40
+ * that remains, and there is nothing left to union across records.
41
+ */
42
+ segments: Record<string, number>;
43
+ }
44
+ /** One or more tasks grouped by their pi session, with the same union rule. */
45
+ export interface SessionView {
46
+ sessionId: string;
47
+ project: string;
48
+ startedAt: string;
49
+ endedAt: string;
50
+ wallMs: number;
51
+ waitingMs: number;
52
+ workMs: number;
53
+ tasks: TaskView[];
54
+ usage: UsageTotals;
55
+ /** Per-tag total milliseconds summed across the session's tasks. See {@link TaskView.segments}. */
56
+ segments: Record<string, number>;
57
+ }
58
+ /**
59
+ * Sum several {@link UsageTotals}, tolerating a missing entry (a record
60
+ * without a `usage` field) and missing or non-finite numeric fields on an
61
+ * entry — both treated as zero rather than corrupting the sum with
62
+ * `undefined`/`NaN`.
63
+ */
64
+ export declare function sumUsage(totals: Array<Partial<UsageTotals> | undefined>): UsageTotals;
65
+ export declare function dedupeById(records: WorkRecord[]): WorkRecord[];
66
+ /**
67
+ * Build one {@link TaskView} per *confirmed* orchestrator record, sorted by
68
+ * `startedAt`. An orchestrator-role record flagged `roleConfidence:
69
+ * "uncertain"` (ADR 0022) never anchors a task here — see
70
+ * {@link isConfirmedOrchestrator} and {@link uncertainRecords}.
71
+ */
72
+ export declare function buildTasks(allRecords: WorkRecord[]): TaskView[];
73
+ /** Subagent records that could not be matched to any orchestrator record. */
74
+ export declare function orphanSubagents(records: WorkRecord[]): WorkRecord[];
75
+ /**
76
+ * Orchestrator-role records that could not be positively proven top-level
77
+ * (ADR 0022's "uncertain" state): no recognised child-env-marker matched,
78
+ * but a live tracked ancestor process was found. Never counted as a new
79
+ * task ({@link buildTasks} excludes them) and never synced as one, but
80
+ * never dropped either — surfaced here so `/kankaku doctor` and the report
81
+ * hint (SUBAGENT-REQ-017) can make the gap visible instead of silent.
82
+ */
83
+ export declare function uncertainRecords(records: WorkRecord[]): WorkRecord[];
84
+ /** One cluster of confirmed-orchestrator records sharing a pid with overlapping `[startedAt, settledAt]` windows — see {@link detectSameProcessOverlaps}. */
85
+ export interface SameProcessOverlap {
86
+ pid: number;
87
+ recordIds: string[];
88
+ /** The union (never the sum) of every clustered record's own wall-time window, via `unionMs`. */
89
+ unionedWallMs: number;
90
+ }
91
+ /**
92
+ * SUBAGENT-REQ-015: flag confirmed-orchestrator records that share an OS
93
+ * pid AND overlap in time — a pattern that should never occur if pi only
94
+ * ever runs one session per process at a time, but is the observable
95
+ * signature an in-process nested session mechanism (if one existed) would
96
+ * leave behind: two independent `WorkTracker` records, same pid, running
97
+ * concurrently. Purely informational (`/kankaku doctor` reads this, see
98
+ * `adapters/kankaku-command.ts`) — it never changes {@link buildTasks}'
99
+ * own per-task `wallMs`, so a plain pi run or today's gentle-pi setup is
100
+ * completely unaffected; each flagged record still anchors its own
101
+ * `TaskView` exactly as before. `unionedWallMs` is provided (via the
102
+ * existing `unionMs` primitive, ADR 0006 — the interval-union rule stays
103
+ * in exactly this one place) so a human reading the doctor report can see
104
+ * what the corrected total would be, without kankaku silently changing any
105
+ * number on its own.
106
+ */
107
+ export declare function detectSameProcessOverlaps(records: WorkRecord[]): SameProcessOverlap[];
108
+ /**
109
+ * Group tasks by `sessionId` (tasks without one fall under `"unknown"`).
110
+ * `wallMs` is the union of every interval — orchestrator and subagent alike
111
+ * — across all of the session's tasks, not a sum of per-task `wallMs`.
112
+ */
113
+ export declare function buildSessions(tasks: TaskView[]): SessionView[];