brainclaw 1.25.0 → 1.26.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 (36) 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 +84 -13
  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 +69 -17
  30. package/dist/core/io.js +27 -0
  31. package/dist/core/project-discovery.js +7 -1
  32. package/dist/core/runtime.js +23 -0
  33. package/dist/facts.js +12 -12
  34. package/dist/facts.json +11 -11
  35. package/docs/code-map.md +36 -27
  36. package/package.json +1 -1
Binary file
@@ -15,10 +15,7 @@ function badgeLine(badge) {
15
15
  const detail = detailKeys.length
16
16
  ? ` (${detailKeys.map((k) => `${k}=${JSON.stringify(badge.details[k])}`).join(', ')})`
17
17
  : '';
18
- // pln#601 — lead with the coarse rollup (uniform across all read surfaces), then
19
- // the precise status + details. `coarse` may be absent on legacy/hand-built badges.
20
- const coarse = badge.coarse ? `${badge.coarse} · ` : '';
21
- return `Freshness: ${coarse}${badge.status}${detail}`;
18
+ return `Freshness: ${badge.freshness}${detail}`;
22
19
  }
23
20
  export async function runCodeMap(subcommand, args, options = {}) {
24
21
  const normalized = (subcommand ?? '').trim().toLowerCase();
@@ -1097,7 +1097,7 @@ async function _executeMcpToolCallInner(payload) {
1097
1097
  const status = await be.status({ cwd, cascade: args.cascade === true });
1098
1098
  return {
1099
1099
  response: toolResponse({
1100
- content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.status}` }],
1100
+ content: [{ type: 'text', text: `Code Map: ${status.store_exists ? 'store present' : 'no store'} — freshness=${status.freshness_badge.freshness}` }],
1101
1101
  structuredContent: { ...status, freshness_badge: status.freshness_badge },
1102
1102
  }),
1103
1103
  };
@@ -1108,7 +1108,7 @@ async function _executeMcpToolCallInner(payload) {
1108
1108
  const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
1109
1109
  return {
1110
1110
  response: toolResponse({
1111
- content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.status}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
1111
+ content: [{ type: 'text', text: `Code Map refresh [${result.scope}]: ran=${result.ran} freshness=${result.freshness_badge.freshness}${cascadeNote}${result.lock_status ? ` (${result.lock_status})` : ''}` }],
1112
1112
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1113
1113
  }),
1114
1114
  };
@@ -1122,7 +1122,7 @@ async function _executeMcpToolCallInner(payload) {
1122
1122
  const result = await be.find({ query, limit, cwd });
1123
1123
  return {
1124
1124
  response: toolResponse({
1125
- content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.status}` }],
1125
+ content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
1126
1126
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1127
1127
  }),
1128
1128
  };
@@ -1143,7 +1143,7 @@ async function _executeMcpToolCallInner(payload) {
1143
1143
  const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
1144
1144
  return {
1145
1145
  response: toolResponse({
1146
- content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.status}` }],
1146
+ content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
1147
1147
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1148
1148
  }),
1149
1149
  };
@@ -1159,7 +1159,7 @@ async function _executeMcpToolCallInner(payload) {
1159
1159
  const result = await be.impact({ target, depth, limit, cwd });
1160
1160
  return {
1161
1161
  response: toolResponse({
1162
- content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.status}` }],
1162
+ content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
1163
1163
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1164
1164
  }),
1165
1165
  };
@@ -1174,7 +1174,7 @@ async function _executeMcpToolCallInner(payload) {
1174
1174
  const result = await be.outline({ path: outlinePath, limit, cwd });
1175
1175
  return {
1176
1176
  response: toolResponse({
1177
- content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.status}` }],
1177
+ content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
1178
1178
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1179
1179
  }),
1180
1180
  };
@@ -1188,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
1188
1188
  const result = await be.brief({ target, limit, cwd });
1189
1189
  return {
1190
1190
  response: toolResponse({
1191
- content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.status}` }],
1191
+ content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
1192
1192
  structuredContent: { ...result, freshness_badge: result.freshness_badge },
1193
1193
  }),
1194
1194
  };
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { execSync } from 'node:child_process';
5
- import { memoryExists, resolveEntityDir } from '../core/io.js';
5
+ import { isSessionSnapshotRecordFilename, memoryExists, resolveEntityDir, sessionSnapshotRecordPaths } from '../core/io.js';
6
6
  import { loadVersionedJsonFile, saveVersionedJsonFile } from '../core/migration.js';
