knodin 0.12.1 → 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 (51) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +230 -31
  3. package/dist/src/agent-events.js +25 -7
  4. package/dist/src/authenticated-cursor.js +81 -0
  5. package/dist/src/backup-retention.js +20 -6
  6. package/dist/src/class-consumer-contract.js +18 -0
  7. package/dist/src/class-consumer-cursor.js +91 -0
  8. package/dist/src/class-consumer-delivery.js +22 -0
  9. package/dist/src/class-consumer-page.js +148 -0
  10. package/dist/src/cli-model.js +9 -3
  11. package/dist/src/docs-sections.js +1 -0
  12. package/dist/src/doctor.js +59 -6
  13. package/dist/src/engine/apex-class-uses.js +430 -0
  14. package/dist/src/engine/apex-entry-points.js +98 -0
  15. package/dist/src/engine/apex-receiver.js +301 -0
  16. package/dist/src/engine/embedding-reuse.js +57 -0
  17. package/dist/src/engine/embeddings.js +22 -0
  18. package/dist/src/engine/index-coverage.js +215 -0
  19. package/dist/src/engine/index.js +2507 -254
  20. package/dist/src/engine/salesforce-components.js +460 -0
  21. package/dist/src/engine/seal.js +3 -0
  22. package/dist/src/engine/sqlite.js +44 -0
  23. package/dist/src/evidence-bundle.js +283 -0
  24. package/dist/src/evidence-graph.js +163 -0
  25. package/dist/src/failure-diagnosis.js +80 -5
  26. package/dist/src/file-dependency.js +35 -0
  27. package/dist/src/graph-query-health.js +47 -1
  28. package/dist/src/implementation-search.js +69 -0
  29. package/dist/src/index-coverage-read.js +33 -0
  30. package/dist/src/init.js +91 -13
  31. package/dist/src/investigation.js +195 -0
  32. package/dist/src/lifecycle-health.js +62 -2
  33. package/dist/src/mcp-reliability.js +4 -0
  34. package/dist/src/mcp-worker-supervisor.js +122 -6
  35. package/dist/src/node-runtime.js +96 -0
  36. package/dist/src/progressive-evidence.js +4 -4
  37. package/dist/src/response-budget.js +129 -3
  38. package/dist/src/server.js +18 -5
  39. package/dist/src/shared-index/publisher.js +41 -1
  40. package/dist/src/tools/knodin-tools.js +224 -41
  41. package/docs/CLI.md +92 -0
  42. package/docs/MCP.md +67 -0
  43. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  44. package/docs/SALESFORCE-BINDINGS.md +121 -0
  45. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  46. package/docs/SCOPED-INDEXING.md +76 -0
  47. package/docs/apex-receiver-resolution.md +41 -0
  48. package/docs/releases/0.12.2.md +88 -0
  49. package/docs/releases/0.13.0.md +62 -0
  50. package/docs/structural-only-indexing.md +20 -0
  51. package/package.json +12 -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];
@@ -195,8 +210,15 @@ function formatLifecycleLine(lifecycle, isMirror) {
195
210
  return "";
196
211
  if (isMirror)
197
212
  return "Hooks: not applicable; a mirror is refreshed explicitly, not by Git events.\n";
198
- if (lifecycle.status === "healthy")
199
- return "Hooks: installed and executable.\n";
213
+ if (lifecycle.status === "healthy") {
214
+ // A silent self-heal would leave the user unable to explain why the hook's
215
+ // interpreter changed; a degraded report for a fixed condition would cry
216
+ // wolf. Say what happened, once, on the healthy line.
217
+ const heal = lifecycle.interpreterSelfHeal;
218
+ return heal
219
+ ? `Hooks: installed and executable; background indexer interpreter self-healed (${heal.from} -> ${heal.to}).\n`
220
+ : "Hooks: installed and executable.\n";
221
+ }
200
222
  const issue = lifecycle.issues[0] ?? "refresh capability is not verified";
201
223
  return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin repair --lifecycle\`.\n`;
202
224
  }
