knodin 0.8.3 → 0.8.4

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/README.md CHANGED
@@ -190,6 +190,8 @@ knodin repair
190
190
  | `knodin status --deep` | Index freshness, health, hooks, and integration status |
191
191
  | `knodin repair` | Repair or rebuild unhealthy local graph state |
192
192
  | `knodin diagnostics …` | Preview and archive explicit-allowlist local support evidence |
193
+ | `knodin agent-hooks …` | Explicitly install, inspect, or remove optional user-global Claude lifecycle hooks |
194
+ | `knodin telemetry …` | Opt into private repository-local adoption evidence and render the local dashboard |
193
195
 
194
196
  Run `knodin --help` or read the [CLI reference](docs/CLI.md) for complete syntax.
195
197
 
@@ -218,7 +220,11 @@ repository, and lifecycle capabilities. See the [MCP guide](docs/MCP.md).
218
220
  Each checkout stores its graph and lifecycle state in `.knodin/`. Shared model
219
221
  files live in the user cache rather than being duplicated per repository.
220
222
  Source and graph data stay local. Telemetry is metadata-only, disabled by
221
- default, and never sent by knodin. Troubleshooting diagnostics are also
223
+ default, and never sent by knodin. Optional Claude hooks are installed only by
224
+ `knodin agent-hooks install --client claude`; they inject bounded, freshness-aware
225
+ session orientation and no-op outside initialized repositories. Adoption
226
+ capture remains a separate opt-in through `knodin telemetry enable`.
227
+ Troubleshooting diagnostics are also
222
228
  explicitly enabled, local-only, bounded, and never uploaded automatically.
223
229
 
224
230
  The optional `prs` command invokes the user's authenticated `gh` CLI. Network
package/dist/bin/cli.js CHANGED
@@ -16,6 +16,8 @@ import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import readline from "node:readline/promises";
18
18
  import { fileURLToPath } from "node:url";
19
+ import { canUseSessionContextCache, parseClaudeHookPayload, readSessionContextCache, recordClaudeLifecycleEvent, renderSessionContext, SESSION_CONTEXT_BUDGET_LINE, sessionStartHookOutput, writeSessionContextCache, } from "../src/agent-events.js";
20
+ import { inspectClaudeAgentHooks, installClaudeAgentHooks, uninstallClaudeAgentHooks, } from "../src/agent-hooks.js";
19
21
  import { detectSupportedAgents, parseInitScope, } from "../src/agent-integration.js";
20
22
  import { refreshExternalGraphArtifacts, writeArtifactRefreshRecord, } from "../src/artifact-refresh.js";
21
23
  import { checkIndexed, extractPositionals, extractRepoFlag, parseReviewArgs, planIndex, resolveCliRuntimeCommand, resolveRepo, } from "../src/cli-args.js";
@@ -43,6 +45,7 @@ import { createRepairPlan, createRepairProgressRenderer, parseRepairCliArgs, res
43
45
  import { runRepositoryInitializationProcess } from "../src/repository-init-process.js";
44
46
  import { detectRepositorySignals, discoverRepositories, formatRepositoryHuman, initializeRepositories, inventoryRepository, parseFleetInitArgs, parseRepositoryCommandArgs, repositorySignalInspectionLimit, searchRepositories, withRepositorySignals, } from "../src/repository-management.js";
45
47
  import { applyResponseBudget } from "../src/response-budget.js";
48
+ import { appendSessionEvent, clearSessionTelemetry, disableSessionTelemetry, enableSessionTelemetry, readSessionEvents, sessionTelemetryStatus, } from "../src/session-telemetry.js";
46
49
  import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../src/system-config.js";
47
50
  import { applyTrustedUpdate, checkTrustedUpdate, claimScheduledUpdateCheck, detectUpdateInstallMethod, explainTrustedUpdate, releaseScheduledUpdateCheck, rollbackTrustedUpdate, trustedUpdateStatus, } from "../src/update-policy.js";
48
51
  import { KNODIN_VERSION } from "../src/version.js";
@@ -684,9 +687,9 @@ async function main() {
684
687
  };
685
688
  const repoFlag = selectorValue("--repo");
686
689
  const runtimeCommand = resolveCliRuntimeCommand(process);
687
- // hook-refresh is an internal machine-to-machine command. Keep it JSON even
690
+ // Internal machine-to-machine commands stay JSON regardless of TTY state.
688
691
  // when an older installed hook predates the explicit --json argument.
689
- const jsonOutput = invocation.options.json === true || cmd === "hook-refresh";
692
+ const jsonOutput = invocation.options.json === true || cmd === "hook-refresh" || cmd === "agent-event";
690
693
  // `--json` is a shared output flag. Repair owns its richer --json/--jsonl
691
694
  // parser; all other commands receive their original arguments minus it.
692
695
  const rest = cmd === "repair" ? rawRest : rawRest.filter((argument) => argument !== "--json");
@@ -1075,12 +1078,43 @@ async function main() {
1075
1078
  background.unref();
1076
1079
  }
1077
1080
  }
1081
+ if (cmd === "agent-hooks") {
1082
+ const action = invocation.commandPath[1];
1083
+ const client = selectorValue("--client") ?? "claude";
1084
+ if (client !== "claude")
1085
+ throw new Error("knodin agent-hooks: only --client claude is supported");
1086
+ if (invocation.options.dryRun === true && action !== "install")
1087
+ throw new Error("knodin agent-hooks: --dry-run applies only to install");
1088
+ let hookResult;
1089
+ if (action === "install") {
1090
+ const quote = (value) => `'${value.replaceAll("'", `'"'"'`)}'`;
1091
+ hookResult = installClaudeAgentHooks({
1092
+ command: runtimeCommand.map(quote).join(" "),
1093
+ dryRun: invocation.options.dryRun === true,
1094
+ });
1095
+ }
1096
+ else if (action === "status")
1097
+ hookResult = inspectClaudeAgentHooks();
1098
+ else if (action === "uninstall")
1099
+ hookResult = uninstallClaudeAgentHooks();
1100
+ else
1101
+ throw new Error("knodin agent-hooks requires install, status, or uninstall");
1102
+ process.stdout.write(jsonOutput
1103
+ ? `${JSON.stringify(hookResult)}\n`
1104
+ : formatGenericHuman("agent-hooks", hookResult));
1105
+ return;
1106
+ }
1078
1107
  const resolved = resolveRepo(repoFlag, process.cwd());
1079
1108
  if (!resolved.ok) {
1109
+ // Global agent hooks also run outside repositories and must fail open.
1110
+ if (cmd === "agent-event")
1111
+ return;
1080
1112
  process.stderr.write(`${resolved.error}\n`);
1081
1113
  process.exit(1);
1082
1114
  }
1083
1115
  const repo = resolved.repo;
