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.
@@ -14,7 +14,7 @@ import type { TaskView } from "./task-view.ts";
14
14
  export interface SyncState {
15
15
  /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
16
16
  syncedThrough?: string;
17
- /** Content hash per task id (see {@link computeTaskContentHash}), pruned to the revisit window so the file stays small. */
17
+ /** 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}). */
18
18
  hashes: Record<string, string>;
19
19
  /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
20
20
  target: string;
@@ -36,7 +36,8 @@ export interface SyncState {
36
36
  * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
37
  * automatic sync attempt, persisted so the automatic path's throttle
38
38
  * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
- * within one. Never touched by a manual sync.
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
40
41
  */
41
42
  lastRunAt?: number;
42
43
  }
@@ -49,7 +50,7 @@ export interface SyncPlanOptions {
49
50
  full?: boolean;
50
51
  }
51
52
  export interface SyncPlan {
52
- /** Tasks whose content changed (or were never synced) and therefore need a request, in chronological (`endedAt`) order. */
53
+ /** 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}). */
53
54
  toSync: TaskView[];
54
55
  /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
55
56
  unchangedCount: number;
@@ -115,8 +116,8 @@ export declare function planSync(tasks: TaskView[], state: SyncState | undefined
115
116
  * for "unchanged but I forgot"), so that window-based pruning made every
116
117
  * task older than the window look permanently changed, forever, the moment
117
118
  * its hash was first pruned: `/kankaku sync status` would report a
118
- * never-shrinking "changed outside the window" count that training taught
119
- * users to ignore (the bug this rewrite fixes).
119
+ * never-shrinking "changed outside the window" count that trained users
120
+ * to ignore it (the bug this rewrite fixes).
120
121
  *
121
122
  * Never pruning by window instead means `hashes` grows with the total
122
123
  * number of distinct tasks a directory has ever synced, not with time — an
@@ -71,6 +71,16 @@ export function computeTaskContentHash(task) {
71
71
  // `undefined` is dropped by the serialiser, so a task that never knew
72
72
  // its reasoning effort keeps the hash it had before this field existed.
73
73
  thinkingLevel: task.orchestrator.thinkingLevel,
74
+ // A task gaining a who-measured identity
75
+ // (domain/hub-entry.ts#resolveTaskIdentity) must resync, since it
76
+ // changes which agent/plugin the row is attributed to; the version
77
+ // fields are left out on purpose so a version-only bump alone does not
78
+ // force a resync. The keys are added CONDITIONALLY: `stableStringify`
79
+ // renders an `undefined` value as `"agent":undefined`, so an
80
+ // unconditional key would change every legacy task's hash and force a
81
+ // full resync right after upgrading (see the golden-hash test).
82
+ ...(task.orchestrator.agent !== undefined ? { agent: task.orchestrator.agent } : {}),
83
+ ...(task.orchestrator.plugin !== undefined ? { plugin: task.orchestrator.plugin } : {}),
74
84
  }));
75
85
  }
76
86
  /**
@@ -100,7 +110,11 @@ export function planSync(tasks, state, options) {
100
110
  eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
101
111
  outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
102
112
  }
103
- const hashes = state?.hashes ?? {};
113
+ // A state written for another hub contributes nothing, not even its
114
+ // hashes: the new hub has none of these rows, so a task that matched
115
+ // the OLD hub's hash must still be pushed. `isFullSync` alone only
116
+ // widened the window; without this gate every task looked "unchanged".
117
+ const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
104
118
  const changed = (task) => hashes[task.id] !== computeTaskContentHash(task);
105
119
  // A row the hub ALREADY holds is corrected wherever it sits: the window
106
120
  // bounds how far back NEW work is looked for, never whether a known row
@@ -118,10 +132,10 @@ export function planSync(tasks, state, options) {
118
132
  const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
119
133
  const correctionsDeferred = allCorrections.length - knownAndChanged.length;
120
134
  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.
135
+ // R3: cheap, pure visibility into a task this incremental run's window
136
+ // will not look at and that this hub has NEVER received (no stored hash)
137
+ // — see SyncPlan's doc comment. A known row that changed is a correction
138
+ // and is handled above, not reported here.
125
139
  const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
126
140
  return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
127
141
  }
@@ -139,8 +153,8 @@ export function planSync(tasks, state, options) {
139
153
  * for "unchanged but I forgot"), so that window-based pruning made every
140
154
  * task older than the window look permanently changed, forever, the moment
141
155
  * 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).
156
+ * never-shrinking "changed outside the window" count that trained users
157
+ * to ignore it (the bug this rewrite fixes).
144
158
  *
145
159
  * Never pruning by window instead means `hashes` grows with the total
146
160
  * number of distinct tasks a directory has ever synced, not with time — an
@@ -192,6 +192,23 @@ export interface WorkRecordMetadata {
192
192
  * (SUBAGENT-REQ-017). Never set on an `orchestrator` record.
193
193
  */
