knodin 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +173 -14
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/class-consumer-contract.js +18 -0
  6. package/dist/src/class-consumer-cursor.js +91 -0
  7. package/dist/src/class-consumer-delivery.js +22 -0
  8. package/dist/src/class-consumer-page.js +148 -0
  9. package/dist/src/cli-model.js +9 -3
  10. package/dist/src/docs-sections.js +1 -0
  11. package/dist/src/engine/apex-class-uses.js +430 -0
  12. package/dist/src/engine/apex-entry-points.js +98 -0
  13. package/dist/src/engine/apex-receiver.js +301 -0
  14. package/dist/src/engine/embedding-reuse.js +57 -0
  15. package/dist/src/engine/embeddings.js +22 -0
  16. package/dist/src/engine/index-coverage.js +215 -0
  17. package/dist/src/engine/index.js +2495 -252
  18. package/dist/src/engine/salesforce-components.js +460 -0
  19. package/dist/src/engine/seal.js +3 -0
  20. package/dist/src/engine/sqlite.js +44 -0
  21. package/dist/src/evidence-bundle.js +283 -0
  22. package/dist/src/evidence-graph.js +163 -0
  23. package/dist/src/failure-diagnosis.js +80 -5
  24. package/dist/src/file-dependency.js +35 -0
  25. package/dist/src/graph-query-health.js +47 -1
  26. package/dist/src/implementation-search.js +69 -0
  27. package/dist/src/index-coverage-read.js +33 -0
  28. package/dist/src/investigation.js +195 -0
  29. package/dist/src/mcp-reliability.js +4 -0
  30. package/dist/src/mcp-worker-supervisor.js +122 -6
  31. package/dist/src/progressive-evidence.js +4 -4
  32. package/dist/src/response-budget.js +129 -3
  33. package/dist/src/server.js +18 -5
  34. package/dist/src/shared-index/publisher.js +41 -1
  35. package/dist/src/tools/knodin-tools.js +224 -41
  36. package/docs/CLI.md +92 -0
  37. package/docs/MCP.md +67 -0
  38. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  39. package/docs/SALESFORCE-BINDINGS.md +121 -0
  40. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  41. package/docs/SCOPED-INDEXING.md +76 -0
  42. package/docs/apex-receiver-resolution.md +41 -0
  43. package/docs/releases/0.13.0.md +62 -0
  44. package/docs/structural-only-indexing.md +20 -0
  45. package/package.json +11 -5
package/README.md CHANGED
@@ -150,7 +150,7 @@ Start broad, inspect the likely change surface, then review the actual diff:
150
150
 
151
151
  ```bash
152
152
  # Orient on an unfamiliar checkout
153
- knodin context
153
+ knodin context "understand this checkout"
154
154
 
155
155
  # Explain a symbol with source and relationships
156
156
  knodin explain createServer
@@ -170,6 +170,21 @@ knodin map
170
170
  knodin pack createServer --max-tokens 4000
171
171
  ```
172
172
 
