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 +7 -1
- package/dist/bin/cli.js +228 -59
- package/dist/src/agent-events.js +128 -0
- package/dist/src/agent-hooks.js +156 -0
- package/dist/src/cli-model.js +16 -1
- package/dist/src/output-telemetry.js +8 -3
- package/dist/src/session-telemetry.js +163 -0
- package/docs/CLI.md +17 -0
- package/docs/INSTALLATION.md +11 -0
- package/docs/TELEMETRY.md +19 -1
- package/docs/releases/0.8.4.md +22 -0
- package/package.json +2 -1
- package/roadmap/competitive-roadmap.md +20 -0
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.
|
|
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
|
-
//
|
|
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 === "
|
|
1977
|
-
result =
|
|
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
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
if (argument
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
"
|
|
2147
|
-
"
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
"
|
|
2154
|
-
"
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
+
}
|
package/dist/src/cli-model.js
CHANGED
|
@@ -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([
|
|
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
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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
|