194
194
  profile?: string;
195
+ /**
196
+ * Coding agent that MEASURED this record, lowercase slug (e.g. `"pi"`).
197
+ * Who measured, not who syncs — a different process (a standalone
198
+ * `kankaku` TUI, or a session for a different agent sharing the same
199
+ * `worklog.jsonl`) may later push this record to the hub, and must never
200
+ * overwrite this identity with its own. See kankaku-hub `docs/contract.md`
201
+ * "Agent and measurement quality" and `domain/hub-entry.ts`. Optional and
202
+ * additive: `WORK_RECORD_SCHEMA` is unchanged and an older record without
203
+ * it still validates.
204
+ */
205
+ agent?: string;
206
+ /** The measuring agent's own version, when it could be determined without a hot-path cost. Never guessed — omitted rather than sent wrong. */
207
+ agentVersion?: string;
208
+ /** The integration that wrote this record, lowercase slug (e.g. `"kankaku"`). Same who-measured-not-who-syncs rule as {@link agent}. */
209
+ plugin?: string;
210
+ /** This integration's own version, from its `package.json`, read once. */
211
+ pluginVersion?: string;
195
212
  }
196
213
  export type WorkRecord = WorkRecordCore & WorkRecordMetadata;
197
214
  export declare function emptyUsage(): UsageTotals;
@@ -83,5 +83,9 @@ export function isWorkRecord(value) {
83
83
  (record["roleConfidence"] === undefined || record["roleConfidence"] === "uncertain") &&
84
84
  (record["orchestratorRef"] === undefined || isOrchestratorRef(record["orchestratorRef"])) &&
85
85
  (record["costObserved"] === undefined || record["costObserved"] === true) &&
86
- (record["profile"] === undefined || typeof record["profile"] === "string"));
86
+ (record["profile"] === undefined || typeof record["profile"] === "string") &&
87
+ (record["agent"] === undefined || typeof record["agent"] === "string") &&
88
+ (record["agentVersion"] === undefined || typeof record["agentVersion"] === "string") &&
89
+ (record["plugin"] === undefined || typeof record["plugin"] === "string") &&
90
+ (record["pluginVersion"] === undefined || typeof record["pluginVersion"] === "string"));
87
91
  }
@@ -7,7 +7,9 @@
7
7
  * for which ones and why.
8
8
  */
9
9
  export * from "../adapters/cached-catalog.ts";
10
+ export * from "../adapters/export-writer.ts";
10
11
  export * from "../adapters/file-modes.ts";
12
+ export * from "../adapters/hub-actions.ts";
11
13
  export * from "../adapters/hub-credentials.ts";
12
14
  export * from "../adapters/jsonl-work-log.ts";
13
15
  export * from "../adapters/kankaku-dir.ts";
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.ts";
15
17
  export * from "../adapters/pocketbase-catalog.ts";
16
18
  export * from "../adapters/pocketbase-client.ts";
17
19
  export * from "../adapters/pocketbase-sink.ts";
20
+ export * from "../adapters/project-config.ts";
21
+ export * from "../adapters/report-data.ts";
22
+ export * from "../adapters/report.ts";
23
+ export * from "../adapters/report-views.ts";
18
24
  export * from "../adapters/sync-runner.ts";
