brainclaw 1.25.0 → 1.26.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 (37) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +1 -4
  3. package/dist/commands/mcp.js +7 -7
  4. package/dist/commands/session-start.js +137 -15
  5. package/dist/core/bootstrap.js +28 -4
  6. package/dist/core/code-map/aggregate.js +36 -31
  7. package/dist/core/code-map/backend.js +4 -4
  8. package/dist/core/code-map/core.js +1 -0
  9. package/dist/core/code-map/export.js +4 -4
  10. package/dist/core/code-map/finalizer.js +57 -2
  11. package/dist/core/code-map/freshness.js +78 -13
  12. package/dist/core/code-map/impact.js +36 -4
  13. package/dist/core/code-map/indexes.js +37 -0
  14. package/dist/core/code-map/lang/python/index.js +4 -2
  15. package/dist/core/code-map/lang/query-runtime.js +2 -0
  16. package/dist/core/code-map/lang/typescript/index.js +4 -2
  17. package/dist/core/code-map/lang/usages.js +333 -0
  18. package/dist/core/code-map/memory-reader.js +15 -0
  19. package/dist/core/code-map/query.js +209 -58
  20. package/dist/core/code-map/refresh.js +0 -0
  21. package/dist/core/code-map/resolve.js +27 -2
  22. package/dist/core/code-map/store.js +1 -0
  23. package/dist/core/code-map/types.js +55 -9
  24. package/dist/core/code-map/vocabulary.js +6 -0
  25. package/dist/core/code-map/work-section.js +12 -14
  26. package/dist/core/context-diff.js +17 -3
  27. package/dist/core/entity-operations.js +14 -2
  28. package/dist/core/hint-aging.js +4 -1
  29. package/dist/core/identity.js +284 -91
  30. package/dist/core/io.js +192 -0
  31. package/dist/core/project-discovery.js +7 -1
  32. package/dist/core/runtime.js +99 -11
  33. package/dist/core/store-resolution.js +5 -21
  34. package/dist/facts.js +12 -12
  35. package/dist/facts.json +11 -11
  36. package/docs/code-map.md +36 -27
  37. package/package.json +1 -1
@@ -26,6 +26,12 @@ function coercePaths(raw) {
26
26
  return raw.filter((p) => typeof p === 'string');
27
27
  return [];
28
28
  }
