knodin 0.12.2 → 0.13.1

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 (58) hide show
  1. package/README.md +16 -1
  2. package/dist/bin/cli.js +245 -18
  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 +345 -12
  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 +21 -3
  11. package/dist/src/docs-sections.js +1 -0
  12. package/dist/src/engine/apex-class-uses.js +430 -0
  13. package/dist/src/engine/apex-entry-points.js +98 -0
  14. package/dist/src/engine/apex-receiver.js +301 -0
  15. package/dist/src/engine/candidate-database.js +624 -66
  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 +3854 -448
  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/engine/state-paths.js +192 -31
  24. package/dist/src/evidence-bundle.js +283 -0
  25. package/dist/src/evidence-graph.js +163 -0
  26. package/dist/src/failure-diagnosis.js +80 -5
  27. package/dist/src/file-dependency.js +35 -0
  28. package/dist/src/graph-query-health.js +47 -1
  29. package/dist/src/implementation-search.js +69 -0
  30. package/dist/src/index-coverage-read.js +33 -0
  31. package/dist/src/investigation.js +195 -0
  32. package/dist/src/manager-update.js +9 -10
  33. package/dist/src/mcp-reliability.js +4 -0
  34. package/dist/src/mcp-worker-supervisor.js +122 -6
  35. package/dist/src/mirror.js +2 -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/shared-index/restore.js +16 -6
  41. package/dist/src/storage-budget.js +146 -0
  42. package/dist/src/storage-inventory.js +263 -0
  43. package/dist/src/storage-management.js +204 -0
  44. package/dist/src/storage-policy-contract.js +121 -0
  45. package/dist/src/tools/knodin-tools.js +224 -41
  46. package/dist/src/worktree-seed.js +7 -1
  47. package/docs/BACKUP-RETENTION.md +46 -2
  48. package/docs/CLI.md +98 -2
  49. package/docs/MCP.md +75 -0
  50. package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
  51. package/docs/SALESFORCE-BINDINGS.md +121 -0
  52. package/docs/SALESFORCE-DEAD-CODE.md +45 -0
  53. package/docs/SCOPED-INDEXING.md +76 -0
  54. package/docs/apex-receiver-resolution.md +41 -0
  55. package/docs/releases/0.13.0.md +62 -0
  56. package/docs/releases/0.13.1.md +216 -0
  57. package/docs/structural-only-indexing.md +20 -0
  58. package/package.json +12 -4
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";
@@ -27,23 +31,29 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
27
31
  import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
28
32
  import { getDocSection, listDocTopics } from "../src/docs-sections.js";
29
33
  import { diagnoseInstallation } from "../src/doctor.js";
34
+ import { sweepAbandonedCandidates } from "../src/engine/candidate-database.js";
30
35
  import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
31
36
  import { runSeal } from "../src/engine/seal-command.js";
32
37
  import { runSealedQuery } from "../src/engine/sealed-query.js";
33
38
  import { resolveDbPath } from "../src/engine/state-paths.js";
39
+ import { deliverEvidenceBundle } from "../src/evidence-bundle.js";
40
+ import { createEvidenceGraphAdapter } from "../src/evidence-graph.js";
34
41
  import { diagnoseFailure, } from "../src/failure-diagnosis.js";
35
42
  import { gitExecutable } from "../src/git-executable.js";
36
43
  import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../src/graph-query-health.js";
44
+ import { findImplementationCandidates } from "../src/implementation-search.js";
37
45
  import { createIndexActivityReporter } from "../src/index-activity.js";
46
+ import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "../src/index-coverage-read.js";
38
47
  import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, repairLifecycleRouting, } from "../src/init.js";
39
48
  import { createInitProgressRenderer } from "../src/init-progress.js";
49
+ import { buildTaskInvestigation } from "../src/investigation.js";
40
50
  import { attachLifecycleHealth, attachRepairLifecycle } from "../src/lifecycle-health.js";
41
51
  import { acknowledgeUpdateFailure, queryOwnerAvailability, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setManualPin, unpinUpdate, updateAttention, writeManagerUpdateState, } from "../src/manager-update.js";
42
52
  import { addMirror, listMirrors, refreshMirror, removeMirror } from "../src/mirror.js";