19
25
  export * from "../adapters/sync-state-store.ts";
package/dist/hub/index.js CHANGED
@@ -7,7 +7,9 @@
7
7
  * for which ones and why.
8
8
  */
9
9
  export * from "../adapters/cached-catalog.js";
10
+ export * from "../adapters/export-writer.js";
10
11
  export * from "../adapters/file-modes.js";
12
+ export * from "../adapters/hub-actions.js";
11
13
  export * from "../adapters/hub-credentials.js";
12
14
  export * from "../adapters/jsonl-work-log.js";
13
15
  export * from "../adapters/kankaku-dir.js";
@@ -15,5 +17,9 @@ export * from "../adapters/lazy-jsonl-work-log.js";
15
17
  export * from "../adapters/pocketbase-catalog.js";
16
18
  export * from "../adapters/pocketbase-client.js";
17
19
  export * from "../adapters/pocketbase-sink.js";
20
+ export * from "../adapters/project-config.js";
21
+ export * from "../adapters/report-data.js";
22
+ export * from "../adapters/report.js";
23
+ export * from "../adapters/report-views.js";
18
24
  export * from "../adapters/sync-runner.js";
19
25
  export * from "../adapters/sync-state-store.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.7.1",
3
+ "version": "0.8.2",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -6,15 +6,14 @@
6
6
  * see odd/tasks/kankaku-panel.md P4.
7
7
  */
8
8
  import type { CatalogSnapshot } from "../ports/catalog.ts";
9
- import type { SyncCommandDeps } from "./kankaku-command.ts";
10
- import type { SyncSummary } from "./sync-runner.ts";
9
+ import type { SyncStatusSnapshot, SyncSummary } from "./sync-runner.ts";
11
10
 
12
11
  /**
13
12
  * `/kankaku sync status`'s report lines: the watermark, the pending count,
14
13
  * how many tasks fell outside this run's revisit window (R3), and the last
15
14
  * error, when any. Exact body of `handleSyncCommand`'s `status` branch.
16
15
  */
