knodin 0.8.2 → 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
@@ -31,16 +31,32 @@ one persistent local graph.
31
31
  - **Adopt locally:** initialize the checkout you already have and use the CLI or
32
32
  connect one MCP gateway.
33
33
 
34
- The practical payoff is less manual context assembly, fewer agent round trips,
35
- and fewer dependencies missed during reviewwith no hosted-service or
36
- source-egress overhead.
37
-
38
- The checked [behavioral-contract work](docs/BEHAVIORAL-CONTRACT.md) declares
39
- each production operation's intended guarantees and release-gates
40
- composition, recovery, confidence, and locality fixtures across every production
41
- operation/action path and declared egress boundary. C95 is implemented and
42
- terminal under the checked verifier and roadmap acceptance criteria; its stated
43
- limitations remain product bounds, not unsupported superiority claims.
34
+ The practical value is less manual context assembly and fewer dependencies
35
+ missed during review, with no hosted-service or source-egress overhead. Fewer
36
+ agent round trips are an intended payoff; the narrow C93 replay measured no
37
+ round-trip difference, so knodin does not claim a general productivity effect.
38
+
39
+ Four checked answers define the product rather than a feature-count claim:
40
+
41
+ - **Value:** less manual context assembly and fewer missed dependencies. C93
42
+ records the paired task method; C100 narrows one missed-dependency result from
43
+ a Salesforce class file to the exact Flow-invoked Apex method.
44
+ - **ROI:** the intended mechanism is fewer corrective agent round trips without
45
+ hosted-service, authentication, or source-egress overhead. C93's accepted
46
+ two-task replay measured no round-trip benefit (both arms required one
47
+ correction), so this remains a product hypothesis rather than a measured
48
+ productivity claim. Billed cost and unobserved tokens remain unknown.
49
+ - **Tomorrow:** run `knodin init`, check `knodin status`, then use the local CLI
50
+ or single MCP gateway against the checkout already on disk.
51
+ - **Secret sauce:** stable identity, exact source evidence, explicit freshness,
52
+ and truthful budgets compose behind one compact local tool.
53
+
54
+ The checked [behavioral contract](docs/BEHAVIORAL-CONTRACT.md) release-gates
55
+ that composition. C101 binds the bounded resource-reachability, TypeScript DI,
56
+ and Salesforce Flow-to-Apex evidence to the retained one-process semantic
57
+ design. It rejects stale, truncated, ambiguous, or omission-only output when a
58
+ mutation tries to promote it into a complete or exact claim. These are
59
+ fixture-scoped guarantees with recorded limits, not universal superiority.
44
60
 
45
61
  ## How it compares
46
62
 
@@ -50,10 +66,11 @@ context over one locally persisted index.
50
66
 
51
67
  | Job | Typical specialized tools | knodin |
52
68
  |---|---|---|
53
- | Symbol navigation | CodeGraph, Serena, Sourcegraph | Ambiguity-safe definitions, callers, callees, paths, and verbatim source |
69
+ | Symbol navigation | CodeGraph, Serena, Sourcegraph | Ambiguity-safe definitions, callers, callees, paths, bounded Inversify/tsyringe DI edges, and verbatim source |
54
70
  | Change impact and review | code-review-graph, Greptile, Graphify | Diff-aware review, impact traversal, tests, risks, and PR triage |
55
71
  | Architecture mapping | Graphify, GitNexus | Communities, hubs, boundaries, flows, wiki, and local visualization |
56
72
  | Search and context assembly | Aider repo map, Repomix, semantic search tools | Hybrid search, bounded source packs, and recoverable diagnostic compression |
73
+ | Bounded resource and framework relationships | Code-Graph-RAG and framework-aware analyzers | Literal TS/JS resource paths, declared Inversify/tsyringe wiring, and one exact Salesforce Flow-to-Apex boundary, each with explicit omissions |
57
74
  | Deployment | Hosted services or separate local tools | One local process, SQLite graph, CLI, and one MCP gateway |
58
75
 
59
76
  ### Measured results
@@ -159,7 +176,7 @@ knodin repair
159
176
  | Command | Purpose |
160
177
  |---|---|
161
178
  | `knodin context` | Compact repository orientation and next-operation hint |
162
- | `knodin explain <symbol>` | Source, identity, callers, callees, and blast radius |
179
+ | `knodin explain <symbol>` | Source, identity, typed callers/callees, static-analysis omissions, and blast radius |
163
180
  | `knodin review` | Itemized graph impact, test gaps, centrality, and bounded Git-history evidence |
164
181
  | `knodin query …` | Impact, paths, callers, callees, tests, flows, and structured graph queries |
165
182
  | `knodin search <query>` | Explainable structural-first search with a bounded local embedding fallback |
@@ -173,6 +190,8 @@ knodin repair
173
190
  | `knodin status --deep` | Index freshness, health, hooks, and integration status |