29
+ function optionalString(raw) {
30
+ return typeof raw === 'string' ? raw : undefined;
31
+ }
32
+ function optionalCount(raw) {
33
+ return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
34
+ }
29
35
  function toRelatedMemoryItem(kind, item) {
30
36
  return {
31
37
  id: typeof item.id === 'string' ? item.id : '',
@@ -33,6 +39,15 @@ function toRelatedMemoryItem(kind, item) {
33
39
  text: typeof item.text === 'string' ? item.text : '',
34
40
  tags: coerceTags(item.tags),
35
41
  related_paths: coercePaths(item.related_paths),
42
+ created_at: optionalString(item.created_at),
43
+ last_confirmed_at: optionalString(item.last_confirmed_at),
44
+ last_infirmed_at: optionalString(item.last_infirmed_at),
45
+ confirmation_count: optionalCount(item.confirmation_count),
46
+ infirmation_count: optionalCount(item.infirmation_count),
47
+ saved_me_count: optionalCount(item.saved_me_count),
48
+ misled_me_count: optionalCount(item.misled_me_count),
49
+ verified_at: optionalString(item.verified_at),
50
+ verify_cmd: optionalString(item.verify_cmd),
36
51
  };
37
52
  }
38
53
  /**
@@ -11,8 +11,9 @@
11
11
  */
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
+ import { getLifecycleStats } from '../memory-lifecycle.js';
14
15
  import { hashContent } from './extractor.js';
15
- import { coarseFreshness } from './freshness.js';
16
+ import { makeFreshnessBadge } from './freshness.js';
16
17
  import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
17
18
  // --- lazy read-path freshness budget (spec §6.1) ---
18
19
  /** Default per-query lazy-check budget (spec §6.1). */
@@ -50,6 +51,7 @@ function budgetExhausted(checker) {
50
51
  }
51
52
  export function newAccumulator() {
52
53
  return {
54
+ checkedPaths: new Set(),
53
55
  staleChangedPaths: new Set(),
54
56
  missingPaths: new Set(),
55
57
  uncheckedPaths: new Set(),
@@ -76,6 +78,7 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
76
78
  if (cached !== undefined)
77
79
  return cached;
78
80
  const abs = path.join(projectRoot, entry.path);
81
+ acc.checkedPaths.add(entry.path);
79
82
  let stat;
80
83
  try {
81
84
  stat = fs.statSync(abs);
@@ -151,46 +154,26 @@ export function validateStoreEntry(entry, checker, acc, cwd, preferredDirName) {
151
154
  * change/deletion yields `stale_changed_files`; else the manifest base status.
152
155
  */
153
156
  export function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
154
- const details = {};
155
- if (acc.staleChangedPaths.size > 0) {
156
- details.stale_changed_files = [...acc.staleChangedPaths].sort();
157
- }
158
- if (acc.missingPaths.size > 0) {
159
- details.deleted_files = [...acc.missingPaths].sort();
160
- }
161
- if (acc.uncheckedPaths.size > 0) {
162
- details.unchecked_files = [...acc.uncheckedPaths].sort();
163
- }
164
- let status = base;
165
- if (emptyIndex && base !== 'missing_index') {
166
- // §6.1 — zero confident matches: hint refresh rather than imply absence.
167
- details.hint = 'missing_index_or_refresh';
168
- }
169
- if (acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0) {
170
- status = 'stale_changed_files';
171
- }
172
- // §6.1.6 — `partial` means the lazy-check budget (file count / wall clock) ran
173
- // out before we could validate everything. Reserve it for that cause only:
174
- // unchecked-for-other-reasons (oversized file per §6.1.4, missing shard,
175
- // unreadable file) must NOT be mislabeled as budget exhaustion. When the budget
176
- // truly ran out, `partial` wins the top-line status — the agent should refresh
177
- // before trusting the result — and the confirmed-stale list still rides along
178
- // in `details.stale_changed_files`.
179
- if (budgetExhausted || acc.budgetSkippedPaths.size > 0) {
180
- status = 'partial';
181
- details.partial_reason = 'lazy_check_budget_exhausted';
182
- details.budget = { ...LAZY_BUDGET };
183
- }
184
- // pln#593 #2 — distinguish INDEX freshness (manifest state) from THIS call's
185
- // read-path spot-check. When the call-level status diverges from the index
186
- // status (a budget-limited `partial`, or a per-file `stale_changed_files` over a
187
- // `fresh` index), surface the index status so an agent does not read
188
- // status()=fresh vs find()/brief()=partial as a contradiction: it's "index
189
- // <index_status>, this call's spot-check <status>".
190
- if (status !== base)
191
- details.index_status = base;
157
+ const hasStale = acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0;
158
+ const partial = budgetExhausted || acc.budgetSkippedPaths.size > 0;
159
+ const spotStatus = partial ? 'partial' : hasStale ? 'stale' : acc.checkedPaths.size > 0 ? 'fresh' : 'not_run';
160
+ const hint = emptyIndex && base !== 'missing_index' ? 'missing_index_or_refresh' : undefined;
192
161
  void hadConfidentMatch;
193
- return { status, coarse: coarseFreshness(status), details };
162
+ // `base` stays the top-level index classification. A stale or budget-limited
163
+ // candidate spot-check is honest evidence, but it must not turn find()/brief()
164
+ // into a different badge than status()/work() for the same index state.
165
+ return makeFreshnessBadge(base, {
166
+ spotCheck: {
167
+ status: spotStatus,
168
+ checked_files: acc.checkedPaths.size,
169
+ stale_changed_files: [...acc.staleChangedPaths].sort(),
170
+ deleted_files: [...acc.missingPaths].sort(),
171
+ unchecked_files: [...acc.uncheckedPaths].sort(),
172
+ budget_exhausted: partial,
173
+ partial_reason: partial ? 'lazy_check_budget_exhausted' : null,
174
+ },
175
+ extra: hint ? { hint } : undefined,
176
+ });
194
177
  }
195
178
  const DEFAULT_FIND_LIMIT = 20;
196
179
  /** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
@@ -411,7 +394,7 @@ export function find(query, limit, ctx) {
411
394
  return {
412
395
  query,
413
396
  matches: [],
414
- freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
397
+ freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
415
398
  };
416
399
  }
417
400
  const capped = r.matches.slice(0, limit ?? DEFAULT_FIND_LIMIT);
@@ -420,43 +403,178 @@ export function find(query, limit, ctx) {
420
403
  }
421
404
  /** spec §11 — cap related memory at top 5 by relevance. */
422
405
  export const RELATED_MEMORY_CAP = 5;
406
+ const MEMORY_JOIN_MIN_IDENTIFIER_LENGTH = 4;
407
+ const MEMORY_JOIN_STOP_WORDS = new Set([
408
+ 'default', 'module', 'index', 'main', 'test', 'tests', 'spec', 'react', 'node',
409
+ ]);
410
+ function stableNames(names) {
411
+ const byNormalized = new Map();
412
+ for (const raw of names) {
413
+ const name = raw.trim();
414
+ const normalized = normIdent(name);
415
+ if (!normalized || byNormalized.has(normalized))
416
+ continue;
417
+ byNormalized.set(normalized, name);
418
+ }
419
+ return [...byNormalized.values()].sort((a, b) => a.localeCompare(b));
420
+ }
421
+ /** Avoid joining a prose memory to generic import noise such as `default` or `React`. */
422
+ function memoryJoinTerms(names) {
423
+ return stableNames(names).filter((name) => {
424
+ const normalized = normIdent(name);
425
+ return normalized.length >= MEMORY_JOIN_MIN_IDENTIFIER_LENGTH
426
+ && !MEMORY_JOIN_STOP_WORDS.has(normalized)
427
+ && !isTestHelperSymbol(name);
428
+ });
429
+ }
430
+ function escapeRegExp(text) {
431
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
432
+ }
433
+ /**
434
+ * Identifier-aware, case-insensitive text match. The surrounding guards keep
435
+ * `Auth` from matching `author`, while still matching `ENTITY_REGISTRY` in a
436
+ * prose trap written as `entity_registry`.
437
+ */
438
+ function textMentionsIdentifier(text, identifier) {
439
+ const escaped = escapeRegExp(identifier);
440
+ return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}(?=$|[^A-Za-z0-9_$])`, 'i').test(text);
441
+ }
442
+ function isLifecycleMemoryKind(kind) {
443
+ return kind === 'decision' || kind === 'constraint' || kind === 'trap';
444
+ }
445
+ function validDate(value) {
446
+ if (!value)
447
+ return null;
448
+ const ms = Date.parse(value);
449
+ return Number.isFinite(ms) ? ms : null;
450
+ }
451
+ /**
452
+ * Make memory age explicit on the brief response. `verify_cmd` marks a
453
+ * perishable fact, so its empirical verification wins over the gentler
454
+ * lifecycle curve: missing verification is `unverified`, and one older than
455
+ * 30 days is `stale` (the same threshold used by memory anti-staleness).
456
+ */
457
+ function describeMemoryFreshness(item) {
458
+ const verifiedAt = item.verified_at ?? null;
459
+ const verificationMs = validDate(item.verified_at);
460
+ const nowMs = Date.now();
461
+ if (item.verify_cmd) {
462
+ if (verificationMs === null) {
463
+ return { status: 'unverified', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
464
+ }
465
+ const ageDays = Math.max(0, Math.floor((nowMs - verificationMs) / 86_400_000));
466
+ if (ageDays > 30)
467
+ return { status: 'stale', as_of: item.verified_at, age_days: ageDays, verified_at: verifiedAt };
468
+ }
469
+ if (!isLifecycleMemoryKind(item.kind) || validDate(item.created_at) === null) {
470
+ return { status: 'unknown', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
471
+ }
472
+ const stats = getLifecycleStats({
473
+ entity: item.kind,
474
+ created_at: item.created_at,
475
+ last_confirmed_at: item.last_confirmed_at,
476
+ last_infirmed_at: item.last_infirmed_at,
477
+ confirmation_count: item.confirmation_count,
478
+ infirmation_count: item.infirmation_count,
479
+ saved_me_count: item.saved_me_count,
480
+ misled_me_count: item.misled_me_count,
481
+ nowMs,
482
+ });
483
+ return {
484
+ status: stats.classification,
485
+ as_of: stats.anchor_at,
486
+ age_days: stats.age_days,
487
+ verified_at: verifiedAt,
488
+ };
489
+ }
423
490
  /**
424
491
  * Match memory items to a set of candidate file paths + the query symbol name
425
492
  * by (spec §11): related_paths, tags, or a literal file-path mention in the
426
493
  * memory text. Returns the top `RELATED_MEMORY_CAP` by relevance.
427
494
  */
428
- export function attachRelatedMemory(items, paths, symbolNames) {
495
+ export function attachRelatedMemory(items, paths, symbolNames, importNames = []) {
429
496
  const pathSet = new Set(paths.map((p) => p.replace(/\\/g, '/')));
430
497
  const baseNames = new Set(paths.map((p) => path.basename(p)));
431
498
  const symLower = new Set(symbolNames.map((s) => s.toLowerCase()));
499
+ const symbolTerms = memoryJoinTerms(symbolNames);
500
+ const importTerms = memoryJoinTerms(importNames);
432
501
  const scored = [];
433
502
  for (const item of items) {
434
503
  let score = 0;
504
+ const sources = new Set();
435
505
  // related_paths — strongest signal.
436
506
  for (const rp of item.related_paths ?? []) {
437
507
  const norm = rp.replace(/\\/g, '/');
438
- if (pathSet.has(norm))
508
+ if (pathSet.has(norm)) {
439
509
  score += 5;
440
- else if (baseNames.has(path.basename(norm)))
510
+ sources.add('related_path');
511
+ }
512
+ else if (baseNames.has(path.basename(norm))) {
441
513
  score += 3;
514
+ sources.add('related_path_basename');
515
+ }
442
516
  }
443
517
  // literal file-path mention in the memory text.
444
518
  const text = item.text ?? '';
445
519
  for (const p of pathSet) {
446
- if (text.includes(p))
520
+ if (text.includes(p)) {
447
521
  score += 2;
522
+ sources.add('path_mention');
523
+ }
448
524
  }
449
525
  for (const bn of baseNames) {
450
- if (text.includes(bn))
526
+ if (text.includes(bn)) {
451
527
  score += 1;
528
+ sources.add('path_basename_mention');
529
+ }
452
530
  }
453
531
  // tags matching a symbol name (e.g. tag "App" / "useAuth").
454
532
  for (const tag of item.tags ?? []) {
455
- if (symLower.has(tag.toLowerCase()))
533
+ if (symLower.has(tag.toLowerCase())) {
456
534
  score += 2;
535
+ sources.add('symbol_tag');
536
+ }
537
+ }
538
+ // pln#601 amendment — response-local join from MEMORY TEXT to the names
539
+ // defined/imported by the brief target. We only admit identifier-shaped,
540
+ // non-test-helper terms and retain the exact terms as inspectable evidence.
541
+ const matchedSymbols = symbolTerms.filter((term) => textMentionsIdentifier(text, term));
542
+ const matchedImports = importTerms.filter((term) => textMentionsIdentifier(text, term));
543
+ if (matchedSymbols.length > 0) {
544
+ score += 4 * matchedSymbols.length;
545
+ sources.add('symbol_text');
546
+ }
547
+ if (matchedImports.length > 0) {
548
+ score += 3 * matchedImports.length;
549
+ sources.add('import_text');
550
+ }
551
+ if (score > 0) {
552
+ let confidence = 0.6;
553
+ if (sources.has('related_path'))
554
+ confidence = 1;
555
+ else if (sources.has('path_mention'))
556
+ confidence = 0.95;
557
+ else if (sources.has('related_path_basename'))
558
+ confidence = 0.85;
559
+ else if (sources.has('symbol_text'))
560
+ confidence = 0.82;
561
+ else if (sources.has('import_text'))
562
+ confidence = 0.72;
563
+ else if (sources.has('symbol_tag'))
564
+ confidence = 0.65;
565
+ const evidence = {
566
+ sources: [...sources].sort(),
567
+ matched_symbols: matchedSymbols,
568
+ matched_imports: matchedImports,
569
+ confidence,
570
+ };
571
+ // Clone: these response annotations must never mutate the records returned
572
+ // by the memory reader or be persisted into Code Map shards.
573
+ scored.push({
574
+ item: { ...item, match_evidence: evidence, memory_freshness: describeMemoryFreshness(item) },
575
+ score,
576
+ });
457
577
  }
458
- if (score > 0)
459
- scored.push({ item, score });
460
578
  }
461
579
  scored.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
462
580
  return scored.slice(0, RELATED_MEMORY_CAP).map((s) => s.item);
@@ -649,6 +767,24 @@ function reverseDeps(resolutionIndex, definingPaths, definingByNodeId) {
649
767
  }
650
768
  return [...byPath.values()];
651
769
  }
770
+ function targetMemoryTerms(definingPaths, definingFileIds, nodeIndex, cwd, preferredDirName) {
771
+ const symbols = [...nodeIndex.values()]
772
+ .filter((entry) => definingPaths.has(entry.path))
773
+ .map((entry) => entry.name);
774
+ const imports = new Set();
775
+ for (const fileId of new Set(definingFileIds)) {
776
+ const shard = readShard(fileId, cwd, preferredDirName);
777
+ if (!shard)
778
+ continue;
779
+ for (const node of shard.nodes) {
780
+ if (node.kind !== 'module')
781
+ continue;
782
+ for (const imported of node.imported_names)
783
+ imports.add(imported);
784
+ }
785
+ }
786
+ return { symbolNames: stableNames(symbols), importNames: stableNames([...imports]) };
787
+ }
652
788
  /**
653
789
  * Heuristic: does the brief target denote a file PATH rather than a bare symbol
654
790
  * name? A path separator or a supported source extension marks a path target —
@@ -694,6 +830,8 @@ export function briefInStore(target, ctx, checker, acc) {
694
830
  return {
695
831
  defining: [],
696
832
  definingPaths: new Set(),
833
+ memorySymbolNames: [],
834
+ memoryImportNames: [],
697
835
  matchKind: 'none',
698
836
  confident: [],
699
837
  base: 'missing_index',
@@ -749,6 +887,7 @@ export function briefInStore(target, ctx, checker, acc) {
749
887
  confidentDefiningFileIds.set(e.path, e.file_id);
750
888
  }
751
889
  const nodeIndex = buildNodeIdIndex(symbolsIndex);
890
+ const memoryTerms = targetMemoryTerms(definingPaths, defining.map((entry) => entry.file_id), nodeIndex, ctx.cwd, ctx.preferredDirName);
752
891
  const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
753
892
  const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
754
893
  const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
@@ -763,13 +902,25 @@ export function briefInStore(target, ctx, checker, acc) {
763
902
  continue;
764
903
  confident.push(rf);
765
904
  }
766
- return { defining, definingPaths, matchKind, confident, base, hasIndex: true, emptyRanked: ranked.length === 0, acc };
905
+ return {
906
+ defining,
907
+ definingPaths,
908
+ memorySymbolNames: memoryTerms.symbolNames,
909
+ memoryImportNames: memoryTerms.importNames,
910
+ matchKind,
911
+ confident,
912
+ base,
913
+ hasIndex: true,
914
+ emptyRanked: ranked.length === 0,
915
+ acc,
916
+ };
767
917
  }
768
918
  /**
769
919
  * Attach related-memory ids per reading-list entry (spec §11). Shared by the
770
920
  * single-store brief() and the workspace aggregation so both surface memory identically.
771
921
  */
772
- export function attachMemoryIds(capped, related) {
922
+ export function attachMemoryIds(capped, related, targetPaths = new Set()) {
923
+ const normalizedTargetPaths = new Set([...targetPaths].map((p) => p.replace(/\\/g, '/')));
773
924
  return capped.map((f) => {
774
925
  const ids = related
775
926
  .filter((m) => {
@@ -777,7 +928,9 @@ export function attachMemoryIds(capped, related) {
777
928
  const base2 = path.basename(fileNorm);
778
929
  const inPaths = (m.related_paths ?? []).some((rp) => rp.replace(/\\/g, '/') === fileNorm || path.basename(rp) === base2);
779
930
  const inText = (m.text ?? '').includes(fileNorm) || (m.text ?? '').includes(base2);
780
- return inPaths || inText;
931
+ const symbolTextMatch = (m.match_evidence?.matched_symbols.length ?? 0) > 0
932
+ || (m.match_evidence?.matched_imports.length ?? 0) > 0;
933
+ return inPaths || inText || (normalizedTargetPaths.has(fileNorm) && symbolTextMatch);
781
934
  })
782
935
  .map((m) => m.id);
783
936
  return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
@@ -792,7 +945,7 @@ export function brief(target, limit, ctx, memoryReader) {
792
945
  target,
793
946
  suggested_files_to_read: [],
794
947
  related_memory: [],
795
- freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
948
+ freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
796
949
  };
797
950
  }
798
951
  const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
@@ -800,11 +953,9 @@ export function brief(target, limit, ctx, memoryReader) {
800
953
  const capped = reserveSourceSlots(r.confident, cap, r.definingPaths);
801
954
  // Related memory (spec §11): match by the candidate paths + symbol names.
802
955
  const candidatePaths = capped.map((f) => f.path);
803
- const symbolNames = [...new Set(r.defining.map((e) => e.name))];
804
- if (symbolNames.length === 0)
805
- symbolNames.push(target);
806
- const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames);
807
- const suggested = attachMemoryIds(capped, related);
956
+ const symbolNames = r.memorySymbolNames.length > 0 ? r.memorySymbolNames : [target];
957
+ const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames, r.memoryImportNames);
958
+ const suggested = attachMemoryIds(capped, related, r.definingPaths);
808
959
  const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyRanked);
809
960
  return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: badge };
810
961
  }
Binary file
@@ -55,7 +55,7 @@ function edgesArrayEqual(a, b) {
55
55
  for (let i = 0; i < a.length; i++) {
56
56
  const x = a[i];
57
57
  const y = b[i];
58
- if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence) {
58
+ if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence || x.origin !== y.origin) {
59
59
  return false;
60
60
  }
61
61
  }
@@ -101,9 +101,12 @@ export async function resolveProjectImports(input) {
101
101
  const provider = registry.providerForLang(shard.lang);
102
102
  // Non-pass-owned edges are preserved byte-identical (same order). Filtering BOTH
103
103
  // pass-owned kinds also strips any stale A/B edge from a prior run (idempotency).
104
- const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind));
104
+ const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind) && e.origin !== 'usage_import');
105
105
  // Fresh pass-owned set (A resolves_to + B imports_symbol). Empty when no resolver.
106
106
  const fresh = [];
107
+ const seen = new Set();
108
+ // module + imported-name → unique target proven by imports_symbol below.
109
+ const resolvedImportedBindings = new Map();
107
110
  let freshSymbolCount = 0;
108
111
  if (provider?.resolveImport) {
109
112
  const seen = new Set();
@@ -149,11 +152,33 @@ export async function resolveProjectImports(input) {
149
152
  continue; // dedup (duplicate names / re-imports)
150
153
  seen.add(symId);
151
154
  fresh.push({ id: symId, from: mod.id, to: target.id, kind: 'imports_symbol', confidence, source });
155
+ resolvedImportedBindings.set(`${r.source}\0${name}`, target);
152
156
  freshSymbolCount++;
153
157
  }
154
158
  }
155
159
  }
156
160
  }
161
+ // P4 imported-binding usages are emitted only after the exact `imports_symbol`
162
+ // proof above. If a module is unresolved, ambiguous, wildcard/default, or its
163
+ // target no longer exports the symbol, no call/reference edge survives.
164
+ for (const candidate of shard.reference_candidates ?? []) {
165
+ const target = resolvedImportedBindings.get(`${candidate.module}\0${candidate.imported_name}`);
166
+ if (!target)
167
+ continue;
168
+ const id = edgeId({ projectId, from: candidate.from, to: target.id, kind: candidate.kind });
169
+ if (seen.has(id))
170
+ continue;
171
+ seen.add(id);
172
+ fresh.push({
173
+ id,
174
+ from: candidate.from,
175
+ to: target.id,
176
+ kind: candidate.kind,
177
+ confidence: Math.min(clampConfidence(candidate.confidence), 1.0),
178
+ source: candidate.source,
179
+ origin: 'usage_import',
180
+ });
181
+ }
157
182
  // Single deterministic order over A+B so re-runs are byte-identical (from, to, kind).
158
183
  fresh.sort((a, b) => {
159
184
  if (a.from !== b.from)
@@ -193,6 +193,7 @@ export function readResolutionIndex(cwd, preferredDirName) {
193
193
  // 'toString' (a path or node id) can't resolve to an inherited prototype member.
194
194
  parsed.data.dependents_by_file = Object.assign(Object.create(null), parsed.data.dependents_by_file);
195
195
  parsed.data.dependents_by_symbol = Object.assign(Object.create(null), parsed.data.dependents_by_symbol);
196
+ parsed.data.usages_by_symbol = Object.assign(Object.create(null), parsed.data.usages_by_symbol);
196
197
  return parsed.data;
197
198
  }
198
199
  export function writeResolutionIndex(index, cwd, preferredDirName) {
@@ -110,6 +110,30 @@ export const EdgeSchema = z.object({
110
110
  })
111
111
  .nullable()
112
112
  .optional(),
113
+ /**
114
+ * Optional provenance for P4 usage edges. It lets the project resolver replace
115
+ * only import-derived usages on a later refresh without deleting lexical calls
116
+ * already proven inside the source file.
117
+ */
118
+ origin: z.enum(['usage_local', 'usage_import', 'usage_textual']).optional(),
119
+ });
120
+ /** P4's three deliberately non-interchangeable usage classifications. */
121
+ export const UsageKindSchema = z.enum(['calls', 'references', 'possible_textual_match']);
122
+ /**
123
+ * An import-binding usage that is lexical in one file but needs the project-wide
124
+ * import pass before its target symbol is known. This is persisted on the shard,
125
+ * never surfaced as a `calls`/`references` edge until that target is unique.
126
+ */
127
+ export const ReferenceCandidateSchema = z.object({
128
+ from: z.string(),
129
+ kind: z.enum(['calls', 'references']),
130
+ module: z.string(),
131
+ imported_name: z.string(),
132
+ confidence: z.number().default(1.0),
133
+ source: z.object({
134
+ path: z.string(),
135
+ line: z.number().int().nullable().optional(),
136
+ }),
113
137
  });
114
138
  // --- Per-file shard (spec §5.3) ---
115
139
  export const ShardFreshnessSchema = z.object({
@@ -136,6 +160,8 @@ export const FileShardSchema = z.object({
136
160
  freshness: ShardFreshnessSchema,
137
161
  nodes: z.array(NodeSchema).default([]),
138
162
  edges: z.array(EdgeSchema).default([]),
163
+ /** Deferred P4 imported-binding usages; resolved by the whole-project pass. */
164
+ reference_candidates: z.array(ReferenceCandidateSchema).optional(),
139
165
  diagnostics: z.array(z.unknown()).default([]),
140
166
  });
141
167
  // --- manifest.json (spec §5.1) ---
@@ -282,6 +308,19 @@ export const DependencyIndexEntrySchema = z.object({
282
308
  * read-path scan of every shard. Forward deps are read straight from a target's own
283
309
  * shard, so they need no index.
284
310
  */
311
+ /** A persisted, high-confidence P4 usage cause targeting one symbol. */
312
+ export const UsageReasonSchema = z.object({
313
+ kind: z.enum(['calls', 'references']),
314
+ caller_node_id: z.string(),
315
+ confidence: z.number(),
316
+ source_line: z.number().int().nullable().optional(),
317
+ });
318
+ /** One importing file's lexical uses of a target symbol. */
319
+ export const UsageIndexEntrySchema = z.object({
320
+ path: z.string(),
321
+ file_id: Sha256Hash,
322
+ reasons: z.array(UsageReasonSchema).default([]),
323
+ });
285
324
  export const ResolutionIndexSchema = z.object({
286
325
  schema_version: z.number().int().default(CODE_MAP_SCHEMA_VERSION),
287
326
  project_id: z.string(),
@@ -290,6 +329,8 @@ export const ResolutionIndexSchema = z.object({
290
329
  dependents_by_file: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
291
330
  /** Keys are TARGET symbol node ids (reverse `imports_symbol`). */
292
331
  dependents_by_symbol: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
332
+ /** Keys are TARGET symbol ids; only proven P4 call/reference usages are indexed. */
333
+ usages_by_symbol: z.record(z.string(), z.array(UsageIndexEntrySchema)).default({}),
293
334
  });
294
335
  // --- .lock (spec §5.8) ---
295
336
  export const CodeLockSchema = z.object({
@@ -307,20 +348,25 @@ export const CodeLockSchema = z.object({
307
348
  stale_after_ms: z.number().int(),
308
349
  });
309
350
  /**
310
- * spec §9 (pln#601) the COARSE, surface-uniform rollup of the detailed 7-value
311
- * {@link FreshnessStatus}. find/brief/status/work each expose their own precise
312
- * status, but an agent wants one consistent top-line signal to decide "trust this
313
- * or refresh first" without memorizing which `stale_*` variant applies. `coarse`
314
- * collapses the detail: every `stale_*` → `stale`, `missing_index` → `missing`,
315
- * `partial`/`fresh` unchanged. Derived (never independently authored) via
316
- * `coarseFreshness()` so it can never contradict `status`.
351
+ * The one agent-facing freshness signal. Detailed index causes and per-call
352
+ * spot-check observations intentionally live under `FreshnessBadge.details`.
317
353
  */
318
354
  export const CoarseFreshnessSchema = z.enum(['fresh', 'stale', 'partial', 'missing']);
319
355
  /** Freshness badge attached to every agent-facing read response (spec §9). */
320
356
  export const FreshnessBadgeSchema = z.object({
357
+ /**
358
+ * Stable top-line signal shared by work/status/find/brief. It MUST NOT be
359
+ * changed by a query's bounded spot-check: that observation belongs in
360
+ * `details.spot_check`, so an agent never sees incompatible badges for the
361
+ * same index state.
362
+ */
363
+ freshness: CoarseFreshnessSchema,
364
+ /**
365
+ * Detailed index classification retained for API compatibility. It describes
366
+ * the index only (never a query spot-check); new consumers branch on
367
+ * `freshness` and inspect `details` for the reason.
368
+ */
321
369
  status: FreshnessStatusSchema,
322
- /** pln#601 — coarse rollup of `status`, uniform across all read surfaces. */
323
- coarse: CoarseFreshnessSchema.optional(),
324
370
  details: z.record(z.string(), z.unknown()).default({}),
325
371
  });
326
372
  //# sourceMappingURL=types.js.map
@@ -47,6 +47,12 @@ export const UniversalEdgeKinds = [
47
47
  'resolves_to',
48
48
  'imports_symbol',
49
49
  'tests_for',
50
+ // P4 usages: a direct lexical invocation, a non-call lexical binding use, or
51
+ // an explicitly low-confidence text/property hint. Only `calls` is a proven
52
+ // invocation; consumers must never promote `possible_textual_match`.
53
+ 'calls',
54
+ 'references',
55
+ 'possible_textual_match',
50
56
  'extends',
51
57
  'implements',
52
58
  'annotates',