kibi-agent-core 0.10.0 → 0.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # kibi-agent-core
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43d4366: Usage telemetry data is cleaner and more complete. Telemetry is now off when the home directory lies inside the temp directory, as eval, packaging and end-to-end harnesses set it, so each harness run no longer counts as a new install; Kibi's own eval and packed-test sandboxes also set `KIBI_TELEMETRY=0`. Events now name the host agent from `CLAUDECODE`, `CURSOR_AGENT`, `CODEX_SANDBOX`, `GEMINI_CLI`, `OPENCODE`, `AI_AGENT` or the MCP client name, carry a hashed host session id in every host, and say whether they come from a hosted cloud workspace. Failed CLI commands and MCP tool results now carry a fixed error category: the result's error code (for example `upsert_dry_run_invalid`), the failing `kibi doctor` checks, or the `kibi migrate` approval problem. `kibi migrate --apply-safe` now prints the current plan hash when the approved hash is missing or out of date.
8
+
9
+ Hooks in Claude Code, Codex, Cursor, ZCode and OpenCode now all send the anonymous `kibi_hook` event (before, only Claude Code did, and only in diagnostic mode for the others), and each hook remembers, as hashes in `telemetry-sessions.json`, which host session last ran in the workspace. CLI and MCP events take the session id from `KIBI_SESSION_ID`, `CLAUDE_CODE_SESSION_ID` or `CODEX_THREAD_ID` when the host exposes one, and otherwise from that workspace marker (at most 30 minutes old), so events from Cursor and OpenCode sessions, which expose no session variable, can be grouped too. Every host's hook also sends the queue when it is due, and right away at the end of a turn.
10
+
11
+ ### Patch Changes
12
+
13
+ - ce41f02: Agents now keep a signoff in the pull request it approves. The guidance used to time the signoff as an end-of-work step and said only that an agent must never set `approved_by` itself, so agents often opened the pull request first and then carried the person's answer in a separate follow-up pull request.
14
+ - kibi-usage 2.37.0, kibi-decisions 1.5.0 and kibi-commit-review 1.4.0 (and the signoff-views resource) say to ask for the signoff before opening or merging the pull request, and to record the person's answer (their name in `approved_by` or a `*_waiver_approved_by` field, their words in `origin.ref` or `approval_ref`) on the same branch and pull request. Writing down an answer the person gave is not self-approval. A pull request that only carries a signoff, firmness or waiver approval is ruled out.
15
+ - The stop-hook signoff reminder (Claude Code, Codex, Cursor, ZCode) adds the same rule. So do the `agent-requirement-unapproved` check action, the `dimension_unrepresented` repair hint and the waiver lines of `kibi proof explain`.
16
+
3
17
  ## 0.10.0
4
18
 
5
19
  ### Minor Changes