7
7
  import { buildOperationalIdentity, loadAllSessions, saveCurrentSession } from '../core/identity.js';
8
8
  import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAgentIdentity } from '../core/agent-registry.js';
@@ -25,11 +25,55 @@ import { loadHygienePolicy } from '../core/hygiene-policy.js';
25
25
  import { maybeCreateCheckpoint } from '../core/events/checkpoint.js';
26
26
  import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
27
27
  import { materializeFederationSignal } from '../core/federation-materialize.js';
28
- function sessionsDir(cwd) {
29
- return resolveEntityDir('sessions', cwd ?? process.cwd(), 'read');
28
+ /**
29
+ * pln#670 snapshot writes always target the canonical directory ('write' mode).
30
+ * The previous 'read'-mode resolution meant a fresh store (no coordination/sessions
31
+ * yet) landed the snapshot in the legacy dir — the current_session home — where
32
+ * saveCurrentSession then clobbered it (same `<session_id>.json` name, same id).
33
+ */
34
+ function sessionSnapshotWriteDir(cwd) {
35
+ return resolveEntityDir('sessions', cwd ?? process.cwd(), 'write');
30
36
  }
31
37
  function sessionSnapshotPath(sessionId, cwd) {
32
- return path.join(sessionsDir(cwd), `${sessionId}.json`);
38
+ return path.join(sessionSnapshotWriteDir(cwd), `${sessionId}.snapshot.json`);
39
+ }
40
+ /**
41
+ * pln#670 — lazy migration of pre-split snapshot records: rename `<id>.json` to
42
+ * `<id>.snapshot.json` in the CANONICAL sessions directory only. The legacy
43
+ * directory is never scanned — it is the current_session home. Only files that
44
+ * validate as session_snapshot are touched; anything else is left in place.
45
+ */
46
+ export function migrateLegacySnapshotNames(cwd) {
47
+ const dir = sessionSnapshotWriteDir(cwd);
48
+ if (!fs.existsSync(dir))
49
+ return 0;
50
+ let renamed = 0;
51
+ for (const file of fs.readdirSync(dir)) {
52
+ // Case-insensitive suffix checks (codex review P1): Windows filesystems
53
+ // match names case-insensitively — an upper-cased `.SNAPSHOT.json` is the
54
+ // same record a lower-case probe reads, and must not be re-suffixed.
55
+ if (!file.toLowerCase().endsWith('.json') || isSessionSnapshotRecordFilename(file))
56
+ continue;
57
+ const from = path.join(dir, file);
58
+ try {
59
+ // Discriminate on the RAW file — the migration loader zod-strips unknown
60
+ // keys, so a current_session record parses as a clean snapshot after it.
61
+ // Key PRESENCE, not value type (codex review P1): the invariant is
62
+ // "never touch a record CARRYING last_seen_at", and `last_seen_at: null`
63
+ // must be rejected too.
64
+ const raw = JSON.parse(fs.readFileSync(from, 'utf-8'));
65
+ if (Object.hasOwn(raw, 'last_seen_at'))
66
+ continue;
67
+ SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', from).document);
68
+ const to = path.join(dir, `${file.slice(0, -'.json'.length)}.snapshot.json`);
69
+ if (!fs.existsSync(to)) {
70
+ fs.renameSync(from, to);
71
+ renamed++;
72
+ }
73
+ }
74
+ catch { /* not a session_snapshot — leave it alone */ }
75
+ }
76
+ return renamed;
33
77
  }
34
78
  export async function runSessionStart(options = {}) {
35
79
  try {
@@ -155,7 +199,7 @@ export async function startSession(options = {}) {
155
199
  ...(model ? { model } : {}),
156
200
  };
157
201
  // Persist snapshot
158
- const dir = sessionsDir(options.cwd);
202
+ const dir = sessionSnapshotWriteDir(options.cwd);
159
203
  if (!fs.existsSync(dir))
160
204
  fs.mkdirSync(dir, { recursive: true });
161
205
  saveVersionedJsonFile('session_snapshot', sessionSnapshotPath(snapshot.session_id, options.cwd), SessionSnapshotSchema.parse(snapshot));
@@ -218,6 +262,14 @@ export async function startSession(options = {}) {
218
262
  inventoryAdvisory = lines;
219
263
  }
220
264
  catch { /* non-fatal — inventory scan failure should not block session start */ }
265
+ // pln#670 — lazy rename of pre-split snapshot records to the type-suffixed
266
+ // name. Session-start full maintenance is the natural sweep point (no daemon,
267
+ // feedback_lazy_reconcile_pattern); dual-read keeps unrenamed records readable
268
+ // in the meantime.
269
+ try {
270
+ migrateLegacySnapshotNames(options.cwd);
271
+ }
272
+ catch { /* non-fatal — name migration must never block session start */ }
221
273
  // pln#564 step B — cap the runtime-note tree on session start (no LLM gate,
222
274
  // unlike the compaction-phase archiveSessionNotes). Keeps the newest N
223
275
  // session/lifecycle notes per agent + all genuine observations, parks the
@@ -367,14 +419,33 @@ function isPidAlive(pid) {
367
419
  }
368
420
  }
369
421
  export function loadSessionSnapshot(sessionId, cwd) {
370
- const p = sessionSnapshotPath(sessionId, cwd);
371
- if (!fs.existsSync(p))
372
- return undefined;
373
- try {
374
- return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
375
- }
376
- catch {
377
- return undefined;
422
+ // pln#670 probe the type-suffixed name first, then the pre-split `<id>.json`
423
+ // layouts. SessionSnapshotSchema is non-strict, so a current_session record for
424
+ // the same id would parse too (zod strips unknown keys) — the negative
425
+ // discriminant is `last_seen_at`, which only current_session carries.
426
+ for (const p of sessionSnapshotRecordPaths(sessionId, cwd)) {
427
+ if (!fs.existsSync(p))
428
+ continue;
429
+ try {
430
+ // Discriminate on the RAW file: the migration loader zod-strips unknown
431
+ // keys, so a current_session record would come back looking like a clean
432
+ // snapshot. Key PRESENCE, not value type (codex review P1) — a record
433
+ // carrying `last_seen_at: null` must be rejected too.
434
+ const raw = JSON.parse(fs.readFileSync(p, 'utf-8'));
435
+ if (Object.hasOwn(raw, 'last_seen_at'))
436
+ continue;
437
+ const snapshot = SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', p).document);
438
+ // The filename is not a type-safe identity boundary by itself (codex
439
+ // review): querying `sess_x.snapshot` also constructs `sess_x.snapshot.json`
440
+ // — the snapshot of sess_x. Only return a payload that names the caller's id.
441
+ if (snapshot.session_id !== sessionId)
442
+ continue;
443
+ return snapshot;
444
+ }
445
+ catch {
446
+ continue;
447
+ }
378
448
  }
449
+ return undefined;
379
450
  }
380
451
  //# sourceMappingURL=session-start.js.map
@@ -9,6 +9,7 @@ import { mutate } from './mutation-pipeline.js';
9
9
  import { BootstrapApplicationReceiptSchema, BootstrapInterviewAnswerSchema, BootstrapInterviewPlanSchema, BootstrapInterviewQuestionSchema, BootstrapImportPlanDocumentSchema, BootstrapProfileDocumentSchema, BootstrapSuggestionDocumentSchema, MemorySeedDocumentSchema, } from './schema.js';
10
10
  import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
11
11
  import { analyzeRepository, findNestedAgentsFiles } from './repo-analysis.js';
12
+ import { isManagedByBrainclaw } from './project-discovery.js';
12
13
  import { buildExecutionContext, compactExecutionContext } from './execution-context.js';
13
14
  import { buildAgentToolingContext } from './agent-context.js';
14
15
  import { createInstruction, loadInstructions, saveInstruction } from './instructions.js';
@@ -256,15 +257,38 @@ function buildBootstrapArtifacts(input) {
256
257
  sourcesScanned.push('README');
257
258
  seeds.push(...extractReadmeSeeds(readmePath, input.target));
258
259
  }
260
+ // pln#671 — instruction files generated by `brainclaw export` derive FROM
261
+ // brainclaw memory: deriving seeds from them feeds the store its own output
262
+ // back as "new" knowledge. Seed extraction skips managed files; DETECTION
263
+ // (agentsPresent, native_instruction_files, source fingerprint) stays
264
+ // complete — that is environment inventory, not knowledge to import. The
265
+ // skip is traced in sources_scanned so the exclusion is never silent.
259
266
  const agentsPath = path.join(scanRoot, 'AGENTS.md');
260
267
  const agentsPresent = fs.existsSync(agentsPath);
261
268
  if (agentsPresent) {
262
- sourcesScanned.push('AGENTS.md');
263
- seeds.push(...extractAgentsSeeds(agentsPath, input.target));
269
+ if (isManagedByBrainclaw(agentsPath)) {
270
+ sourcesScanned.push('AGENTS.md (brainclaw-managed — skipped)');
271
+ }
272
+ else {
273
+ sourcesScanned.push('AGENTS.md');
274
+ seeds.push(...extractAgentsSeeds(agentsPath, input.target));
275
+ }
264
276
  }
265
277
  if (nativeInstructionFiles.length > 0) {
266
- sourcesScanned.push('native_instructions');
267
- seeds.push(...extractNativeInstructionSeeds(nativeInstructionFiles.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
278
+ // AGENTS.md is handled by its own extractor above — keep it out of the
279
+ // native accounting so a managed AGENTS.md is not counted as skipped twice.
280
+ const nativeCandidates = nativeInstructionFiles
281
+ .filter((relativePath) => path.basename(relativePath) !== 'AGENTS.md');
282
+ const humanAuthored = nativeCandidates
283
+ .filter((relativePath) => !isManagedByBrainclaw(path.join(scanRoot, relativePath)));
284
+ const skippedManaged = nativeCandidates.length - humanAuthored.length;
285
+ if (humanAuthored.length > 0) {
286
+ sourcesScanned.push('native_instructions');
287
+ seeds.push(...extractNativeInstructionSeeds(humanAuthored.map((relativePath) => path.join(scanRoot, relativePath)), scanRoot, input.target));
288
+ }
289
+ if (skippedManaged > 0) {
290
+ sourcesScanned.push(`native_instructions (${skippedManaged} brainclaw-managed — skipped)`);
291
+ }
268
292
  }
269
293
  const manifestResult = extractManifestSeeds(scanRoot, input.target);
270
294
  if (manifestResult.seeds.length > 0) {
@@ -26,7 +26,7 @@ import path from 'node:path';
26
26
  import { loadConfig } from '../config.js';
27
27
  import { listNestedProjects } from './cascade.js';
28
28
  import { readManifest, readImportsIndex } from './store.js';
29
- import { coarseFreshness, applyGitHeadDrift } from './freshness.js';
29
+ import { makeFreshnessBadge, applyGitHeadDrift } from './freshness.js';
30
30
  import { findInStore, briefInStore, makeLazyChecker, newAccumulator, deriveBadge, reserveSourceSlots, attachRelatedMemory, attachMemoryIds, validateStoreEntry, BRIEF_FILE_CAP, LAZY_BUDGET, } from './query.js';
31
31
  /** Same default cap as the single-store find (query.ts DEFAULT_FIND_LIMIT). */
32
32
  const DEFAULT_FIND_LIMIT = 20;
@@ -191,22 +191,11 @@ function statusRank(s) {
191
191
  function mergeBadges(perStore) {
192
192
  const total = perStore.length;
193
193
  const indexed = perStore.filter((p) => p.hasIndex);
194
- const unindexed = perStore
195
- .filter((p) => !p.hasIndex)
196
- .map((p) => p.ref.relPath || '.')
197
- .sort();
194
+ const unindexed = perStore.filter((p) => !p.hasIndex).map((p) => p.ref.relPath || '.').sort();
198
195
  if (indexed.length === 0) {
199
- return {
200
- status: 'missing_index',
201
- coarse: 'missing',
202
- details: {
203
- traversal: 'workspace',
204
- projects_indexed: 0,
205
- projects_total: total,
206
- unindexed_projects: unindexed,
207
- hint: 'run refresh --cascade',
208
- },
209
- };
196
+ return makeFreshnessBadge('missing_index', {
197
+ extra: { traversal: 'workspace', projects_indexed: 0, projects_total: total, unindexed_projects: unindexed, hint: 'run refresh --cascade' },
198
+ });
210
199
  }
211
200
  let worst = indexed[0].badge.status;
212
201
  for (const p of indexed) {
@@ -221,12 +210,11 @@ function mergeBadges(perStore) {
221
210
  };
222
211
  if (unindexed.length)
223
212
  details.unindexed_projects = unindexed;
224
- // Merge the per-store detail path-sets, prefixing each with its store's
225
- // workspace-relative dir so a bare `src/index.ts` isn't ambiguous across packages.
226
213
  const prefixMerge = (key) => {
227
214
  const out = [];
228
215
  for (const p of indexed) {
229
- const arr = p.badge.details?.[key];
216
+ const spot = p.badge.details.spot_check;
217
+ const arr = spot?.[key];
230
218
  if (Array.isArray(arr)) {
231
219
  for (const f of arr)
232
220
  out.push(p.ref.relPath ? `${p.ref.relPath}/${String(f)}` : String(f));
@@ -234,16 +222,25 @@ function mergeBadges(perStore) {
234
222
  }
235
223
  return out.sort();
236
224
  };
237
- for (const key of ['stale_changed_files', 'deleted_files', 'unchecked_files']) {
238
- const merged = prefixMerge(key);
239
- if (merged.length)
240
- details[key] = merged;
241
- }
242
- const partialStore = indexed.find((p) => p.badge.status === 'partial');
243
- if (partialStore) {
244
- details.partial_reason = partialStore.badge.details?.partial_reason ?? 'lazy_check_budget_exhausted';
245
- }
246
- return { status: worst, coarse: coarseFreshness(worst), details };
225
+ const spotChecks = indexed.map((p) => p.badge.details.spot_check);
226
+ const spotStatus = spotChecks.some((spot) => spot?.status === 'partial')
227
+ ? 'partial'
228
+ : spotChecks.some((spot) => spot?.status === 'stale')
229
+ ? 'stale'
230
+ : spotChecks.some((spot) => spot?.status === 'fresh') ? 'fresh' : 'not_run';
231
+ const partialSpot = spotChecks.find((spot) => spot?.status === 'partial');
232
+ return makeFreshnessBadge(worst, {
233
+ spotCheck: {
234
+ status: spotStatus,
235
+ checked_files: spotChecks.reduce((sum, spot) => sum + (spot?.checked_files ?? 0), 0),
236
+ stale_changed_files: prefixMerge('stale_changed_files'),
237
+ deleted_files: prefixMerge('deleted_files'),
238
+ unchecked_files: prefixMerge('unchecked_files'),
239
+ budget_exhausted: spotChecks.some((spot) => spot?.budget_exhausted === true),
240
+ partial_reason: partialSpot?.partial_reason ?? null,
241
+ },
242
+ extra: details,
243
+ });
247
244
  }
248
245
  /**
249
246
  * Aggregated find across a resolved multi-project workspace. Shares ONE lazy budget
@@ -426,9 +423,15 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
426
423
  const seen = new Set();
427
424
  const mergedDefiningPaths = new Set();
428
425
  const symbolNames = new Set();
426
+ const memorySymbolNames = new Set();
427
+ const memoryImportNames = new Set();
429
428
  for (const p of contributing) {
430
429
  for (const e of p.r.defining)
431
430
  symbolNames.add(e.name);
431
+ for (const name of p.r.memorySymbolNames)
432
+ memorySymbolNames.add(name);
433
+ for (const name of p.r.memoryImportNames)
434
+ memoryImportNames.add(name);
432
435
  for (const dp of p.r.definingPaths)
433
436
  mergedDefiningPaths.add(p.ref.relPath ? `${p.ref.relPath}/${dp}` : dp);
434
437
  for (const rf of p.r.confident) {
@@ -460,8 +463,10 @@ export function aggregateBrief(target, limit, resolved, currentHead, memoryReade
460
463
  const capped = reserveSourceSlots(merged, cap, mergedDefiningPaths);
461
464
  if (symbolNames.size === 0)
462
465
  symbolNames.add(target);
463
- const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...symbolNames]);
464
- const baseEntries = attachMemoryIds(capped, related);
466
+ if (memorySymbolNames.size === 0)
467
+ memorySymbolNames.add(target);
468
+ const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...memorySymbolNames], [...memoryImportNames]);
469
+ const baseEntries = attachMemoryIds(capped, related, mergedDefiningPaths);
465
470
  const suggested = baseEntries.map((s, i) => ({
466
471
  ...s,
467
472
  project: capped[i].project,
@@ -12,7 +12,7 @@ import { execFileSync } from 'node:child_process';
12
12
  import path from 'node:path';
13
13
  import { readManifest, readShard, storeExists } from './store.js';
14
14
  import { refresh as runRefresh } from './refresh.js';
15
- import { applyGitHeadDrift, withCoarse } from './freshness.js';
15
+ import { applyGitHeadDrift, withFreshness } from './freshness.js';
16
16
  import { brief as runBrief, find as runFind } from './query.js';
17
17
  import { impact as runImpact } from './impact.js';
18
18
  import { exportSubgraph } from './export.js';
@@ -32,9 +32,9 @@ export const OUTLINE_SYMBOL_CAP = 200;
32
32
  /** Diagnostics are useful context, but unbounded provider facts are not. */
33
33
  export const OUTLINE_DIAGNOSTIC_CAP = 20;
34
34
  function badge(status, details = {}) {
35
- // pln#601 — stamp the coarse rollup at construction so every backend-built badge
36
- // (status, missing_index fallbacks, find/brief base) carries it uniformly.
37
- return withCoarse({ status, details });
35
+ // pln#601 — build the uniform freshness envelope for every backend surface
36
+ // It always includes index and spot-check details.
37
+ return withFreshness({ status, details });
38
38
  }
39
39
  /**
40
40
  * Convert a user path into the POSIX project-relative identity used by shards.
@@ -15,6 +15,7 @@ function fileOnlyResult(input, parseStatus) {
15
15
  imports: [],
16
16
  exports: [],
17
17
  tests: [],
18
+ usages: [],
18
19
  facts: [{ code: 'skipped_unsupported', message: `no provider for ${input.path}` }],
19
20
  attributes: { parseStatus },
20
21
  }, input);
@@ -3,7 +3,7 @@
3
3
  * model; it never takes a second, potentially different graph traversal.
4
4
  */
5
5
  import path from 'node:path';
6
- import { withCoarse } from './freshness.js';
6
+ import { withFreshness } from './freshness.js';
7
7
  import { listShards, readManifest } from './store.js';
8
8
  /** Absolute traversal and response ceilings: a whole-graph export is impossible. */
9
9
  export const CODE_EXPORT_MAX_DEPTH = 4;
@@ -113,12 +113,12 @@ export function exportSubgraph(targetInput, options, ctx) {
113
113
  min_confidence: clampConfidence(options?.minConfidence),
114
114
  };
115
115
  const manifest = readManifest(ctx.cwd, ctx.preferredDirName);
116
- const missing = withCoarse({ status: 'missing_index', details: { hint: 'run refresh' } });
116
+ const missing = withFreshness({ status: 'missing_index', details: { hint: 'run refresh' } });
117
117
  if (!manifest || manifest.freshness.status === 'missing_index' || !target)
118
118
  return emptyOutput(target, targetKind, limits, missing, format);
119
119
  const targetPath = targetKind === 'file' ? normalizeFileTarget(target, manifest.project_root) : null;
120
120
  if (targetKind === 'file' && !targetPath) {
121
- return emptyOutput(target, targetKind, limits, withCoarse({
121
+ return emptyOutput(target, targetKind, limits, withFreshness({
122
122
  status: manifest.freshness.status, details: { invalid_target: 'file path must be inside the indexed project' },
123
123
  }), format);
124
124
  }
@@ -187,7 +187,7 @@ export function exportSubgraph(targetInput, options, ctx) {
187
187
  nodes: [...selected].map((id) => nodes.get(id)).filter((node) => node !== undefined).sort(compareNodes).map(graphNode),
188
188
  edges: [...selectedEdges.values()].sort(compareEdges).map(graphEdge),
189
189
  limits, truncated: { roots: rootsTruncated, nodes: nodesTruncated, edges: edgesTruncated, depth: depthTruncated },
190
- freshness_badge: withCoarse({ status: manifest.freshness.status,
190
+ freshness_badge: withFreshness({ status: manifest.freshness.status,
191
191
  details: { stale_file_count: manifest.freshness.stale_file_count, partial_reason: manifest.freshness.partial_reason } }),
192
192
  };
193
193
  return format === 'mermaid' ? { ...graph, format, mermaid: toMermaid(graph) } : { ...graph, format };
@@ -82,6 +82,9 @@ export function finalize(draft, input) {
82
82
  const byName = new Map();
83
83
  // node id -> index in `nodes`, so an export clause can flip `exported` in place.
84
84
  const nodeIndexById = new Map();
85
+ // P4 resolves provider draft ordinals to final symbol ids only here, after the
86
+ // identity authority has minted them. This keeps providers id-free.
87
+ const definitionIdsByOrdinal = new Map();
85
88
  const pushSymbol = (subtype, name, span, exported, confidence) => {
86
89
  const id = symNodeId(projectId, path, lang, subtype, name, span);
87
90
  nodeIndexById.set(id, nodes.length);
@@ -127,7 +130,8 @@ export function finalize(draft, input) {
127
130
  for (const item of items) {
128
131
  if (item.kind === 'def') {
129
132
  const d = item.ref;
130
- pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
133
+ const id = pushSymbol(d.subtype, d.name, d.span, d.exported === true, d.confidence ?? 1.0);
134
+ definitionIdsByOrdinal.set(d.ordinal, id);
131
135
  }
132
136
  else if (item.kind === 'import') {
133
137
  const im = item.ref;
@@ -179,6 +183,46 @@ export function finalize(draft, input) {
179
183
  });
180
184
  }
181
185
  }
186
+ // P4 lexical usages. Local targets are already proven by the provider's tree
187
+ // walk; imported bindings become candidates and are materialized only by the
188
+ // whole-project resolver when the target symbol is unique and importable.
189
+ const referenceCandidates = [];
190
+ for (const usage of draft.usages ?? []) {
191
+ const from = usage.fromDefinitionOrdinal === undefined
192
+ ? fileNode
193
+ : definitionIdsByOrdinal.get(usage.fromDefinitionOrdinal);
194
+ if (!from)
195
+ continue;
196
+ const confidence = usage.confidence ?? 1.0;
197
+ const source = { path, line: usage.span.start_line };
198
+ if (usage.target.kind === 'import') {
199
+ // Textual hints are deliberately local-only. An imported binding gets no
200
+ // graph edge until `resolveProjectImports` proves its target symbol.
201
+ if (usage.kind === 'calls' || usage.kind === 'references') {
202
+ referenceCandidates.push({
203
+ from,
204
+ kind: usage.kind,
205
+ module: usage.target.module,
206
+ imported_name: usage.target.importedName,
207
+ confidence,
208
+ source,
209
+ });
210
+ }
211
+ continue;
212
+ }
213
+ const to = definitionIdsByOrdinal.get(usage.target.definitionOrdinal);
214
+ if (!to)
215
+ continue;
216
+ edges.push({
217
+ id: edgeId({ projectId, from, to, kind: usage.kind }),
218
+ from,
219
+ to,
220
+ kind: usage.kind,
221
+ confidence,
222
+ source,
223
+ origin: usage.kind === 'possible_textual_match' ? 'usage_textual' : 'usage_local',
224
+ });
225
+ }
182
226
  const parseStatus = draft.attributes?.parseStatus ?? 'parsed';
183
227
  const diagnostics = draft.facts.map((f) => ({ ...f }));
184
228
  // Validate the finalized output against the durable schemas (spec §6).
@@ -186,6 +230,17 @@ export function finalize(draft, input) {
186
230
  NodeSchema.parse(n);
187
231
  for (const e of edges)
188
232
  EdgeSchema.parse(e);
189
- return { parseStatus, nodes, edges, diagnostics };
233
+ const result = { parseStatus, nodes, edges, diagnostics };
234
+ // P1's oracle intentionally compares the enumerable JSON result. Keep the
235
+ // resolver hand-off available to refresh without changing that stable shape.
236
+ if (referenceCandidates.length > 0) {
237
+ Object.defineProperty(result, 'referenceCandidates', {
238
+ value: referenceCandidates,
239
+ enumerable: false,
240
+ configurable: false,
241
+ writable: false,
242
+ });
243
+ }
244
+ return result;
190
245
  }
191
246
  //# sourceMappingURL=finalizer.js.map
@@ -45,9 +45,72 @@ export function coarseFreshness(status) {
45
45
  }
46
46
  }
47
47
  }
48
- /** pln#601 — stamp/refresh a badge's `coarse` rollup from its (possibly just-adjusted) status. */
49
- export function withCoarse(b) {
50
- return { ...b, coarse: coarseFreshness(b.status) };
48
+ /**
49
+ * Build the canonical, surface-uniform badge. `freshness` is derived solely from
50
+ * the index state supplied as `status`; a query's bounded spot-check is diagnostic
51
+ * evidence under `details.spot_check`, never a competing top-level badge.
52
+ */
53
+ export function makeFreshnessBadge(status, options = {}) {
54
+ const spot = options.spotCheck ?? {};
55
+ return {
56
+ freshness: coarseFreshness(status),
57
+ status,
58
+ details: {
59
+ ...(options.extra ?? {}),
60
+ index: {
61
+ status,
62
+ stale_file_count: options.staleFileCount ?? 0,
63
+ partial_reason: options.partialReason ?? null,
64
+ git_head_changed: options.gitHeadChanged ?? null,
65
+ },
66
+ spot_check: {
67
+ status: spot.status ?? 'not_run',
68
+ checked_files: spot.checked_files ?? 0,
69
+ stale_changed_files: spot.stale_changed_files ?? [],
70
+ deleted_files: spot.deleted_files ?? [],
71
+ unchecked_files: spot.unchecked_files ?? [],
72
+ budget_exhausted: spot.budget_exhausted ?? false,
73
+ partial_reason: spot.partial_reason ?? null,
74
+ },
75
+ },
76
+ };
77
+ }
78
+ /**
79
+ * Compatibility normalizer for internal callers that previously constructed a
80
+ * `{ status, details }` badge. It preserves non-freshness metadata while always
81
+ * adding the two canonical detail sections.
82
+ */
83
+ export function withFreshness(b) {
84
+ const raw = b.details ?? {};
85
+ const index = raw.index;
86
+ const spot = raw.spot_check;
87
+ const known = new Set([
88
+ 'index', 'spot_check', 'stale_file_count', 'partial_reason', 'git_head_changed',
89
+ 'stale_changed_files', 'deleted_files', 'unchecked_files', 'budget',
90
+ ]);
91
+ const extra = Object.fromEntries(Object.entries(raw).filter(([key]) => !known.has(key)));
92
+ const stringArray = (value) => Array.isArray(value) ? value.map(String).sort() : [];
93
+ const numberValue = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
94
+ const nullableString = (value) => typeof value === 'string' ? value : value === null ? null : undefined;
95
+ const git = (index?.git_head_changed ?? raw.git_head_changed);
96
+ const gitHeadChanged = git && typeof git.index_head === 'string' && typeof git.current_head === 'string'
97
+ ? { index_head: git.index_head, current_head: git.current_head }
98
+ : null;
99
+ return makeFreshnessBadge(b.status, {
100
+ staleFileCount: numberValue(index?.stale_file_count ?? raw.stale_file_count),
101
+ partialReason: nullableString(index?.partial_reason ?? raw.partial_reason),
102
+ gitHeadChanged,
103
+ spotCheck: {
104
+ status: spot?.status,
105
+ checked_files: numberValue(spot?.checked_files),
106
+ stale_changed_files: stringArray(spot?.stale_changed_files ?? raw.stale_changed_files),
107
+ deleted_files: stringArray(spot?.deleted_files ?? raw.deleted_files),
108
+ unchecked_files: stringArray(spot?.unchecked_files ?? raw.unchecked_files),
109
+ budget_exhausted: spot?.budget_exhausted === true,
110
+ partial_reason: nullableString(spot?.partial_reason),
111
+ },
112
+ extra,
113
+ });
51
114
  }
52
115
  /** Stable serialization: sort object keys recursively so hashing is order-independent. */
53
116
  function stableStringify(value) {
@@ -165,16 +228,18 @@ export function summarizeFreshness(shards) {
165
228
  * actionable status; only the cause detail is added.
166
229
  */
167
230
  export function applyGitHeadDrift(badge, indexHead, currentHead) {
231
+ const normalized = withFreshness(badge);
232
+ const currentIndex = normalized.details.index;
168
233
  if (!indexHead || !currentHead || indexHead === currentHead)
169
- return withCoarse(badge);
170
- const status = badge.status === 'fresh' ? 'stale_git_head' : badge.status;
171
- return {
172
- status,
173
- coarse: coarseFreshness(status),
174
- details: {
175
- ...badge.details,
176
- git_head_changed: { index_head: indexHead, current_head: currentHead },
177
- },
178
- };
234
+ return normalized;
235
+ const status = normalized.status === 'fresh' ? 'stale_git_head' : normalized.status;
236
+ const extra = Object.fromEntries(Object.entries(normalized.details).filter(([key]) => key !== 'index' && key !== 'spot_check'));
237
+ return makeFreshnessBadge(status, {
238
+ staleFileCount: currentIndex.stale_file_count,
239
+ partialReason: currentIndex.partial_reason,
240
+ gitHeadChanged: { index_head: indexHead, current_head: currentHead },
241
+ spotCheck: normalized.details.spot_check,
242
+ extra,
243
+ });
179
244
  }
180
245
  //# sourceMappingURL=freshness.js.map