17
- export function buildSyncStatusLines(status: ReturnType<SyncCommandDeps["status"]>): string[] {
16
+ export function buildSyncStatusLines(status: SyncStatusSnapshot): string[] {
18
17
  const { state, pending, staleOutsideWindow } = status;
19
18
  const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
20
19
  if (staleOutsideWindow > 0) {
@@ -7,24 +7,20 @@ import { formatWorkTargetLabel } from "../domain/work-target.ts";
7
7
  import { findAmbiguousToolNames } from "../domain/subagent-profile.ts";
8
8
  import type { SubagentProfile } from "../domain/subagent-profile.ts";
9
9
  import type { RejectedChildEnvMarker } from "../config.ts";
10
- import type { SyncState } from "../domain/sync-plan.ts";
11
10
  import type { RegistryClassification } from "../domain/registry-health.ts";
12
11
  import type { Catalog } from "../ports/catalog.ts";
13
12
  import type { WorkLog } from "../ports/work-log.ts";
14
13
  import { buildSyncStatusLines, formatBackfillLines, formatCatalogRefreshLines, formatSyncSummaryLines } from "./hub-actions.ts";
15
14
  import { buildClientsView, buildExportContent, buildProjectsView, buildSessionsView, buildSummaryView, buildTasksView } from "./report-views.ts";
16
- import type { SyncSummary, SyncTrigger } from "./sync-runner.ts";
15
+ import type { SyncStatusSnapshot, SyncSummary, SyncTrigger } from "./sync-runner.ts";
17
16
  import type { SessionClient } from "./session-client.ts";
18
17
  import { readNonDefaultSessionDir } from "./session-dir.ts";
19
18
  import type { SessionTarget } from "./session-target.ts";
19
+ import type { KankakuReportData } from "./report-data.ts";
20
20
 
21
- const REPORT_ENTRY_TYPE = "kankaku-report";
21
+ export type { KankakuReportData } from "./report-data.ts";
22
22
 
23
- /** Durable report rendered inside the chat transcript; never sent to the LLM. */
24
- export interface KankakuReportData {
25
- title: string;
26
- lines: string[];
27
- }
23
+ const REPORT_ENTRY_TYPE = "kankaku-report";
28
24
 
29
25
  /** Notify the user of an error through the UI, when one is available. */
30
26
  export function notifyError(ctx: ExtensionContext, error: unknown): void {
@@ -217,7 +213,7 @@ export interface SyncCommandDeps {
217
213
  * count, and (R3) how many tasks changed since their last sync but fall
218
214
  * outside this run's revisit window — needs `sync all`. No network.
219
215
  */
220
- status: () => { state: SyncState | undefined; pending: number; staleOutsideWindow: number };
216
+ status: () => SyncStatusSnapshot;
221
217
  }
222
218
 
223
219
  export interface KankakuCommandDeps {
@@ -496,6 +496,14 @@ export function createPiTracker(pi: ExtensionAPI, deps: PiTrackerDeps): void {
496
496
  ...(roleConfidence !== undefined ? { roleConfidence } : {}),
497
497
  ...(deps.orchestratorRef !== undefined ? { orchestratorRef: deps.orchestratorRef } : {}),
498
498
  ...(deps.profile !== undefined ? { profile: deps.profile } : {}),
499
+ // Who measured this record, not who later syncs it — see
500
+ // `domain/work-record.ts#WorkRecordMetadata.agent`. Always stamped for
501
+ // every record this process appends; the versions are the same ones
502
+ // `extension.ts` already resolves for the `about` screen.
503
+ agent: "pi",
504
+ ...(deps.agentVersion !== undefined ? { agentVersion: deps.agentVersion } : {}),
505
+ plugin: "kankaku",
506
+ ...(deps.pluginVersion !== undefined ? { pluginVersion: deps.pluginVersion } : {}),
499
507
  };
500
508
  }
501
509
 
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Pi-free home for {@link KankakuReportData}, the shape every `/kankaku`
3
+ * report view and the panel's report screen produce. Extracted out of
4
+ * `kankaku-command.ts` (which still re-exports it) so `report-views.ts` can
5
+ * be published through `kankaku/hub` without pulling in anything that
6
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
7
+ */
8
+
9
+ /** Durable report rendered inside the chat transcript; never sent to the LLM. */
10
+ export interface KankakuReportData {
11
+ title: string;
12
+ lines: string[];
13
+ }
@@ -9,7 +9,7 @@
9
9
  import { buildSessions, buildTasks } from "../domain/task-view.ts";
10
10
  import { exportRows, toCsv, toJson } from "../domain/export.ts";
11
11
  import type { WorkRecord } from "../domain/work-record.ts";
12
- import type { KankakuReportData } from "./kankaku-command.ts";
12
+ import type { KankakuReportData } from "./report-data.ts";
13
13
  import { countUncertain, formatClients, formatProjects, formatReport, formatSessions, formatTasks, localDay, summarize, summarizeByClient, summarizeByProject } from "./report.ts";
14
14
 
15
15
  /** Shared by every view except `buildTasksView`: `all` includes every day, otherwise only today's local day. */
@@ -108,7 +108,11 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
108
108
  const startedAt = deps.clock.now();
109
109
 
110
110
  if (options.trigger !== undefined) {
111
- const peek = deps.stateStore.read();
111
+ // Gated like every other read below: a state written for another hub
112
+ // says nothing about THIS hub, so neither its `logVersion` (which
113
+ // would otherwise skip the new hub's very first run until the next
114
+ // record is appended) nor its `lastRunAt` throttle may apply.
115
+ const peek = stateForTarget(deps, deps.stateStore.read());
112
116
  const currentVersion = deps.log.version?.();
113
117
  const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
114
118
  if (versionUnchanged && peek?.lastError === undefined) {
@@ -125,11 +129,18 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
125
129
  try {
126
130
  lockAcquired = deps.stateStore.tryLock();
127
131
  if (!lockAcquired) {
128
- const state = deps.stateStore.read();
132
+ const state = stateForTarget(deps, deps.stateStore.read());
129
133
  return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
130
134
  }
131
135
 
132
136
  const state = deps.stateStore.read();
137
+ // A state from another hub is ignored wholesale, on EVERY path below
138
+ // (the summary, the error path, and the successful merge): its
139
+ // watermark and its hashes describe rows the configured hub does not
140
+ // have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
141
+ // still receives the raw `state`, since it applies that same gate
142
+ // itself and its `target` comparison is what makes the run a full one.
143
+ const priorState = stateForTarget(deps, state);
133
144
  // Captured once, here, and persisted as-is below: this is the version
134
145
  // the tasks below were actually built from, not whatever the log might
135
146
  // become by the time an awaited push finishes.
@@ -144,10 +155,15 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
144
155
  // WorkSink implementations are expected never to throw, but this
145
156
  // runner must hold that guarantee even if one does.
146
157
  const message = error instanceof Error ? error.message : String(error);
147
- const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
158
+ const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
148
159
  summary.skipped = plan.unchangedCount;
149
160
  summary.error = message;
150
- persistError(deps, state, message, logVersionAtRead);
161
+ // `priorState`, never `state`: a throwing sink on the first run
162
+ // against a NEW hub must not carry the old hub's hashes and
163
+ // watermark over under the new target, or the next run would
164
+ // skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
165
+ // successful path).
166
+ persistError(deps, priorState, message, logVersionAtRead);
151
167
  return summary;
152
168
  }
153
169
 
@@ -163,7 +179,7 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
163
179
  const unassigned = new Map<string, number>();
164
180
  let uploaded = 0;
165
181
  let updated = 0;
166
- let syncedThrough = state?.syncedThrough;
182
+ let syncedThrough = priorState?.syncedThrough;
167
183
  let stopError: string | undefined;
168
184
  // Whether at least one task was actually resolved (pushed or recorded
169
185
  // as failed) this run — as opposed to the run stopping on its very
@@ -196,14 +212,17 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
196
212
  progressed = true;
197
213
  } else {
198
214
  // "error": a network/timeout/5xx/auth failure. Stop here — nothing
199
- // after this point in the (chronologically sorted) results is
200
- // considered resolved, so syncedThrough does not advance past it.
215
+ // after this point in the results is considered resolved. The
216
+ // results follow `plan.toSync`'s order (new work oldest-first,
217
+ // then corrections newest-first), and `syncedThrough` is a max
218
+ // over what WAS resolved, so stopping early can only leave it
219
+ // lower, never advance it past an unresolved task.
201
220
  stopError = result.outcome.reason;
202
221
  break;
203
222
  }
204
223
  }
205
224
 
206
- const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
225
+ const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
207
226
  // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
208
227
  // doc comment. `tasks` here is every task `buildTasks` currently knows
209
228
  // about (the full `readAll()`, not just this run's eligible/window
@@ -239,6 +258,12 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
239
258
  }
240
259
  }
241
260
 
261
+ /** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
262
+ function stateForTarget(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>): ReturnType<SyncStateStore["read"]> {
263
+ return state !== undefined && state.target === deps.target ? state : undefined;
264
+ }
265
+
266
+ /** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
242
267
  function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>, message: string, logVersionAtRead: string | number | undefined): void {
243
268
  deps.stateStore.write({
244
269
  target: deps.target,
@@ -250,12 +275,31 @@ function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["re
250
275
  });
251
276
  }
252
277
 
253
- /** Number of tasks pending a sync right now, for `/kankaku sync status` — computed locally, no network. */
278
+ /**
279
+ * Number of tasks pending a sync right now — computed locally, no network.
280
+ *
281
+ * @deprecated Superseded by {@link computeSyncStatus}, which returns the same
282
+ * `pending` count together with `state` and `staleOutsideWindow`. Nothing in
283
+ * kankaku calls this any more; it stays only because it is part of the
284
+ * published `kankaku/hub` surface. It will be removed in the next minor.
285
+ */
254
286
  export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore["read"]>, target: string, windowHours?: number): number {
255
287
  const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
256
288
  return plan.toSync.length + plan.correctionsDeferred;
257
289
  }
258
290
 
291
+ /**
292
+ * {@link computeSyncStatus}'s return shape, exported so a caller (e.g.
293
+ * `hub-actions.ts#buildSyncStatusLines`, or a future standalone TUI reusing
294
+ * `kankaku/hub`) can depend on this type without importing anything that
295
+ * touches `@earendil-works/*` — see AGENTS.md "Code conventions".
296
+ */
297
+ export interface SyncStatusSnapshot {
298
+ state: SyncState | undefined;
299
+ pending: number;
300
+ staleOutsideWindow: number;
301
+ }
302
+
259
303
  /**
260
304
  * `/kankaku sync status`: the persisted state, a locally-computed pending
261
305
  * count, and (R3) how many tasks changed since their last sync but fall
@@ -263,12 +307,7 @@ export function pendingCount(tasks: TaskView[], state: ReturnType<SyncStateStore
263
307
  * (see `domain/sync-plan.ts#SyncPlan.staleOutsideWindow`, and README "Hub
264
308
  * (PocketBase)" > "Sync" > "Limitations"). No network.
265
309
  */
266
- export function computeSyncStatus(
267
- log: WorkLog,
268
- stateStore: SyncStateStore,
269
- target: string,
270
- windowHours?: number,
271
- ): { state: ReturnType<SyncStateStore["read"]>; pending: number; staleOutsideWindow: number } {
310
+ export function computeSyncStatus(log: WorkLog, stateStore: SyncStateStore, target: string, windowHours?: number): SyncStatusSnapshot {
272
311
  const state = stateStore.read();
273
312
  const tasks = buildTasks(log.readAll());
274
313
  const plan = planSync(tasks, state, { target, ...(windowHours !== undefined ? { windowHours } : {}) });
@@ -207,10 +207,12 @@ export interface TaskEntryPayload {
207
207
  legacy_client_label: string;
208
208
  repo_project: string;
209
209
  schema: number;
210
+ /** 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}. */
210
211
  agent: string;
211
212
  agent_version?: string;
212
213
  /** Non-default session directory, see `TaskView.sessionDir`. A measurement field, not assignment: sent on both create and update. */
213
214
  session_dir?: string;
215
+ /** The integration that wrote this task's records, lowercase slug — same record-wins-over-ctx rule as {@link agent}. */
214
216
  plugin: string;
215
217
  plugin_version?: string;
216
218
  waiting_quality: WaitingQuality;
@@ -218,12 +220,59 @@ export interface TaskEntryPayload {
218
220
  subagent_linkage: SubagentLinkage;
219
221
  }
220
222
 
221
- /** `TaskEntryPayload` minus the assignment fields — what an update sends. See the module docs' CRITICAL rule. */
222
- export type TaskEntryUpdatePayload = Omit<TaskEntryPayload, "client" | "project" | "task" | "legacy_client_label">;
223
+ /**
224
+ * `TaskEntryPayload` minus the assignment fields — what an update sends. See
225
+ * the module docs' CRITICAL rule.
226
+ *
227
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are optional here (unlike
228
+ * on `TaskEntryPayload`, where they are always sent on create): an update for
229
+ * a task whose orchestrator record carries no who-measured identity (a
230
+ * legacy record, written before this feature existed) omits them entirely,
231
+ * so a re-sync by a DIFFERENT process never overwrites a row's original
232
+ * identity with its own. See {@link buildTaskEntryUpdatePayload}.
233
+ */
234
+ export type TaskEntryUpdatePayload = Omit<
235
+ TaskEntryPayload,
236
+ "client" | "project" | "task" | "legacy_client_label" | "agent" | "agent_version" | "plugin" | "plugin_version"
237
+ > & {
238
+ agent?: string;
239
+ agent_version?: string;
240
+ plugin?: string;
241
+ plugin_version?: string;
242
+ };
243
+
244
+ /**
245
+ * The who-measured identity (`agent`/`agentVersion`/`plugin`/`pluginVersion`)
246
+ * for a task's payload: the orchestrator record's own fields, taken as a
247
+ * unit, when it carries an `agent` — a missing version on the record is
248
+ * omitted, never backfilled from `ctx` — otherwise `ctx`'s identity (the
249
+ * syncing process's own), exactly as before this feature existed. See
250
+ * `domain/work-record.ts#WorkRecordMetadata.agent`'s doc comment.
251
+ */
252
+ function resolveTaskIdentity(
253
+ task: TaskView,
254
+ ctx: HubEntryContext,
255
+ ): { agent: string; agentVersion?: string; plugin: string; pluginVersion?: string } {
256
+ const orchestrator = task.orchestrator;
257
+ if (orchestrator.agent !== undefined) {
258
+ // The record's four fields are taken as a unit. A record written by an
259
+ // integration that stamped `agent` but not `plugin` gets the syncing
260
+ // context's `plugin` ONLY on create, so the row is never blank; the
261
+ // update payload below never resends a plugin the record does not own.
262
+ return {
263
+ agent: orchestrator.agent,
264
+ agentVersion: orchestrator.agentVersion,
265
+ plugin: orchestrator.plugin ?? ctx.plugin,
266
+ pluginVersion: orchestrator.plugin !== undefined ? orchestrator.pluginVersion : undefined,
267
+ };
268
+ }
269
+ return { agent: ctx.agent, agentVersion: ctx.agentVersion, plugin: ctx.plugin, pluginVersion: ctx.pluginVersion };
270
+ }
223
271
 
224
272
  /** Build the full `task_entries` payload for a **create** request — every field, including assignment. */
225
273
  export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryPayload {
226
274
  const assignment = resolveTaskAssignment(task, ctx.clients, ctx.projects, ctx.tasks);
275
+ const identity = resolveTaskIdentity(task, ctx);
227
276
  return {
228
277
  task_id: task.id,
229
278
  client: assignment.clientId,
@@ -253,11 +302,11 @@ export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext
253
302
  legacy_client_label: assignment.legacyClientLabel,
254
303
  repo_project: task.project,
255
304
  schema: task.orchestrator.schema,
256
- agent: ctx.agent,
257
- ...(ctx.agentVersion !== undefined ? { agent_version: ctx.agentVersion } : {}),
305
+ agent: identity.agent,
306
+ ...(identity.agentVersion !== undefined ? { agent_version: identity.agentVersion } : {}),
258
307
  ...(task.sessionDir !== undefined ? { session_dir: task.sessionDir } : {}),
259
- plugin: ctx.plugin,
260
- ...(ctx.pluginVersion !== undefined ? { plugin_version: ctx.pluginVersion } : {}),
308
+ plugin: identity.plugin,
309
+ ...(identity.pluginVersion !== undefined ? { plugin_version: identity.pluginVersion } : {}),
261
310
  waiting_quality: computeWaitingQuality(),
262
311
  cost_quality: computeCostQuality(task),
263
312
  subagent_linkage: computeSubagentLinkage(task),
@@ -269,10 +318,40 @@ export function buildTaskEntryCreatePayload(task: TaskView, ctx: HubEntryContext
269
318
  * fields only — never `client`, `project`, `task` or `legacy_client_label`,
270
319
  * so a re-sync can never undo a reassignment made in the web. See the
271
320
  * module docs' CRITICAL rule.
321
+ *
322
+ * `agent`/`agent_version`/`plugin`/`plugin_version` are included ONLY when
323
+ * the orchestrator record itself carries a who-measured `agent` — otherwise
324
+ * they are OMITTED entirely (never sent as `ctx`'s own identity), so a
325
+ * re-sync of a legacy record by a different process never overwrites the
326
+ * row's original identity in the hub. See `domain/work-record.ts`'s
327
+ * "Measurement rules" and {@link resolveTaskIdentity}.
272
328
  */
273
329
  export function buildTaskEntryUpdatePayload(task: TaskView, ctx: HubEntryContext): TaskEntryUpdatePayload {
274
- const { client: _client, project: _project, task: _task, legacy_client_label: _legacy, ...rest } = buildTaskEntryCreatePayload(task, ctx);
275
- return rest;
330
+ const {
331
+ client: _client,
332
+ project: _project,
333
+ task: _task,
334
+ legacy_client_label: _legacy,
335
+ agent,
336
+ agent_version,
337
+ plugin,
338
+ plugin_version,
339
+ ...rest
340
+ } = buildTaskEntryCreatePayload(task, ctx);
341
+
342
+ if (task.orchestrator.agent === undefined) return rest;
343
+
344
+ // `plugin`/`plugin_version` are resent only when the record itself carries
345
+ // them; a record with `agent` but no `plugin` must never have the syncing
346
+ // process's plugin written over the row's original one.
347
+ const recordHasPlugin = task.orchestrator.plugin !== undefined;
348
+ return {
349
+ ...rest,
350
+ agent,
351
+ ...(agent_version !== undefined ? { agent_version } : {}),
352
+ ...(recordHasPlugin ? { plugin } : {}),
353
+ ...(recordHasPlugin && plugin_version !== undefined ? { plugin_version } : {}),
354
+ };
276
355
  }
277
356
 
278
357
  /** One `work_records` row — raw per-`WorkRecord` detail, always `rollup: false`. */
@@ -17,7 +17,7 @@ import type { TaskView } from "./task-view.ts";
17
17
  export interface SyncState {
18
18
  /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
19
19
  syncedThrough?: string;
20
- /** Content hash per task id (see {@link computeTaskContentHash}), pruned to the revisit window so the file stays small. */
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
21
  hashes: Record<string, string>;
22
22
  /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
23
23
  target: string;
@@ -36,7 +36,8 @@ export interface SyncState {
36
36
  * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
37
  * automatic sync attempt, persisted so the automatic path's throttle
38
38
  * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
- * within one. Never touched by a manual sync.
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
40
41
  */
41
42
  lastRunAt?: number;
42
43
  }
@@ -51,7 +52,7 @@ export interface SyncPlanOptions {
51
52
  }
52
53
 
53
54
  export interface SyncPlan {
54
- /** Tasks whose content changed (or were never synced) and therefore need a request, in chronological (`endedAt`) order. */
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}). */
55
56
  toSync: TaskView[];
56
57
  /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
57
58
  unchangedCount: number;
@@ -134,6 +135,16 @@ export function computeTaskContentHash(task: TaskView): string {
134
135
  // `undefined` is dropped by the serialiser, so a task that never knew
135
136
  // its reasoning effort keeps the hash it had before this field existed.
136
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 } : {}),
137
148
  }),
138
149
  );
139
150
  }
@@ -167,7 +178,11 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
167
178
  outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
168
179
  }
169
180
 
170
- const hashes = state?.hashes ?? {};
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 : {};
171
186
  const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
172
187
  // A row the hub ALREADY holds is corrected wherever it sits: the window
173
188
  // bounds how far back NEW work is looked for, never whether a known row
@@ -185,10 +200,10 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
185
200
  const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
186
201
  const correctionsDeferred = allCorrections.length - knownAndChanged.length;
187
202
  const toSync = [...eligible.filter(changed), ...knownAndChanged];
188
- // R3: cheap, pure visibility into a task that changed but that this
189
- // incremental run's window will not re-evaluate — see SyncPlan's doc
190
- // comment. No extra work: `outsideWindow` is already computed above,
191
- // this just re-applies the same hash-mismatch check to it.
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.
192
207
  const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
193
208
 
194
209
  return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
@@ -208,8 +223,8 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
208
223
  * for "unchanged but I forgot"), so that window-based pruning made every
209
224
  * task older than the window look permanently changed, forever, the moment
210
225
  * its hash was first pruned: `/kankaku sync status` would report a
211
- * never-shrinking "changed outside the window" count that training taught
212
- * users to ignore (the bug this rewrite fixes).
226
+ * never-shrinking "changed outside the window" count that trained users
227
+ * to ignore it (the bug this rewrite fixes).
213
228
  *
214
229
  * Never pruning by window instead means `hashes` grows with the total
215
230
  * number of distinct tasks a directory has ever synced, not with time — an