@@ -260,7 +260,7 @@ export function claimSignoffReminder(session, workspaceRoot) {
260
260
  export function signoffReminder() {
261
261
  return [
262
262
  "Kibi: this session recorded or changed requirements, and the person has not seen them as a signoff view.",
263
- "Before reporting the work done or asking to commit, run `kibi signoff` and put its output in the reply unchanged; a requirement the agent wrote waits for the person's approval, so ask for it with this view, never in your own words. When replies go through a messaging tool, pass the output there as the message text, and share `.kb/briefs/signoff/latest.html` only as a rendered page (the Artifact tool in Claude apps), never as an attached file, which chat apps show as raw source (kibi-decisions, resources/signoff-views.md).",
263
+ "Before reporting the work done, asking to commit, or opening or merging the pull request, run `kibi signoff` and put its output in the reply unchanged; a requirement the agent wrote waits for the person's approval, so ask for it with this view, never in your own words. When they answer, record their answer on the same branch and push it to the same pull request, never to a separate one. When replies go through a messaging tool, pass the output there as the message text, and share `.kb/briefs/signoff/latest.html` only as a rendered page (the Artifact tool in Claude apps), never as an attached file, which chat apps show as raw source (kibi-decisions, resources/signoff-views.md).",
264
264
  ].join("\n");
265
265
  }
266
266
  function readSettled(file) {
@@ -35,6 +35,8 @@ export declare function hookTelemetryEnabled(env?: NodeJS.ProcessEnv): boolean;
35
35
  export type HookUsageRow = {
36
36
  /** Host agent, e.g. `claude-code`, `cursor`, `codex`, `zcode`, `opencode`. */
37
37
  host: string;
38
+ /** Adapter npm package; defaults to `kibi-<host>` (`kibi-claude` for Claude Code). */
39
+ packageName?: string | undefined;
38
40
  /** Version of the adapter package that wrote the row, when known. */
39
41
  packageVersion?: string | null | undefined;
40
42
  workspaceRoot: string;
@@ -44,7 +46,15 @@ export type HookUsageRow = {
44
46
  trace: HookTrace;
45
47
  startedAt: Date;
46
48
  };
47
- /** Append one hook row to `.kb/usage.log` when the operator opted in. */
49
+ /**
50
+ * Whether a host should build hook traces at all: for the opt-in
51
+ * `.kb/usage.log` rows or for anonymous usage telemetry.
52
+ */
53
+ export declare function hookTracesWanted(env?: NodeJS.ProcessEnv): boolean;
54
+ /**
55
+ * Record one hook decision: an anonymous summary event (unless telemetry is
56
+ * off) and, when the operator opted in, the detailed `.kb/usage.log` row.
57
+ */
48
58
  export declare function appendHookUsage(row: HookUsageRow, env?: NodeJS.ProcessEnv): void;
49
59
  /**
50
60
  * Trace for a tool call that ran a Kibi operation through MCP or the
@@ -59,6 +69,17 @@ export declare function kbUsageTrace(toolName: string | undefined, toolInput: un
59
69
  export declare function editTraces(relativePaths: readonly string[], requirementIdsFor: (relativePath: string) => string[]): HookTrace[];
60
70
  /** Append one row per trace, sharing the call's host, session and tool. */
61
71
  export declare function appendHookUsageRows(row: Omit<HookUsageRow, "trace">, traces: readonly HookTrace[], env?: NodeJS.ProcessEnv): void;
72
+ /**
73
+ * End-of-hook bookkeeping every host runs: remember the host session for the
74
+ * workspace, so CLI and MCP events from that workspace can carry it, and send
75
+ * the anonymous telemetry queue when it is due, or always at the end of a
76
+ * turn. Never throws.
77
+ */
78
+ export declare function finishHookTelemetry(input: {
79
+ readonly event: string;
80
+ readonly workspaceRoot: string | undefined;
81
+ readonly sessionId: string | undefined;
82
+ }, env?: NodeJS.ProcessEnv): Promise<void>;
62
83
  /**
63
84
  * Version of the adapter package whose `package.json` sits at
64
85
  * `packageJsonPath`, or null when it cannot be read.
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { hostKbOperation } from "./kb-mcp-tools.js";
6
6
  import { classifyPath } from "./path-policy.js";
7
+ import { flushTelemetry, recordHookTelemetry, rememberHookSession, telemetryEnabled, } from "./usage-telemetry.js";
7
8
  // implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-telemetry-acceptance-gate-v2
8
9
  /** Hook actions the acceptance report reads; hosts emit these names. */
9
10
  export const HOOK_ACTION_EDITED = "edited";
@@ -15,9 +16,38 @@ export function hookTelemetryEnabled(env = process.env) {
15
16
  return value === "1" || value === "true";
16
17
  }
17
18
  // implements REQ-claude-hook-usage-telemetry-v2
18
- /** Append one hook row to `.kb/usage.log` when the operator opted in. */
19
+ /**
20
+ * Whether a host should build hook traces at all: for the opt-in
21
+ * `.kb/usage.log` rows or for anonymous usage telemetry.
22
+ */
23
+ export function hookTracesWanted(env = process.env) {
24
+ return hookTelemetryEnabled(env) || telemetryEnabled(env);
25
+ }
26
+ function adapterPackage(host) {
27
+ return host === "claude-code" ? "kibi-claude" : `kibi-${host}`;
28
+ }
29
+ // implements REQ-claude-hook-usage-telemetry-v2, REQ-kibi-anonymous-usage-telemetry
30
+ /**
31
+ * Record one hook decision: an anonymous summary event (unless telemetry is
32
+ * off) and, when the operator opted in, the detailed `.kb/usage.log` row.
33
+ */
19
34
  export function appendHookUsage(row, env = process.env) {
20
- if (!hookTelemetryEnabled(env) || row.trace.action === undefined)
35
+ if (row.trace.action === undefined)
36
+ return;
37
+ recordHookTelemetry({
38
+ packageName: row.packageName ?? adapterPackage(row.host),
39
+ packageVersion: row.packageVersion,
40
+ host: row.host,
41
+ sessionId: row.sessionId,
42
+ }, {
43
+ hookEvent: row.event,
44
+ hookAction: row.trace.action,
45
+ pathKind: row.trace.pathKind,
46
+ kbOperation: row.trace.kbOperation,
47
+ kbUsedBefore: row.trace.kbUsedBefore,
48
+ requirementCount: row.trace.requirementIds?.length,
49
+ }, env);
50
+ if (!hookTelemetryEnabled(env))
21
51
  return;
22
52
  const finishedAt = new Date();
23
53
  const { trace } = row;
@@ -85,11 +115,32 @@ export function editTraces(relativePaths, requirementIdsFor) {
85
115
  // implements REQ-claude-hook-usage-telemetry-v2
86
116
  /** Append one row per trace, sharing the call's host, session and tool. */
87
117
  export function appendHookUsageRows(row, traces, env = process.env) {
88
- if (!hookTelemetryEnabled(env))
118
+ if (!hookTracesWanted(env))
89
119
  return;
90
120
  for (const trace of traces)
91
121
  appendHookUsage({ ...row, trace }, env);
92
122
  }
123
+ /** Host events that end a turn or a session: the queue is sent right away. */
124
+ const TURN_END_EVENTS = new Set(["stop", "sessionend"]);
125
+ // implements REQ-kibi-anonymous-usage-telemetry
126
+ /**
127
+ * End-of-hook bookkeeping every host runs: remember the host session for the
128
+ * workspace, so CLI and MCP events from that workspace can carry it, and send
129
+ * the anonymous telemetry queue when it is due, or always at the end of a
130
+ * turn. Never throws.
131
+ */
132
+ export async function finishHookTelemetry(input, env = process.env) {
133
+ try {
134
+ if (input.workspaceRoot && input.sessionId) {
135
+ rememberHookSession(input.workspaceRoot, input.sessionId, env);
136
+ }
137
+ const turnEnd = TURN_END_EVENTS.has(input.event.toLowerCase().replace(/[^a-z]/g, ""));
138
+ await flushTelemetry({ force: turnEnd }, env);
139
+ }
140
+ catch {
141
+ // Telemetry is best effort and must never disturb the hook.
142
+ }
143
+ }
93
144
  // implements REQ-claude-hook-usage-telemetry-v2
94
145
  /**
95
146
  * Version of the adapter package whose `package.json` sits at
@@ -14,7 +14,8 @@
14
14
  * Events are appended to a per-user queue file and sent in batches, so
15
15
  * short-lived processes (hooks, the CLI) do not wait on the network.
16
16
  * `KIBI_TELEMETRY=0`, `DO_NOT_TRACK=1`, `kibi telemetry disable`, CI and test
17
- * runs turn it off.
17
+ * runs, and sandboxes whose home directory is a temp directory (eval and
18
+ * packaging harnesses) turn it off.
18
19
  */
19
20
  /** Public, write-only PostHog project key: it can send events, not read them. */
20
21
  export declare const DEFAULT_TELEMETRY_KEY = "phc_A5z8jRhD528abGJiTtPfgu4d4hpbEbfiJr9iUGn9TMsY";
@@ -62,11 +63,28 @@ export type TelemetrySource = {
62
63
  host?: string | undefined;
63
64
  /** Host session id; only its hash is sent. */
64
65
  sessionId?: string | null | undefined;
66
+ /**
67
+ * Workspace the CLI or MCP server works in. Without a session id of its
68
+ * own, the event takes the session a host hook last reported there.
69
+ */
70
+ workspaceRoot?: string | undefined;
65
71
  };
66
72
  /** Variables CI providers set; any non-false value marks a CI run. */
67
73
  export declare const CI_ENV_KEYS: readonly ["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI", "TRAVIS", "TF_BUILD", "JENKINS_URL", "TEAMCITY_VERSION", "BITBUCKET_BUILD_NUMBER", "CODEBUILD_BUILD_ID", "DRONE", "APPVEYOR", "SEMAPHORE", "BUILDKITE_BUILD_ID", "CF_BUILD_ID", "HEROKU_TEST_RUN_ID"];
68
74
  /** Variables test runners set for the code under test (`NAME=value` must match). */
69
75
  export declare const TEST_ENV_KEYS: readonly ["NODE_ENV=test", "JEST_WORKER_ID", "VITEST", "VITEST_WORKER_ID", "NODE_TEST_CONTEXT"];
76
+ /** Variables an agent host sets for the processes it starts, in priority order. */
77
+ export declare const HOST_ENV_KEYS: readonly [readonly ["CLAUDECODE=1", "claude-code"], readonly ["CURSOR_AGENT=1", "cursor"], readonly ["CODEX_SANDBOX", "codex"], readonly ["CODEX_THREAD_ID", "codex"], readonly ["GEMINI_CLI=1", "gemini-cli"], readonly ["OPENCODE=1", "opencode"]];
78
+ /** Variables that carry the host agent's session id; only its hash is sent. */
79
+ export declare const SESSION_ENV_KEYS: readonly ["KIBI_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"];
80
+ /**
81
+ * The agent host running Kibi, from an explicit `KIBI_HOST`/`KIBI_MCP_HOST`,
82
+ * the variables hosts set for their children, or the `AI_AGENT` convention
83
+ * (`claude-code_2-1-3_agent`). Undefined when nothing names one.
84
+ */
85
+ export declare function detectTelemetryHost(env?: Env): string | undefined;
86
+ /** The host agent's session id from the environment, when it exposes one. */
87
+ export declare function detectTelemetrySession(env?: Env): string | undefined;
70
88
  /** Per-user directory holding the telemetry config and queue. */
71
89
  export declare function telemetryDirectory(env?: Env): string;
72
90
  export declare function readTelemetryConfig(env?: Env): TelemetryConfig;
@@ -89,6 +107,24 @@ export declare function hashTelemetryId(value: string): string;
89
107
  * shown. Callers that can reach the user (CLI and MCP stderr) print it.
90
108
  */
91
109
  export declare function takeTelemetryNotice(env?: Env): string | null;
110
+ /**
111
+ * Remember which host session last ran a hook in a workspace. Every host
112
+ * hands its hooks the session id, but only some expose it to the CLI and MCP
113
+ * server through the environment. Only hashes are stored: of the workspace
114
+ * path and of the session id.
115
+ */
116
+ export declare function rememberHookSession(workspaceRoot: string, sessionId: string, env?: Env): void;
117
+ /**
118
+ * Hashed session a host hook recently reported for this directory or the
119
+ * nearest ancestor that has one, or undefined.
120
+ */
121
+ export declare function workspaceSessionHash(startDirectory: string, env?: Env): string | undefined;
122
+ /**
123
+ * An error category as sent: fixed identifiers such as `tool_timeout` or
124
+ * `UPSERT_DRY_RUN_INVALID` pass, lower-cased; anything that looks like free
125
+ * text is replaced, so a message can never leak through this field.
126
+ */
127
+ export declare function telemetryCategory(value: string | undefined): string | null;
92
128
  export declare function recordOperationTelemetry(source: TelemetrySource, operation: OperationTelemetry, env?: Env): void;
93
129
  export declare function recordHookTelemetry(source: TelemetrySource, hook: HookTelemetry, env?: Env): void;
94
130
  export declare function clearTelemetryQueue(env?: Env): void;
@@ -19,7 +19,8 @@ import path from "node:path";
19
19
  * Events are appended to a per-user queue file and sent in batches, so
20
20
  * short-lived processes (hooks, the CLI) do not wait on the network.
21
21
  * `KIBI_TELEMETRY=0`, `DO_NOT_TRACK=1`, `kibi telemetry disable`, CI and test
22
- * runs turn it off.
22
+ * runs, and sandboxes whose home directory is a temp directory (eval and
23
+ * packaging harnesses) turn it off.
23
24
  */
24
25
  // implements REQ-kibi-anonymous-usage-telemetry
25
26
  /** Public, write-only PostHog project key: it can send events, not read them. */
@@ -29,6 +30,10 @@ export const DEFAULT_TELEMETRY_HOST = "https://eu.i.posthog.com";
29
30
  // implements REQ-kibi-anonymous-usage-telemetry
30
31
  export const TELEMETRY_DOCS_URL = "https://looted.github.io/kibi/guide/telemetry.html";
31
32
  const QUEUE_FILE = "telemetry-queue.jsonl";
33
+ const SESSIONS_FILE = "telemetry-sessions.json";
34
+ /** A hook-recorded session older than this no longer names the workspace's session. */
35
+ const SESSION_MARKER_MAX_AGE_MS = 30 * 60 * 1000;
36
+ const MAX_SESSION_MARKERS = 200;
32
37
  const CONFIG_FILE = "telemetry.json";
33
38
  const SENDING_SUFFIX = ".sending";
34
39
  /** Queue size that makes a flush due regardless of age. */
@@ -100,6 +105,85 @@ function automationName(env, keys) {
100
105
  return undefined;
101
106
  }
102
107
  // implements REQ-kibi-anonymous-usage-telemetry
108
+ /** Variables an agent host sets for the processes it starts, in priority order. */
109
+ export const HOST_ENV_KEYS = [
110
+ ["CLAUDECODE=1", "claude-code"],
111
+ ["CURSOR_AGENT=1", "cursor"],
112
+ ["CODEX_SANDBOX", "codex"],
113
+ ["CODEX_THREAD_ID", "codex"],
114
+ ["GEMINI_CLI=1", "gemini-cli"],
115
+ ["OPENCODE=1", "opencode"],
116
+ ];
117
+ // implements REQ-kibi-anonymous-usage-telemetry
118
+ /** Variables that carry the host agent's session id; only its hash is sent. */
119
+ export const SESSION_ENV_KEYS = [
120
+ "KIBI_SESSION_ID",
121
+ "CLAUDE_CODE_SESSION_ID",
122
+ "CODEX_THREAD_ID",
123
+ ];
124
+ /** `Claude Code`, `claude_code` and `claude-code` name the same host. */
125
+ function hostKey(name) {
126
+ return name
127
+ .trim()
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, "-")
130
+ .replace(/^-|-$/g, "");
131
+ }
132
+ // implements REQ-kibi-anonymous-usage-telemetry
133
+ /**
134
+ * The agent host running Kibi, from an explicit `KIBI_HOST`/`KIBI_MCP_HOST`,
135
+ * the variables hosts set for their children, or the `AI_AGENT` convention
136
+ * (`claude-code_2-1-3_agent`). Undefined when nothing names one.
137
+ */
138
+ export function detectTelemetryHost(env = process.env) {
139
+ const explicit = env.KIBI_HOST?.trim() || env.KIBI_MCP_HOST?.trim();
140
+ if (explicit)
141
+ return hostKey(explicit) || undefined;
142
+ for (const [key, host] of HOST_ENV_KEYS) {
143
+ if (automationName(env, [key]))
144
+ return host;
145
+ }
146
+ const agent = env.AI_AGENT?.trim().split("_")[0];
147
+ return agent ? hostKey(agent) || undefined : undefined;
148
+ }
149
+ // implements REQ-kibi-anonymous-usage-telemetry
150
+ /** The host agent's session id from the environment, when it exposes one. */
151
+ export function detectTelemetrySession(env = process.env) {
152
+ for (const key of SESSION_ENV_KEYS) {
153
+ const value = env[key]?.trim();
154
+ if (value)
155
+ return value;
156
+ }
157
+ return undefined;
158
+ }
159
+ /** Whether the process runs in a hosted cloud workspace rather than on a laptop. */
160
+ function remoteEnvironment(env) {
161
+ return (flagValue(env.CLAUDE_CODE_REMOTE) === true ||
162
+ flagValue(env.CODESPACES) === true ||
163
+ Boolean(env.GITPOD_WORKSPACE_ID?.trim()));
164
+ }
165
+ function realDirectory(directory) {
166
+ try {
167
+ return fs.realpathSync(directory);
168
+ }
169
+ catch {
170
+ return path.resolve(directory);
171
+ }
172
+ }
173
+ /**
174
+ * Eval, packaging and e2e harnesses give each run a fresh home under the temp
175
+ * directory, so every run would count as a new install. A real user's home
176
+ * never lives there.
177
+ */
178
+ function sandboxedHome(env) {
179
+ const home = (env.HOME ?? env.USERPROFILE)?.trim();
180
+ if (!home)
181
+ return false;
182
+ const temp = realDirectory(os.tmpdir());
183
+ const resolved = realDirectory(home);
184
+ return resolved !== temp && resolved.startsWith(`${temp}${path.sep}`);
185
+ }
186
+ // implements REQ-kibi-anonymous-usage-telemetry
103
187
  /** Per-user directory holding the telemetry config and queue. */
104
188
  export function telemetryDirectory(env = process.env) {
105
189
  const explicit = env.KIBI_TELEMETRY_DIR?.trim();
@@ -176,6 +260,9 @@ export function telemetryStatus(env = process.env) {
176
260
  automationName(processEnv, TEST_ENV_KEYS);
177
261
  if (testName)
178
262
  return status(false, `test environment (${testName})`);
263
+ if (sandboxedHome(env)) {
264
+ return status(false, "sandboxed home in the temp directory (harness run)");
265
+ }
179
266
  if (preference === "enabled") {
180
267
  return status(true, "enabled with `kibi telemetry enable`");
181
268
  }
@@ -244,8 +331,89 @@ function runtimeName() {
244
331
  ? "node"
245
332
  : "bun";
246
333
  }
334
+ function sessionMarkersPath(env) {
335
+ return path.join(telemetryDirectory(env), SESSIONS_FILE);
336
+ }
337
+ function readSessionMarkers(env) {
338
+ try {
339
+ const parsed = JSON.parse(fs.readFileSync(sessionMarkersPath(env), "utf8"));
340
+ return parsed !== null &&
341
+ typeof parsed === "object" &&
342
+ !Array.isArray(parsed)
343
+ ? parsed
344
+ : {};
345
+ }
346
+ catch {
347
+ return {};
348
+ }
349
+ }
350
+ function workspaceKey(workspaceRoot) {
351
+ return hashTelemetryId(`workspace\0${path.resolve(workspaceRoot)}`);
352
+ }
353
+ // implements REQ-kibi-anonymous-usage-telemetry
354
+ /**
355
+ * Remember which host session last ran a hook in a workspace. Every host
356
+ * hands its hooks the session id, but only some expose it to the CLI and MCP
357
+ * server through the environment. Only hashes are stored: of the workspace
358
+ * path and of the session id.
359
+ */
360
+ export function rememberHookSession(workspaceRoot, sessionId, env = process.env) {
361
+ if (!sessionId.trim() || !telemetryEnabled(env))
362
+ return;
363
+ const now = Date.now();
364
+ const markers = readSessionMarkers(env);
365
+ markers[workspaceKey(workspaceRoot)] = {
366
+ session: hashTelemetryId(sessionId.trim()),
367
+ at: now,
368
+ };
369
+ const kept = Object.entries(markers)
370
+ .filter(([, marker]) => typeof marker?.at === "number" &&
371
+ now - marker.at <= SESSION_MARKER_MAX_AGE_MS)
372
+ .sort(([, left], [, right]) => right.at - left.at)
373
+ .slice(0, MAX_SESSION_MARKERS);
374
+ try {
375
+ const file = sessionMarkersPath(env);
376
+ fs.mkdirSync(path.dirname(file), { recursive: true });
377
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
378
+ fs.writeFileSync(temporary, JSON.stringify(Object.fromEntries(kept)));
379
+ fs.renameSync(temporary, file);
380
+ }
381
+ catch {
382
+ // Telemetry is best effort and must never disturb the hook.
383
+ }
384
+ }
385
+ // implements REQ-kibi-anonymous-usage-telemetry
386
+ /**
387
+ * Hashed session a host hook recently reported for this directory or the
388
+ * nearest ancestor that has one, or undefined.
389
+ */
390
+ export function workspaceSessionHash(startDirectory, env = process.env) {
391
+ const markers = readSessionMarkers(env);
392
+ const now = Date.now();
393
+ let current = path.resolve(startDirectory);
394
+ for (;;) {
395
+ const marker = markers[workspaceKey(current)];
396
+ if (marker &&
397
+ typeof marker.session === "string" &&
398
+ typeof marker.at === "number" &&
399
+ now - marker.at <= SESSION_MARKER_MAX_AGE_MS) {
400
+ return marker.session;
401
+ }
402
+ const parent = path.dirname(current);
403
+ if (parent === current)
404
+ return undefined;
405
+ current = parent;
406
+ }
407
+ }
408
+ function sessionProperty(source, env) {
409
+ const sessionId = source.sessionId?.trim() || detectTelemetrySession(env);
410
+ if (sessionId)
411
+ return hashTelemetryId(sessionId);
412
+ return source.workspaceRoot
413
+ ? (workspaceSessionHash(source.workspaceRoot, env) ?? null)
414
+ : null;
415
+ }
247
416
  function baseProperties(source, env) {
248
- const sessionId = source.sessionId?.trim();
249
417
  return {
250
418
  distinct_id: installId(env),
251
419
  $process_person_profile: false,
@@ -253,8 +421,11 @@ function baseProperties(source, env) {
253
421
  $lib: "kibi",
254
422
  package: source.packageName,
255
423
  package_version: source.packageVersion ?? null,
256
- host: source.host?.trim() || "unknown",
257
- session: sessionId ? hashTelemetryId(sessionId) : null,
424
+ host: (source.host?.trim() && hostKey(source.host)) ||
425
+ detectTelemetryHost(env) ||
426
+ "unknown",
427
+ remote: remoteEnvironment(env),
428
+ session: sessionProperty(source, env),
258
429
  os: process.platform,
259
430
  arch: process.arch,
260
431
  runtime: runtimeName(),
@@ -282,6 +453,20 @@ function appendEvent(event, env) {
282
453
  }
283
454
  }
284
455
  // implements REQ-kibi-anonymous-usage-telemetry
456
+ /**
457
+ * An error category as sent: fixed identifiers such as `tool_timeout` or
458
+ * `UPSERT_DRY_RUN_INVALID` pass, lower-cased; anything that looks like free
459
+ * text is replaced, so a message can never leak through this field.
460
+ */
461
+ export function telemetryCategory(value) {
462
+ const trimmed = value?.trim();
463
+ if (!trimmed)
464
+ return null;
465
+ return /^[A-Za-z0-9_.:+-]{1,160}$/.test(trimmed)
466
+ ? trimmed.toLowerCase()
467
+ : "unclassified";
468
+ }
469
+ // implements REQ-kibi-anonymous-usage-telemetry
285
470
  export function recordOperationTelemetry(source, operation, env = process.env) {
286
471
  if (!telemetryEnabled(env))
287
472
  return;
@@ -294,7 +479,7 @@ export function recordOperationTelemetry(source, operation, env = process.env) {
294
479
  operation: operation.operation,
295
480
  status: operation.status,
296
481
  duration_ms: Math.max(0, Math.round(operation.durationMs)),
297
- error_category: operation.errorCategory ?? null,
482
+ error_category: telemetryCategory(operation.errorCategory),
298
483
  exit_code: operation.exitCode ?? null,
299
484
  },
300
485
  }, env);
@@ -328,7 +513,9 @@ export function clearTelemetryQueue(env = process.env) {
328
513
  return;
329
514
  }
330
515
  for (const name of names) {
331
- if (name === QUEUE_FILE || name.endsWith(SENDING_SUFFIX)) {
516
+ if (name === QUEUE_FILE ||
517
+ name === SESSIONS_FILE ||
518
+ name.endsWith(SENDING_SUFFIX)) {
332
519
  fs.rmSync(path.join(directory, name), { force: true });
333
520
  }
334
521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-agent-core",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Shared low-latency hook helpers for Kibi agent adapters",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",