kankaku 0.7.1 → 0.8.2

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.
@@ -0,0 +1,112 @@
1
+ import type { SessionView, TaskView } from "../domain/task-view.ts";
2
+ import type { WorkRecord, WorkRole } from "../domain/work-record.ts";
3
+ export { localDay } from "../domain/day.ts";
4
+ export interface RoleTotals {
5
+ workMs: number;
6
+ waitingMs: number;
7
+ wallMs: number;
8
+ count: number;
9
+ /** Estimated cost in USD, as priced by pi's model table. */
10
+ cost: number;
11
+ /** Prompt input tokens, summed across every record of this role. */
12
+ input: number;
13
+ /** Prompt-cache read tokens, summed across every record of this role. */
14
+ cacheRead: number;
15
+ /** Prompt-cache write tokens, summed across every record of this role. */
16
+ cacheWrite: number;
17
+ /** Per-tag total milliseconds summed across every record of this role. */
18
+ segments: Record<string, number>;
19
+ }
20
+ export interface TaskTotals {
21
+ count: number;
22
+ wallMs: number;
23
+ workMs: number;
24
+ /** Estimated cost in USD, orchestrator and subagents combined. */
25
+ cost: number;
26
+ /** Prompt input tokens, orchestrator and subagents combined. */
27
+ input: number;
28
+ /** Prompt-cache read tokens, orchestrator and subagents combined. */
29
+ cacheRead: number;
30
+ /** Prompt-cache write tokens, orchestrator and subagents combined. */
31
+ cacheWrite: number;
32
+ /** Per-tag total milliseconds summed across every task (orchestrator and subagents). */
33
+ segments: Record<string, number>;
34
+ }
35
+ export type Summary = Record<WorkRole, RoleTotals> & {
36
+ tasks: TaskTotals;
37
+ };
38
+ export interface SummarizeOptions {
39
+ /** Local day in `YYYY-MM-DD` format. Defaults to today when `all` is not set. */
40
+ day?: string;
41
+ /** Include every record regardless of day. */
42
+ all?: boolean;
43
+ }
44
+ /**
45
+ * Aggregate work records by role, restricted to one local day unless `all`
46
+ * is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
47
+ * over one orchestrator run and its subagents) for tasks whose `startedAt`
48
+ * falls on the same day.
49
+ */
50
+ export declare function summarize(records: WorkRecord[], options: SummarizeOptions): Summary;
51
+ /**
52
+ * Count of `uncertain` records (ADR 0022) within the same day scope
53
+ * `summarize` uses, so the `/kankaku` report can surface a one-line hint
54
+ * when some records are silently excluded from the task count
55
+ * (SUBAGENT-REQ-017): an undercount must never be silent.
56
+ */
57
+ export declare function countUncertain(records: WorkRecord[], options: SummarizeOptions): number;
58
+ /** Render a short, human-readable summary for the `/kankaku` command. */
59
+ export declare function formatReport(summary: Summary): string;
60
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
61
+ export declare function formatTasks(tasks: TaskView[]): string;
62
+ export interface ClientTotals {
63
+ wallMs: number;
64
+ waitingMs: number;
65
+ workMs: number;
66
+ /** Estimated cost in USD, summed across this client's tasks. */
67
+ cost: number;
68
+ /** Prompt input tokens, summed across this client's tasks. */
69
+ input: number;
70
+ /** Prompt-cache read tokens, summed across this client's tasks. */
71
+ cacheRead: number;
72
+ /** Prompt-cache write tokens, summed across this client's tasks. */
73
+ cacheWrite: number;
74
+ count: number;
75
+ }
76
+ /**
77
+ * Aggregate tasks by billing client (see `domain/client-label.ts`), summing
78
+ * work/waiting/wall time, cost, and task count. Tasks without a `client`
79
+ * are grouped under `"(none)"`. Returned as a `Map` rather than a plain
80
+ * object so an attacker-controlled client name can never repoint a
81
+ * prototype property.
82
+ */
83
+ export declare function summarizeByClient(tasks: TaskView[]): Map<string, ClientTotals>;
84
+ export interface ProjectTotals {
85
+ /** Display name: the project's `projectName`, or `"(no project)"` for the ungrouped bucket. */
86
+ name: string;
87
+ wallMs: number;
88
+ waitingMs: number;
89
+ workMs: number;
90
+ /** Estimated cost in USD, summed across this project's tasks. */
91
+ cost: number;
92
+ /** Prompt input tokens, summed across this project's tasks. */
93
+ input: number;
94
+ /** Prompt-cache read tokens, summed across this project's tasks. */
95
+ cacheRead: number;
96
+ /** Prompt-cache write tokens, summed across this project's tasks. */
97
+ cacheWrite: number;
98
+ count: number;
99
+ }
100
+ /**
101
+ * Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
102
+ * `projectId` (so two projects that happen to share a display name are
103
+ * never merged) with the name denormalised alongside for display. Tasks
104
+ * without a `projectId` are grouped under `"(no project)"`.
105
+ */
106
+ export declare function summarizeByProject(tasks: TaskView[]): Map<string, ProjectTotals>;
107
+ /** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
108
+ export declare function formatProjects(totals: Map<string, ProjectTotals>): string;
109
+ /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
110
+ export declare function formatClients(totals: Map<string, ClientTotals>): string;
111
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
112
+ export declare function formatSessions(sessions: SessionView[]): string;
@@ -0,0 +1,236 @@
1
+ import { buildTasks, uncertainRecords } from "../domain/task-view.js";
2
+ import { localDay } from "../domain/day.js";
3
+ import { cacheHitRatio, finiteOrZero } from "../domain/work-record.js";
4
+ export { localDay } from "../domain/day.js";
5
+ const ROLES = ["orchestrator", "subagent"];
6
+ function emptyTotals() {
7
+ return { workMs: 0, waitingMs: 0, wallMs: 0, count: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} };
8
+ }
9
+ /**
10
+ * Add per-tag milliseconds from `segments` (missing on older records) into
11
+ * `into`, a `Map` rather than a plain object so a tag from a hand-edited
12
+ * worklog line named `__proto__` or `constructor` accumulates as a normal
13
+ * entry instead of silently reading (and arithmetically corrupting) an
14
+ * inherited `Object.prototype` value.
15
+ */
16
+ function addSegments(into, segments) {
17
+ for (const [tag, ms] of Object.entries(segments ?? {})) {
18
+ into.set(tag, (into.get(tag) ?? 0) + ms);
19
+ }
20
+ }
21
+ /**
22
+ * Aggregate work records by role, restricted to one local day unless `all`
23
+ * is set. Also computes a `tasks` segment (union-based `wallMs`/`workMs`
24
+ * over one orchestrator run and its subagents) for tasks whose `startedAt`
25
+ * falls on the same day.
26
+ */
27
+ export function summarize(records, options) {
28
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
29
+ const summary = {
30
+ orchestrator: emptyTotals(),
31
+ subagent: emptyTotals(),
32
+ tasks: { count: 0, wallMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, segments: {} },
33
+ };
34
+ // Accumulated in Maps (see `addSegments`) and only converted to the
35
+ // returned plain objects at the very end, via `Object.fromEntries`.
36
+ const segmentsByRole = { orchestrator: new Map(), subagent: new Map() };
37
+ const taskSegments = new Map();
38
+ for (const record of records) {
39
+ if (targetDay !== undefined && localDay(record.startedAt) !== targetDay)
40
+ continue;
41
+ const totals = summary[record.role];
42
+ totals.workMs += record.workMs;
43
+ totals.waitingMs += record.waitingMs;
44
+ totals.wallMs += record.wallMs;
45
+ totals.count += 1;
46
+ totals.cost += finiteOrZero(record.usage.cost);
47
+ totals.input += finiteOrZero(record.usage.input);
48
+ totals.cacheRead += finiteOrZero(record.usage.cacheRead);
49
+ totals.cacheWrite += finiteOrZero(record.usage.cacheWrite);
50
+ addSegments(segmentsByRole[record.role], record.segments);
51
+ }
52
+ const tasks = buildTasks(records).filter((task) => targetDay === undefined || localDay(task.startedAt) === targetDay);
53
+ for (const task of tasks) {
54
+ summary.tasks.count += 1;
55
+ summary.tasks.wallMs += task.wallMs;
56
+ summary.tasks.workMs += task.workMs;
57
+ summary.tasks.cost += task.usage.cost;
58
+ summary.tasks.input += finiteOrZero(task.usage.input);
59
+ summary.tasks.cacheRead += finiteOrZero(task.usage.cacheRead);
60
+ summary.tasks.cacheWrite += finiteOrZero(task.usage.cacheWrite);
61
+ addSegments(taskSegments, task.segments);
62
+ }
63
+ summary.orchestrator.segments = Object.fromEntries(segmentsByRole.orchestrator);
64
+ summary.subagent.segments = Object.fromEntries(segmentsByRole.subagent);
65
+ summary.tasks.segments = Object.fromEntries(taskSegments);
66
+ return summary;
67
+ }
68
+ /**
69
+ * Count of `uncertain` records (ADR 0022) within the same day scope
70
+ * `summarize` uses, so the `/kankaku` report can surface a one-line hint
71
+ * when some records are silently excluded from the task count
72
+ * (SUBAGENT-REQ-017): an undercount must never be silent.
73
+ */
74
+ export function countUncertain(records, options) {
75
+ const targetDay = options.all ? undefined : (options.day ?? localDay(new Date().toISOString()));
76
+ return uncertainRecords(records).filter((record) => targetDay === undefined || localDay(record.startedAt) === targetDay).length;
77
+ }
78
+ function formatMinutes(ms) {
79
+ const totalSeconds = Math.round(ms / 1000);
80
+ const minutes = Math.floor(totalSeconds / 60);
81
+ const seconds = totalSeconds % 60;
82
+ return `${minutes}m${String(seconds).padStart(2, "0")}s`;
83
+ }
84
+ /** Estimated USD cost with two decimals, e.g. `$1.23`. */
85
+ function formatCost(cost) {
86
+ return `$${cost.toFixed(2)}`;
87
+ }
88
+ function formatTime(iso) {
89
+ const date = new Date(iso);
90
+ const hours = String(date.getHours()).padStart(2, "0");
91
+ const minutes = String(date.getMinutes()).padStart(2, "0");
92
+ return `${hours}:${minutes}`;
93
+ }
94
+ /** Render `cache hit NN%` (integer percent) for a usage totals triple, or `undefined` when the ratio is undefined (nothing recorded). */
95
+ function formatCacheHit(usage) {
96
+ const ratio = cacheHitRatio(usage);
97
+ return ratio === undefined ? undefined : `cache hit ${Math.round(ratio * 100)}%`;
98
+ }
99
+ /** Render non-zero segment tags as `tag Xm00s` pairs, sorted alphabetically, joined by `, `. Undefined when none are non-zero. */
100
+ function formatSegmentTags(segments) {
101
+ const tags = Object.keys(segments)
102
+ .filter((tag) => segments[tag] > 0)
103
+ .sort();
104
+ if (tags.length === 0)
105
+ return undefined;
106
+ return tags.map((tag) => `${tag} ${formatMinutes(segments[tag])}`).join(", ");
107
+ }
108
+ /** Render a short, human-readable summary for the `/kankaku` command. */
109
+ export function formatReport(summary) {
110
+ const lines = ROLES.map((role) => {
111
+ const totals = summary[role];
112
+ const cacheHit = formatCacheHit(totals);
113
+ const cacheHitPart = cacheHit !== undefined ? `, ${cacheHit}` : "";
114
+ return `${role}: work ${formatMinutes(totals.workMs)}, waiting ${formatMinutes(totals.waitingMs)}, ${totals.count} record(s), ${formatCost(totals.cost)}${cacheHitPart}`;
115
+ });
116
+ const tasksCacheHit = formatCacheHit(summary.tasks);
117
+ const tasksCacheHitPart = tasksCacheHit !== undefined ? `, ${tasksCacheHit}` : "";
118
+ lines.push(`tasks: ${summary.tasks.count}, wall ${formatMinutes(summary.tasks.wallMs)}, work ${formatMinutes(summary.tasks.workMs)}, ${formatCost(summary.tasks.cost)}${tasksCacheHitPart}`);
119
+ const segmentTags = formatSegmentTags(summary.tasks.segments);
120
+ if (segmentTags !== undefined) {
121
+ lines.push(`segments: ${segmentTags}`);
122
+ }
123
+ return lines.join(" | ");
124
+ }
125
+ /** Maximum visible width of a task prompt before it is truncated with an ellipsis marker. */
126
+ const PROMPT_DISPLAY_LIMIT = 60;
127
+ /** Truncate `text` to `limit` visible characters, appending `…` (counted within the limit) when it was cut. */
128
+ function truncateWithEllipsis(text, limit) {
129
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
130
+ }
131
+ /** Render one line per task: time, client (when present), union-based wall/work, cost, non-zero segment tags, subagent count, and a truncated prompt (`…` marks a cut). */
132
+ export function formatTasks(tasks) {
133
+ if (tasks.length === 0)
134
+ return "no tasks";
135
+ return tasks
136
+ .map((task) => {
137
+ const prompt = truncateWithEllipsis(task.prompt, PROMPT_DISPLAY_LIMIT);
138
+ const segmentTags = formatSegmentTags(task.segments);
139
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
140
+ const clientPart = task.client !== undefined ? ` client:${task.client}` : "";
141
+ const cacheHit = formatCacheHit(task.usage);
142
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
143
+ return `${formatTime(task.startedAt)}${clientPart} wall ${formatMinutes(task.wallMs)} work ${formatMinutes(task.workMs)} ${formatCost(task.usage.cost)}${cacheHitPart}${segmentPart} subagents ${task.subagents.length} ${prompt}`;
144
+ })
145
+ .join("\n");
146
+ }
147
+ /** Client name under which tasks without a resolved client are grouped. */
148
+ const NO_CLIENT = "(none)";
149
+ /**
150
+ * Aggregate tasks by billing client (see `domain/client-label.ts`), summing
151
+ * work/waiting/wall time, cost, and task count. Tasks without a `client`
152
+ * are grouped under `"(none)"`. Returned as a `Map` rather than a plain
153
+ * object so an attacker-controlled client name can never repoint a
154
+ * prototype property.
155
+ */
156
+ export function summarizeByClient(tasks) {
157
+ const totals = new Map();
158
+ for (const task of tasks) {
159
+ const key = task.client ?? NO_CLIENT;
160
+ const entry = totals.get(key) ?? { wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
161
+ entry.wallMs += task.wallMs;
162
+ entry.waitingMs += task.waitingMs;
163
+ entry.workMs += task.workMs;
164
+ entry.cost += finiteOrZero(task.usage.cost);
165
+ entry.input += finiteOrZero(task.usage.input);
166
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
167
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
168
+ entry.count += 1;
169
+ totals.set(key, entry);
170
+ }
171
+ return totals;
172
+ }
173
+ /** Key (and display name) under which tasks without a resolved project are grouped. */
174
+ const NO_PROJECT = "(no project)";
175
+ /**
176
+ * Aggregate tasks by hub project (see `domain/work-target.ts`), keyed by
177
+ * `projectId` (so two projects that happen to share a display name are
178
+ * never merged) with the name denormalised alongside for display. Tasks
179
+ * without a `projectId` are grouped under `"(no project)"`.
180
+ */
181
+ export function summarizeByProject(tasks) {
182
+ const totals = new Map();
183
+ for (const task of tasks) {
184
+ const key = task.projectId ?? NO_PROJECT;
185
+ const name = task.projectId !== undefined ? (task.projectName ?? task.projectId) : NO_PROJECT;
186
+ const entry = totals.get(key) ?? { name, wallMs: 0, waitingMs: 0, workMs: 0, cost: 0, input: 0, cacheRead: 0, cacheWrite: 0, count: 0 };
187
+ entry.wallMs += task.wallMs;
188
+ entry.waitingMs += task.waitingMs;
189
+ entry.workMs += task.workMs;
190
+ entry.cost += finiteOrZero(task.usage.cost);
191
+ entry.input += finiteOrZero(task.usage.input);
192
+ entry.cacheRead += finiteOrZero(task.usage.cacheRead);
193
+ entry.cacheWrite += finiteOrZero(task.usage.cacheWrite);
194
+ entry.count += 1;
195
+ totals.set(key, entry);
196
+ }
197
+ return totals;
198
+ }
199
+ /** Render one line per project, sorted alphabetically by display name, with work/waiting/wall time, cost, and task count. */
200
+ export function formatProjects(totals) {
201
+ if (totals.size === 0)
202
+ return "no projects";
203
+ return Array.from(totals.values())
204
+ .sort((a, b) => a.name.localeCompare(b.name))
205
+ .map((t) => {
206
+ const cacheHit = formatCacheHit(t);
207
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
208
+ return `${t.name} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
209
+ })
210
+ .join("\n");
211
+ }
212
+ /** Render one line per client, sorted alphabetically, with work/waiting/wall time, cost, and task count. */
213
+ export function formatClients(totals) {
214
+ if (totals.size === 0)
215
+ return "no clients";
216
+ return Array.from(totals.entries())
217
+ .sort(([a], [b]) => a.localeCompare(b))
218
+ .map(([client, t]) => {
219
+ const cacheHit = formatCacheHit(t);
220
+ const cacheHitPart = cacheHit !== undefined ? ` ${cacheHit}` : "";
221
+ return `${client} work ${formatMinutes(t.workMs)} waiting ${formatMinutes(t.waitingMs)} wall ${formatMinutes(t.wallMs)} ${formatCost(t.cost)}${cacheHitPart} tasks ${t.count}`;
222
+ })
223
+ .join("\n");
224
+ }
225
+ /** Render one line per session: truncated id, time range, union-based wall/work, cost, non-zero segment tags, and task count. */
226
+ export function formatSessions(sessions) {
227
+ if (sessions.length === 0)
228
+ return "no sessions";
229
+ return sessions
230
+ .map((session) => {
231
+ const segmentTags = formatSegmentTags(session.segments);
232
+ const segmentPart = segmentTags !== undefined ? ` ${segmentTags}` : "";
233
+ return `${session.sessionId.slice(0, 8)} ${formatTime(session.startedAt)}–${formatTime(session.endedAt)} wall ${formatMinutes(session.wallMs)} work ${formatMinutes(session.workMs)} ${formatCost(session.usage.cost)}${segmentPart} tasks ${session.tasks.length}`;
234
+ })
235
+ .join("\n");
236
+ }
@@ -5,6 +5,7 @@
5
5
  * Never throws to its caller: every failure mode is folded into the
6
6
  * returned {@link SyncSummary}.
7
7
  */
8
+ import type { SyncState } from "../domain/sync-plan.ts";
8
9
  import type { TaskView } from "../domain/task-view.ts";
9
10
  import type { Clock } from "../ports/clock.ts";
10
11
  import type { WorkLog } from "../ports/work-log.ts";
@@ -70,8 +71,26 @@ export declare function runSync(deps: SyncRunnerDeps, options?: {
70
71
  full?: boolean;
71
72
  trigger?: SyncTrigger;
72
73
  }): Promise<SyncSummary>;
73
- /** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
74
+ /**
75
+ * Number of tasks pending a sync right now — computed locally, no network.
76
+ *
77
+ * @deprecated Superseded by {@link computeSyncStatus}, which returns the same
78
+ * `pending` count together with `state` and `staleOutsideWindow`. Nothing in
79
+ * kankaku calls this any more; it stays only because it is part of the
80
+ * published `kankaku/hub` surface. It will be removed in the next minor.
81
+ */
74
82
  export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number;
83
+ /**
84
+ * {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
85
+ * `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
86
+ * `kankaku/hub`) can depend on this type without importing anything that
87
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
88
+ */
89
+ export interface SyncStatusSnapshot {
90
+ state: SyncState | undefined;
91
+ pending: number;
92
+ staleOutsideWindow: number;
93
+ }
75
94
  /**
76
95
  * `/kankaku sync status`: the persisted state, a locally-computed pending
77
96
  * count, and (R3) how many tasks changed since their last sync but fall
@@ -79,11 +98,7 @@ export declare function pendingCount(tasks: TaskView[], state: ReturnType<SyncSt
79
98
  * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
80
99
  * (PocketBase)" > "Sync" > "Limitations"). No network.
81
100
  */
82
- export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): {
83
- state: ReturnType<SyncStateStore["read"]>;
84
- pending: number;
85
- staleOutsideWindow: number;
86
- };
101
+ export declare function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot;
87
102
  /**
88
103
  * Wrap an async function so concurrent callers share one in-flight call
89
104
  * instead of starting a new one each — kankaku's single-flight guard for
@@ -60,7 +60,11 @@ function isThrottled(state, trigger, now, minIntervalMs) {
60
60
  export async function runSync(deps, options = {}) {
61
61
  const startedAt = deps.clock.now();
62
62
  if (options.trigger !== undefined) {
63
- const peek = deps.stateStore.read();
63
+ // Gated like every other read below: a state written for another hub
64
+ // says nothing about THIS hub, so neither its `logVersion` (which
65
+ // would otherwise skip the new hub's very first run until the next
66
+ // record is appended) nor its `lastRunAt` throttle may apply.
67
+ const peek = stateForTarget(deps, deps.stateStore.read());
64
68
  const currentVersion = deps.log.version?.();
65
69
  const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
66
70
  if (versionUnchanged && peek?.lastError === undefined) {
@@ -75,10 +79,17 @@ export async function runSync(deps, options = {}) {
75
79
  try {
76
80
  lockAcquired = deps.stateStore.tryLock();
77
81
  if (!lockAcquired) {
78
- const state = deps.stateStore.read();
82
+ const state = stateForTarget(deps, deps.stateStore.read());
79
83
  return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
80
84
  }
81
85
  const state = deps.stateStore.read();
86
+ // A state from another hub is ignored wholesale, on EVERY path below
87
+ // (the summary, the error path, and the successful merge): its
88
+ // watermark and its hashes describe rows the configured hub does not
89
+ // have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
90
+ // still receives the raw `state`, since it applies that same gate
91
+ // itself and its `target` comparison is what makes the run a full one.
92
+ const priorState = stateForTarget(deps, state);
82
93
  // Captured once, here, and persisted as-is below: this is the version
83
94
  // the tasks below were actually built from, not whatever the log might
84
95
  // become by the time an awaited push finishes.
@@ -93,10 +104,15 @@ export async function runSync(deps, options = {}) {
93
104
  // WorkSink implementations are expected never to throw, but this
94
105
  // runner must hold that guarantee even if one does.
95
106
  const message = error instanceof Error ? error.message : String(error);
96
- const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
107
+ const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
97
108
  summary.skipped = plan.unchangedCount;
98
109
  summary.error = message;
99
- persistError(deps, state, message, logVersionAtRead);
110
+ // `priorState`, never `state`: a throwing sink on the first run
111
+ // against a NEW hub must not carry the old hub's hashes and
112
+ // watermark over under the new target, or the next run would
113
+ // skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
114
+ // successful path).
115
+ persistError(deps, priorState, message, logVersionAtRead);
100
116
  return summary;
101
117
  }
102
118
  const byId = new Map(plan.toSync.map((task) => [task.id, task]));
@@ -111,7 +127,7 @@ export async function runSync(deps, options = {}) {
111
127
  const unassigned = new Map();
112
128
  let uploaded = 0;
113
129
  let updated = 0;
114
- let syncedThrough = state?.syncedThrough;
130
+ let syncedThrough = priorState?.syncedThrough;
115
131
  let stopError;
116
132
  // Whether at least one task was actually resolved (pushed or recorded
117
133
  // as failed) this run — as opposed to the run stopping on its very
@@ -147,13 +163,16 @@ export async function runSync(deps, options = {}) {
147
163
  }
148
164
  else {
149
165
  // "error": a network/timeout/5xx/auth failure. Stop here — nothing
150
- // after this point in the (chronologically sorted) results is
151
- // considered resolved, so syncedThrough does not advance past it.
166
+ // after this point in the results is considered resolved. The
167
+ // results follow `plan.toSync`'s order (new work oldest-first,
168
+ // then corrections newest-first), and `syncedThrough` is a max
169
+ // over what WAS resolved, so stopping early can only leave it
170
+ // lower, never advance it past an unresolved task.
152
171
  stopError = result.outcome.reason;
153
172
  break;
154
173
  }
155
174
  }
156
- const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
175
+ const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
157
176
  // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
158
177
  // doc comment. `tasks` here is every task `buildTasks` currently knows
159
178
  // about (the full `readAll()`, not just this run's eligible/window
@@ -188,6 +207,11 @@ export async function runSync(deps, options = {}) {
188
207
  deps.stateStore.unlock();
189
208
  }
190
209
  }
210
+ /** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
211
+ function stateForTarget(deps, state) {
212
+ return state !== undefined && state.target === deps.target ? state : undefined;
213
+ }
214
+ /** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
191
215
  function persistError(deps, state, message, logVersionAtRead) {
192
216
  deps.stateStore.write({
193
217
  target: deps.target,
@@ -198,7 +222,14 @@ function persistError(deps, state, message, logVersionAtRead) {
198
222
  lastRunAt: deps.clock.now(),
199
223
  });
200
224
  }
201
- /** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
225
+ /**
226
+ * Number of tasks pending a sync right now — computed locally, no network.
227
+ *
228
+ * @deprecated Superseded by {@link computeSyncStatus}, which returns the same
229
+ * `pending` count together with `state` and `staleOutsideWindow`. Nothing in
230
+ * kankaku calls this any more; it stays only because it is part of the
231
+ * published `kankaku/hub` surface. It will be removed in the next minor.
232
+ */
202
233
  export function pendingCount(tasks, state, target, windowHours) {
203
234
  const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
204
235
  return plan.toSync.length + plan.correctionsDeferred;
@@ -145,18 +145,35 @@ export interface TaskEntryPayload {
145
145
  legacy_client_label: string;
146
146
  repo_project: string;
147
147
  schema: number;
148
+ /** Coding agent that MEASURED this task, lowercase slug — the orchestrator record's own {@link WorkRecordMetadata.agent} when it carries one (who measured, not who syncs), else `ctx.agent` (the syncing process's own identity) as a legacy fallback. See {@link WorkRecordMetadata.agent}. */
148
149
  agent: string;
149
150
  agent_version?: string;
150
151
  /** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
151
152
  session_dir?: string;
153
+ /** The integration that wrote this task's records, lowercase slug — same record-wins-over-ctx rule as {@link agent}. */
152
154
  plugin: string;
153
155
  plugin_version?: string;
154
156
  waiting_quality: WaitingQuality;
155
157
  cost_quality: CostQuality;
156
158
  subagent_linkage: SubagentLinkage;
157
159
  }
158
- /** `TaskEntryPayload` minus the assignment fields — what an update sends. See the module docs' CRITICAL rule. */
159
- export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project" | "task" | "legacy_client_label">;
160
+ /**
161
+ * `TaskEntryPayload` minus the assignment fields — what an update sends. See
162
+ * the module docs' CRITICAL rule.
163
+ *
164
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are optional here (unlike
165
+ * on `TaskEntryPayload`, where they are always sent on create): an update for
166
+ * a task whose orchestrator record carries no who-measured identity (a
167
+ * legacy record, written before this feature existed) omits them entirely,
168
+ * so a re-sync by a DIFFERENT process never overwrites a row's original
169
+ * identity with its own. See {@link buildTaskEntryUpdatePayload}.
170
+ */
171
+ export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project" | "task" | "legacy_client_label" | "agent" | "agent_version" | "plugin" | "plugin_version"> & {
172
+ agent?: string;
173
+ agent_version?: string;
174
+ plugin?: string;
175
+ plugin_version?: string;
176
+ };
160
177
  /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
161
178
  export declare function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload;
162
179
  /**
@@ -164,6 +181,13 @@ export declare function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntr
164
181
  * fields only — never `client`, `project`, `task` or `legacy_client_label`,
165
182
  * so a re-sync can never undo a reassignment made in the web. See the
166
183
  * module docs' CRITICAL rule.
184
+ *
185
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
186
+ * the orchestrator record itself carries a who-measured `agent` — otherwise
187
+ * they are OMITTED entirely (never sent as `ctx`'s own identity), so a
188
+ * re-sync of a legacy record by a different process never overwrites the
189
+ * row's original identity in the hub. See `domain/work-record.ts`'s
190
+ * "Measurement rules" and {@link resolveTaskIdentity}.
167
191
  */
168
192
  export declare function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload;
169
193
  /** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
@@ -126,9 +126,34 @@ export function computeSubagentLinkage(task) {
126
126
  return "not_applicable";
127
127
  return task.subagents.length >= spanCount ? "linked" : "unlinked";
128
128
  }
129
+ /**
130
+ * The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
131
+ * for a task's payload: the orchestrator record's own fields, taken as a
132
+ * unit, when it carries an `agent` — a missing version on the record is
133
+ * omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
134
+ * syncing process's own), exactly as before this feature existed. See
135
+ * `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
136
+ */
137
+ function resolveTaskIdentity(task, ctx) {
138
+ const orchestrator = task.orchestrator;
139
+ if (orchestrator.agent !== undefined) {
140
+ // The record's four fields are taken as a unit. A record written by an
141
+ // integration that stamped `agent` but not `plugin` gets the syncing
142
+ // context's `plugin` ONLY on create, so the row is never blank; the
143
+ // update payload below never resends a plugin the record does not own.
144
+ return {
145
+ agent: orchestrator.agent,
146
+ agentVersion: orchestrator.agentVersion,
147
+ plugin: orchestrator.plugin ?? ctx.plugin,
148
+ pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
149
+ };
150
+ }
151
+ return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
152
+ }
129
153
  /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
130
154
  export function buildTaskEntryCreatePayload(task, ctx) {
131
155
  const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
156
+ const identity = resolveTaskIdentity(task, ctx);
132
157
  return {
133
158
  task_id: task.id,
134
159
  client: assignment.clientId,
@@ -158,11 +183,11 @@ export function buildTaskEntryCreatePayload(task, ctx) {
158
183
  legacy_client_label: assignment.legacyClientLabel,
159
184
  repo_project: task.project,
160
185
  schema: task.orchestrator.schema,
161
- agent: ctx.agent,
162
- ...(ctx.agentVersion !== undefined ? { agent_version: ctx.agentVersion } : {}),
186
+ agent: identity.agent,
187
+ ...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
163
188
  ...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
164
- plugin: ctx.plugin,
165
- ...(ctx.pluginVersion !== undefined ? { plugin_version: ctx.pluginVersion } : {}),
189
+ plugin: identity.plugin,
190
+ ...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
166
191
  waiting_quality: computeWaitingQuality(),
167
192
  cost_quality: computeCostQuality(task),
168
193
  subagent_linkage: computeSubagentLinkage(task),
@@ -173,10 +198,29 @@ export function buildTaskEntryCreatePayload(task, ctx) {
173
198
  * fields only — never `client`, `project`, `task` or `legacy_client_label`,
174
199
  * so a re-sync can never undo a reassignment made in the web. See the
175
200
  * module docs' CRITICAL rule.
201
+ *
202
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
203
+ * the orchestrator record itself carries a who-measured `agent` — otherwise
204
+ * they are OMITTED entirely (never sent as `ctx`'s own identity), so a
205
+ * re-sync of a legacy record by a different process never overwrites the
206
+ * row's original identity in the hub. See `domain/work-record.ts`'s
207
+ * "Measurement rules" and {@link resolveTaskIdentity}.
176
208
  */
177
209
  export function buildTaskEntryUpdatePayload(task, ctx) {
178
- const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, ...rest } = buildTaskEntryCreatePayload(task, ctx);
179
- return rest;
210
+ const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, agent, agent_version, plugin, plugin_version, ...rest } = buildTaskEntryCreatePayload(task, ctx);
211
+ if (task.orchestrator.agent === undefined)
212
+ return rest;
213
+ // `plugin`/`plugin_version` are resent only when the record itself carries
214
+ // them; a record with `agent` but no `plugin` must never have the syncing
215
+ // process's plugin written over the row's original one.
216
+ const recordHasPlugin = task.orchestrator.plugin !== undefined;
217
+ return {
218
+ ...rest,
219
+ agent,
220
+ ...(agent_version !== undefined ? { agent_version } : {}),
221
+ ...(recordHasPlugin ? { plugin } : {}),
222
+ ...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
223
+ };
180
224
  }
181
225
  /**
182
226
  * Build one `work_records` payload from a raw {@link WorkRecord}