173
+ For a task that needs an implementation and its dependencies:
174
+
175
+ ```bash
176
+ knodin context "find code that persists an order" --implementations --json
177
+ knodin context "rename persist" --symbol persist --file src/storage.ts --json
178
+ knodin evidence expand --files src/storage.ts --items 100 --json
179
+ ```
180
+
181
+ Discovery returns candidates for you to select; investigation collects source,
182
+ callers, importers, tests, and impact for the selected identity. Evidence bundles
183
+ page exact source and issue reusable per-file receipts after complete delivery.
184
+ Ambiguity, incomplete coverage, and budget limits stay visible. See the
185
+ [CLI guide](docs/CLI.md#task-discovery-and-investigation) and
186
+ [evidence protocol](docs/PROGRESSIVE-EVIDENCE.md#graph-backed-file-bundles).
187
+
173
188
  If freshness or lifecycle checks fail:
174
189
 
175
190
  ```bash
package/dist/bin/cli.js CHANGED
@@ -20,6 +20,10 @@ import { inspectClaudeAgentHooks, installClaudeAgentHooks, uninstallClaudeAgentH
20
20
  import { detectSupportedAgents, parseInitScope, } from "../src/agent-integration.js";
21
21
  import { refreshExternalGraphArtifacts, writeArtifactRefreshRecord, } from "../src/artifact-refresh.js";
22
22
  import { formatBackupHuman, installBackupRetention, listBackups, maybeRunOpportunisticRetention, pruneBackups, removeBackupRetention, retentionDoctor, retentionStatus, runInstalledRetention, } from "../src/backup-retention.js";
23
+ import { ClassConsumerQueryError, } from "../src/class-consumer-contract.js";
24
+ import { readClassConsumerCursor } from "../src/class-consumer-cursor.js";
25
+ import { validateClassConsumerDelivery } from "../src/class-consumer-delivery.js";
26
+ import { finalizeClassConsumerPage } from "../src/class-consumer-page.js";
23
27
  import { checkIndexed, extractPositionals, extractRepoFlag, parseReviewArgs, planIndex, resolveCliRuntimeCommand, resolveRepo, } from "../src/cli-args.js";
24
28
  import { helpCommandPath, parseCliInvocation, renderCliHelp } from "../src/cli-model.js";
25
29
  import { buildKnodinContext } from "../src/context.js";
@@ -31,19 +35,24 @@ import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PA
31
35
  import { runSeal } from "../src/engine/seal-command.js";
32
36
  import { runSealedQuery } from "../src/engine/sealed-query.js";
33
37
  import { resolveDbPath } from "../src/engine/state-paths.js";
38
+ import { deliverEvidenceBundle } from "../src/evidence-bundle.js";
39
+ import { createEvidenceGraphAdapter } from "../src/evidence-graph.js";
34
40
  import { diagnoseFailure, } from "../src/failure-diagnosis.js";
35
41
  import { gitExecutable } from "../src/git-executable.js";
36
42
  import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../src/graph-query-health.js";
43
+ import { findImplementationCandidates } from "../src/implementation-search.js";
37
44
  import { createIndexActivityReporter } from "../src/index-activity.js";
45
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "../src/index-coverage-read.js";
38
46
  import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, repairLifecycleRouting, } from "../src/init.js";
39
47
  import { createInitProgressRenderer } from "../src/init-progress.js";
48
+ import { buildTaskInvestigation } from "../src/investigation.js";
40
49
  import { attachLifecycleHealth, attachRepairLifecycle } from "../src/lifecycle-health.js";
41
50
  import { acknowledgeUpdateFailure, queryOwnerAvailability, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setManualPin, unpinUpdate, updateAttention, writeManagerUpdateState, } from "../src/manager-update.js";
42
51
  import { addMirror, listMirrors, refreshMirror, removeMirror } from "../src/mirror.js";
43
52
  import { compressOutput, compressOutputFile, deleteOutputArtifact, readOutputArtifact, } from "../src/output-compression.js";
44
53
  import { clearTelemetry, exportTelemetry, readTelemetryRecords, telemetryStatus, writeTelemetryReport, } from "../src/output-telemetry.js";
45
54
  import { auditPullRequests } from "../src/pr-triage.js";
46
- import { deliverProgressiveEvidence, } from "../src/progressive-evidence.js";
55
+ import { deliverProgressiveEvidence, evidenceHandleSecret, } from "../src/progressive-evidence.js";
47
56
  import { acquireRepairLease } from "../src/repair-lease.js";
48
57
  import { createRepairPlan, createRepairProgressRenderer, parseRepairCliArgs, resolveRepairProgressMode, serializeRepairJsonlRecord, } from "../src/repair-progress.js";
49
58
  import { runRepositoryInitializationProcess } from "../src/repository-init-process.js";
@@ -161,15 +170,21 @@ function formatSemanticGap(readiness) {
161
170
  }
162
171
  function formatIndexHuman(result) {
163
172
  const semantic = formatSemanticGap(result.semanticReadiness);
173
+ const scope = formatIndexCoverageHuman(result.indexCoverage);
164
174
  if (result.indexed.length === 0 && result.unchanged.length > 0) {
165
175
  const noun = result.unchanged.length === 1 ? "file" : "files";
166
- return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n`;
176
+ return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n${scope}\n`;
167
177
  }
168
178
  const unchanged = result.unchanged.length > 0
169
179
  ? `; ${result.unchanged.length.toLocaleString()} already current`
170
180
  : "";
171
181
  const noun = result.indexed.length === 1 ? "file" : "files";
172
- return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n`;
182
+ return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n${scope}\n`;
183
+ }
184
+ function formatIndexCoverageHuman(coverage) {
185
+ if (!coverage)
186
+ return "Local graph index coverage is unknown; a negative graph answer does not establish absence.";
187
+ return `Local graph index coverage: ${coverage.mode}; intent ${coverage.intentState}; repository inventory complete: ${coverage.repositoryComplete}. ${indexCoverageNotice(coverage) ?? "Inventory extent does not establish complete static relationships or runtime use."}`;
173
188
  }
174
189
  function formatIndexVerificationError(result) {
175
190
  const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
@@ -414,7 +429,7 @@ function formatStatusHuman(result) {
414
429
  const indexedCounts = result.coverage.countsUnknown
415
430
  ? "indexed and symbol counts unknown; the graph could not be read"
416
431
  : `${result.coverage.indexedFiles} indexed files, ${result.coverage.filesWithSymbols} files with symbols`;
417
- const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}`;
432
+ const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}. ${formatIndexCoverageHuman(result.indexCoverage)}`;
418
433
  if (result.status === "indexing" && result.activity) {
419
434
  const count = result.activity.phaseTotal === undefined
420
435
  ? ""
@@ -1537,6 +1552,8 @@ async function main() {
1537
1552
  return attemptOpportunisticSharedRestore(repo, engine);
1538
1553
  };
1539
1554
  let result;
1555
+ let finalizedClassConsumer = false;
1556
+ let classConsumerErrorContract;
1540
1557
  let repairOutput;
1541
1558
  let repairWasPlan = false;
1542
1559
  let repairWasLifecycle = false;
@@ -1555,7 +1572,7 @@ async function main() {
1555
1572
  process.exitCode = 1;
1556
1573
  return verified;
1557
1574
  }
1558
- return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
1575
+ return decorateGraphQueryResult(value, verified.state, verified.graph.freshness, verified.graph.indexCoverage, health.graph.indexCoverage);
1559
1576
  };
1560
1577
  switch (cmd) {
1561
1578
  case "shared": {
@@ -1830,10 +1847,11 @@ async function main() {
1830
1847
  }
1831
1848
  const freshness = status.freshness?.state ?? "unknown";
1832
1849
  const cacheKey = JSON.stringify({
1833
- schemaVersion: 1,
1850
+ schemaVersion: 2,
1834
1851
  head: status.freshness?.currentHead,
1835
1852
  fingerprint: status.freshness?.workingTree?.indexedFingerprint,
1836
1853
  generation: status.indexGeneration,
1854
+ indexCoverage: indexCoverageCore(status.indexCoverage),
1837
1855
  budgets: [600, 8192, 12],
1838
1856
  });
1839
1857
  const cacheEligible = canUseSessionContextCache(status.status, freshness);
@@ -1848,7 +1866,11 @@ async function main() {
1848
1866
  cacheHit = false;
1849
1867
  try {
1850
1868
  const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
1851
- text = renderSessionContext(context, freshness);
1869
+ const verified = await engine.status(repo, { audit: "cached" });
1870
+ if (indexCoverageSnapshot(status.indexCoverage) !==
1871
+ indexCoverageSnapshot(verified.indexCoverage))
1872
+ throw new Error("Index coverage changed during session context generation");
1873
+ text = renderSessionContext(context, freshness, verified.indexCoverage);
1852
1874
  writeSessionContextCache(repo, cacheKey, text);
1853
1875
  }
1854
1876
  catch {
@@ -2687,6 +2709,24 @@ async function main() {
2687
2709
  case "evidence": {
2688
2710
  const level = rest[0];
2689
2711
  const file = rest[1];
2712
+ const files = selectorValue("--files")?.split(",");
2713
+ if (files) {
2714
+ if (level !== "evidence" && level !== "expand")
2715
+ throw new Error("Evidence bundles require evidence or expand level");
2716
+ if (file && !file.startsWith("--"))
2717
+ throw new Error("Choose a file or --files, not both");
2718
+ result = await deliverEvidenceBundle({
2719
+ repo,
2720
+ files,
2721
+ level,
2722
+ continuation: selectorValue("--continuation"),
2723
+ alreadyPresent: selectorValue("--already-present")?.split(","),
2724
+ byteLimit: responseBudget.bytes,
2725
+ tokenLimit: responseBudget.tokens,
2726
+ itemLimit: responseBudget.items,
2727
+ }, createEvidenceGraphAdapter(engine));
2728
+ break;
2729
+ }
2690
2730
  if (!file || !["locate", "outline", "evidence", "expand"].includes(level))
2691
2731
  throw new Error("knodin evidence requires locate|outline|evidence|expand <file>");
2692
2732
  result = deliverProgressiveEvidence({
@@ -2717,7 +2757,7 @@ async function main() {
2717
2757
  ? ""
2718
2758
  : (rest[1] ?? "");
2719
2759
  if (!pattern) {
2720
- 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|file_metrics|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");
2760
+ 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|file_metrics|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|class_consumers|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
2721
2761
  process.exit(1);
2722
2762
  }
2723
2763
  const directionValue = selectorValue("--direction");
@@ -2788,6 +2828,82 @@ async function main() {
2788
2828
  process.exitCode = 1;
2789
2829
  break;
2790
2830
  }
2831
+ if (pattern === "class_consumers") {
2832
+ if (queryLimitRaw && Number(queryLimitRaw) > 1000)
2833
+ throw new Error("class_consumers --limit must not exceed 1000 sites");
2834
+ if (["--offset", "--kinds", "--relations", "--direction", "--depth", "--exclude-tests"].some((flag) => rest.includes(flag)))
2835
+ throw new Error("class_consumers supports --test-scope/--path and --continuation, not offset/kinds/relations/direction/depth/exclude-tests");
2836
+ const secret = evidenceHandleSecret(repo).secret;
2837
+ const token = selectorValue("--continuation");
2838
+ const cursor = token ? readClassConsumerCursor(token, secret) : undefined;
2839
+ const request = {
2840
+ symbol: target,
2841
+ selector,
2842
+ testScope: (testScopeValue ?? "all"),
2843
+ path: selectorValue("--path"),
2844
+ limit: queryLimitRaw ? Number(queryLimitRaw) : 100,
2845
+ ...(cursor
2846
+ ? {
2847
+ after: cursor.after,
2848
+ expectedQueryDigest: cursor.queryDigest,
2849
+ expectedSnapshotDigest: cursor.snapshotDigest,
2850
+ }
2851
+ : {}),
2852
+ };
2853
+ try {
2854
+ const page = await engine.queryClassConsumers(repo, request);
2855
+ // Defer maintenance, not the audit, until a later eligible status or close.
2856
+ const verified = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached", checkpoint: "defer" })));
2857
+ if (!verified.available) {
2858
+ result = verified;
2859
+ process.exitCode = 1;
2860
+ break;
2861
+ }
2862
+ await validateClassConsumerDelivery(page, request, (next) => engine.queryClassConsumers(repo, next));
2863
+ result = finalizeClassConsumerPage(page, {
2864
+ secret,
2865
+ budget: responseBudget,
2866
+ decorate: (candidate) => decorateGraphQueryResult(candidate, verified.state, undefined, verified.graph.indexCoverage, queryHealth?.available ? queryHealth.graph.indexCoverage : undefined),
2867
+ });
2868
+ finalizedClassConsumer = true;
2869
+ }
2870
+ catch (error) {
2871
+ if (error instanceof ClassConsumerQueryError &&
2872
+ [
2873
+ "CLASS_CONSUMER_TARGET_NOT_FOUND",
2874
+ "CLASS_CONSUMER_TARGET_AMBIGUOUS",
2875
+ "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING",
2876
+ ].includes(error.code)) {
2877
+ result = {
2878
+ available: false,
2879
+ status: "unavailable",
2880
+ pattern,
2881
+ targetResolution: error.code === "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING"
2882
+ ? "capability-missing"
2883
+ : error.code === "CLASS_CONSUMER_TARGET_NOT_FOUND"
2884
+ ? "not-found"
2885
+ : "ambiguous",
2886
+ code: error.code,
2887
+ error: error.message,
2888
+ candidates: error.candidates,
2889
+ ...(queryHealth?.available
2890
+ ? {
2891
+ indexCoverage: queryHealth.graph.indexCoverage,
2892
+ freshness: queryHealth.graph.freshness,
2893
+ }
2894
+ : {}),
2895
+ };
2896
+ classConsumerErrorContract = {
2897
+ code: error.code,
2898
+ targetResolution: result.targetResolution,
2899
+ };
2900
+ process.exitCode = 1;
2901
+ }
2902
+ else
2903
+ throw error;
2904
+ }
2905
+ break;
2906
+ }
2791
2907
  result = await engine.query(pattern, target, repo, to, queryLimitRaw ? Number(queryLimitRaw) : undefined, depth, detailLevel, selector, pattern === "impact"
2792
2908
  ? {
2793
2909
  mode: selectorValue("--impact-mode") === "file" ? "file" : "symbol",
@@ -2847,12 +2963,12 @@ async function main() {
2847
2963
  process.exitCode = 1;
2848
2964
  break;
2849
2965
  }
2850
- result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
2966
+ result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness, verifiedQueryHealth.graph.indexCoverage, queryHealth.graph.indexCoverage);
2851
2967
  }
2852
2968
  // A target that does not exist is operator error, not a negative
2853
2969
  // answer, so it exits non-zero like an unavailable graph rather than
2854
2970
  // like real dead code (KNODIN-28). `resolved` with zero rows keeps
2855
- // exit 0: the symbol exists and genuinely has no callers.
2971
+ // exit 0: no callers were found within the reported index coverage.
2856
2972
  if (result?.targetResolution === "not-found")
2857
2973
  process.exitCode = 1;
2858
2974
  break;
@@ -3034,7 +3150,26 @@ async function main() {
3034
3150
  process.stderr.write('knodin context requires a "<task>" description\n');
3035
3151
  process.exit(1);
3036
3152
  }
3037
- result = await graphRead(() => buildKnodinContext(engine, task, repo, rest[1]));
3153
+ const symbol = selectorValue("--symbol");
3154
+ if (rest.includes("--implementations")) {
3155
+ if (symbol)
3156
+ throw new Error("Choose --implementations or --symbol, not both");
3157
+ result = await graphRead(() => findImplementationCandidates(engine, repo, {
3158
+ task,
3159
+ limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
3160
+ offset: selectorValue("--offset") ? Number(selectorValue("--offset")) : undefined,
3161
+ }));
3162
+ break;
3163
+ }
3164
+ result = await graphRead(() => symbol
3165
+ ? buildTaskInvestigation(engine, repo, {
3166
+ task,
3167
+ symbol,
3168
+ ...selector,
3169
+ limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
3170
+ depth: selectorValue("--depth") ? Number(selectorValue("--depth")) : undefined,
3171
+ })
3172
+ : buildKnodinContext(engine, task, repo, rest[1]));
3038
3173
  break;
3039
3174
  }
3040
3175
  default:
@@ -3042,6 +3177,8 @@ async function main() {
3042
3177
  process.exit(1);
3043
3178
  }
3044
3179
  await engine.close();
3180
+ if (result?.available === false)
3181
+ process.exitCode = 1;
3045
3182
  if (agentEventOutput) {
3046
3183
  if (result !== null && result !== undefined)
3047
3184
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -3050,9 +3187,31 @@ async function main() {
3050
3187
  if (statusWasWatched)
3051
3188
  return;
3052
3189
  const fileMetricsOutput = cmd === "query" && rest[0] === "file_metrics";
3053
- const boundedResult = applyResponseBudget(result, cmd, responseBudget, fileMetricsOutput
3054
- ? { bytes: 16_777_216, tokens: 4_194_304, items: 100_000 }
3055
- : { bytes: 65_536, tokens: 16_384, items: 100 });
3190
+ // The bundle protocol budgets its signed manifest, source and continuation together.
3191
+ // Generic post-truncation would invalidate that recoverable envelope.
3192
+ const evidenceBundle = cmd === "evidence" && selectorValue("--files") !== undefined;
3193
+ const boundedResult = evidenceBundle || finalizedClassConsumer
3194
+ ? result
3195
+ : applyResponseBudget(result, cmd === "query" && rest[0] === "flow_analysis" ? "query:flow_analysis" : cmd, responseBudget, fileMetricsOutput
3196
+ ? { bytes: 16_777_216, tokens: 4_194_304, items: 100_000 }
3197
+ : { bytes: 65_536, tokens: 16_384, items: 100 });
3198
+ if (classConsumerErrorContract) {
3199
+ const delivered = boundedResult;
3200
+ if (delivered.code !== classConsumerErrorContract.code ||
3201
+ delivered.targetResolution !== classConsumerErrorContract.targetResolution ||
3202
+ delivered.available !== false ||
3203
+ delivered.status !== "unavailable")
3204
+ throw new Error("Response budget cannot preserve the class-consumer error contract; increase the budget.");
3205
+ }
3206
+ if (cmd === "query" && rest[0] === "inheritors_of") {
3207
+ const qualification = result
3208
+ ?.inheritanceQuery;
3209
+ const delivered = boundedResult
3210
+ ?.inheritanceQuery;
3211
+ if (qualification &&
3212
+ ["version", "mode", "partial", "truncated", "apex"].some((key) => delivered?.[key] !== qualification[key]))
3213
+ throw new Error("Response budget cannot preserve the inheritance qualification; increase the budget.");
3214
+ }
3056
3215
  const finalExitCode = Math.max(Number(process.exitCode ?? 0), repairExitCode);
3057
3216
  if (cmd === "init" && !jsonOutput) {
3058
3217
  process.stdout.write(formatInitHuman(boundedResult));
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { indexCoverageCore, indexCoverageNotice } from "./index-coverage-read.js";
3
4
  import { countOutputTokens } from "./output-telemetry.js";
4
5
  import { appendSessionEvent, } from "./session-telemetry.js";
5
6
  export const SESSION_CONTEXT_BUDGET_LINE = "Budget: at most 600 tokens, 8 KiB, 12 items.";
@@ -81,23 +82,40 @@ export function writeSessionContextCache(repo, key, text) {
81
82
  export function canUseSessionContextCache(status, freshness) {
82
83
  return status === "healthy" && freshness === "fresh";
83
84
  }
84
- export function renderSessionContext(context, freshness) {
85
+ export function renderSessionContext(context, freshness, coverage) {
85
86
  const list = (values, render) => (values ?? []).slice(0, 3).map(render).join(", ") || "none reported";
86
- let text = [
87
+ const header = [
87
88
  "## knodin session context",
88
89
  `Graph evidence: ${freshness}. Treat stale or unavailable evidence as incomplete.`,
89
90
  SESSION_CONTEXT_BUDGET_LINE,
90
- `Repository: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
91
+ `Index coverage: ${JSON.stringify(indexCoverageCore(coverage) ?? { mode: "unknown", repositoryComplete: false, negativeScope: "unknown" })}`,
92
+ indexCoverageNotice(coverage) ??
93
+ (coverage
94
+ ? "Inventory extent does not prove complete relationships or runtime use."
95
+ : "Index coverage is unknown; negatives do not establish repository-wide absence."),
96
+ ];
97
+ const details = [
98
+ `Indexed scope: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
91
99
  `Subsystems: ${list(context.communities, (value) => `${value.name} (${value.size})`)}`,
92
100
  `Hubs: ${list(context.hubs, (value) => `${value.symbol} (${value.degree})`)}`,
93
101
  `Flows: ${list(context.flows, (value) => value.symbol ?? "unnamed")}`,
94
102
  `Suggested next operation: ${context.suggestedOperation ?? "context"} (heuristic only).`,
95
103
  "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.`;
104
+ ];
105
+ let truncated = false;
106
+ for (;;) {
107
+ const text = [
108
+ ...header,
109
+ ...details,
110
+ ...(truncated ? ["Context truncated to the configured session budget."] : []),
111
+ ].join("\n");
112
+ if (countOutputTokens(text) <= 600 && Buffer.byteLength(text) <= 8192)
113
+ return text;
114
+ if (details.length === 0)
115
+ throw new RangeError("Session context budget cannot preserve index coverage");
116
+ details.pop();
117
+ truncated = true;
99
118
  }