@@ -407,7 +429,7 @@ function formatStatusHuman(result) {
407
429
  const indexedCounts = result.coverage.countsUnknown
408
430
  ? "indexed and symbol counts unknown; the graph could not be read"
409
431
  : `${result.coverage.indexedFiles} indexed files, ${result.coverage.filesWithSymbols} files with symbols`;
410
- 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)}`;
411
433
  if (result.status === "indexing" && result.activity) {
412
434
  const count = result.activity.phaseTotal === undefined
413
435
  ? ""
@@ -449,24 +471,34 @@ function formatStatusHuman(result) {
449
471
  // led with an arbitrary source filename, so an absent graph read as damage
450
472
  // proportional to repository size. `missing.files` still carries the repair
451
473
  // worklist — only the count and the headline shown to a human change here.
452
- const outstanding = result.coverage.countsUnknown
453
- ? result.missing.records.length
454
- : result.missing.files.length + result.missing.records.length;
455
- const firstIssue = result.coverage.countsUnknown
456
- ? result.missing.records[0]
457
- : (result.missing.files[0] ?? result.missing.records[0]);
458
- const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
474
+ const issueItems = result.coverage.countsUnknown
475
+ ? result.missing.records
476
+ : [...result.missing.files, ...result.missing.records];
477
+ const outstanding = issueItems.length;
478
+ // One issue keeps the established single-line shape; more are listed bounded
479
+ // rather than hidden behind "First issue" — a report that names one problem
480
+ // out of several sends the user to fix it and then surprises them with the
481
+ // rest, one status invocation at a time (KNODIN-38).
482
+ const shownIssues = issueItems.slice(0, 5);
483
+ const hiddenIssues = outstanding - shownIssues.length;
484
+ const detail = shownIssues.length === 0
485
+ ? ""
486
+ : shownIssues.length === 1
487
+ ? ` First issue: ${shownIssues[0]}.`
488
+ : ` Issues: ${shownIssues.join("; ")}${hiddenIssues > 0 ? `; +${hiddenIssues} more (see --json)` : ""}.`;
459
489
  // A linked worktree with no database is the one case where `repair` is both
460
490
  // the wrong first step and the expensive one: `init` seeds from an indexed
461
491
  // sibling and reconciles only what differs, while `repair` builds from
462
492
  // scratch. The engine has already worked out that this is that case, so
463
493
  // defer to the step it wrote rather than recomputing the judgement here.
464
494
  const worktreeStep = result.repairSteps?.find((step) => step.includes("per-worktree"));
465
- const repairCommand = worktreeStep ??
466
- (result.lifecycle?.status === "degraded" &&
467
- result.missing.records.every((record) => result.lifecycle?.issues.includes(record))
468
- ? "Run `knodin init`."
469
- : "Run `knodin repair`.");
495
+ // Lifecycle-only damage takes `repair --lifecycle`, not `init`: the graph is
496
+ // healthy, and the engine's own repairSteps already say so — prescribing
497
+ // `init` here contradicted the lifecycle line printed directly beneath it.
498
+ const lifecycleOnly = result.lifecycle?.status === "degraded" &&
499
+ (result.coverage.countsUnknown || result.missing.files.length === 0) &&
500
+ result.missing.records.every((record) => result.lifecycle?.issues.includes(record));
501
+ const repairCommand = worktreeStep ?? (lifecycleOnly ? "Run `knodin repair --lifecycle`." : "Run `knodin repair`.");
470
502
  return `Graph or lifecycle needs repair: ${outstanding} issue(s) found (${coverage}).${detail} ${repairCommand}\n${lifecycleLine}${integrationLine}`;
471
503
  }
472
504
  function humanLabel(key) {
@@ -1520,6 +1552,8 @@ async function main() {
1520
1552
  return attemptOpportunisticSharedRestore(repo, engine);
1521
1553
  };
1522
1554
  let result;
1555
+ let finalizedClassConsumer = false;
1556
+ let classConsumerErrorContract;
1523
1557
  let repairOutput;
1524
1558
  let repairWasPlan = false;
1525
1559
  let repairWasLifecycle = false;
@@ -1538,7 +1572,7 @@ async function main() {
1538
1572
  process.exitCode = 1;
1539
1573
  return verified;
1540
1574
  }
1541
- return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
1575
+ return decorateGraphQueryResult(value, verified.state, verified.graph.freshness, verified.graph.indexCoverage, health.graph.indexCoverage);
1542
1576
  };
1543
1577
  switch (cmd) {
1544
1578
  case "shared": {
@@ -1813,10 +1847,11 @@ async function main() {
1813
1847
  }
1814
1848
  const freshness = status.freshness?.state ?? "unknown";
1815
1849
  const cacheKey = JSON.stringify({
1816
- schemaVersion: 1,
1850
+ schemaVersion: 2,
1817
1851
  head: status.freshness?.currentHead,
1818
1852
  fingerprint: status.freshness?.workingTree?.indexedFingerprint,
1819
1853
  generation: status.indexGeneration,
1854
+ indexCoverage: indexCoverageCore(status.indexCoverage),
1820
1855
  budgets: [600, 8192, 12],
1821
1856
  });
1822
1857
  const cacheEligible = canUseSessionContextCache(status.status, freshness);
@@ -1831,7 +1866,11 @@ async function main() {
1831
1866
  cacheHit = false;
1832
1867
  try {
1833
1868
  const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
1834
- 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);
1835
1874
  writeSessionContextCache(repo, cacheKey, text);
1836
1875
  }
1837
1876
  catch {
@@ -1867,13 +1906,20 @@ async function main() {
1867
1906
  if (client !== undefined &&
1868
1907
  !["claude", "codex", "gemini", "copilot", "antigravity"].includes(client))
1869
1908
  throw new Error("knodin doctor: --client must be claude, codex, gemini, copilot, or antigravity");
1870
- const unsupported = rest.filter((argument, index) => argument !== "--client" && rest[index - 1] !== "--client");
1909
+ const unsupported = rest.filter((argument, index) => argument !== "--client" && argument !== "--deep" && rest[index - 1] !== "--client");
1871
1910
  if (unsupported.length > 0)
1872
1911
  throw new Error(`knodin doctor: unknown option ${unsupported[0]}`);
1873
1912
  const diagnosis = await diagnoseInstallation(repo, {
1874
1913
  currentVersion: KNODIN_VERSION,
1875
1914
  runtimeCommand: [...runtimeCommand, "serve"],
1876
- graph: await engine.status(repo, { audit: "deep" }),
1915
+ // Adaptive, like `status`: the persisted-audit path runs the same
1916
+ // drift probe, so a stale graph still reports stale, while a forced
1917
+ // deep audit on a very large repository cost doctor two full stat
1918
+ // sweeps every run (KNODIN-39). `--deep` keeps the exact audit
1919
+ // available on demand.
1920
+ graph: await engine.status(repo, {
1921
+ audit: rest.includes("--deep") ? "deep" : "adaptive",
1922
+ }),
1877
1923
  client,
1878
1924
  });
1879
1925
  const manager = diagnosis.manager.name;
@@ -1884,7 +1930,23 @@ async function main() {
1884
1930
  : "unknown",
1885
1931
  env: process.env,
1886
1932
  });
1887
- result = diagnosis;
1933
+ // One MCP probe backs every client row; the human listing printed the
1934
+ // identical initialize/toolsList block five times, reading as five
1935
+ // independent server checks (KNODIN-40). JSON keeps the per-client
1936
+ // shape for compatibility.
1937
+ const agents = diagnosis.agents;
1938
+ result = jsonOutput
1939
+ ? diagnosis
1940
+ : {
1941
+ ...diagnosis,
1942
+ agents: {
1943
+ ...agents,
1944
+ clients: agents.clients?.map((clientRow) => ({
1945
+ ...clientRow,
1946
+ server: "shared MCP probe; see the mcp section",
1947
+ })),
1948
+ },
1949
+ };
1888
1950
  break;
1889
1951
  }
1890
1952
  case "system": {
@@ -2647,6 +2709,24 @@ async function main() {
2647
2709
  case "evidence": {
2648
2710
  const level = rest[0];
2649
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
+ }
2650
2730
  if (!file || !["locate", "outline", "evidence", "expand"].includes(level))
2651
2731
  throw new Error("knodin evidence requires locate|outline|evidence|expand <file>");
2652
2732
  result = deliverProgressiveEvidence({
@@ -2677,7 +2757,7 @@ async function main() {
2677
2757
  ? ""
2678
2758
  : (rest[1] ?? "");
2679
2759
  if (!pattern) {
2680
- 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");
2681
2761
  process.exit(1);
2682
2762
  }
2683
2763
  const directionValue = selectorValue("--direction");
@@ -2748,6 +2828,82 @@ async function main() {
2748
2828
  process.exitCode = 1;
2749
2829
  break;
2750
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
+ }
2751
2907
  result = await engine.query(pattern, target, repo, to, queryLimitRaw ? Number(queryLimitRaw) : undefined, depth, detailLevel, selector, pattern === "impact"
2752
2908
  ? {
2753
2909
  mode: selectorValue("--impact-mode") === "file" ? "file" : "symbol",
@@ -2807,12 +2963,12 @@ async function main() {
2807
2963
  process.exitCode = 1;
2808
2964
  break;
2809
2965
  }
2810
- result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
2966
+ result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness, verifiedQueryHealth.graph.indexCoverage, queryHealth.graph.indexCoverage);
2811
2967
  }
2812
2968
  // A target that does not exist is operator error, not a negative
2813
2969
  // answer, so it exits non-zero like an unavailable graph rather than
2814
2970
  // like real dead code (KNODIN-28). `resolved` with zero rows keeps
2815
- // exit 0: the symbol exists and genuinely has no callers.
2971
+ // exit 0: no callers were found within the reported index coverage.
2816
2972
  if (result?.targetResolution === "not-found")
2817
2973
  process.exitCode = 1;
2818
2974
  break;
@@ -2994,7 +3150,26 @@ async function main() {
2994
3150
  process.stderr.write('knodin context requires a "<task>" description\n');
2995
3151
  process.exit(1);
2996
3152
  }
2997
- 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]));
2998
3173
  break;
2999
3174
  }
3000
3175
  default:
@@ -3002,6 +3177,8 @@ async function main() {
3002
3177
  process.exit(1);
3003
3178
  }
3004
3179
  await engine.close();
3180
+ if (result?.available === false)
3181
+ process.exitCode = 1;
3005
3182
  if (agentEventOutput) {
3006
3183
  if (result !== null && result !== undefined)
3007
3184
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -3010,9 +3187,31 @@ async function main() {
3010
3187
  if (statusWasWatched)
3011
3188
  return;
3012
3189
  const fileMetricsOutput = cmd === "query" && rest[0] === "file_metrics";
3013
- const boundedResult = applyResponseBudget(result, cmd, responseBudget, fileMetricsOutput
3014
- ? { bytes: 16_777_216, tokens: 4_194_304, items: 100_000 }
3015
- : { 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
+ }
3016
3215
  const finalExitCode = Math.max(Number(process.exitCode ?? 0), repairExitCode);
3017
3216
  if (cmd === "init" && !jsonOutput) {
3018
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
+ }
@@ -355,12 +355,19 @@ function parseRetentionReceipt(value) {
355
355
  }
356
356
  function readInstalledRetention(options) {
357
357
  const locations = homes(options);
358
+ const receiptPath = path.join(locations.stateRoot, "receipt.json");
358
359
  const policy = parseRetentionPolicy(readJson(locations.policy));
359
- const receipt = parseRetentionReceipt(readJson(path.join(locations.stateRoot, "receipt.json")));
360
+ const receipt = parseRetentionReceipt(readJson(receiptPath));
361
+ // A machine where NEITHER file exists never had retention installed; that is
362
+ // a clean state, not a broken one, and reporting "missing-or-invalid" for it
363
+ // taught every fresh doctor run to cry attention-required (KNODIN-40). Any
364
+ // on-disk trace — even an unparseable file — is installation evidence and
365
+ // keeps the strict issue reporting.
366
+ const installEvidence = fs.existsSync(locations.policy) || fs.existsSync(receiptPath);
360
367
  const issues = [];
361
- if (!policy)
368
+ if (installEvidence && !policy)
362
369
  issues.push("policy-missing-or-invalid");
363
- if (!receipt)
370
+ if (installEvidence && !receipt)
364
371
  issues.push("receipt-missing-or-invalid");
365
372
  if (policy && receipt && digest(`${JSON.stringify(policy, null, 2)}\n`) !== receipt.policyDigest)
366
373
  issues.push("policy-receipt-mismatch");
@@ -373,7 +380,7 @@ function readInstalledRetention(options) {
373
380
  issues.push(`scheduler-missing: ${artifact.path}`);
374
381
  }
375
382
  }
376
- return { policy, receipt, issues };
383
+ return { policy, receipt, issues, installEvidence };
377
384
  }
378
385
  function commandRunner(command, args) {
379
386
  const result = childProcess.spawnSync(command, args, { encoding: "utf8" });
@@ -571,11 +578,18 @@ export function installBackupRetention(roots, options = {}) {
571
578
  }
572
579
  export function retentionStatus(options = {}) {
573
580
  const locations = homes(options);
574
- const { policy, receipt, issues } = readInstalledRetention(options);
581
+ const { policy, receipt, issues, installEvidence } = readInstalledRetention(options);
575
582
  const lastRun = readJson(path.join(locations.stateRoot, "last-run.json"));
576
583
  return {
577
584
  schemaVersion: 1,
578
- status: issues.length === 0 ? "healthy" : policy || receipt ? "attention-required" : "not-installed",
585
+ // Keyed on on-disk evidence rather than parse success: a never-installed
586
+ // machine is "not-installed" with no issues, while an installed-but-broken
587
+ // state (either file present, parseable or not) keeps attention-required.
588
+ status: !installEvidence && issues.length === 0
589
+ ? "not-installed"
590
+ : issues.length === 0
591
+ ? "healthy"
592
+ : "attention-required",
579
593
  installed: Boolean(policy && receipt),
580
594
  policy,
581
595
  receipt,