174
191
  | `knodin repair` | Repair or rebuild unhealthy local graph state |
175
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 |
176
195
 
177
196
  Run `knodin --help` or read the [CLI reference](docs/CLI.md) for complete syntax.
178
197
 
@@ -201,7 +220,11 @@ repository, and lifecycle capabilities. See the [MCP guide](docs/MCP.md).
201
220
  Each checkout stores its graph and lifecycle state in `.knodin/`. Shared model
202
221
  files live in the user cache rather than being duplicated per repository.
203
222
  Source and graph data stay local. Telemetry is metadata-only, disabled by
204
- 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
205
228
  explicitly enabled, local-only, bounded, and never uploaded automatically.
206
229
 
207
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";
@@ -158,7 +161,11 @@ function formatRepairHuman(result) {
158
161
  return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin init\`, then \`knodin status\`.\n`;
159
162
  return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols).\n`;
160
163
  }
161
- return `Repair finished with remaining issues. Run \`knodin status --deep\` for details.\n`;
164
+ const outstanding = result.outstandingIssues?.total ??
165
+ result.after.missing.files.length + result.after.missing.records.length;
166
+ const firstIssue = result.after.missing.files[0] ?? result.after.missing.records[0];
167
+ const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
168
+ return `Repair finished with ${outstanding.toLocaleString()} remaining issue(s).${detail} Run \`knodin status --deep\` for details.\n`;
162
169
  }