43
53
  import { compressOutput, compressOutputFile, deleteOutputArtifact, readOutputArtifact, } from "../src/output-compression.js";
44
54
  import { clearTelemetry, exportTelemetry, readTelemetryRecords, telemetryStatus, writeTelemetryReport, } from "../src/output-telemetry.js";
45
55
  import { auditPullRequests } from "../src/pr-triage.js";
46
- import { deliverProgressiveEvidence, } from "../src/progressive-evidence.js";
56
+ import { deliverProgressiveEvidence, evidenceHandleSecret, } from "../src/progressive-evidence.js";
47
57
  import { acquireRepairLease } from "../src/repair-lease.js";
48
58
  import { createRepairPlan, createRepairProgressRenderer, parseRepairCliArgs, resolveRepairProgressMode, serializeRepairJsonlRecord, } from "../src/repair-progress.js";
49
59
  import { runRepositoryInitializationProcess } from "../src/repository-init-process.js";
@@ -51,6 +61,8 @@ import { detectRepositorySignals, discoverRepositories, formatRepositoryHuman, i
51
61
  import { applyResponseBudget } from "../src/response-budget.js";
52
62
  import { appendSessionEvent, clearSessionTelemetry, disableSessionTelemetry, enableSessionTelemetry, readSessionEvents, sessionTelemetryStatus, } from "../src/session-telemetry.js";
53
63
  import { inspectKnodinSkills, installKnodinSkills, KNODIN_SKILLS, removeKnodinSkills, } from "../src/skill-management.js";
64
+ import { inventoryStorage } from "../src/storage-inventory.js";
65
+ import { configureStoragePolicy, postOperationStorageMaintenance, resolveStoragePolicy, } from "../src/storage-management.js";
54
66
  import { configuredRepositoryInitMemoryLimitBytes, enrichSystemRelationships, incorporateSystemQueryEvidence, indexModeForPath, loadSystemConfiguration, queryConfiguredSystem, systemMembershipsForPath, validateSystemHealth, } from "../src/system-config.js";
55
67
  import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, planSystemUnlink, planSystemUnrelate, } from "../src/system-management.js";
56
68
  import { coordinationStatus } from "../src/update-coordination.js";
@@ -161,15 +173,21 @@ function formatSemanticGap(readiness) {
161
173
  }
162
174
  function formatIndexHuman(result) {
163
175
  const semantic = formatSemanticGap(result.semanticReadiness);
176
+ const scope = formatIndexCoverageHuman(result.indexCoverage);
164
177
  if (result.indexed.length === 0 && result.unchanged.length > 0) {
165
178
  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`;
179
+ return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n${scope}\n`;
167
180
  }
168
181
  const unchanged = result.unchanged.length > 0
169
182
  ? `; ${result.unchanged.length.toLocaleString()} already current`
170
183
  : "";
171
184
  const noun = result.indexed.length === 1 ? "file" : "files";
172
- return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n`;
185
+ return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n${scope}\n`;
186
+ }
187
+ function formatIndexCoverageHuman(coverage) {
188
+ if (!coverage)
189
+ return "Local graph index coverage is unknown; a negative graph answer does not establish absence.";
190
+ 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
191
  }
