kibi-agent-core 0.10.0 → 0.11.1
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 +24 -0
- package/dist/decision-guard.js +1 -1
- package/dist/hook-usage-log.d.ts +22 -1
- package/dist/hook-usage-log.js +54 -3
- package/dist/knowledge-index.d.ts +15 -0
- package/dist/knowledge-index.js +103 -1
- package/dist/snippets.d.ts +14 -4
- package/dist/snippets.js +122 -11
- package/dist/usage-telemetry.d.ts +61 -1
- package/dist/usage-telemetry.js +274 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# kibi-agent-core
|
|
2
2
|
|
|
3
|
+
## 0.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- The telemetry status carries the channel in effect and where it comes from (`channel`, `channelSource`), and `telemetryChannel` returns both.
|
|
8
|
+
|
|
9
|
+
- Edit snippets say what the lead requirement must keep true as the claims its facts model, in words with their operator and members (for example "REQ-GREET must keep true: The greet function must not call console.log."), or as the requirement's own statement, instead of listing internal fact IDs and subject keys. A requirement title that an older Kibi cut at a dot inside a name is shown whole.
|
|
10
|
+
|
|
11
|
+
- f9ec786: Usage telemetry events now carry a `channel`: `release` for an installed Kibi package, `source` when Kibi runs from a checkout of its own repository, or the value of `KIBI_TELEMETRY_CHANNEL` when a harness sets one (for example `agent-test`). Development and test runs can then be filtered out of real usage. Nothing else about what is sent changes.
|
|
12
|
+
|
|
13
|
+
## 0.11.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
- 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.
|
|
25
|
+
- 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`.
|
|
26
|
+
|
|
3
27
|
## 0.10.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/dist/decision-guard.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/hook-usage-log.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/hook-usage-log.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 (
|
|
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 (!
|
|
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
|
|
@@ -82,6 +82,21 @@ export type EntitySummary = {
|
|
|
82
82
|
* with canonical relationship-shard records whose `from` is this entity.
|
|
83
83
|
*/
|
|
84
84
|
links?: EntityLink[];
|
|
85
|
+
/** Requirement prose: `semantic_text`, else the body's first paragraph. */
|
|
86
|
+
statement?: string;
|
|
87
|
+
/** The claim a fact models, from its typed front matter fields. */
|
|
88
|
+
claim?: FactClaim;
|
|
89
|
+
};
|
|
90
|
+
/** A fact's typed fields, as authored; absent fields are left out. */
|
|
91
|
+
export type FactClaim = {
|
|
92
|
+
factKind?: string;
|
|
93
|
+
subjectKey?: string;
|
|
94
|
+
propertyKey?: string;
|
|
95
|
+
operator?: string;
|
|
96
|
+
/** `value_set` members, or the one scalar value. */
|
|
97
|
+
values?: string[];
|
|
98
|
+
unit?: string;
|
|
99
|
+
claimText?: string;
|
|
85
100
|
};
|
|
86
101
|
/**
|
|
87
102
|
* Read an entity's title, status and outgoing links. Title and status come
|
package/dist/knowledge-index.js
CHANGED
|
@@ -636,6 +636,62 @@ const ENTITY_LANES = [
|
|
|
636
636
|
["EVT-", "events"],
|
|
637
637
|
];
|
|
638
638
|
const SAFE_ENTITY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
639
|
+
/** Front matter scalars a summary keeps, by field name. */
|
|
640
|
+
const SUMMARY_SCALARS = {
|
|
641
|
+
semantic_text: "statement",
|
|
642
|
+
fact_kind: "factKind",
|
|
643
|
+
subject_key: "subjectKey",
|
|
644
|
+
property_key: "propertyKey",
|
|
645
|
+
operator: "operator",
|
|
646
|
+
unit: "unit",
|
|
647
|
+
claim_text: "claimText",
|
|
648
|
+
};
|
|
649
|
+
const SCALAR_VALUE_FIELDS = new Set([
|
|
650
|
+
"value_string",
|
|
651
|
+
"value_int",
|
|
652
|
+
"value_number",
|
|
653
|
+
"value_bool",
|
|
654
|
+
]);
|
|
655
|
+
/**
|
|
656
|
+
* A front matter scalar starting at `lines[index]` with `raw` after its key:
|
|
657
|
+
* a plain or quoted value with its indented continuation lines, or a block
|
|
658
|
+
* scalar (`>-`, `|`). Returns the value and the last line it used.
|
|
659
|
+
*/
|
|
660
|
+
function readScalar(lines, index, raw) {
|
|
661
|
+
const block = /^[>|][+-]?$/.test(raw.trim());
|
|
662
|
+
const parts = block ? [] : [raw.trim()];
|
|
663
|
+
let last = index;
|
|
664
|
+
while (last + 1 < lines.length) {
|
|
665
|
+
const next = lines[last + 1] ?? "";
|
|
666
|
+
if (next.trim() === "---" || !/^\s+\S/.test(next))
|
|
667
|
+
break;
|
|
668
|
+
if (!block && /^\s+-\s/.test(next))
|
|
669
|
+
break;
|
|
670
|
+
parts.push(next.trim());
|
|
671
|
+
last += 1;
|
|
672
|
+
}
|
|
673
|
+
const joined = parts.join(" ").trim();
|
|
674
|
+
return { value: block ? joined : unquote(joined), last };
|
|
675
|
+
}
|
|
676
|
+
/** The first prose paragraph of a Markdown body, headings skipped. */
|
|
677
|
+
function firstParagraph(lines) {
|
|
678
|
+
const paragraph = [];
|
|
679
|
+
for (const line of lines) {
|
|
680
|
+
const text = line.trim();
|
|
681
|
+
if (text === "") {
|
|
682
|
+
if (paragraph.length > 0)
|
|
683
|
+
break;
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
if (/^#{1,6}\s/.test(text)) {
|
|
687
|
+
if (paragraph.length > 0)
|
|
688
|
+
break;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
paragraph.push(text);
|
|
692
|
+
}
|
|
693
|
+
return paragraph.length > 0 ? paragraph.join(" ") : undefined;
|
|
694
|
+
}
|
|
639
695
|
const MAX_SUMMARY_LINKS = 24;
|
|
640
696
|
const RELATIONSHIPS_DIR = ".kb/relationships";
|
|
641
697
|
/**
|
|
@@ -713,16 +769,53 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
713
769
|
return { id: entityId };
|
|
714
770
|
const summary = { id: entityId };
|
|
715
771
|
const authored = [];
|
|
772
|
+
const claim = {};
|
|
773
|
+
const values = [];
|
|
716
774
|
let inLinks = false;
|
|
775
|
+
let inValueSet = false;
|
|
717
776
|
let inOrigin = false;
|
|
718
777
|
let originKind;
|
|
719
778
|
let closed = false;
|
|
779
|
+
let bodyStart = lines.length;
|
|
720
780
|
let pending;
|
|
721
|
-
for (
|
|
781
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
782
|
+
const line = lines[index] ?? "";
|
|
722
783
|
if (line.trim() === "---") {
|
|
723
784
|
closed = true;
|
|
785
|
+
bodyStart = index + 1;
|
|
724
786
|
break;
|
|
725
787
|
}
|
|
788
|
+
const scalar = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
789
|
+
const scalarField = scalar ? SUMMARY_SCALARS[scalar[1] ?? ""] : undefined;
|
|
790
|
+
if (scalar && scalarField && scalar[2]) {
|
|
791
|
+
const { value, last } = readScalar(lines, index, scalar[2]);
|
|
792
|
+
if (value !== "") {
|
|
793
|
+
if (scalarField === "statement")
|
|
794
|
+
summary.statement = value;
|
|
795
|
+
else if (scalarField !== "values")
|
|
796
|
+
claim[scalarField] = value;
|
|
797
|
+
}
|
|
798
|
+
index = last;
|
|
799
|
+
flushLink(authored, pending);
|
|
800
|
+
pending = undefined;
|
|
801
|
+
inLinks = false;
|
|
802
|
+
inValueSet = false;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (scalar && SCALAR_VALUE_FIELDS.has(scalar[1] ?? "") && scalar[2]) {
|
|
806
|
+
values.push(unquote(scalar[2]));
|
|
807
|
+
}
|
|
808
|
+
const flowSet = /^value_set:\s*\[(.*)\]\s*$/.exec(line);
|
|
809
|
+
if (flowSet?.[1]?.trim()) {
|
|
810
|
+
values.push(...flowSet[1].split(",").map((member) => unquote(member)));
|
|
811
|
+
}
|
|
812
|
+
if (/^\S/.test(line))
|
|
813
|
+
inValueSet = /^value_set:\s*$/.test(line);
|
|
814
|
+
else if (inValueSet) {
|
|
815
|
+
const member = /^\s*-\s*(.+)$/.exec(line);
|
|
816
|
+
if (member)
|
|
817
|
+
values.push(unquote(member[1] ?? ""));
|
|
818
|
+
}
|
|
726
819
|
// implements REQ-kb-decision-firmness-from-origin
|
|
727
820
|
const flowOrigin = /^origin:\s*\{.*\bkind:\s*["']?([a-z]+)/.exec(line);
|
|
728
821
|
if (flowOrigin)
|
|
@@ -791,5 +884,14 @@ export function readEntitySummary(workspaceRoot, entityId) {
|
|
|
791
884
|
}
|
|
792
885
|
if (links.length > 0)
|
|
793
886
|
summary.links = links;
|
|
887
|
+
if (values.length > 0)
|
|
888
|
+
claim.values = values;
|
|
889
|
+
if (Object.keys(claim).length > 0)
|
|
890
|
+
summary.claim = claim;
|
|
891
|
+
if (summary.statement === undefined && lane[1] === "requirements" && closed) {
|
|
892
|
+
const paragraph = firstParagraph(lines.slice(bodyStart));
|
|
893
|
+
if (paragraph)
|
|
894
|
+
summary.statement = paragraph;
|
|
895
|
+
}
|
|
794
896
|
return summary;
|
|
795
897
|
}
|
package/dist/snippets.d.ts
CHANGED
|
@@ -44,10 +44,20 @@ export declare function unreadableManifestNote(relativePath: string): string;
|
|
|
44
44
|
/** Link types through which a requirement is grounded in semantic facts. */
|
|
45
45
|
export declare const GROUNDING_LINK_TYPES: readonly string[];
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* A property fact as a sentence naming its operator and members ("The greet
|
|
48
|
+
* function must not call console.log"); a predicate or rule fact as the
|
|
49
|
+
* claim text it models. Undefined for a subject fact and for a fact without
|
|
50
|
+
* readable fields.
|
|
51
|
+
*/
|
|
52
|
+
export declare function factClaimSentence(summary: EntitySummary): string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* What a requirement says must stay true and the decision behind it (a
|
|
55
|
+
* linked ADR), read from its frontmatter links and relationship-shard
|
|
56
|
+
* records. Each linked fact is shown as the claim it models, with its
|
|
57
|
+
* operator and members (a fact without typed fields by its title); without
|
|
58
|
+
* one the requirement's own statement is shown. Fact IDs are internal and
|
|
59
|
+
* never shown. A retired requirement is not current policy, so its grounding
|
|
60
|
+
* is not presented as something to keep true.
|
|
51
61
|
*/
|
|
52
62
|
export declare function requirementGroundingLines(requirementId: string, summarize: Summarize, options?: {
|
|
53
63
|
maxFacts?: number;
|
package/dist/snippets.js
CHANGED
|
@@ -18,6 +18,7 @@ const MAX_SYMBOLS_PER_REQUIREMENT = 3;
|
|
|
18
18
|
const MAX_TESTS = 3;
|
|
19
19
|
const MAX_TITLE = 80;
|
|
20
20
|
const MAX_GROUNDING = 2;
|
|
21
|
+
const MAX_CLAIM = 160;
|
|
21
22
|
/** Files larger than this are not scanned to locate an edit. */
|
|
22
23
|
const MAX_FOCUS_SCAN_BYTES = 2 * 1024 * 1024;
|
|
23
24
|
// implements REQ-claude-code-kibi-plugin-v1, REQ-cursor-kibi-plugin-v1
|
|
@@ -118,10 +119,25 @@ export function describeEntity(summary) {
|
|
|
118
119
|
: " [exploratory]"
|
|
119
120
|
: "";
|
|
120
121
|
const notable = `${retired}${firmness}`;
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
const title = displayTitle(summary);
|
|
123
|
+
return title
|
|
124
|
+
? `${summary.id}${notable}: ${truncate(title, MAX_TITLE)}`
|
|
123
125
|
: `${summary.id}${notable}`;
|
|
124
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The title, completed from the statement when an older title was cut at a
|
|
129
|
+
* dot inside a name ("...call console" for "...call console.log").
|
|
130
|
+
*/
|
|
131
|
+
function displayTitle(summary) {
|
|
132
|
+
const { title, statement } = summary;
|
|
133
|
+
if (!title || !statement?.startsWith(title))
|
|
134
|
+
return title;
|
|
135
|
+
const rest = statement.slice(title.length);
|
|
136
|
+
if (!/^\.\S/.test(rest))
|
|
137
|
+
return title;
|
|
138
|
+
const end = rest.search(/[.!?](?:\s|$)/);
|
|
139
|
+
return end < 0 ? statement : statement.slice(0, title.length + end);
|
|
140
|
+
}
|
|
125
141
|
function jsonString(value) {
|
|
126
142
|
return JSON.stringify(value);
|
|
127
143
|
}
|
|
@@ -178,12 +194,94 @@ export const GROUNDING_LINK_TYPES = [
|
|
|
178
194
|
"requires_predicate",
|
|
179
195
|
"requires_rule",
|
|
180
196
|
];
|
|
197
|
+
/** Set-valued properties whose claim reads as a verb on the members. */
|
|
198
|
+
const PROPERTY_VERBS = {
|
|
199
|
+
calls: "call",
|
|
200
|
+
imports: "import",
|
|
201
|
+
reads: "read",
|
|
202
|
+
uses: "use",
|
|
203
|
+
throws: "throw",
|
|
204
|
+
};
|
|
205
|
+
/** How a scalar or set property is constrained, by fact operator. */
|
|
206
|
+
const OPERATOR_PHRASES = {
|
|
207
|
+
eq: "must be",
|
|
208
|
+
neq: "must not be",
|
|
209
|
+
lt: "must be less than",
|
|
210
|
+
lte: "must be at most",
|
|
211
|
+
gt: "must be greater than",
|
|
212
|
+
gte: "must be at least",
|
|
213
|
+
matches: "must match",
|
|
214
|
+
includes: "must include",
|
|
215
|
+
subset_of: "must be within",
|
|
216
|
+
excludes: "must not include",
|
|
217
|
+
};
|
|
218
|
+
function joinMembers(values, conjunction) {
|
|
219
|
+
if (values.length <= 1)
|
|
220
|
+
return values[0] ?? "";
|
|
221
|
+
return `${values.slice(0, -1).join(", ")} ${conjunction} ${values.at(-1)}`;
|
|
222
|
+
}
|
|
223
|
+
const MODAL = /^(.{1,100}?)\s+(?:must|may|can|cannot|should|shall|will|never)\b/i;
|
|
224
|
+
/** The claim's own subject words, before its modal verb, else the key's. */
|
|
225
|
+
function subjectPhrase(claim) {
|
|
226
|
+
const spoken = MODAL.exec(claim.claimText ?? "")?.[1];
|
|
227
|
+
if (spoken)
|
|
228
|
+
return spoken;
|
|
229
|
+
return claim.subjectKey?.replace(/[._]+/g, " ").trim() || undefined;
|
|
230
|
+
}
|
|
231
|
+
// implements REQ-agent-core-edit-snippets
|
|
232
|
+
/**
|
|
233
|
+
* A property fact as a sentence naming its operator and members ("The greet
|
|
234
|
+
* function must not call console.log"); a predicate or rule fact as the
|
|
235
|
+
* claim text it models. Undefined for a subject fact and for a fact without
|
|
236
|
+
* readable fields.
|
|
237
|
+
*/
|
|
238
|
+
export function factClaimSentence(summary) {
|
|
239
|
+
const claim = summary.claim;
|
|
240
|
+
if (!claim || claim.factKind === "subject")
|
|
241
|
+
return undefined;
|
|
242
|
+
const values = claim.values ?? [];
|
|
243
|
+
if (claim.propertyKey && claim.operator && values.length > 0) {
|
|
244
|
+
const subject = subjectPhrase(claim);
|
|
245
|
+
if (!subject)
|
|
246
|
+
return claim.claimText;
|
|
247
|
+
const verb = PROPERTY_VERBS[claim.propertyKey];
|
|
248
|
+
const verbal = verb
|
|
249
|
+
? {
|
|
250
|
+
excludes: `must not ${verb} ${joinMembers(values, "or")}`,
|
|
251
|
+
includes: `must ${verb} ${joinMembers(values, "and")}`,
|
|
252
|
+
subset_of: `may ${verb} only ${joinMembers(values, "or")}`,
|
|
253
|
+
eq: `must ${verb} exactly ${joinMembers(values, "and")}`,
|
|
254
|
+
}[claim.operator]
|
|
255
|
+
: undefined;
|
|
256
|
+
if (verbal)
|
|
257
|
+
return `${subject} ${verbal}`;
|
|
258
|
+
const phrase = OPERATOR_PHRASES[claim.operator];
|
|
259
|
+
if (phrase) {
|
|
260
|
+
const property = claim.propertyKey.replace(/_+/g, " ");
|
|
261
|
+
const unit = claim.unit ? ` ${claim.unit}` : "";
|
|
262
|
+
return `${subject}: ${property} ${phrase} ${values.join(", ")}${unit}`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return claim.claimText;
|
|
266
|
+
}
|
|
267
|
+
/** A fact's claim sentence, else its authored title; a subject fact adds nothing. */
|
|
268
|
+
function groundingText(summary) {
|
|
269
|
+
if (summary.claim?.factKind === "subject")
|
|
270
|
+
return undefined;
|
|
271
|
+
return factClaimSentence(summary) ?? summary.title;
|
|
272
|
+
}
|
|
273
|
+
function withoutClosingStop(text) {
|
|
274
|
+
return text.replace(/[.\s]+$/, "");
|
|
275
|
+
}
|
|
181
276
|
// implements REQ-agent-core-edit-snippets, REQ-claude-code-kibi-plugin-v1, REQ-opencode-kibi-plugin-v1
|
|
182
277
|
/**
|
|
183
|
-
* What a requirement says must stay true
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
278
|
+
* What a requirement says must stay true and the decision behind it (a
|
|
279
|
+
* linked ADR), read from its frontmatter links and relationship-shard
|
|
280
|
+
* records. Each linked fact is shown as the claim it models, with its
|
|
281
|
+
* operator and members (a fact without typed fields by its title); without
|
|
282
|
+
* one the requirement's own statement is shown. Fact IDs are internal and
|
|
283
|
+
* never shown. A retired requirement is not current policy, so its grounding
|
|
284
|
+
* is not presented as something to keep true.
|
|
187
285
|
*/
|
|
188
286
|
export function requirementGroundingLines(requirementId, summarize, options = {}) {
|
|
189
287
|
const maxFacts = options.maxFacts ?? MAX_GROUNDING;
|
|
@@ -203,11 +301,24 @@ export function requirementGroundingLines(requirementId, summarize, options = {}
|
|
|
203
301
|
];
|
|
204
302
|
const lines = [];
|
|
205
303
|
if (facts.length > 0) {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
304
|
+
const claims = [
|
|
305
|
+
...new Set(facts
|
|
306
|
+
.map((id) => groundingText(summarize(id)))
|
|
307
|
+
.filter((sentence) => Boolean(sentence))
|
|
308
|
+
.map(withoutClosingStop)),
|
|
309
|
+
];
|
|
310
|
+
const statement = requirement.statement
|
|
311
|
+
? withoutClosingStop(requirement.statement)
|
|
312
|
+
: undefined;
|
|
313
|
+
const shown = claims.length > 0
|
|
314
|
+
? claims.slice(0, maxFacts)
|
|
315
|
+
: statement
|
|
316
|
+
? [statement]
|
|
317
|
+
: [];
|
|
318
|
+
const more = claims.length > maxFacts ? ` +${claims.length - maxFacts} more` : "";
|
|
319
|
+
if (shown.length > 0) {
|
|
320
|
+
lines.push(`${requirementId} must keep true: ${shown.map((claim) => truncate(claim, MAX_CLAIM)).join("; ")}${more}.`);
|
|
321
|
+
}
|
|
211
322
|
}
|
|
212
323
|
const adr = adrs[0];
|
|
213
324
|
if (adr)
|
|
@@ -14,7 +14,11 @@
|
|
|
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
|
|
17
|
+
* runs, and sandboxes whose home directory is a temp directory (eval and
|
|
18
|
+
* packaging harnesses) turn it off. Runs of Kibi from a checkout of its own
|
|
19
|
+
* repository, and runs a harness marks with `KIBI_TELEMETRY_CHANNEL`, are
|
|
20
|
+
* still sent but carry a `channel` other than `release`, so they can be
|
|
21
|
+
* filtered out of real usage.
|
|
18
22
|
*/
|
|
19
23
|
/** Public, write-only PostHog project key: it can send events, not read them. */
|
|
20
24
|
export declare const DEFAULT_TELEMETRY_KEY = "phc_A5z8jRhD528abGJiTtPfgu4d4hpbEbfiJr9iUGn9TMsY";
|
|
@@ -32,7 +36,16 @@ export type TelemetryStatus = {
|
|
|
32
36
|
/** Why telemetry is on or off, e.g. `KIBI_TELEMETRY=0` or `default`. */
|
|
33
37
|
reason: string;
|
|
34
38
|
directory: string;
|
|
39
|
+
/** The channel events carry, e.g. `release`, `source` or `agent-test`. */
|
|
40
|
+
channel: string;
|
|
41
|
+
/** Where the channel comes from. */
|
|
42
|
+
channelSource: TelemetryChannelSource;
|
|
35
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* `KIBI_TELEMETRY_CHANNEL` (a harness set it), `checkout` (Kibi runs from a
|
|
46
|
+
* checkout of its own repository) or `installed` (an installed package).
|
|
47
|
+
*/
|
|
48
|
+
export type TelemetryChannelSource = "KIBI_TELEMETRY_CHANNEL" | "checkout" | "installed";
|
|
36
49
|
/** Summary of one Kibi operation run through MCP or the CLI. */
|
|
37
50
|
export type OperationTelemetry = {
|
|
38
51
|
interface: "mcp" | "cli";
|
|
@@ -62,11 +75,40 @@ export type TelemetrySource = {
|
|
|
62
75
|
host?: string | undefined;
|
|
63
76
|
/** Host session id; only its hash is sent. */
|
|
64
77
|
sessionId?: string | null | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Workspace the CLI or MCP server works in. Without a session id of its
|
|
80
|
+
* own, the event takes the session a host hook last reported there.
|
|
81
|
+
*/
|
|
82
|
+
workspaceRoot?: string | undefined;
|
|
65
83
|
};
|
|
66
84
|
/** Variables CI providers set; any non-false value marks a CI run. */
|
|
67
85
|
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
86
|
/** Variables test runners set for the code under test (`NAME=value` must match). */
|
|
69
87
|
export declare const TEST_ENV_KEYS: readonly ["NODE_ENV=test", "JEST_WORKER_ID", "VITEST", "VITEST_WORKER_ID", "NODE_TEST_CONTEXT"];
|
|
88
|
+
/** Variables an agent host sets for the processes it starts, in priority order. */
|
|
89
|
+
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"]];
|
|
90
|
+
/** Variables that carry the host agent's session id; only its hash is sent. */
|
|
91
|
+
export declare const SESSION_ENV_KEYS: readonly ["KIBI_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"];
|
|
92
|
+
/**
|
|
93
|
+
* The agent host running Kibi, from an explicit `KIBI_HOST`/`KIBI_MCP_HOST`,
|
|
94
|
+
* the variables hosts set for their children, or the `AI_AGENT` convention
|
|
95
|
+
* (`claude-code_2-1-3_agent`). Undefined when nothing names one.
|
|
96
|
+
*/
|
|
97
|
+
export declare function detectTelemetryHost(env?: Env): string | undefined;
|
|
98
|
+
/** The host agent's session id from the environment, when it exposes one. */
|
|
99
|
+
export declare function detectTelemetrySession(env?: Env): string | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Where a run comes from: the `KIBI_TELEMETRY_CHANNEL` a harness sets (for
|
|
102
|
+
* example `agent-test`), `source` when Kibi runs from a checkout of its own
|
|
103
|
+
* repository (local development, the repository's own hooks, e2e and agent
|
|
104
|
+
* test rounds built from source), and `release` for an installed package.
|
|
105
|
+
*/
|
|
106
|
+
export declare function detectTelemetryChannel(env?: Env, moduleFile?: string | null): string;
|
|
107
|
+
/** The channel in effect and where it comes from. */
|
|
108
|
+
export declare function telemetryChannel(env?: Env, moduleFile?: string | null): {
|
|
109
|
+
channel: string;
|
|
110
|
+
source: TelemetryChannelSource;
|
|
111
|
+
};
|
|
70
112
|
/** Per-user directory holding the telemetry config and queue. */
|
|
71
113
|
export declare function telemetryDirectory(env?: Env): string;
|
|
72
114
|
export declare function readTelemetryConfig(env?: Env): TelemetryConfig;
|
|
@@ -89,6 +131,24 @@ export declare function hashTelemetryId(value: string): string;
|
|
|
89
131
|
* shown. Callers that can reach the user (CLI and MCP stderr) print it.
|
|
90
132
|
*/
|
|
91
133
|
export declare function takeTelemetryNotice(env?: Env): string | null;
|
|
134
|
+
/**
|
|
135
|
+
* Remember which host session last ran a hook in a workspace. Every host
|
|
136
|
+
* hands its hooks the session id, but only some expose it to the CLI and MCP
|
|
137
|
+
* server through the environment. Only hashes are stored: of the workspace
|
|
138
|
+
* path and of the session id.
|
|
139
|
+
*/
|
|
140
|
+
export declare function rememberHookSession(workspaceRoot: string, sessionId: string, env?: Env): void;
|
|
141
|
+
/**
|
|
142
|
+
* Hashed session a host hook recently reported for this directory or the
|
|
143
|
+
* nearest ancestor that has one, or undefined.
|
|
144
|
+
*/
|
|
145
|
+
export declare function workspaceSessionHash(startDirectory: string, env?: Env): string | undefined;
|
|
146
|
+
/**
|
|
147
|
+
* An error category as sent: fixed identifiers such as `tool_timeout` or
|
|
148
|
+
* `UPSERT_DRY_RUN_INVALID` pass, lower-cased; anything that looks like free
|
|
149
|
+
* text is replaced, so a message can never leak through this field.
|
|
150
|
+
*/
|
|
151
|
+
export declare function telemetryCategory(value: string | undefined): string | null;
|
|
92
152
|
export declare function recordOperationTelemetry(source: TelemetrySource, operation: OperationTelemetry, env?: Env): void;
|
|
93
153
|
export declare function recordHookTelemetry(source: TelemetrySource, hook: HookTelemetry, env?: Env): void;
|
|
94
154
|
export declare function clearTelemetryQueue(env?: Env): void;
|
package/dist/usage-telemetry.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
6
7
|
/**
|
|
7
8
|
* Anonymous usage telemetry shared by the CLI, the MCP server and the host
|
|
8
9
|
* hooks.
|
|
@@ -19,7 +20,11 @@ import path from "node:path";
|
|
|
19
20
|
* Events are appended to a per-user queue file and sent in batches, so
|
|
20
21
|
* short-lived processes (hooks, the CLI) do not wait on the network.
|
|
21
22
|
* `KIBI_TELEMETRY=0`, `DO_NOT_TRACK=1`, `kibi telemetry disable`, CI and test
|
|
22
|
-
* runs
|
|
23
|
+
* runs, and sandboxes whose home directory is a temp directory (eval and
|
|
24
|
+
* packaging harnesses) turn it off. Runs of Kibi from a checkout of its own
|
|
25
|
+
* repository, and runs a harness marks with `KIBI_TELEMETRY_CHANNEL`, are
|
|
26
|
+
* still sent but carry a `channel` other than `release`, so they can be
|
|
27
|
+
* filtered out of real usage.
|
|
23
28
|
*/
|
|
24
29
|
// implements REQ-kibi-anonymous-usage-telemetry
|
|
25
30
|
/** Public, write-only PostHog project key: it can send events, not read them. */
|
|
@@ -29,6 +34,10 @@ export const DEFAULT_TELEMETRY_HOST = "https://eu.i.posthog.com";
|
|
|
29
34
|
// implements REQ-kibi-anonymous-usage-telemetry
|
|
30
35
|
export const TELEMETRY_DOCS_URL = "https://looted.github.io/kibi/guide/telemetry.html";
|
|
31
36
|
const QUEUE_FILE = "telemetry-queue.jsonl";
|
|
37
|
+
const SESSIONS_FILE = "telemetry-sessions.json";
|
|
38
|
+
/** A hook-recorded session older than this no longer names the workspace's session. */
|
|
39
|
+
const SESSION_MARKER_MAX_AGE_MS = 30 * 60 * 1000;
|
|
40
|
+
const MAX_SESSION_MARKERS = 200;
|
|
32
41
|
const CONFIG_FILE = "telemetry.json";
|
|
33
42
|
const SENDING_SUFFIX = ".sending";
|
|
34
43
|
/** Queue size that makes a flush due regardless of age. */
|
|
@@ -100,6 +109,158 @@ function automationName(env, keys) {
|
|
|
100
109
|
return undefined;
|
|
101
110
|
}
|
|
102
111
|
// implements REQ-kibi-anonymous-usage-telemetry
|
|
112
|
+
/** Variables an agent host sets for the processes it starts, in priority order. */
|
|
113
|
+
export const HOST_ENV_KEYS = [
|
|
114
|
+
["CLAUDECODE=1", "claude-code"],
|
|
115
|
+
["CURSOR_AGENT=1", "cursor"],
|
|
116
|
+
["CODEX_SANDBOX", "codex"],
|
|
117
|
+
["CODEX_THREAD_ID", "codex"],
|
|
118
|
+
["GEMINI_CLI=1", "gemini-cli"],
|
|
119
|
+
["OPENCODE=1", "opencode"],
|
|
120
|
+
];
|
|
121
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
122
|
+
/** Variables that carry the host agent's session id; only its hash is sent. */
|
|
123
|
+
export const SESSION_ENV_KEYS = [
|
|
124
|
+
"KIBI_SESSION_ID",
|
|
125
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
126
|
+
"CODEX_THREAD_ID",
|
|
127
|
+
];
|
|
128
|
+
/** `Claude Code`, `claude_code` and `claude-code` name the same host. */
|
|
129
|
+
function hostKey(name) {
|
|
130
|
+
return name
|
|
131
|
+
.trim()
|
|
132
|
+
.toLowerCase()
|
|
133
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
134
|
+
.replace(/^-|-$/g, "");
|
|
135
|
+
}
|
|
136
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
137
|
+
/**
|
|
138
|
+
* The agent host running Kibi, from an explicit `KIBI_HOST`/`KIBI_MCP_HOST`,
|
|
139
|
+
* the variables hosts set for their children, or the `AI_AGENT` convention
|
|
140
|
+
* (`claude-code_2-1-3_agent`). Undefined when nothing names one.
|
|
141
|
+
*/
|
|
142
|
+
export function detectTelemetryHost(env = process.env) {
|
|
143
|
+
const explicit = env.KIBI_HOST?.trim() || env.KIBI_MCP_HOST?.trim();
|
|
144
|
+
if (explicit)
|
|
145
|
+
return hostKey(explicit) || undefined;
|
|
146
|
+
for (const [key, host] of HOST_ENV_KEYS) {
|
|
147
|
+
if (automationName(env, [key]))
|
|
148
|
+
return host;
|
|
149
|
+
}
|
|
150
|
+
const agent = env.AI_AGENT?.trim().split("_")[0];
|
|
151
|
+
return agent ? hostKey(agent) || undefined : undefined;
|
|
152
|
+
}
|
|
153
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
154
|
+
/** The host agent's session id from the environment, when it exposes one. */
|
|
155
|
+
export function detectTelemetrySession(env = process.env) {
|
|
156
|
+
for (const key of SESSION_ENV_KEYS) {
|
|
157
|
+
const value = env[key]?.trim();
|
|
158
|
+
if (value)
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
/** Whether the process runs in a hosted cloud workspace rather than on a laptop. */
|
|
164
|
+
function remoteEnvironment(env) {
|
|
165
|
+
return (flagValue(env.CLAUDE_CODE_REMOTE) === true ||
|
|
166
|
+
flagValue(env.CODESPACES) === true ||
|
|
167
|
+
Boolean(env.GITPOD_WORKSPACE_ID?.trim()));
|
|
168
|
+
}
|
|
169
|
+
function realDirectory(directory) {
|
|
170
|
+
try {
|
|
171
|
+
return fs.realpathSync(directory);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return path.resolve(directory);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Eval, packaging and e2e harnesses give each run a fresh home under the temp
|
|
179
|
+
* directory, so every run would count as a new install. A real user's home
|
|
180
|
+
* never lives there.
|
|
181
|
+
*/
|
|
182
|
+
function sandboxedHome(env) {
|
|
183
|
+
const home = (env.HOME ?? env.USERPROFILE)?.trim();
|
|
184
|
+
if (!home)
|
|
185
|
+
return false;
|
|
186
|
+
const temp = realDirectory(os.tmpdir());
|
|
187
|
+
const resolved = realDirectory(home);
|
|
188
|
+
return resolved !== temp && resolved.startsWith(`${temp}${path.sep}`);
|
|
189
|
+
}
|
|
190
|
+
/** The Kibi monorepo root's package name. */
|
|
191
|
+
const KIBI_REPOSITORY_PACKAGE = "kibi";
|
|
192
|
+
/** This module's file, or undefined when a bundle format drops `import.meta.url`. */
|
|
193
|
+
function thisModuleFile() {
|
|
194
|
+
try {
|
|
195
|
+
const url = import.meta.url;
|
|
196
|
+
return typeof url === "string" && url !== ""
|
|
197
|
+
? fileURLToPath(url)
|
|
198
|
+
: undefined;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Whether a file belongs to a checkout of the Kibi repository rather than to
|
|
206
|
+
* an installed package. Installed copies live under `node_modules` or in a
|
|
207
|
+
* host's plugin cache; a checkout has the monorepo root `package.json` (named
|
|
208
|
+
* `kibi`, with a `packages/agent-core` workspace) above the file.
|
|
209
|
+
*/
|
|
210
|
+
function insideKibiCheckout(file) {
|
|
211
|
+
const resolved = realDirectory(file);
|
|
212
|
+
if (resolved.split(/[\\/]/).includes("node_modules"))
|
|
213
|
+
return false;
|
|
214
|
+
let current = path.dirname(resolved);
|
|
215
|
+
for (;;) {
|
|
216
|
+
try {
|
|
217
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(current, "package.json"), "utf8"));
|
|
218
|
+
if (manifest.name === KIBI_REPOSITORY_PACKAGE &&
|
|
219
|
+
fs.existsSync(path.join(current, "packages", "agent-core"))) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// No readable manifest here; keep walking up.
|
|
225
|
+
}
|
|
226
|
+
const parent = path.dirname(current);
|
|
227
|
+
if (parent === current)
|
|
228
|
+
return false;
|
|
229
|
+
current = parent;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const checkoutCache = new Map();
|
|
233
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
234
|
+
/**
|
|
235
|
+
* Where a run comes from: the `KIBI_TELEMETRY_CHANNEL` a harness sets (for
|
|
236
|
+
* example `agent-test`), `source` when Kibi runs from a checkout of its own
|
|
237
|
+
* repository (local development, the repository's own hooks, e2e and agent
|
|
238
|
+
* test rounds built from source), and `release` for an installed package.
|
|
239
|
+
*/
|
|
240
|
+
export function detectTelemetryChannel(env = process.env, moduleFile = thisModuleFile() ?? null) {
|
|
241
|
+
return telemetryChannel(env, moduleFile).channel;
|
|
242
|
+
}
|
|
243
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
244
|
+
/** The channel in effect and where it comes from. */
|
|
245
|
+
export function telemetryChannel(env = process.env, moduleFile = thisModuleFile() ?? null) {
|
|
246
|
+
const explicit = env.KIBI_TELEMETRY_CHANNEL?.trim();
|
|
247
|
+
if (explicit)
|
|
248
|
+
return {
|
|
249
|
+
channel: telemetryCategory(explicit) ?? "release",
|
|
250
|
+
source: "KIBI_TELEMETRY_CHANNEL",
|
|
251
|
+
};
|
|
252
|
+
if (!moduleFile)
|
|
253
|
+
return { channel: "release", source: "installed" };
|
|
254
|
+
let fromCheckout = checkoutCache.get(moduleFile);
|
|
255
|
+
if (fromCheckout === undefined) {
|
|
256
|
+
fromCheckout = insideKibiCheckout(moduleFile);
|
|
257
|
+
checkoutCache.set(moduleFile, fromCheckout);
|
|
258
|
+
}
|
|
259
|
+
return fromCheckout
|
|
260
|
+
? { channel: "source", source: "checkout" }
|
|
261
|
+
: { channel: "release", source: "installed" };
|
|
262
|
+
}
|
|
263
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
103
264
|
/** Per-user directory holding the telemetry config and queue. */
|
|
104
265
|
export function telemetryDirectory(env = process.env) {
|
|
105
266
|
const explicit = env.KIBI_TELEMETRY_DIR?.trim();
|
|
@@ -146,10 +307,13 @@ export function setTelemetryPreference(preference, env = process.env) {
|
|
|
146
307
|
*/
|
|
147
308
|
export function telemetryStatus(env = process.env) {
|
|
148
309
|
const directory = telemetryDirectory(env);
|
|
310
|
+
const { channel, source } = telemetryChannel(env);
|
|
149
311
|
const status = (enabled, reason) => ({
|
|
150
312
|
enabled,
|
|
151
313
|
reason,
|
|
152
314
|
directory,
|
|
315
|
+
channel,
|
|
316
|
+
channelSource: source,
|
|
153
317
|
});
|
|
154
318
|
const explicit = flagValue(env.KIBI_TELEMETRY);
|
|
155
319
|
if (explicit === false)
|
|
@@ -176,6 +340,9 @@ export function telemetryStatus(env = process.env) {
|
|
|
176
340
|
automationName(processEnv, TEST_ENV_KEYS);
|
|
177
341
|
if (testName)
|
|
178
342
|
return status(false, `test environment (${testName})`);
|
|
343
|
+
if (sandboxedHome(env)) {
|
|
344
|
+
return status(false, "sandboxed home in the temp directory (harness run)");
|
|
345
|
+
}
|
|
179
346
|
if (preference === "enabled") {
|
|
180
347
|
return status(true, "enabled with `kibi telemetry enable`");
|
|
181
348
|
}
|
|
@@ -244,8 +411,89 @@ function runtimeName() {
|
|
|
244
411
|
? "node"
|
|
245
412
|
: "bun";
|
|
246
413
|
}
|
|
414
|
+
function sessionMarkersPath(env) {
|
|
415
|
+
return path.join(telemetryDirectory(env), SESSIONS_FILE);
|
|
416
|
+
}
|
|
417
|
+
function readSessionMarkers(env) {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(fs.readFileSync(sessionMarkersPath(env), "utf8"));
|
|
420
|
+
return parsed !== null &&
|
|
421
|
+
typeof parsed === "object" &&
|
|
422
|
+
!Array.isArray(parsed)
|
|
423
|
+
? parsed
|
|
424
|
+
: {};
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return {};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function workspaceKey(workspaceRoot) {
|
|
431
|
+
return hashTelemetryId(`workspace\0${path.resolve(workspaceRoot)}`);
|
|
432
|
+
}
|
|
433
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
434
|
+
/**
|
|
435
|
+
* Remember which host session last ran a hook in a workspace. Every host
|
|
436
|
+
* hands its hooks the session id, but only some expose it to the CLI and MCP
|
|
437
|
+
* server through the environment. Only hashes are stored: of the workspace
|
|
438
|
+
* path and of the session id.
|
|
439
|
+
*/
|
|
440
|
+
export function rememberHookSession(workspaceRoot, sessionId, env = process.env) {
|
|
441
|
+
if (!sessionId.trim() || !telemetryEnabled(env))
|
|
442
|
+
return;
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
const markers = readSessionMarkers(env);
|
|
445
|
+
markers[workspaceKey(workspaceRoot)] = {
|
|
446
|
+
session: hashTelemetryId(sessionId.trim()),
|
|
447
|
+
at: now,
|
|
448
|
+
};
|
|
449
|
+
const kept = Object.entries(markers)
|
|
450
|
+
.filter(([, marker]) => typeof marker?.at === "number" &&
|
|
451
|
+
now - marker.at <= SESSION_MARKER_MAX_AGE_MS)
|
|
452
|
+
.sort(([, left], [, right]) => right.at - left.at)
|
|
453
|
+
.slice(0, MAX_SESSION_MARKERS);
|
|
454
|
+
try {
|
|
455
|
+
const file = sessionMarkersPath(env);
|
|
456
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
457
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
458
|
+
fs.writeFileSync(temporary, JSON.stringify(Object.fromEntries(kept)));
|
|
459
|
+
fs.renameSync(temporary, file);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Telemetry is best effort and must never disturb the hook.
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
466
|
+
/**
|
|
467
|
+
* Hashed session a host hook recently reported for this directory or the
|
|
468
|
+
* nearest ancestor that has one, or undefined.
|
|
469
|
+
*/
|
|
470
|
+
export function workspaceSessionHash(startDirectory, env = process.env) {
|
|
471
|
+
const markers = readSessionMarkers(env);
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
let current = path.resolve(startDirectory);
|
|
474
|
+
for (;;) {
|
|
475
|
+
const marker = markers[workspaceKey(current)];
|
|
476
|
+
if (marker &&
|
|
477
|
+
typeof marker.session === "string" &&
|
|
478
|
+
typeof marker.at === "number" &&
|
|
479
|
+
now - marker.at <= SESSION_MARKER_MAX_AGE_MS) {
|
|
480
|
+
return marker.session;
|
|
481
|
+
}
|
|
482
|
+
const parent = path.dirname(current);
|
|
483
|
+
if (parent === current)
|
|
484
|
+
return undefined;
|
|
485
|
+
current = parent;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function sessionProperty(source, env) {
|
|
489
|
+
const sessionId = source.sessionId?.trim() || detectTelemetrySession(env);
|
|
490
|
+
if (sessionId)
|
|
491
|
+
return hashTelemetryId(sessionId);
|
|
492
|
+
return source.workspaceRoot
|
|
493
|
+
? (workspaceSessionHash(source.workspaceRoot, env) ?? null)
|
|
494
|
+
: null;
|
|
495
|
+
}
|
|
247
496
|
function baseProperties(source, env) {
|
|
248
|
-
const sessionId = source.sessionId?.trim();
|
|
249
497
|
return {
|
|
250
498
|
distinct_id: installId(env),
|
|
251
499
|
$process_person_profile: false,
|
|
@@ -253,8 +501,12 @@ function baseProperties(source, env) {
|
|
|
253
501
|
$lib: "kibi",
|
|
254
502
|
package: source.packageName,
|
|
255
503
|
package_version: source.packageVersion ?? null,
|
|
256
|
-
host: source.host?.trim() ||
|
|
257
|
-
|
|
504
|
+
host: (source.host?.trim() && hostKey(source.host)) ||
|
|
505
|
+
detectTelemetryHost(env) ||
|
|
506
|
+
"unknown",
|
|
507
|
+
remote: remoteEnvironment(env),
|
|
508
|
+
channel: detectTelemetryChannel(env),
|
|
509
|
+
session: sessionProperty(source, env),
|
|
258
510
|
os: process.platform,
|
|
259
511
|
arch: process.arch,
|
|
260
512
|
runtime: runtimeName(),
|
|
@@ -282,6 +534,20 @@ function appendEvent(event, env) {
|
|
|
282
534
|
}
|
|
283
535
|
}
|
|
284
536
|
// implements REQ-kibi-anonymous-usage-telemetry
|
|
537
|
+
/**
|
|
538
|
+
* An error category as sent: fixed identifiers such as `tool_timeout` or
|
|
539
|
+
* `UPSERT_DRY_RUN_INVALID` pass, lower-cased; anything that looks like free
|
|
540
|
+
* text is replaced, so a message can never leak through this field.
|
|
541
|
+
*/
|
|
542
|
+
export function telemetryCategory(value) {
|
|
543
|
+
const trimmed = value?.trim();
|
|
544
|
+
if (!trimmed)
|
|
545
|
+
return null;
|
|
546
|
+
return /^[A-Za-z0-9_.:+-]{1,160}$/.test(trimmed)
|
|
547
|
+
? trimmed.toLowerCase()
|
|
548
|
+
: "unclassified";
|
|
549
|
+
}
|
|
550
|
+
// implements REQ-kibi-anonymous-usage-telemetry
|
|
285
551
|
export function recordOperationTelemetry(source, operation, env = process.env) {
|
|
286
552
|
if (!telemetryEnabled(env))
|
|
287
553
|
return;
|
|
@@ -294,7 +560,7 @@ export function recordOperationTelemetry(source, operation, env = process.env) {
|
|
|
294
560
|
operation: operation.operation,
|
|
295
561
|
status: operation.status,
|
|
296
562
|
duration_ms: Math.max(0, Math.round(operation.durationMs)),
|
|
297
|
-
error_category: operation.errorCategory
|
|
563
|
+
error_category: telemetryCategory(operation.errorCategory),
|
|
298
564
|
exit_code: operation.exitCode ?? null,
|
|
299
565
|
},
|
|
300
566
|
}, env);
|
|
@@ -328,7 +594,9 @@ export function clearTelemetryQueue(env = process.env) {
|
|
|
328
594
|
return;
|
|
329
595
|
}
|
|
330
596
|
for (const name of names) {
|
|
331
|
-
if (name === QUEUE_FILE ||
|
|
597
|
+
if (name === QUEUE_FILE ||
|
|
598
|
+
name === SESSIONS_FILE ||
|
|
599
|
+
name.endsWith(SENDING_SUFFIX)) {
|
|
332
600
|
fs.rmSync(path.join(directory, name), { force: true });
|
|
333
601
|
}
|
|
334
602
|
}
|