1116
+ if (cmd === "agent-event" && !fs.existsSync(resolveDbPath(repo)))
1117
+ return;
1084
1118
  if (cmd === "doctor") {
1085
1119
  const gitProbe = spawnSync(gitExecutable(), ["rev-parse", "--is-inside-work-tree"], {
1086
1120
  cwd: repo,
@@ -1231,6 +1265,7 @@ async function main() {
1231
1265
  let repairWasPlan = false;
1232
1266
  let repairExitCode = 0;
1233
1267
  let statusWasWatched = false;
1268
+ let agentEventOutput = false;
1234
1269
  const graphRead = async (run) => {
1235
1270
  const health = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
1236
1271
  if (!health.available) {
@@ -1246,6 +1281,103 @@ async function main() {
1246
1281
  return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
1247
1282
  };
1248
1283
  switch (cmd) {
1284
+ case "agent-event": {
1285
+ agentEventOutput = true;
1286
+ const event = invocation.positionals[0];
1287
+ if (!event)
1288
+ throw new Error("knodin agent-event requires an event");
1289
+ let payload;
1290
+ try {
1291
+ payload = parseClaudeHookPayload(await readBoundedStdin(65_536));
1292
+ }
1293
+ catch {
1294
+ result = null;
1295
+ break;
1296
+ }
1297
+ if (event !== "session-start") {
1298
+ try {
1299
+ recordClaudeLifecycleEvent(repo, event, payload);
1300
+ }
1301
+ catch {
1302
+ // Optional telemetry must never interrupt the agent.
1303
+ }
1304
+ result = null;
1305
+ break;
1306
+ }
1307
+ const startedAt = performance.now();
1308
+ let status;
1309
+ try {
1310
+ status = await engine.status(repo, { audit: "cached" });
1311
+ }
1312
+ catch {
1313
+ if (payload.session_id)
1314
+ try {
1315
+ appendSessionEvent(repo, {
1316
+ event: "session_start",
1317
+ sessionId: payload.session_id,
1318
+ context: "unavailable",
1319
+ freshness: "unavailable",
1320
+ latencyMs: Math.round((performance.now() - startedAt) * 100) / 100,
1321
+ cacheHit: false,
1322
+ });
1323
+ }
1324
+ catch {
1325
+ // Best-effort local recording remains fail-open.
1326
+ }
1327
+ result = sessionStartHookOutput(`## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status before relying on graph evidence.`);
1328
+ break;
1329
+ }
1330
+ const freshness = status.freshness?.state ?? "unknown";
1331
+ const cacheKey = JSON.stringify({
1332
+ schemaVersion: 1,
1333
+ head: status.freshness?.currentHead,
1334
+ fingerprint: status.freshness?.workingTree?.indexedFingerprint,
1335
+ generation: status.indexGeneration,
1336
+ budgets: [600, 8192, 12],
1337
+ });
1338
+ const cacheEligible = canUseSessionContextCache(status.status, freshness);
1339
+ let text = cacheEligible ? readSessionContextCache(repo, cacheKey) : null;
1340
+ let cacheHit = text !== null;
1341
+ let contextState = "delivered";
1342
+ if (!cacheEligible) {
1343
+ contextState = "degraded";
1344
+ text = `## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nGraph evidence is ${freshness}. Run knodin status and follow its repair or reindex guidance before relying on graph evidence.`;
1345
+ }
1346
+ else if (!text) {
1347
+ cacheHit = false;
1348
+ try {
1349
+ const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
1350
+ text = renderSessionContext(context, freshness);
1351
+ writeSessionContextCache(repo, cacheKey, text);
1352
+ }
1353
+ catch {
1354
+ contextState = "unavailable";
1355
+ text = `## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status, then call the connected knodin context operation when needed.`;
1356
+ }
1357
+ }
1358
+ if (payload.session_id)
1359
+ try {
1360
+ appendSessionEvent(repo, {
1361
+ event: "session_start",
1362
+ sessionId: payload.session_id,
1363
+ context: contextState,
1364
+ freshness: freshness === "fresh"
1365
+ ? "fresh"
1366
+ : status.status === "repair-needed"
1367
+ ? "repair-needed"
1368
+ : freshness === "unknown"
1369
+ ? "unavailable"
1370
+ : "stale",
1371
+ latencyMs: Math.round((performance.now() - startedAt) * 100) / 100,
1372
+ cacheHit,
1373
+ });
1374
+ }
1375
+ catch {
1376
+ // Best-effort local recording remains fail-open.
1377
+ }
1378
+ result = sessionStartHookOutput(text);
1379
+ break;
1380
+ }
1249
1381
  case "doctor": {
1250
1382
  const client = selectorValue("--client");
1251
1383
  if (client !== undefined &&
@@ -1973,16 +2105,23 @@ async function main() {
1973
2105
  if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650)
1974
2106
  throw new Error("knodin telemetry: --retention-days must be an integer from 1 to 3650");
1975
2107
  const input = selectorValue("--input");
1976
- if (action === "status")
1977
- result = telemetryStatus(repo, input, retentionDays);
2108
+ if (action === "enable")
2109
+ result = enableSessionTelemetry(repo);
2110
+ else if (action === "disable")
2111
+ result = disableSessionTelemetry(repo);
2112
+ else if (action === "status")
2113
+ result = {
2114
+ ...telemetryStatus(repo, input, retentionDays),
2115
+ session: sessionTelemetryStatus(repo, retentionDays),
2116
+ };
1978
2117
  else if (action === "report")
1979
- result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
2118
+ result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
1980
2119
  else if (action === "export")
1981
- result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
2120
+ result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
1982
2121
  else if (action === "clear")
1983
- result = clearTelemetry(repo, input);
2122
+ result = { ...clearTelemetry(repo, input), session: clearSessionTelemetry(repo) };
1984
2123
  else
1985
- throw new Error("knodin telemetry requires status, report, export, or clear");
2124
+ throw new Error("knodin telemetry requires enable, disable, status, report, export, or clear");
1986
2125
  break;
1987
2126
  }
1988
2127
  case "diagnostics": {
@@ -2074,6 +2213,11 @@ async function main() {
2074
2213
  process.exit(1);
2075
2214
  }
2076
2215
  await engine.close();
2216
+ if (agentEventOutput) {
2217
+ if (result !== null && result !== undefined)
2218
+ process.stdout.write(`${JSON.stringify(result)}\n`);
2219
+ return;
2220
+ }
2077
2221
  if (statusWasWatched)
2078
2222
  return;
2079
2223
  const boundedResult = applyResponseBudget(result, cmd, responseBudget, {
@@ -2128,55 +2272,80 @@ try {
2128
2272
  }
2129
2273
  catch (err) {
2130
2274
  const argv = process.argv.slice(2);
2131
- const repoIndex = argv.indexOf("--repo");
2132
- const equalsRepo = argv.find((argument) => argument.startsWith("--repo="));
2133
- const explicitRepo = repoIndex >= 0 ? argv[repoIndex + 1] : undefined;
2134
- let candidate = process.cwd();
2135
- if (explicitRepo)
2136
- candidate = explicitRepo;
2137
- else if (equalsRepo)
2138
- candidate = equalsRepo.slice("--repo=".length);
2139
- const command = argv.find((argument, index) => {
2140
- if (argument.startsWith("-"))
2141
- return false;
2142
- return !(index > 0 && argv[index - 1] === "--repo") && argument !== candidate;
2143
- });
2144
- const knownCommands = new Set([
2145
- "init",
2146
- "configure",
2147
- "index",
2148
- "doctor",
2149
- "status",
2150
- "wait",
2151
- "repair",
2152
- "serve",
2153
- "context",
2154
- "explain",
2155
- "review",
2156
- "map",
2157
- "search",
2158
- "query",
2159
- "rename",
2160
- "wiki",
2161
- "visualize",
2162
- "pack",
2163
- "compress",
2164
- "prs",
2165
- "worktrees",
2166
- "telemetry",
2167
- "diagnostics",
2168
- "system",
2169
- "repos",
2170
- "remote",
2171
- "update",
2172
- ]);
2173
- const diagnostic = recordDiagnosticFailure(candidate, {
2174
- surface: "cli",
2175
- operation: command && knownCommands.has(command) ? command : "unknown",
2176
- phase: "dispatch",
2177
- error: err,
2178
- });
2179
- const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2180
- console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
2181
- process.exit(1);
2275
+ let invokedCommand;
2276
+ for (let index = 0; index < argv.length; index += 1) {
2277
+ const argument = argv[index];
2278
+ if (argument === "--repo") {
2279
+ index += 1;
2280
+ continue;
2281
+ }
2282
+ if (argument?.startsWith("--repo=") || argument === "--json")
2283
+ continue;
2284
+ if (!argument?.startsWith("-")) {
2285
+ invokedCommand = argument;
2286
+ break;
2287
+ }
2288
+ }
2289
+ if (invokedCommand === "agent-event") {
2290
+ const eventIndex = argv.indexOf("agent-event");
2291
+ if (argv[eventIndex + 1] === "session-start") {
2292
+ process.stdout.write(`${JSON.stringify(sessionStartHookOutput(`## knodin session context\n${SESSION_CONTEXT_BUDGET_LINE}\nContext generation was unavailable. Run knodin status before relying on graph evidence.`))}\n`);
2293
+ }
2294
+ process.exitCode = 0;
2295
+ }
2296
+ else {
2297
+ const repoIndex = argv.indexOf("--repo");
2298
+ const equalsRepo = argv.find((argument) => argument.startsWith("--repo="));
2299
+ const explicitRepo = repoIndex >= 0 ? argv[repoIndex + 1] : undefined;
2300
+ let candidate = process.cwd();
2301
+ if (explicitRepo)
2302
+ candidate = explicitRepo;
2303
+ else if (equalsRepo)
2304
+ candidate = equalsRepo.slice("--repo=".length);
2305
+ const command = argv.find((argument, index) => {
2306
+ if (argument.startsWith("-"))
2307
+ return false;
2308
+ return !(index > 0 && argv[index - 1] === "--repo") && argument !== candidate;
2309
+ });
2310
+ const knownCommands = new Set([
2311
+ "init",
2312
+ "configure",
2313
+ "agent-hooks",
2314
+ "agent-event",
2315
+ "index",
2316
+ "doctor",
2317
+ "status",
2318
+ "wait",
2319
+ "repair",
2320
+ "serve",
2321
+ "context",
2322
+ "explain",
2323
+ "review",
2324
+ "map",
2325
+ "search",
2326
+ "query",
2327
+ "rename",
2328
+ "wiki",
2329
+ "visualize",
2330
+ "pack",
2331
+ "compress",
2332
+ "prs",
2333
+ "worktrees",
2334
+ "telemetry",
2335
+ "diagnostics",
2336
+ "system",
2337
+ "repos",
2338
+ "remote",
2339
+ "update",
2340
+ ]);
2341
+ const diagnostic = recordDiagnosticFailure(candidate, {
2342
+ surface: "cli",
2343
+ operation: command && knownCommands.has(command) ? command : "unknown",
2344
+ phase: "dispatch",
2345
+ error: err,
2346
+ });
2347
+ const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2348
+ console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
2349
+ process.exit(1);
2350
+ }
2182
2351
  }
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { countOutputTokens } from "./output-telemetry.js";
4
+ import { appendSessionEvent, } from "./session-telemetry.js";
5
+ export const SESSION_CONTEXT_BUDGET_LINE = "Budget: at most 600 tokens, 8 KiB, 12 items.";
6
+ const KNOWN_OPERATIONS = new Set([
7
+ "context",
8
+ "explain",
9
+ "review",
10
+ "map",
11
+ "search",
12
+ "query",
13
+ "pack",
14
+ "compress",
15
+ "execute",
16
+ "prs",
17
+ "wiki",
18
+ "docs",
19
+ "remote",
20
+ ]);
21
+ function knownOperation(value) {
22
+ return typeof value === "string" && KNOWN_OPERATIONS.has(value) ? value : undefined;
23
+ }
24
+ export function parseClaudeHookPayload(raw) {
25
+ if (Buffer.byteLength(raw) > 65_536)
26
+ throw new Error("knodin agent-event: hook input exceeds 65536 bytes");
27
+ const parsed = JSON.parse(raw);
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
29
+ throw new Error("knodin agent-event: hook input must be a JSON object");
30
+ return parsed;
31
+ }
32
+ export function classifyTool(payload) {
33
+ const name = payload.tool_name ?? "";
34
+ if (name === "mcp__knodin__knodin") {
35
+ const operation = knownOperation(payload.tool_input?.operation);
36
+ return {
37
+ toolCategory: "knodin",
38
+ ...(operation ? { operation } : {}),
39
+ };
40
+ }
41
+ if (["Read", "Glob", "Grep"].includes(name))
42
+ return { toolCategory: "manual_traversal" };
43
+ if (["Edit", "Write", "NotebookEdit", "MultiEdit"].includes(name))
44
+ return { toolCategory: "edit" };
45
+ if (name === "Bash") {
46
+ const command = typeof payload.tool_input?.command === "string" ? payload.tool_input.command : "";
47
+ const knodin = command.match(/(?:^|[;&|]\s*)knodin\s+([a-z][a-z0-9-]*)/i);
48
+ if (knodin) {
49
+ const operation = knownOperation(knodin[1]?.toLowerCase());
50
+ return { toolCategory: "knodin", ...(operation ? { operation } : {}) };
51
+ }
52
+ if (/(?:^|[;&|]\s*)(?:rg|grep|find)\b/.test(command))
53
+ return { toolCategory: "manual_traversal" };
54
+ }
55
+ return { toolCategory: "other" };
56
+ }
57
+ function cachePath(repo) {
58
+ return path.join(repo, ".knodin", "session-context-cache.json");
59
+ }
60
+ export function readSessionContextCache(repo, key, now = Date.now()) {
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(cachePath(repo), "utf8"));
63
+ return parsed.key === key && typeof parsed.at === "number" && now - parsed.at <= 300_000
64
+ ? (parsed.text ?? null)
65
+ : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ export function writeSessionContextCache(repo, key, text) {
72
+ const filePath = cachePath(repo);
73
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
74
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
75
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, key, at: Date.now(), text }), {
76
+ mode: 0o600,
77
+ flag: "wx",
78
+ });
79
+ fs.renameSync(temporary, filePath);
80
+ }
81
+ export function canUseSessionContextCache(status, freshness) {
82
+ return status === "healthy" && freshness === "fresh";
83
+ }
84
+ export function renderSessionContext(context, freshness) {
85
+ const list = (values, render) => (values ?? []).slice(0, 3).map(render).join(", ") || "none reported";
86
+ let text = [
87
+ "## knodin session context",
88
+ `Graph evidence: ${freshness}. Treat stale or unavailable evidence as incomplete.`,
89
+ SESSION_CONTEXT_BUDGET_LINE,
90
+ `Repository: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
91
+ `Subsystems: ${list(context.communities, (value) => `${value.name} (${value.size})`)}`,
92
+ `Hubs: ${list(context.hubs, (value) => `${value.symbol} (${value.degree})`)}`,
93
+ `Flows: ${list(context.flows, (value) => value.symbol ?? "unnamed")}`,
94
+ `Suggested next operation: ${context.suggestedOperation ?? "context"} (heuristic only).`,
95
+ "Use exact source evidence and preserve ambiguity, freshness, omissions, and response budgets.",
96
+ ].join("\n");
97
+ while ((countOutputTokens(text) > 600 || Buffer.byteLength(text) > 8192) && text.includes("\n")) {
98
+ text = `${text.slice(0, text.lastIndexOf("\n"))}\nContext truncated to the configured session budget.`;
99
+ }
100
+ return text;
101
+ }
102
+ export function recordClaudeLifecycleEvent(repo, event, payload) {
103
+ const sessionId = payload.session_id;
104
+ if (!sessionId)
105
+ return false;
106
+ const names = {
107
+ "session-start": "session_start",
108
+ "session-end": "session_end",
109
+ "turn-start": "turn_start",
110
+ "turn-end": "turn_end",
111
+ "pre-tool": "tool_start",
112
+ "post-tool": "tool_success",
113
+ "tool-failure": "tool_failure",
114
+ };
115
+ const mapped = names[event];
116
+ if (!mapped)
117
+ return false;
118
+ const tool = event.includes("tool") ? classifyTool(payload) : undefined;
119
+ return appendSessionEvent(repo, { event: mapped, sessionId, ...tool });
120
+ }
121
+ export function sessionStartHookOutput(text) {
122
+ return {
123
+ hookSpecificOutput: {
124
+ hookEventName: "SessionStart",
125
+ additionalContext: text,
126
+ },
127
+ };
128
+ }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const OWNED_COMMAND = "knodin agent-event";
5
+ function settingsPath(homeDir = os.homedir()) {
6
+ return path.join(homeDir, ".claude", "settings.json");
7
+ }
8
+ function assertNotSymlink(filePath) {
9
+ for (const candidate of [path.dirname(filePath), filePath]) {
10
+ try {
11
+ if (fs.lstatSync(candidate).isSymbolicLink())
12
+ throw new Error(`knodin agent-hooks: refusing symlinked path ${candidate}`);
13
+ }
14
+ catch (error) {
15
+ if (error.code !== "ENOENT")
16
+ throw error;
17
+ }
18
+ }
19
+ }
20
+ function readSettings(filePath) {
21
+ if (!fs.existsSync(filePath))
22
+ return {};
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
26
+ }
27
+ catch (error) {
28
+ throw new Error(`knodin agent-hooks: invalid Claude settings JSON: ${error.message}`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
31
+ throw new Error("knodin agent-hooks: Claude settings must contain a JSON object");
32
+ return parsed;
33
+ }
34
+ function atomicWrite(filePath, content) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
36
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
38
+ fs.renameSync(temporary, filePath);
39
+ }
40
+ function ownedEntry(value) {
41
+ return JSON.stringify(value).includes(OWNED_COMMAND);
42
+ }
43
+ function removeOwned(document) {
44
+ const hooks = document.hooks;
45
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
46
+ return;
47
+ const hookObject = hooks;
48
+ for (const [event, value] of Object.entries(hookObject)) {
49
+ if (!Array.isArray(value))
50
+ continue;
51
+ const retained = value.filter((entry) => !ownedEntry(entry));
52
+ if (retained.length === 0)
53
+ delete hookObject[event];
54
+ else
55
+ hookObject[event] = retained;
56
+ }
57
+ if (Object.keys(hookObject).length === 0)
58
+ delete document.hooks;
59
+ }
60
+ function hook(command, event, matcher) {
61
+ return {
62
+ ...(matcher ? { matcher } : {}),
63
+ hooks: [
64
+ {
65
+ type: "command",
66
+ command: `${command} agent-event ${event}`,
67
+ timeout: 5,
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ function managedHooks(command) {
73
+ const observed = "mcp__knodin__knodin|Bash|Read|Glob|Grep|Edit|Write|NotebookEdit";
74
+ return {
75
+ SessionStart: [hook(command, "session-start", "startup|resume|clear|compact")],
76
+ UserPromptSubmit: [hook(command, "turn-start")],
77
+ PreToolUse: [hook(command, "pre-tool", observed)],
78
+ PostToolUse: [hook(command, "post-tool", observed)],
79
+ PostToolUseFailure: [hook(command, "tool-failure", observed)],
80
+ Stop: [hook(command, "turn-end")],
81
+ SessionEnd: [hook(command, "session-end")],
82
+ };
83
+ }
84
+ function mergedSettings(existing, command) {
85
+ const document = structuredClone(existing);
86
+ removeOwned(document);
87
+ const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
88
+ ? document.hooks
89
+ : {};
90
+ for (const [event, entries] of Object.entries(managedHooks(command))) {
91
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
92
+ hooks[event] = [...current, ...entries];
93
+ }
94
+ document.hooks = hooks;
95
+ return document;
96
+ }
97
+ export function inspectClaudeAgentHooks(options = {}) {
98
+ const filePath = settingsPath(options.homeDir);
99
+ assertNotSymlink(filePath);
100
+ const document = readSettings(filePath);
101
+ const serialized = JSON.stringify(document);
102
+ const count = serialized.split(OWNED_COMMAND).length - 1;
103
+ return {
104
+ schemaVersion: 1,
105
+ client: "claude",
106
+ settingsPath: filePath,
107
+ installed: count > 0,
108
+ managedEntries: count,
109
+ };
110
+ }
111
+ export function installClaudeAgentHooks(options = {}) {
112
+ const filePath = settingsPath(options.homeDir);
113
+ assertNotSymlink(filePath);
114
+ const before = readSettings(filePath);
115
+ const after = mergedSettings(before, options.command ?? "knodin");
116
+ const beforeText = `${JSON.stringify(before, null, 2)}\n`;
117
+ const afterText = `${JSON.stringify(after, null, 2)}\n`;
118
+ const changed = beforeText !== afterText;
119
+ if (changed && !options.dryRun) {
120
+ if (fs.existsSync(filePath)) {
121
+ const backup = `${filePath}.knodin-backup`;
122
+ if (!fs.existsSync(backup))
123
+ fs.copyFileSync(filePath, backup, fs.constants.COPYFILE_EXCL);
124
+ }
125
+ atomicWrite(filePath, afterText);
126
+ }
127
+ return {
128
+ schemaVersion: 1,
129
+ client: "claude",
130
+ settingsPath: filePath,
131
+ changed,
132
+ dryRun: options.dryRun === true,
133
+ managedEntries: Object.keys(managedHooks(options.command ?? "knodin")).length,
134
+ };
135
+ }
136
+ export function uninstallClaudeAgentHooks(options = {}) {
137
+ const filePath = settingsPath(options.homeDir);
138
+ assertNotSymlink(filePath);
139
+ if (!fs.existsSync(filePath))
140
+ return { removed: false, settingsPath: filePath };
141
+ const document = readSettings(filePath);
142
+ const hadOwned = ownedEntry(document);
143
+ removeOwned(document);
144
+ if (hadOwned) {
145
+ if (Object.keys(document).length === 0)
146
+ fs.unlinkSync(filePath);
147
+ else
148
+ atomicWrite(filePath, `${JSON.stringify(document, null, 2)}\n`);
149
+ }
150
+ return {
151
+ schemaVersion: 1,
152
+ client: "claude",
153
+ removed: hadOwned,
154
+ settingsPath: filePath,
155
+ };
156
+ }
@@ -251,6 +251,14 @@ function createCliProgram(capture = () => { }) {
251
251
  leaf(program, "serve", "run the one-tool MCP gateway on stdio", capture);
252
252
  leaf(program, "version", "print the installed knodin version", capture);
253
253
  leaf(program, "hook-refresh <kind> [values...]", "internal Git lifecycle refresh", capture);
254
+ const agentHooks = program
255
+ .command("agent-hooks")
256
+ .description("manage optional user-global coding-agent lifecycle hooks");
257
+ for (const action of ["install", "status", "uninstall"])
258
+ leaf(agentHooks, action, `${action} optional coding-agent hooks`, capture)
259
+ .option("--client <client>", "coding-agent client; currently claude")
260
+ .option("--dry-run", "preview settings changes without writing");
261
+ leaf(program, "agent-event <event>", "internal coding-agent lifecycle event", capture);
254
262
  leaf(program, "refresh-artifacts [event]", "refresh external graph artifacts", capture);
255
263
  addRepositoryCommands(program, capture);
256
264
  addRemoteCommands(program, capture);
@@ -283,7 +291,14 @@ function createCliProgram(capture = () => { }) {
283
291
  .command("telemetry")
284
292
  .description("manage local opt-in ROI telemetry")
285
293
  .allowExcessArguments(false)
286
- .addArgument(new Argument("<action>").choices(["status", "report", "export", "clear"]));
294
+ .addArgument(new Argument("<action>").choices([
295
+ "enable",
296
+ "disable",
297
+ "status",
298
+ "report",
299
+ "export",
300
+ "clear",
301
+ ]));
287
302
  telemetry
288
303
  .option("--input <path>", "telemetry input path")
289
304
  .option("--output <path>", "dashboard or evidence-bundle output path")
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { encode } from "gpt-tokenizer/encoding/o200k_base";
5
5
  import { compareBytes } from "./compare.js";
6
6
  import { resolveDbPath } from "./engine/state-paths.js";
7
+ import { summarizeAdoption } from "./session-telemetry.js";
7
8
  export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
8
9
  export const countOutputTokens = (value) => encode(value).length;
9
10
  export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
@@ -182,7 +183,7 @@ function telemetrySummary(records) {
182
183
  indexingMs: records.reduce((sum, record) => sum + (record.indexingMs ?? 0), 0),
183
184
  };
184
185
  }
185
- export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/telemetry-report.html") {
186
+ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/telemetry-report.html", sessionEvents = []) {
186
187
  const { repo, target: output } = privatePath(repoPath, outputPath, "report");
187
188
  const groups = new Map();
188
189
  for (const record of records) {
@@ -201,6 +202,7 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
201
202
  })
202
203
  .join("");
203
204
  const summary = telemetrySummary(records);
205
+ const adoption = summarizeAdoption(sessionEvents);
204
206
  const timeSeries = new Map();
205
207
  for (const record of records) {
206
208
  const day = typeof record.at === "string" ? record.at.slice(0, 10) : "unknown";
@@ -221,12 +223,13 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
221
223
  .sort(([left], [right]) => compareBytes(left, right))
222
224
  .map(([id, calls]) => `<tr><td>${escapeHtml(id)}</td><td>${calls}</td></tr>`)
223
225
  .join("");
224
- const html = `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>knodin telemetry</title><style>:root{color-scheme:light dark;--line:#d0d7de;--card:#f6f8fa}body{font:14px system-ui;margin:2rem;max-width:76rem}section{background:var(--card);padding:1rem;margin:1rem 0;border-radius:.5rem}table{border-collapse:collapse;width:100%}th,td{padding:.55rem;border-bottom:1px solid var(--line);text-align:left}@media(prefers-color-scheme:dark){:root{--line:#30363d;--card:#161b22}}</style><h1>knodin local telemetry</h1><p>Tokenizer: ${TELEMETRY_TOKENIZER}. Local, opt-in, metadata-only; source, raw output, command text, usernames, and paths are not persisted.</p><section><h2>Summary</h2><p>Total measured tokens avoided: ${summary.measuredTokensAvoided}</p><p>Calls: ${summary.calls}; successes: ${summary.successes}; errors: ${summary.errors}; measured baselines: ${summary.measuredBaselines}</p><p>Latency p50 / p95: ${summary.latencyP50Ms} / ${summary.latencyP95Ms} ms</p><p>Freshness failures: ${summary.freshnessFailures}; compression fidelity samples / p50: ${summary.compressionFidelitySamples} / ${summary.compressionFidelityP50}; indexing time: ${summary.indexingMs} ms; index storage: ${summary.indexStorageBytes} bytes</p></section><section><h2>Operation breakdown</h2><table><thead><tr><th>Operation</th><th>Calls</th><th>Errors</th><th>Measured baselines</th><th>Net tokens saved</th><th>p50 ms</th><th>p95 ms</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Time series</h2><table><thead><tr><th>Day</th><th>Calls</th><th>Tokens avoided</th></tr></thead><tbody>${seriesRows}</tbody></table></section><section><h2>Repository breakdown</h2><table><thead><tr><th>Private repository id</th><th>Calls</th></tr></thead><tbody>${repositoryRows}</tbody></table></section><p>Use <code>knodin telemetry export</code> for the machine-readable evidence bundle and <code>knodin telemetry clear</code> to delete persisted records.</p>`;
226
+ const html = `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><title>knodin telemetry</title><style>:root{color-scheme:light dark;--line:#d0d7de;--card:#f6f8fa}body{font:14px system-ui;margin:2rem;max-width:76rem}section{background:var(--card);padding:1rem;margin:1rem 0;border-radius:.5rem}table{border-collapse:collapse;width:100%}th,td{padding:.55rem;border-bottom:1px solid var(--line);text-align:left}@media(prefers-color-scheme:dark){:root{--line:#30363d;--card:#161b22}}</style><h1>knodin local telemetry</h1><p>Tokenizer: ${TELEMETRY_TOKENIZER}. Local, opt-in, metadata-only; source, raw output, command text, prompts, usernames, and paths are not persisted.</p><section><h2>Summary</h2><p>Total measured tokens avoided: ${summary.measuredTokensAvoided}</p><p>Calls: ${summary.calls}; successes: ${summary.successes}; errors: ${summary.errors}; measured baselines: ${summary.measuredBaselines}</p><p>Latency p50 / p95: ${summary.latencyP50Ms} / ${summary.latencyP95Ms} ms</p><p>Freshness failures: ${summary.freshnessFailures}; compression fidelity samples / p50: ${summary.compressionFidelitySamples} / ${summary.compressionFidelityP50}; indexing time: ${summary.indexingMs} ms; index storage: ${summary.indexStorageBytes} bytes</p></section><section><h2>Adoption evidence</h2><p>Sessions: ${adoption.sessions}; context delivered / degraded: ${adoption.contextDelivered} / ${adoption.contextDegraded}; sessions using knodin: ${adoption.knodinSessions}; observed turns: ${adoption.turns}</p><p>Fallback traversal after knodin: ${adoption.fallbackTraversalAfterKnodin}; edits preceded by knodin evidence in the same turn: ${adoption.evidenceBeforeEdit}</p><p>Corrective round trips: unavailable. Ordinary hook events do not prove why a follow-up occurred.</p></section><section><h2>Operation breakdown</h2><table><thead><tr><th>Operation</th><th>Calls</th><th>Errors</th><th>Measured baselines</th><th>Net tokens saved</th><th>p50 ms</th><th>p95 ms</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Time series</h2><table><thead><tr><th>Day</th><th>Calls</th><th>Tokens avoided</th></tr></thead><tbody>${seriesRows}</tbody></table></section><section><h2>Repository breakdown</h2><table><thead><tr><th>Private repository id</th><th>Calls</th></tr></thead><tbody>${repositoryRows}</tbody></table></section><p>Use <code>knodin telemetry export</code> for the machine-readable evidence bundle and <code>knodin telemetry clear</code> to delete persisted records.</p>`;
225
227
  atomicPrivateWrite(output, html);
226
228
  return {
227
229
  outputPath: path.relative(repo, output),
228
230
  records: records.length,
229
231
  operations: groups.size,
232
+ sessionEvents: sessionEvents.length,
230
233
  };
231
234
  }
232
235
  function sanitizeTelemetryRecord(record) {
@@ -335,7 +338,7 @@ export function telemetryStatus(repoPath, inputPath = DEFAULT_INPUT, retentionDa
335
338
  tokenizer: TELEMETRY_TOKENIZER,
336
339
  };
337
340
  }
338
- export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemetry-export.json") {
341
+ export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemetry-export.json", sessionEvents = []) {
339
342
  const { repo, target } = privatePath(repoPath, outputPath, "export");
340
343
  const bundle = {
341
344
  schemaVersion: 1,
@@ -349,6 +352,8 @@ export function exportTelemetry(repoPath, records, outputPath = ".knodin/telemet
349
352
  },
350
353
  summary: telemetrySummary(records),
351
354
  records,
355
+ adoption: summarizeAdoption(sessionEvents),
356
+ sessionEvents,
352
357
  };
353
358
  atomicPrivateWrite(target, `${JSON.stringify(bundle, null, 2)}\n`);
354
359
  return {
@@ -0,0 +1,163 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const CONFIG = ".knodin/telemetry-config.json";
5
+ const EVENTS = ".knodin/session-events.jsonl";
6
+ const KEY = ".knodin/telemetry-key";
7
+ function target(repo, relative) {
8
+ const root = fs.realpathSync(repo);
9
+ const value = path.resolve(root, relative);
10
+ if (!value.startsWith(`${root}${path.sep}`))
11
+ throw new Error("knodin telemetry path escaped repository");
12
+ let candidate = root;
13
+ for (const segment of path.relative(root, value).split(path.sep)) {
14
+ candidate = path.join(candidate, segment);
15
+ try {
16
+ if (fs.lstatSync(candidate).isSymbolicLink())
17
+ throw new Error("knodin telemetry refuses symlinks");
18
+ }
19
+ catch (error) {
20
+ if (error.code !== "ENOENT")
21
+ throw error;
22
+ }
23
+ }
24
+ return value;
25
+ }
26
+ function atomic(filePath, value) {
27
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
28
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
29
+ fs.writeFileSync(temporary, value, { mode: 0o600, flag: "wx" });
30
+ fs.renameSync(temporary, filePath);
31
+ }
32
+ function enabled(repo) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(target(repo, CONFIG), "utf8")).enabled === true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ export function enableSessionTelemetry(repo) {
41
+ atomic(target(repo, CONFIG), `${JSON.stringify({ schemaVersion: 1, enabled: true })}\n`);
42
+ const key = target(repo, KEY);
43
+ if (!fs.existsSync(key))
44
+ atomic(key, crypto.randomBytes(32).toString("hex"));
45
+ return { schemaVersion: 1, enabled: true, localOnly: true };
46
+ }
47
+ export function disableSessionTelemetry(repo) {
48
+ atomic(target(repo, CONFIG), `${JSON.stringify({ schemaVersion: 1, enabled: false })}\n`);
49
+ return { schemaVersion: 1, enabled: false, retained: readSessionEvents(repo).length };
50
+ }
51
+ function sessionHash(repo, sessionId) {
52
+ const secret = fs.readFileSync(target(repo, KEY), "utf8").trim();
53
+ return `session_${crypto.createHmac("sha256", secret).update(sessionId).digest("hex").slice(0, 20)}`;
54
+ }
55
+ export function appendSessionEvent(repo, input) {
56
+ if (!enabled(repo))
57
+ return false;
58
+ if (!input.sessionId || input.sessionId.length > 256)
59
+ return false;
60
+ const record = {
61
+ schemaVersion: 1,
62
+ event: input.event,
63
+ session: sessionHash(repo, input.sessionId),
64
+ at: input.at ?? new Date().toISOString(),
65
+ client: input.client ?? "claude",
66
+ ...(input.context ? { context: input.context } : {}),
67
+ ...(input.toolCategory ? { toolCategory: input.toolCategory } : {}),
68
+ ...(input.operation && /^[a-z][a-z0-9_-]{0,63}$/.test(input.operation)
69
+ ? { operation: input.operation }
70
+ : {}),
71
+ ...(input.freshness ? { freshness: input.freshness } : {}),
72
+ ...(typeof input.latencyMs === "number" ? { latencyMs: input.latencyMs } : {}),
73
+ ...(typeof input.cacheHit === "boolean" ? { cacheHit: input.cacheHit } : {}),
74
+ };
75
+ const filePath = target(repo, EVENTS);
76
+ fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
77
+ return true;
78
+ }
79
+ export function readSessionEvents(repo, retentionDays, now = new Date()) {
80
+ const filePath = target(repo, EVENTS);
81
+ if (!fs.existsSync(filePath))
82
+ return [];
83
+ const cutoff = retentionDays === undefined ? undefined : now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
84
+ return fs
85
+ .readFileSync(filePath, "utf8")
86
+ .split(/\r?\n/)
87
+ .filter(Boolean)
88
+ .flatMap((line) => {
89
+ try {
90
+ const value = JSON.parse(line);
91
+ if (value.schemaVersion !== 1 || typeof value.session !== "string")
92
+ return [];
93
+ if (cutoff !== undefined) {
94
+ const timestamp = typeof value.at === "string" ? Date.parse(value.at) : Number.NaN;
95
+ if (!Number.isFinite(timestamp) || timestamp < cutoff)
96
+ return [];
97
+ }
98
+ return [value];
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ });
104
+ }
105
+ export function summarizeAdoption(events) {
106
+ const sessions = new Set(events.map(({ session }) => session));
107
+ const evidenceOperations = new Set(["context", "explain", "review", "map", "search", "query"]);
108
+ const knodinSessions = new Set(events
109
+ .filter(({ event, toolCategory, operation }) => event === "tool_success" &&
110
+ toolCategory === "knodin" &&
111
+ operation !== undefined &&
112
+ evidenceOperations.has(operation))
113
+ .map(({ session }) => session));
114
+ let fallbackTraversalAfterKnodin = 0;
115
+ let evidenceBeforeEdit = 0;
116
+ const seenKnodin = new Set();
117
+ for (const event of events) {
118
+ if (event.event === "turn_start")
119
+ seenKnodin.delete(event.session);
120
+ if (event.toolCategory === "knodin" &&
121
+ event.event === "tool_success" &&
122
+ event.operation !== undefined &&
123
+ evidenceOperations.has(event.operation))
124
+ seenKnodin.add(event.session);
125
+ if (event.event === "tool_success" &&
126
+ event.toolCategory === "manual_traversal" &&
127
+ seenKnodin.has(event.session))
128
+ fallbackTraversalAfterKnodin += 1;
129
+ if (event.event === "tool_success" &&
130
+ event.toolCategory === "edit" &&
131
+ seenKnodin.has(event.session))
132
+ evidenceBeforeEdit += 1;
133
+ if (event.event === "turn_end")
134
+ seenKnodin.delete(event.session);
135
+ }
136
+ return {
137
+ sessions: sessions.size,
138
+ contextDelivered: events.filter(({ event, context }) => event === "session_start" && context === "delivered").length,
139
+ contextDegraded: events.filter(({ event, context }) => event === "session_start" && context === "degraded").length,
140
+ knodinSessions: knodinSessions.size,
141
+ turns: events.filter(({ event }) => event === "turn_start").length,
142
+ fallbackTraversalAfterKnodin,
143
+ evidenceBeforeEdit,
144
+ correctiveRoundTrips: null,
145
+ };
146
+ }
147
+ export function sessionTelemetryStatus(repo, retentionDays) {
148
+ const records = readSessionEvents(repo, retentionDays);
149
+ return {
150
+ schemaVersion: 1,
151
+ enabled: enabled(repo),
152
+ localOnly: true,
153
+ records: records.length,
154
+ adoption: summarizeAdoption(records),
155
+ };
156
+ }
157
+ export function clearSessionTelemetry(repo) {
158
+ const filePath = target(repo, EVENTS);
159
+ const records = readSessionEvents(repo).length;
160
+ const bytes = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
161
+ fs.rmSync(filePath, { force: true });
162
+ return { removed: bytes > 0, records, bytes };
163
+ }
package/docs/CLI.md CHANGED
@@ -32,6 +32,23 @@ example repository existence, mutually exclusive configuration modes, and
32
32
  graph-query target rules), but syntax cannot reach a handler unless the shared
33
33
  declarative model accepts it first.
34
34
 
35
+ Optional Claude lifecycle integration is explicit and user-global:
36
+
37
+ ```bash
38
+ knodin agent-hooks install --client claude --dry-run
39
+ knodin agent-hooks install --client claude
40
+ knodin agent-hooks status --client claude
41
+ knodin agent-hooks uninstall --client claude
42
+ ```
43
+
44
+ Installation atomically merges knodin-owned `SessionStart`, turn, tool, and
45
+ session-end handlers into the user's Claude settings. It preserves unrelated
46
+ settings and hooks, including aidev-track, and uninstall removes only exact
47
+ knodin entries. SessionStart emits at most 600 tokens, 8 KiB, and 12 bounded
48
+ orientation items. Hooks fail open and no-op outside initialized repositories.
49
+ The internal `agent-event` command is machine-facing and consumes bounded JSON
50
+ from stdin; it is not a general event-ingestion API.
51
+
35
52
  `knodin query resource_reachability --limit 100` runs cached, on-demand static
36
53
  TS/JS source-to-sink analysis. It covers literal `process.env` and
37
54
  `fs.readFileSync` reads reaching `console.log`, `fetch`, or `db.query` through
@@ -205,6 +205,17 @@ idempotent. Local fixtures use zero hosted spend, no credentials, and no source
205
205
  egress. Diagnostic archives remain governed by the privacy-safe preview and
206
206
  archive contract in [`DIAGNOSTICS.md`](DIAGNOSTICS.md).
207
207
 
208
+ Claude users may explicitly install one user-global lifecycle adapter after
209
+ previewing its settings merge:
210
+
211
+ ```bash
212
+ knodin agent-hooks install --client claude --dry-run
213
+ knodin agent-hooks install --client claude
214
+ ```
215
+
216
+ This is independent of `knodin init` and telemetry persistence. Remove only
217
+ knodin-owned entries with `knodin agent-hooks uninstall --client claude`.
218
+
208
219
  Upgrade with the manager that owns the executable:
209
220
 
210
221
  ```bash
package/docs/TELEMETRY.md CHANGED
@@ -25,13 +25,31 @@ knodin telemetry export
25
25
  knodin telemetry clear
26
26
  ```
27
27
 
28
+ Claude adoption evidence is an independent repository-local opt-in:
29
+
30
+ ```text
31
+ knodin telemetry enable
32
+ knodin telemetry disable
33
+ ```
34
+
35
+ When enabled and the optional Claude hooks are installed, knodin records HMAC'd
36
+ session identity, lifecycle event kind, turn boundaries, coarse tool category,
37
+ supported knodin operation, freshness, context delivery, cache state, and
38
+ latency. It never persists prompts, transcript paths, source, raw tool input or
39
+ output, shell command text, usernames, or absolute paths. Disable stops new
40
+ session events without deleting retained history; `clear` removes both
41
+ operation and session event stores.
42
+
28
43
  `--retention-days 1..3650` changes the read/persistence window. `--input` and
29
44
  `--output` accept only repository-contained, non-symlink paths. `clear` is the
30
45
  only command that deletes the private JSONL file; report and export never do.
31
46
 
32
47
  The default report is `.knodin/telemetry-report.html`. It includes summary,
33
48
  operation, time-series, private repository, indexing-cost, latency, freshness,
34
- and fidelity views with light/dark styling. The default machine-readable export
49
+ fidelity, session adoption, evidence-before-edit, and observed fallback-traversal
50
+ views with light/dark styling. Ordinary hooks cannot establish why a follow-up
51
+ occurred, so corrective round trips and task success remain unavailable unless
52
+ supplied by a controlled outcome replay. The default machine-readable export
35
53
  is `.knodin/telemetry-export.json` and labels measured, modeled, and unknown
36
54
  baselines explicitly. Neither artifact needs a daemon, hosted service,
37
55
  authentication, or source egress.
@@ -0,0 +1,22 @@
1
+ # knodin 0.8.4
2
+
3
+ This release adds optional, bounded coding-session orientation and local
4
+ adoption evidence while preserving knodin's freshness and privacy boundaries.
5
+
6
+ - Adds an explicit, idempotent Claude hook installer for compact SessionStart
7
+ context. Injection is limited to 600 tokens, 8 KiB, and 12 items, reports its
8
+ bounds, rejects stale cached evidence, and fails open outside initialized
9
+ repositories.
10
+ - Adds opt-in, repository-local session telemetry and an adoption dashboard for
11
+ delivered context, successful graph-evidence operations, fallback traversal,
12
+ and evidence-before-edit observations.
13
+ - Persists only coarse allowlisted metadata and locally keyed session hashes;
14
+ prompts, source, raw tool input, commands, paths, and unhashed session IDs are
15
+ not retained.
16
+ - Keeps corrective round trips explicitly unavailable because ordinary hook
17
+ events cannot establish why an agent made a follow-up call or prove a causal
18
+ productivity improvement.
19
+ - Adds C102's preregistered paired-evaluation design so future outcome claims
20
+ require replay evidence rather than being inferred from adoption counters.
21
+
22
+ This remains an ordinary 0.x release, not a dogfood-only build and not GA.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,6 +59,7 @@
59
59
  "docs/releases/0.8.0.md",
60
60
  "docs/releases/0.8.2.md",
61
61
  "docs/releases/0.8.3.md",
62
+ "docs/releases/0.8.4.md",
62
63
  "docs/assets/knodin-favicon.svg",
63
64
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
64
65
  "docs/TELEMETRY.md",
@@ -137,6 +137,7 @@ new items or strengthen existing ones; they do not reopen the archived roadmap.
137
137
  | C99 | P1 | implemented | Resolve bounded TypeScript dependency-injection wiring | C2, C3, C24, C95 |
138
138
  | C100 | P1 | implemented | Prove bounded Salesforce Flow-to-Apex paths | C2, C3, C20, C23, C95, C96 |
139
139
  | C101 | P1 | implemented | Strengthen contract-led positioning | C95, C97, C98, C99, C100 |
140
+ | C102 | P1 | implemented — opt-in observational evidence only | Add bounded Claude session context and adoption evidence | C3, C16, C61, C93, C95 |
140
141
 
141
142
  ### C98 — Bounded source-to-sink resource reachability
142
143
 
@@ -192,6 +193,25 @@ new items or strengthen existing ones; they do not reopen the archived roadmap.
192
193
  commits normally form one reasonably sized shared PR; assistants do not
193
194
  auto-open per-item PRs, and unrelated changes are not bundled for size.
194
195
 
196
+ ### C102 — Bounded Claude session context and adoption evidence
197
+
198
+ - Status: implemented — opt-in observational evidence only
199
+ - Priority: P1
200
+ - DependsOn: C3, C16, C61, C93, C95
201
+ - Evidence: `src/agent-hooks.ts`, `src/agent-events.ts`,
202
+ `src/session-telemetry.ts`, focused unit/CLI tests, and
203
+ `benchmarks/evaluations/c102-session-adoption/README.md`.
204
+ - Implementation: an explicit user-global Claude hook installer atomically
205
+ preserves unrelated settings and supports status, dry-run, and targeted
206
+ uninstall. SessionStart injects cached, freshness-aware orientation under
207
+ 600 tokens, 8 KiB, and 12 items. Separate repository-local telemetry opt-in
208
+ records private lifecycle categories and extends the existing local report.
209
+ - Bounds: ordinary hooks prove neither task success nor why a follow-up turn
210
+ occurred. The dashboard labels corrective round trips unavailable, stores no
211
+ prompt/source/command/transcript/tool-output bodies, and authorizes no
212
+ productivity or superiority claim. Claude is the only supported client for
213
+ this adapter; the event model remains client-neutral for later evaluation.
214
+
195
215
  ## Roadmap completion contract
196
216
 
197
217
  This section is the control plane for completing this roadmap with a persistent