163
170
  function formatIndexHuman(result) {
164
171
  if (result.indexed.length === 0 && result.unchanged.length > 0) {
@@ -680,9 +687,9 @@ async function main() {
680
687
  };
681
688
  const repoFlag = selectorValue("--repo");
682
689
  const runtimeCommand = resolveCliRuntimeCommand(process);
683
- // 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.
684
691
  // when an older installed hook predates the explicit --json argument.
685
- const jsonOutput = invocation.options.json === true || cmd === "hook-refresh";
692
+ const jsonOutput = invocation.options.json === true || cmd === "hook-refresh" || cmd === "agent-event";
686
693
  // `--json` is a shared output flag. Repair owns its richer --json/--jsonl
687
694
  // parser; all other commands receive their original arguments minus it.
688
695
  const rest = cmd === "repair" ? rawRest : rawRest.filter((argument) => argument !== "--json");
@@ -1071,12 +1078,43 @@ async function main() {
1071
1078
  background.unref();
1072
1079
  }
1073
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
+ }
1074
1107
  const resolved = resolveRepo(repoFlag, process.cwd());
1075
1108
  if (!resolved.ok) {
1109
+ // Global agent hooks also run outside repositories and must fail open.
1110
+ if (cmd === "agent-event")
1111
+ return;
1076
1112
  process.stderr.write(`${resolved.error}\n`);
1077
1113
  process.exit(1);
1078
1114
  }
1079
1115
  const repo = resolved.repo;
1116
+ if (cmd === "agent-event" && !fs.existsSync(resolveDbPath(repo)))
1117
+ return;
1080
1118
  if (cmd === "doctor") {
1081
1119
  const gitProbe = spawnSync(gitExecutable(), ["rev-parse", "--is-inside-work-tree"], {
1082
1120
  cwd: repo,
@@ -1227,6 +1265,7 @@ async function main() {
1227
1265
  let repairWasPlan = false;
1228
1266
  let repairExitCode = 0;
1229
1267
  let statusWasWatched = false;
1268
+ let agentEventOutput = false;
1230
1269
  const graphRead = async (run) => {
1231
1270
  const health = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
1232
1271
  if (!health.available) {
@@ -1242,6 +1281,103 @@ async function main() {
1242
1281
  return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
1243
1282
  };
1244
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
+ }
1245
1381
  case "doctor": {
1246
1382
  const client = selectorValue("--client");
1247
1383
  if (client !== undefined &&
@@ -1781,7 +1917,7 @@ async function main() {
1781
1917
  ? ""
1782
1918
  : (rest[1] ?? "");
1783
1919
  if (!pattern) {
1784
- process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|batch_outline|project_overview|shortest_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
1920
+ process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|batch_outline|project_overview|shortest_path|cross_substrate_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|resource_reachability|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
1785
1921
  process.exit(1);
1786
1922
  }
1787
1923
  const directionValue = selectorValue("--direction");
@@ -1800,7 +1936,15 @@ async function main() {
1800
1936
  }
1801
1937
  // shortest_path: `knodin query shortest_path <from> <to>`
1802
1938
  // rename_preview: `knodin query rename_preview <old> <new>`
1803
- const to = pattern === "shortest_path" || pattern === "rename_preview" ? rest[2] : undefined;
1939
+ const to = pattern === "shortest_path" ||
1940
+ pattern === "cross_substrate_path" ||
1941
+ pattern === "rename_preview"
1942
+ ? rest[2]
1943
+ : undefined;
1944
+ if (pattern === "cross_substrate_path" && !to) {
1945
+ process.stderr.write("knodin query cross_substrate_path requires <from> and <to>\n");
1946
+ process.exit(1);
1947
+ }
1804
1948
  if (pattern === "rename_preview" && !to) {
1805
1949
  process.stderr.write("knodin query rename_preview requires <old> and <new>\n");
1806
1950
  process.exit(1);
@@ -1863,6 +2007,11 @@ async function main() {
1863
2007
  : undefined,
1864
2008
  includeDataFlow: pattern === "traverse" ? rest.includes("--data-flow") : undefined,
1865
2009
  flowVariable: pattern === "flow_analysis" ? selectorValue("--variable") : undefined,
2010
+ resourceOffset: pattern === "resource_reachability" && selectorValue("--offset")
2011
+ ? Number(selectorValue("--offset"))
2012
+ : undefined,
2013
+ resourceMaxBytes: pattern === "resource_reachability" ? responseBudget.bytes : undefined,
2014
+ resourceMaxTokens: pattern === "resource_reachability" ? responseBudget.tokens : undefined,
1866
2015
  architectureFacets: facetsValue,
1867
2016
  topN: selectorValue("--top") ? Number(selectorValue("--top")) : undefined,
1868
2017
  sort: selectorValue("--sort"),
@@ -1956,16 +2105,23 @@ async function main() {
1956
2105
  if (!Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650)
1957
2106
  throw new Error("knodin telemetry: --retention-days must be an integer from 1 to 3650");
1958
2107
  const input = selectorValue("--input");
1959
- if (action === "status")
1960
- 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
+ };
1961
2117
  else if (action === "report")
1962
- result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
2118
+ result = writeTelemetryReport(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
1963
2119
  else if (action === "export")
1964
- result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"));
2120
+ result = exportTelemetry(repo, readTelemetryRecords(repo, input, retentionDays), selectorValue("--output"), readSessionEvents(repo, retentionDays));
1965
2121
  else if (action === "clear")
1966
- result = clearTelemetry(repo, input);
2122
+ result = { ...clearTelemetry(repo, input), session: clearSessionTelemetry(repo) };
1967
2123
  else
1968
- 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");
1969
2125
  break;
1970
2126
  }
1971
2127
  case "diagnostics": {
@@ -2057,6 +2213,11 @@ async function main() {
2057
2213
  process.exit(1);
2058
2214
  }
2059
2215
  await engine.close();
2216
+ if (agentEventOutput) {
2217
+ if (result !== null && result !== undefined)
2218
+ process.stdout.write(`${JSON.stringify(result)}\n`);
2219
+ return;
2220
+ }
2060
2221
  if (statusWasWatched)
2061
2222
  return;
2062
2223
  const boundedResult = applyResponseBudget(result, cmd, responseBudget, {
@@ -2111,55 +2272,80 @@ try {
2111
2272
  }
2112
2273
  catch (err) {
2113
2274
  const argv = process.argv.slice(2);
2114
- const repoIndex = argv.indexOf("--repo");
2115
- const equalsRepo = argv.find((argument) => argument.startsWith("--repo="));
2116
- const explicitRepo = repoIndex >= 0 ? argv[repoIndex + 1] : undefined;
2117
- let candidate = process.cwd();
2118
- if (explicitRepo)
2119
- candidate = explicitRepo;
2120
- else if (equalsRepo)
2121
- candidate = equalsRepo.slice("--repo=".length);
2122
- const command = argv.find((argument, index) => {
2123
- if (argument.startsWith("-"))
2124
- return false;
2125
- return !(index > 0 && argv[index - 1] === "--repo") && argument !== candidate;
2126
- });
2127
- const knownCommands = new Set([
2128
- "init",
2129
- "configure",
2130
- "index",
2131
- "doctor",
2132
- "status",
2133
- "wait",
2134
- "repair",
2135
- "serve",
2136
- "context",
2137
- "explain",
2138
- "review",
2139
- "map",
2140
- "search",
2141
- "query",
2142
- "rename",
2143
- "wiki",
2144
- "visualize",
2145
- "pack",
2146
- "compress",
2147
- "prs",
2148
- "worktrees",
2149
- "telemetry",
2150
- "diagnostics",
2151
- "system",
2152
- "repos",
2153
- "remote",
2154
- "update",
2155
- ]);
2156
- const diagnostic = recordDiagnosticFailure(candidate, {
2157
- surface: "cli",
2158
- operation: command && knownCommands.has(command) ? command : "unknown",
2159
- phase: "dispatch",
2160
- error: err,
2161
- });
2162
- const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2163
- console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
2164
- 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
+ }
2165
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
+ }