100
- return text;
101
119
  }
102
120
  export function recordClaudeLifecycleEvent(repo, event, payload) {
103
121
  const sessionId = payload.session_id;
@@ -0,0 +1,81 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ export const MAX_CURSOR_PAYLOAD_BYTES = 4096;
3
+ export const MAX_AUTHENTICATED_CURSOR_LENGTH = 6144;
4
+ const DOMAIN = "knodin:authenticated-cursor:v1\0";
5
+ function checkParameters(secret, prefix) {
6
+ if (!Buffer.isBuffer(secret) || secret.length !== 32)
7
+ throw new TypeError("cursor signing key must be 32 bytes");
8
+ if (!/^[a-z][a-z0-9]{1,15}$/.test(prefix))
9
+ throw new TypeError("invalid cursor prefix");
10
+ }
11
+ function signature(secret, prefix, body) {
12
+ return createHmac("sha256", secret)
13
+ .update(DOMAIN)
14
+ .update(prefix)
15
+ .update("\0")
16
+ .update(body)
17
+ .digest();
18
+ }
19
+ function isRecord(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value))
21
+ return false;
22
+ const prototype = Object.getPrototypeOf(value);
23
+ return prototype === Object.prototype || prototype === null;
24
+ }
25
+ /**
26
+ * Authentication only, not encryption, schema validation or a freshness lease.
27
+ * Callers supply the existing per-repository evidence secret and a distinct
28
+ * protocol prefix; they must validate payload shape and current query/snapshot.
29
+ * Sign only the boundary of rows actually delivered after response budgeting.
30
+ */
31
+ export function signAuthenticatedCursor(value, secret, prefix) {
32
+ checkParameters(secret, prefix);
33
+ let serialized;
34
+ try {
35
+ if (!isRecord(value))
36
+ throw new Error("cursor payload is not a record");
37
+ serialized = JSON.stringify(value);
38
+ if (typeof serialized !== "string" || !isRecord(JSON.parse(serialized)))
39
+ throw new Error("serialized cursor payload is not a record");
40
+ }
41
+ catch {
42
+ throw new TypeError("cursor payload must be a serializable JSON object");
43
+ }
44
+ if (Buffer.byteLength(serialized) > MAX_CURSOR_PAYLOAD_BYTES)
45
+ throw new RangeError("cursor payload exceeds its byte limit");
46
+ const body = Buffer.from(serialized).toString("base64url");
47
+ return `${prefix}.${body}.${signature(secret, prefix, body).toString("base64url")}`;
48
+ }
49
+ export function verifyAuthenticatedCursor(value, secret, prefix) {
50
+ checkParameters(secret, prefix);
51
+ const invalid = () => new Error("invalid authenticated cursor");
52
+ if (typeof value !== "string" || value.length > MAX_AUTHENTICATED_CURSOR_LENGTH)
53
+ throw invalid();
54
+ const parts = value.split(".");
55
+ if (parts.length !== 3 || parts[0] !== prefix)
56
+ throw invalid();
57
+ const [, body, encodedSignature] = parts;
58
+ if (!/^[A-Za-z0-9_-]+$/.test(body) || !/^[A-Za-z0-9_-]+$/.test(encodedSignature))
59
+ throw invalid();
60
+ const actual = Buffer.from(encodedSignature, "base64url");
61
+ const expected = signature(secret, prefix, body);
62
+ if (actual.length !== expected.length ||
63
+ actual.toString("base64url") !== encodedSignature ||
64
+ !timingSafeEqual(actual, expected))
65
+ throw invalid();
66
+ const bytes = Buffer.from(body, "base64url");
67
+ if (bytes.length > MAX_CURSOR_PAYLOAD_BYTES || bytes.toString("base64url") !== body)
68
+ throw invalid();
69
+ const text = bytes.toString("utf8");
70
+ if (!Buffer.from(text).equals(bytes))
71
+ throw invalid();
72
+ try {
73
+ const payload = JSON.parse(text);
74
+ if (isRecord(payload))
75
+ return payload;
76
+ }
77
+ catch {
78
+ // Never echo untrusted cursor contents or decoder error details.
79
+ }
80
+ throw invalid();
81
+ }
@@ -0,0 +1,18 @@
1
+ /** Matches SQLite TEXT COLLATE BINARY for canonical UTF-8 paths and ASCII role names. */
2
+ export function compareClassConsumerKeys(left, right) {
3
+ return (Buffer.compare(Buffer.from(left.file), Buffer.from(right.file)) ||
4
+ left.startByte - right.startByte ||
5
+ left.endByte - right.endByte ||
6
+ Buffer.compare(Buffer.from(left.kind), Buffer.from(right.kind)));
7
+ }
8
+ /** Adapters translate these typed failures; never manufacture a successful target identity. */
9
+ export class ClassConsumerQueryError extends Error {
10
+ code;
11
+ candidates;
12
+ constructor(code, message, candidates = []) {
13
+ super(message);
14
+ this.code = code;
15
+ this.candidates = candidates;
16
+ this.name = "ClassConsumerQueryError";
17
+ }
18
+ }
@@ -0,0 +1,91 @@
1
+ import { signAuthenticatedCursor, verifyAuthenticatedCursor } from "./authenticated-cursor.js";
2
+ import { canonicalCoveragePath } from "./engine/index-coverage.js";
3
+ const PREFIX = "cc1";
4
+ const DIGEST = /^[a-f0-9]{64}$/;
5
+ const KINDS = [
6
+ "static_field_read",
7
+ "constructor",
8
+ "variable_type",
9
+ "cast",
10
+ "class_literal",
11
+ "parameter_type",
12
+ "generic_parameter_type",
13
+ "loop_variable_type",
14
+ "generic_type_argument",
15
+ ];
16
+ function record(value) {
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ return false;
19
+ const prototype = Object.getPrototypeOf(value);
20
+ return prototype === Object.prototype || prototype === null;
21
+ }
22
+ function exactKeys(value, keys) {
23
+ return (Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)));
24
+ }
25
+ function validPayload(value) {
26
+ if (!record(value) || !exactKeys(value, ["v", "type", "queryDigest", "snapshotDigest", "after"]))
27
+ return false;
28
+ if (value.v !== 1 ||
29
+ value.type !== "class-consumers" ||
30
+ typeof value.queryDigest !== "string" ||
31
+ !DIGEST.test(value.queryDigest) ||
32
+ typeof value.snapshotDigest !== "string" ||
33
+ !DIGEST.test(value.snapshotDigest))
34
+ return false;
35
+ const key = value.after;
36
+ return (record(key) &&
37
+ exactKeys(key, ["file", "startByte", "endByte", "kind"]) &&
38
+ typeof key.file === "string" &&
39
+ canonicalCoveragePath(key.file) &&
40
+ Buffer.from(key.file).toString("utf8") === key.file &&
41
+ typeof key.startByte === "number" &&
42
+ Number.isSafeInteger(key.startByte) &&
43
+ key.startByte >= 0 &&
44
+ typeof key.endByte === "number" &&
45
+ Number.isSafeInteger(key.endByte) &&
46
+ key.endByte > key.startByte &&
47
+ typeof key.kind === "string" &&
48
+ KINDS.includes(key.kind));
49
+ }
50
+ function invalid() {
51
+ return new Error("invalid class-consumer continuation; restart the query without continuation");
52
+ }
53
+ /** Only call after the final wire envelope is fitted, using its last delivered site key. */
54
+ export function signClassConsumerCursor(payload, secret) {
55
+ if (!validPayload(payload))
56
+ throw invalid();
57
+ // Copy validated fields explicitly: signing never invokes a caller-provided toJSON hook.
58
+ return signAuthenticatedCursor({
59
+ v: 1,
60
+ type: "class-consumers",
61
+ queryDigest: payload.queryDigest,
62
+ snapshotDigest: payload.snapshotDigest,
63
+ after: {
64
+ file: payload.after.file,
65
+ startByte: payload.after.startByte,
66
+ endByte: payload.after.endByte,
67
+ kind: payload.after.kind,
68
+ },
69
+ }, secret, PREFIX);
70
+ }
71
+ /** Authenticates and validates shape. Backend must still bind both digests to its current lease. */
72
+ export function readClassConsumerCursor(cursor, secret) {
73
+ let payload;
74
+ try {
75
+ payload = verifyAuthenticatedCursor(cursor, secret, PREFIX);
76
+ }
77
+ catch {
78
+ throw invalid();
79
+ }
80
+ if (!validPayload(payload))
81
+ throw invalid();
82
+ return payload;
83
+ }
84
+ export function assertClassConsumerCursorBinding(cursor, queryDigest, snapshotDigest) {
85
+ if (!validPayload(cursor) || !DIGEST.test(queryDigest) || !DIGEST.test(snapshotDigest))
86
+ throw invalid();
87
+ if (cursor.queryDigest !== queryDigest)
88
+ throw new Error("class-consumer continuation belongs to another query; restart without continuation");
89
+ if (cursor.snapshotDigest !== snapshotDigest)
90
+ throw new Error("class-consumer evidence changed; restart the query without continuation");
91
+ }
@@ -0,0 +1,22 @@
1
+ import { ClassConsumerQueryError, } from "./class-consumer-contract.js";
2
+ /**
3
+ * Run once after the captured post-read health probe and before page packing.
4
+ * Revalidates the backend facts/coverage lease, not only freshness timestamps.
5
+ * Never replace the original page's rows or freshness with the validation read.
6
+ * Current seam repeats a limit-one raw read; measure before optimizing it into
7
+ * a dedicated lease check. Do not call it per candidate in the packing loop.
8
+ */
9
+ export async function validateClassConsumerDelivery(page, request, read) {
10
+ const checked = await read({
11
+ ...request,
12
+ limit: 1,
13
+ expectedQueryDigest: page.queryDigest,
14
+ expectedSnapshotDigest: page.snapshotDigest,
15
+ });
16
+ if (checked.queryDigest !== page.queryDigest ||
17
+ checked.targetIdentity !== page.targetIdentity ||
18
+ checked.targetKind !== page.targetKind)
19
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_QUERY_MISMATCH", "The class-consumer query changed before delivery.");
20
+ if (checked.snapshotDigest !== page.snapshotDigest)
21
+ throw new ClassConsumerQueryError("CLASS_CONSUMER_STALE_CURSOR", "The class-consumer evidence changed before delivery; restart the query.");
22
+ }