174
192
  function formatIndexVerificationError(result) {
175
193
  const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
@@ -414,7 +432,7 @@ function formatStatusHuman(result) {
414
432
  const indexedCounts = result.coverage.countsUnknown
415
433
  ? "indexed and symbol counts unknown; the graph could not be read"
416
434
  : `${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}`;
435
+ const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}. ${formatIndexCoverageHuman(result.indexCoverage)}`;
418
436
  if (result.status === "indexing" && result.activity) {
419
437
  const count = result.activity.phaseTotal === undefined
420
438
  ? ""
@@ -1274,6 +1292,8 @@ async function main() {
1274
1292
  const depth = invocation.options.depth;
1275
1293
  const retentionDays = invocation.options.retentionDays;
1276
1294
  const keepNewest = invocation.options.keepNewest;
1295
+ const maxCount = invocation.options.maxCount;
1296
+ const maxAllocatedBytes = invocation.options.maxBytes;
1277
1297
  let output;
1278
1298
  if (action === "list")
1279
1299
  output = listBackups(defaultRoots(), { depth });
@@ -1282,16 +1302,70 @@ async function main() {
1282
1302
  depth,
1283
1303
  retentionDays,
1284
1304
  keepNewest,
1305
+ maxCount,
1306
+ maxAllocatedBytes,
1285
1307
  apply: invocation.options.apply === true,
1286
1308
  });
1287
1309
  }
1310
+ else if (action === "policy") {
1311
+ output = defaultRoots().map((repository) => {
1312
+ if (retentionAction === "status")
1313
+ return {
1314
+ repository,
1315
+ policy: resolveStoragePolicy(repository),
1316
+ inventory: inventoryStorage(repository),
1317
+ };
1318
+ if (retentionAction !== "preview" && retentionAction !== "apply")
1319
+ throw new Error("knodin backups policy requires preview, apply, or status");
1320
+ const changes = {
1321
+ apply: retentionAction === "apply",
1322
+ };
1323
+ if (maxCount !== undefined)
1324
+ changes.maxCount = maxCount;
1325
+ if (maxAllocatedBytes !== undefined)
1326
+ changes.maxAllocatedBytes = maxAllocatedBytes;
1327
+ if (invocation.options.reserveBytes !== undefined)
1328
+ changes.minFreeBytes = invocation.options.reserveBytes;
1329
+ const mode = invocation.options.mode;
1330
+ if (mode !== undefined) {
1331
+ changes.enabled = mode !== "disabled";
1332
+ changes.dryRun = mode === "preview";
1333
+ }
1334
+ const configured = configureStoragePolicy(repository, changes);
1335
+ const maintenance = retentionAction === "apply" ? postOperationStorageMaintenance(repository) : null;
1336
+ const candidateWarnings = [];
1337
+ const candidatesRemoved = maintenance?.policy?.enabled && !maintenance.policy.dryRun
1338
+ ? sweepAbandonedCandidates(repository, {
1339
+ onDiagnostic: (warning) => {
1340
+ if (candidateWarnings.length < 8)
1341
+ candidateWarnings.push(warning.reason.slice(0, 512));
1342
+ },
1343
+ })
1344
+ : 0;
1345
+ return {
1346
+ repository,
1347
+ ...configured,
1348
+ inventory: inventoryStorage(repository),
1349
+ cleanup: retentionAction === "apply"
1350
+ ? { ...maintenance, candidatesRemoved, candidateWarnings }
1351
+ : pruneBackups([repository], {
1352
+ ...configured.policy,
1353
+ depth: 0,
1354
+ includeRegistry: false,
1355
+ apply: false,
1356
+ }),
1357
+ };
1358
+ });
1359
+ }
1288
1360
  else if (action === "retention" && retentionAction === "install") {
1289
1361
  output = installBackupRetention(roots, {
1290
1362
  depth,
1291
1363
  retentionDays,
1292
1364
  keepNewest,
1365
+ maxCount,
1366
+ maxAllocatedBytes,
1293
1367
  schedule: invocation.options.schedule,
1294
- dryRun: invocation.options.dryRun === true,
1368
+ dryRun: invocation.options.dryRun,
1295
1369
  launcher: runtimeCommand,
1296
1370
  });
1297
1371
  }
@@ -1374,10 +1448,19 @@ async function main() {
1374
1448
  const repo = resolved.repo;
1375
1449
  if (cmd !== "doctor") {
1376
1450
  try {
1377
- maybeRunOpportunisticRetention(repo);
1451
+ const maintenance = maybeRunOpportunisticRetention(repo);
1452
+ const issues = "issues" in maintenance
1453
+ ? maintenance.issues
1454
+ : "result" in maintenance && maintenance.result
1455
+ ? maintenance.result.skipped.map((entry) => entry.reason)
1456
+ : [];
1457
+ if (issues?.length)
1458
+ process.stderr.write(`knodin storage: ${issues.slice(0, 8).join("; ").slice(0, 4096)}\n`);
1378
1459
  }
1379
- catch {
1380
- // Best-effort cleanup must never make an ordinary command fail.
1460
+ catch (error) {
1461
+ // Cleanup remains nonfatal, but failures are not hidden or reported as
1462
+ // reclaimed storage. Keep stdout/JSON framing untouched.
1463
+ process.stderr.write(`knodin storage: ${String(error).slice(0, 512)}\n`);
1381
1464
  }
1382
1465
  }
1383
1466
  if (cmd === "agent-event" && !fs.existsSync(resolveDbPath(repo)))
@@ -1537,6 +1620,8 @@ async function main() {
1537
1620
  return attemptOpportunisticSharedRestore(repo, engine);
1538
1621
  };
1539
1622
  let result;
1623
+ let finalizedClassConsumer = false;
1624
+ let classConsumerErrorContract;
1540
1625
  let repairOutput;
1541
1626
  let repairWasPlan = false;
1542
1627
  let repairWasLifecycle = false;
@@ -1555,7 +1640,7 @@ async function main() {
1555
1640
  process.exitCode = 1;
1556
1641
  return verified;
1557
1642
  }
1558
- return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
1643
+ return decorateGraphQueryResult(value, verified.state, verified.graph.freshness, verified.graph.indexCoverage, health.graph.indexCoverage);
1559
1644
  };
1560
1645
  switch (cmd) {
1561
1646
  case "shared": {
@@ -1830,10 +1915,11 @@ async function main() {
1830
1915
  }
1831
1916
  const freshness = status.freshness?.state ?? "unknown";
1832
1917
  const cacheKey = JSON.stringify({
1833
- schemaVersion: 1,
1918
+ schemaVersion: 2,
1834
1919
  head: status.freshness?.currentHead,
1835
1920
  fingerprint: status.freshness?.workingTree?.indexedFingerprint,
1836
1921
  generation: status.indexGeneration,
1922
+ indexCoverage: indexCoverageCore(status.indexCoverage),
1837
1923
  budgets: [600, 8192, 12],
1838
1924
  });
1839
1925
  const cacheEligible = canUseSessionContextCache(status.status, freshness);
@@ -1848,7 +1934,11 @@ async function main() {
1848
1934
  cacheHit = false;
1849
1935
  try {
1850
1936
  const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
1851
- text = renderSessionContext(context, freshness);
1937
+ const verified = await engine.status(repo, { audit: "cached" });
1938
+ if (indexCoverageSnapshot(status.indexCoverage) !==
1939
+ indexCoverageSnapshot(verified.indexCoverage))
1940
+ throw new Error("Index coverage changed during session context generation");
1941
+ text = renderSessionContext(context, freshness, verified.indexCoverage);
1852
1942
  writeSessionContextCache(repo, cacheKey, text);
1853
1943
  }
1854
1944
  catch {
@@ -2687,6 +2777,24 @@ async function main() {
2687
2777
  case "evidence": {
2688
2778
  const level = rest[0];
2689
2779
  const file = rest[1];
2780
+ const files = selectorValue("--files")?.split(",");
2781
+ if (files) {
2782
+ if (level !== "evidence" && level !== "expand")
2783
+ throw new Error("Evidence bundles require evidence or expand level");
2784
+ if (file && !file.startsWith("--"))
2785
+ throw new Error("Choose a file or --files, not both");
2786
+ result = await deliverEvidenceBundle({
2787
+ repo,
2788
+ files,
2789
+ level,
2790
+ continuation: selectorValue("--continuation"),
2791
+ alreadyPresent: selectorValue("--already-present")?.split(","),
2792
+ byteLimit: responseBudget.bytes,
2793
+ tokenLimit: responseBudget.tokens,
2794
+ itemLimit: responseBudget.items,
2795
+ }, createEvidenceGraphAdapter(engine));
2796
+ break;
2797
+ }
2690
2798
  if (!file || !["locate", "outline", "evidence", "expand"].includes(level))
2691
2799
  throw new Error("knodin evidence requires locate|outline|evidence|expand <file>");
2692
2800
  result = deliverProgressiveEvidence({
@@ -2717,7 +2825,7 @@ async function main() {
2717
2825
  ? ""
2718
2826
  : (rest[1] ?? "");
2719
2827
  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");
2828
+ 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
2829
  process.exit(1);
2722
2830
  }
2723
2831
  const directionValue = selectorValue("--direction");
@@ -2788,6 +2896,82 @@ async function main() {
2788
2896
  process.exitCode = 1;
2789
2897
  break;
2790
2898
  }
2899
+ if (pattern === "class_consumers") {
2900
+ if (queryLimitRaw && Number(queryLimitRaw) > 1000)
2901
+ throw new Error("class_consumers --limit must not exceed 1000 sites");
2902
+ if (["--offset", "--kinds", "--relations", "--direction", "--depth", "--exclude-tests"].some((flag) => rest.includes(flag)))
2903
+ throw new Error("class_consumers supports --test-scope/--path and --continuation, not offset/kinds/relations/direction/depth/exclude-tests");
2904
+ const secret = evidenceHandleSecret(repo).secret;
2905
+ const token = selectorValue("--continuation");
2906
+ const cursor = token ? readClassConsumerCursor(token, secret) : undefined;
2907
+ const request = {
2908
+ symbol: target,
2909
+ selector,
2910
+ testScope: (testScopeValue ?? "all"),
2911
+ path: selectorValue("--path"),
2912
+ limit: queryLimitRaw ? Number(queryLimitRaw) : 100,
2913
+ ...(cursor
2914
+ ? {
2915
+ after: cursor.after,
2916
+ expectedQueryDigest: cursor.queryDigest,
2917
+ expectedSnapshotDigest: cursor.snapshotDigest,
2918
+ }
2919
+ : {}),
2920
+ };
2921
+ try {
2922
+ const page = await engine.queryClassConsumers(repo, request);
2923
+ // Defer maintenance, not the audit, until a later eligible status or close.
2924
+ const verified = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached", checkpoint: "defer" })));
2925
+ if (!verified.available) {
2926
+ result = verified;
2927
+ process.exitCode = 1;
2928
+ break;
2929
+ }
2930
+ await validateClassConsumerDelivery(page, request, (next) => engine.queryClassConsumers(repo, next));
2931
+ result = finalizeClassConsumerPage(page, {
2932
+ secret,
2933
+ budget: responseBudget,
2934
+ decorate: (candidate) => decorateGraphQueryResult(candidate, verified.state, undefined, verified.graph.indexCoverage, queryHealth?.available ? queryHealth.graph.indexCoverage : undefined),
2935
+ });
2936
+ finalizedClassConsumer = true;
2937
+ }
2938
+ catch (error) {
2939
+ if (error instanceof ClassConsumerQueryError &&
2940
+ [
2941
+ "CLASS_CONSUMER_TARGET_NOT_FOUND",
2942
+ "CLASS_CONSUMER_TARGET_AMBIGUOUS",
2943
+ "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING",
2944
+ ].includes(error.code)) {
2945
+ result = {
2946
+ available: false,
2947
+ status: "unavailable",
2948
+ pattern,
2949
+ targetResolution: error.code === "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING"
2950
+ ? "capability-missing"
2951
+ : error.code === "CLASS_CONSUMER_TARGET_NOT_FOUND"
2952
+ ? "not-found"
2953
+ : "ambiguous",
2954
+ code: error.code,
2955
+ error: error.message,
2956
+ candidates: error.candidates,
2957
+ ...(queryHealth?.available
2958
+ ? {
2959
+ indexCoverage: queryHealth.graph.indexCoverage,
2960
+ freshness: queryHealth.graph.freshness,
2961
+ }
2962
+ : {}),
2963
+ };
2964
+ classConsumerErrorContract = {
2965
+ code: error.code,
2966
+ targetResolution: result.targetResolution,
2967
+ };
2968
+ process.exitCode = 1;
2969
+ }
2970
+ else
2971
+ throw error;
2972
+ }
2973
+ break;
2974
+ }
2791
2975
  result = await engine.query(pattern, target, repo, to, queryLimitRaw ? Number(queryLimitRaw) : undefined, depth, detailLevel, selector, pattern === "impact"
2792
2976
  ? {
2793
2977
  mode: selectorValue("--impact-mode") === "file" ? "file" : "symbol",
@@ -2847,12 +3031,12 @@ async function main() {
2847
3031
  process.exitCode = 1;
2848
3032
  break;
2849
3033
  }
2850
- result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
3034
+ result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness, verifiedQueryHealth.graph.indexCoverage, queryHealth.graph.indexCoverage);
2851
3035
  }
2852
3036
  // A target that does not exist is operator error, not a negative
2853
3037
  // answer, so it exits non-zero like an unavailable graph rather than
2854
3038
  // like real dead code (KNODIN-28). `resolved` with zero rows keeps
2855
- // exit 0: the symbol exists and genuinely has no callers.
3039
+ // exit 0: no callers were found within the reported index coverage.
2856
3040
  if (result?.targetResolution === "not-found")
2857
3041
  process.exitCode = 1;
2858
3042
  break;
@@ -3034,7 +3218,26 @@ async function main() {
3034
3218
  process.stderr.write('knodin context requires a "<task>" description\n');
3035
3219
  process.exit(1);
3036
3220
  }
3037
- result = await graphRead(() => buildKnodinContext(engine, task, repo, rest[1]));
3221
+ const symbol = selectorValue("--symbol");
3222
+ if (rest.includes("--implementations")) {
3223
+ if (symbol)
3224
+ throw new Error("Choose --implementations or --symbol, not both");
3225
+ result = await graphRead(() => findImplementationCandidates(engine, repo, {
3226
+ task,
3227
+ limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
3228
+ offset: selectorValue("--offset") ? Number(selectorValue("--offset")) : undefined,
3229
+ }));
3230
+ break;
3231
+ }
3232
+ result = await graphRead(() => symbol
3233
+ ? buildTaskInvestigation(engine, repo, {
3234
+ task,
3235
+ symbol,
3236
+ ...selector,
3237
+ limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
3238
+ depth: selectorValue("--depth") ? Number(selectorValue("--depth")) : undefined,
3239
+ })
3240
+ : buildKnodinContext(engine, task, repo, rest[1]));
3038
3241
  break;
3039
3242
  }
3040
3243
  default:
@@ -3042,6 +3245,8 @@ async function main() {
3042
3245
  process.exit(1);
3043
3246
  }
3044
3247
  await engine.close();
3248
+ if (result?.available === false)
3249
+ process.exitCode = 1;
3045
3250
  if (agentEventOutput) {
3046
3251
  if (result !== null && result !== undefined)
3047
3252
  process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -3050,9 +3255,31 @@ async function main() {
3050
3255
  if (statusWasWatched)
3051
3256
  return;
3052
3257
  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 });
3258
+ // The bundle protocol budgets its signed manifest, source and continuation together.
3259
+ // Generic post-truncation would invalidate that recoverable envelope.
3260
+ const evidenceBundle = cmd === "evidence" && selectorValue("--files") !== undefined;
3261
+ const boundedResult = evidenceBundle || finalizedClassConsumer
3262
+ ? result
3263
+ : applyResponseBudget(result, cmd === "query" && rest[0] === "flow_analysis" ? "query:flow_analysis" : cmd, responseBudget, fileMetricsOutput
3264
+ ? { bytes: 16_777_216, tokens: 4_194_304, items: 100_000 }
3265
+ : { bytes: 65_536, tokens: 16_384, items: 100 });
3266
+ if (classConsumerErrorContract) {
3267
+ const delivered = boundedResult;
3268
+ if (delivered.code !== classConsumerErrorContract.code ||
3269
+ delivered.targetResolution !== classConsumerErrorContract.targetResolution ||
3270
+ delivered.available !== false ||
3271
+ delivered.status !== "unavailable")
3272
+ throw new Error("Response budget cannot preserve the class-consumer error contract; increase the budget.");
3273
+ }
3274
+ if (cmd === "query" && rest[0] === "inheritors_of") {
3275
+ const qualification = result
3276
+ ?.inheritanceQuery;
3277
+ const delivered = boundedResult
3278
+ ?.inheritanceQuery;
3279
+ if (qualification &&
3280
+ ["version", "mode", "partial", "truncated", "apex"].some((key) => delivered?.[key] !== qualification[key]))
3281
+ throw new Error("Response budget cannot preserve the inheritance qualification; increase the budget.");
3282
+ }
3056
3283
  const finalExitCode = Math.max(Number(process.exitCode ?? 0), repairExitCode);
3057
3284
  if (cmd === "init" && !jsonOutput) {
3058
3285
  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
+ }