brainclaw 1.17.0 → 1.18.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 (80) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +196 -42
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/loop.js +2 -2
  9. package/dist/commands/loops-handlers.js +82 -1
  10. package/dist/commands/mcp-catalog.js +12 -4
  11. package/dist/commands/mcp-read-handlers.js +90 -7
  12. package/dist/commands/mcp-schemas.generated.js +3 -0
  13. package/dist/commands/mcp-write-coordination.js +159 -40
  14. package/dist/commands/mcp.js +11 -2
  15. package/dist/core/agentrun-reconciler.js +171 -7
  16. package/dist/core/agentruns.js +6 -1
  17. package/dist/core/code-map/aggregate.js +473 -0
  18. package/dist/core/code-map/backend.js +36 -10
  19. package/dist/core/code-map/freshness.js +36 -1
  20. package/dist/core/code-map/lang/c/imports.scm +12 -0
  21. package/dist/core/code-map/lang/c/index.js +150 -0
  22. package/dist/core/code-map/lang/c/tags.scm +68 -0
  23. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  24. package/dist/core/code-map/lang/cpp/index.js +149 -0
  25. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  26. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  27. package/dist/core/code-map/lang/csharp/index.js +224 -0
  28. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  29. package/dist/core/code-map/lang/go/imports.scm +13 -0
  30. package/dist/core/code-map/lang/go/index.js +139 -0
  31. package/dist/core/code-map/lang/go/tags.scm +36 -0
  32. package/dist/core/code-map/lang/providers.js +12 -1
  33. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  34. package/dist/core/code-map/lang/ruby/index.js +198 -0
  35. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  36. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  37. package/dist/core/code-map/lang/rust/index.js +136 -0
  38. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  39. package/dist/core/code-map/query.js +229 -80
  40. package/dist/core/code-map/types.js +18 -0
  41. package/dist/core/code-map/work-section.js +8 -7
  42. package/dist/core/codev-responses.js +16 -0
  43. package/dist/core/dispatcher.js +176 -22
  44. package/dist/core/execution-adapters.js +29 -3
  45. package/dist/core/ideation-loop-close.js +124 -0
  46. package/dist/core/loops/artifact-resolver.js +197 -0
  47. package/dist/core/loops/attempt-reservation.js +576 -0
  48. package/dist/core/loops/commit-intent.js +494 -0
  49. package/dist/core/loops/facade-schema.js +48 -0
  50. package/dist/core/loops/impl-bind.js +144 -0
  51. package/dist/core/loops/index.js +1 -1
  52. package/dist/core/loops/iteration-engine.js +29 -0
  53. package/dist/core/loops/lock.js +14 -0
  54. package/dist/core/loops/project-resolution.js +157 -0
  55. package/dist/core/loops/reconcile-turn.js +369 -0
  56. package/dist/core/loops/result-reducers.js +88 -0
  57. package/dist/core/loops/store.js +46 -7
  58. package/dist/core/loops/types.js +139 -11
  59. package/dist/core/loops/verbs.js +9 -3
  60. package/dist/core/loops/verify-command.js +209 -0
  61. package/dist/core/messaging.js +58 -5
  62. package/dist/core/review-loop-close.js +5 -2
  63. package/dist/core/review-loop-turn-dispatch.js +290 -28
  64. package/dist/core/runtime-signals.js +68 -0
  65. package/dist/core/schema.js +24 -0
  66. package/dist/core/worktree.js +24 -0
  67. package/dist/facts.js +9 -9
  68. package/dist/facts.json +8 -8
  69. package/dist/wasm/tree-sitter-c.wasm +0 -0
  70. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  71. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  72. package/dist/wasm/tree-sitter-go.wasm +0 -0
  73. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  74. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  75. package/docs/cli.md +1 -1
  76. package/docs/code-map.md +22 -6
  77. package/docs/concepts/loop-engine.md +24 -0
  78. package/docs/concepts/observer-protocol.md +22 -0
  79. package/docs/mcp-schema-changelog.md +43 -1
  80. package/package.json +1 -1
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Code Map workspace AGGREGATION (pln#631 PR1 — root-aggregated find).
3
+ *
4
+ * A `find` at a multi-project workspace ROOT must surface symbols defined in ANY
5
+ * nested child project's store — today it reads only the root store (which, after a
6
+ * cascade refresh, is scoped to the files no child owns, so a root find returns
7
+ * almost nothing and the agent falls back to grep). This module fans `findInStore`
8
+ * out across the root + every nested project store at READ time (no persisted root
9
+ * super-index, no cross-store index writes) and merges the results.
10
+ *
11
+ * The three semantics the design pins (dec#146):
12
+ * (a) ONE shared lazy-check budget across stores (the checker's memo is file_id-keyed
13
+ * — unique per store — so sharing never collides same-named files across packages).
14
+ * (b) Merged badge = WORST status across the *indexed* stores + coverage
15
+ * (projects_indexed/total, unindexed_projects); a child with no index must NOT
16
+ * drag the top-line to `missing`.
17
+ * (c) Dedupe merged matches on (project_id, node_id) and rewrite paths to
18
+ * workspace-relative, so the `prj_${basename}` id fallback + same-named files
19
+ * across packages can never merge two distinct symbols.
20
+ *
21
+ * Cross-package import resolution + brief aggregation + child-initiated workspace
22
+ * scope are follow-ups (see pln#631).
23
+ */
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { loadConfig } from '../config.js';
27
+ import { listNestedProjects } from './cascade.js';
28
+ import { readManifest, readImportsIndex } from './store.js';
29
+ import { coarseFreshness, applyGitHeadDrift } from './freshness.js';
30
+ import { findInStore, briefInStore, makeLazyChecker, newAccumulator, deriveBadge, reserveSourceSlots, attachRelatedMemory, attachMemoryIds, validateStoreEntry, BRIEF_FILE_CAP, LAZY_BUDGET, } from './query.js';
31
+ /** Same default cap as the single-store find (query.ts DEFAULT_FIND_LIMIT). */
32
+ const DEFAULT_FIND_LIMIT = 20;
33
+ /**
34
+ * pln#631 (review F2) — an aggregated find shares ONE lazy budget across N stores.
35
+ * A flat 32-file budget starves alphabetically-later stores (their drifted candidates
36
+ * get dropped once earlier stores spend it). Scale the budget with the store count so
37
+ * later stores keep headroom, capped so an interactive read stays bounded even on a
38
+ * large monorepo. Fully fresh stores cost nothing (the mtime/size gate short-circuits
39
+ * before the budget), so this only raises the ceiling for genuinely-drifted trees.
40
+ */
41
+ const AGG_MAX_FILES_CAP = 256;
42
+ const AGG_MAX_WALL_CAP_MS = 10_000;
43
+ function aggregateBudget(storeCount) {
44
+ return {
45
+ maxFilesChecked: Math.min(LAZY_BUDGET.maxFilesChecked * Math.max(1, storeCount), AGG_MAX_FILES_CAP),
46
+ maxWallMs: Math.min(LAZY_BUDGET.maxWallMs * Math.max(1, storeCount), AGG_MAX_WALL_CAP_MS),
47
+ };
48
+ }
49
+ /**
50
+ * Walk UP from a child cwd and return the NEAREST ancestor that is a multi-project
51
+ * workspace root. Preferred over "outermost .brainclaw" (which can over-reach to an
52
+ * unrelated ancestor project, or a stray ~/tmp/.brainclaw): the immediate enclosing
53
+ * multi-project root is the child's actual workspace. Bounded ancestor walk.
54
+ */
55
+ function findEnclosingWorkspaceRoot(startDir) {
56
+ let dir = path.dirname(path.resolve(startDir)); // the child itself is not its own workspace root
57
+ const fsRoot = path.parse(dir).root;
58
+ for (let i = 0; i < 64; i++) {
59
+ if (isWorkspaceRoot(dir))
60
+ return dir;
61
+ const parent = path.dirname(dir);
62
+ if (parent === dir || dir === fsRoot)
63
+ break;
64
+ dir = parent;
65
+ }
66
+ return undefined;
67
+ }
68
+ /** True when cwd is a multi-project workspace root (the SAME gate the cascade uses). */
69
+ function isWorkspaceRoot(cwd) {
70
+ let mode;
71
+ try {
72
+ mode = loadConfig(cwd).project_mode;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ return mode === 'multi-project' && listNestedProjects(cwd).length > 0;
78
+ }
79
+ /** Read a store's identity + built-against commit in ONE manifest read. */
80
+ function storeMeta(cwd) {
81
+ const m = readManifest(cwd);
82
+ const gitHead = m?.git?.head ?? null;
83
+ if (m?.project_id)
84
+ return { projectId: m.project_id, gitHead };
85
+ try {
86
+ const id = loadConfig(cwd).project_id;
87
+ if (id)
88
+ return { projectId: id, gitHead };
89
+ }
90
+ catch {
91
+ /* no config — fall through to a cwd-derived default */
92
+ }
93
+ return { projectId: `prj_${path.basename(path.resolve(cwd))}`, gitHead };
94
+ }
95
+ /** True when `localCwd` is `storeCwd` or lives under it (case-folded on win32). */
96
+ function storeContains(storeCwd, localCwd) {
97
+ const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p));
98
+ const rel = path.relative(norm(storeCwd), norm(localCwd));
99
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
100
+ }
101
+ /**
102
+ * Build the root + nested-child StoreRefs for a workspace, flagging the caller-local one.
103
+ * Locality is by CONTAINMENT, not exact equality (review): the caller usually stands in a
104
+ * `src/…` subdir of its package, not exactly at the package root — the DEEPEST store whose
105
+ * cwd contains `localCwd` is the caller's own package. Case-insensitive on win32.
106
+ */
107
+ function buildWorkspaceStores(root, localCwd) {
108
+ const rootMeta = storeMeta(root);
109
+ const stores = [
110
+ { cwd: root, relPath: '', projectId: rootMeta.projectId, gitHead: rootMeta.gitHead, isLocal: false },
111
+ ...listNestedProjects(root).map((childAbs) => {
112
+ const meta = storeMeta(childAbs);
113
+ return {
114
+ cwd: childAbs,
115
+ relPath: path.relative(root, childAbs).replace(/\\/g, '/'),
116
+ projectId: meta.projectId,
117
+ gitHead: meta.gitHead,
118
+ isLocal: false,
119
+ };
120
+ }),
121
+ ];
122
+ // The caller-local store = the DEEPEST (longest cwd) store containing localCwd.
123
+ let localStore;
124
+ for (const s of stores) {
125
+ if (storeContains(s.cwd, localCwd) && (!localStore || s.cwd.length > localStore.cwd.length)) {
126
+ localStore = s;
127
+ }
128
+ }
129
+ if (localStore)
130
+ localStore.isLocal = true;
131
+ return stores;
132
+ }
133
+ /**
134
+ * Resolve which stores a find/brief should read.
135
+ * - `auto` (default): aggregate the whole workspace ONLY when cwd is itself a
136
+ * multi-project root; otherwise single-store (a child cwd stays local, unchanged).
137
+ * - `project`: force single-store.
138
+ * - `workspace`: aggregate the whole workspace. At a root, same as auto. From a CHILD
139
+ * cwd (pln#631 PR4), walk UP to the workspace root and aggregate from there, keeping
140
+ * the caller's package flagged `isLocal` for the locality tiebreak — so an agent
141
+ * inside `packages/api` can search the whole monorepo, with its own package's hits
142
+ * ranked first. Degrades to single-store only when no multi-project root is found.
143
+ */
144
+ export function resolveTraversal(cwd, mode) {
145
+ const abs = path.resolve(cwd);
146
+ if ((mode === 'auto' || mode === 'workspace') && isWorkspaceRoot(abs)) {
147
+ return { workspace: true, root: abs, stores: buildWorkspaceStores(abs, abs) };
148
+ }
149
+ if (mode === 'workspace') {
150
+ // Explicit workspace request from a non-root cwd: find the NEAREST enclosing
151
+ // multi-project root and aggregate from there (auto never does this).
152
+ const wsRoot = findEnclosingWorkspaceRoot(abs);
153
+ if (wsRoot) {
154
+ return { workspace: true, root: wsRoot, stores: buildWorkspaceStores(wsRoot, abs) };
155
+ }
156
+ }
157
+ const meta = storeMeta(abs);
158
+ return {
159
+ workspace: false,
160
+ root: abs,
161
+ stores: [{ cwd: abs, relPath: '', projectId: meta.projectId, gitHead: meta.gitHead, isLocal: true }],
162
+ };
163
+ }
164
+ /** Worst-status precedence for the merged badge (higher = worse = surfaced). */
165
+ function statusRank(s) {
166
+ switch (s) {
167
+ case 'partial':
168
+ return 4;
169
+ case 'stale_changed_files':
170
+ case 'stale_extractor':
171
+ case 'stale_grammar':
172
+ case 'stale_git_head':
173
+ return 3;
174
+ case 'fresh':
175
+ return 1;
176
+ case 'missing_index':
177
+ return 0;
178
+ default: {
179
+ const _exhaustive = s;
180
+ void _exhaustive;
181
+ return 0;
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * Merge per-store badges into one workspace badge: worst status among the INDEXED
187
+ * stores (a missing-index child contributes to coverage, never drags the top-line),
188
+ * plus coverage + workspace-relative detail path-sets. Only when EVERY store is
189
+ * un-indexed is the whole workspace `missing_index`.
190
+ */
191
+ function mergeBadges(perStore) {
192
+ const total = perStore.length;
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();
198
+ 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
+ };
210
+ }
211
+ let worst = indexed[0].badge.status;
212
+ for (const p of indexed) {
213
+ if (statusRank(p.badge.status) > statusRank(worst))
214
+ worst = p.badge.status;
215
+ }
216
+ const details = {
217
+ traversal: 'workspace',
218
+ projects_indexed: indexed.length,
219
+ projects_total: total,
220
+ per_project: Object.fromEntries(perStore.map((p) => [p.ref.relPath || '.', p.badge.status])),
221
+ };
222
+ if (unindexed.length)
223
+ 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
+ const prefixMerge = (key) => {
227
+ const out = [];
228
+ for (const p of indexed) {
229
+ const arr = p.badge.details?.[key];
230
+ if (Array.isArray(arr)) {
231
+ for (const f of arr)
232
+ out.push(p.ref.relPath ? `${p.ref.relPath}/${String(f)}` : String(f));
233
+ }
234
+ }
235
+ return out.sort();
236
+ };
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 };
247
+ }
248
+ /**
249
+ * Aggregated find across a resolved multi-project workspace. Shares ONE lazy budget
250
+ * across stores; dedupes on (project_id, node_id); rewrites paths workspace-relative;
251
+ * re-ranks with the single-store comparator; caps; and merges the freshness badges.
252
+ */
253
+ export function aggregateFind(query, limit, resolved, currentHead) {
254
+ // ONE shared budget across every store, scaled by store count (review F2) so
255
+ // alphabetically-later stores aren't starved by earlier dirty trees.
256
+ const checker = makeLazyChecker(aggregateBudget(resolved.stores.length));
257
+ const perStore = [];
258
+ const merged = [];
259
+ const seen = new Set(); // (project_id, node_id)
260
+ for (const ref of resolved.stores) {
261
+ const acc = newAccumulator();
262
+ const r = findInStore(query, { cwd: ref.cwd }, checker, acc);
263
+ // Per-store badge: drive `partial` from THIS store's own budget-skips (review F2),
264
+ // NOT the shared checker.exhausted flag — else an early store spending the budget
265
+ // would mislabel every fully-fresh later store as `partial` in per_project. Then
266
+ // apply per-store HEAD drift against the one workspace HEAD (review F3) so a child
267
+ // whose index lags the working tree is flagged even under an otherwise-fresh root.
268
+ let badge = deriveBadge(r.base, acc, false, r.matches.length > 0, r.emptyCandidates);
269
+ badge = applyGitHeadDrift(badge, ref.gitHead, currentHead);
270
+ perStore.push({ ref, badge, hasIndex: r.hasIndex });
271
+ for (const m of r.matches) {
272
+ // Dedup key scoped by store cwd (unique per store), NEVER project_id — two stores
273
+ // can share a project_id (review F1), which would false-merge/drop a distinct symbol.
274
+ // Cross-store never merges (different packages = different symbols).
275
+ const key = `${ref.cwd} ${m.node_id}`;
276
+ if (seen.has(key))
277
+ continue;
278
+ seen.add(key);
279
+ merged.push({
280
+ ...m,
281
+ path: ref.relPath ? `${ref.relPath}/${m.path}` : m.path,
282
+ project: ref.relPath,
283
+ project_id: ref.projectId,
284
+ ...(ref.isLocal ? { local: true } : {}),
285
+ });
286
+ }
287
+ }
288
+ // Sort by score, then LOCALITY (caller's own package first — PR4 tiebreak), then path.
289
+ merged.sort((a, b) => b.score - a.score ||
290
+ (b.local ? 1 : 0) - (a.local ? 1 : 0) ||
291
+ a.path.localeCompare(b.path) ||
292
+ a.name.localeCompare(b.name));
293
+ const capped = merged.slice(0, limit ?? DEFAULT_FIND_LIMIT);
294
+ return { query, matches: capped, freshness_badge: mergeBadges(perStore) };
295
+ }
296
+ /** Match-tier precedence for cross-store target selection: exact > path > fuzzy > none. */
297
+ function briefMatchTier(k) {
298
+ return k === 'exact' ? 3 : k === 'path' ? 2 : k === 'fuzzy' ? 1 : 0;
299
+ }
300
+ /** Read a store's package.json `name` (the specifier siblings import it as), or null. */
301
+ function packageNameOf(cwd) {
302
+ try {
303
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
304
+ return typeof pkg.name === 'string' && pkg.name.length > 0 ? pkg.name : null;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
310
+ /**
311
+ * Cross-package reverse dependents (pln#631 PR3): sibling packages that IMPORT the
312
+ * defining package's public name. READ-TIME only — no cross-store index writes (the
313
+ * per-project-store-write invariant is inviolate). For each defining store B (package
314
+ * name `nameB`), scan every OTHER store A's imports index for specifiers `=== nameB`
315
+ * or `startsWith(nameB + '/')`; each importer file is a cross-package dependent.
316
+ * NAME-LEVEL precision: an importer whose `imported[]` names one of the target symbols
317
+ * ranks above a bare package-level import. Reverse-deps ONLY — forward cross-package
318
+ * deps have no single target file (deferred). Rows are graph-derived + flagged
319
+ * cross_package so their (name-based, lower) confidence is legible.
320
+ */
321
+ function crossPackageReverseDeps(contributing, allStores, symbolNames, checker) {
322
+ const targetPkgNames = new Map(); // package name -> defining store B
323
+ for (const ref of contributing) {
324
+ const name = packageNameOf(ref.cwd);
325
+ if (name)
326
+ targetPkgNames.set(name, ref);
327
+ }
328
+ if (targetPkgNames.size === 0)
329
+ return [];
330
+ const matchesTarget = (spec) => {
331
+ for (const nameB of targetPkgNames.keys()) {
332
+ if (spec === nameB || spec.startsWith(`${nameB}/`))
333
+ return true;
334
+ }
335
+ return false;
336
+ };
337
+ // Aggregate per IMPORTER FILE across ALL matching specifiers first (review F2): a file
338
+ // importing the target package under two specifiers — one name-level, one bare — must
339
+ // be scored by its BEST precision, not by whichever specifier `Object.entries` happens
340
+ // to yield first. Keyed by store cwd + path (never project_id).
341
+ const contributingCwds = new Set(contributing.map((r) => r.cwd));
342
+ const byImporter = new Map();
343
+ for (const a of allStores) {
344
+ if (contributingCwds.has(a.cwd))
345
+ continue; // intra-package deps already covered
346
+ const imports = readImportsIndex(a.cwd);
347
+ if (!imports)
348
+ continue;
349
+ for (const [spec, importers] of Object.entries(imports.entries)) {
350
+ if (!matchesTarget(spec))
351
+ continue;
352
+ for (const imp of importers) {
353
+ const key = `${a.cwd}::${imp.path}`;
354
+ let agg = byImporter.get(key);
355
+ if (!agg) {
356
+ agg = { cwd: a.cwd, relPath: a.relPath, path: imp.path, file_id: imp.file_id, local: a.isLocal, namedHits: new Set(), specs: new Set() };
357
+ byImporter.set(key, agg);
358
+ }
359
+ agg.specs.add(spec);
360
+ for (const n of imp.imported)
361
+ if (symbolNames.has(n))
362
+ agg.namedHits.add(n);
363
+ }
364
+ }
365
+ }
366
+ // Emit one row per importer — but LAZY-VALIDATE it against its store first (review F1):
367
+ // a cross-package row is graph-derived, so a deleted/stale importer must be SUPPRESSED
368
+ // just like an intra-package graph row (no silent stale graph hints). Shares the one
369
+ // budget; a throwaway acc (row-drop only — the sibling store's manifest freshness
370
+ // already rides the merged badge via its per-store briefInStore).
371
+ const rows = [];
372
+ const acc = newAccumulator();
373
+ for (const agg of byImporter.values()) {
374
+ const ok = validateStoreEntry({ path: agg.path, file_id: agg.file_id }, checker, acc, agg.cwd, undefined);
375
+ if (!ok)
376
+ continue; // deleted / stale / budget-skipped → suppress (graph-derived)
377
+ const nameLevel = agg.namedHits.size > 0;
378
+ const score = nameLevel ? 5 : 3; // name-level ranks with reverse-deps; package-level below
379
+ const shortestSpec = [...agg.specs].sort((x, y) => x.length - y.length)[0] ?? '';
380
+ rows.push({
381
+ path: agg.relPath ? `${agg.relPath}/${agg.path}` : agg.path,
382
+ file_id: agg.file_id,
383
+ reason: nameLevel
384
+ ? `cross-package: imports ${[...agg.namedHits].sort().join(', ')} from ${shortestSpec}`
385
+ : `cross-package: imports ${shortestSpec}`,
386
+ score,
387
+ bestDelta: score,
388
+ graphDerived: true,
389
+ project: agg.relPath,
390
+ cross_package: true,
391
+ ...(agg.local ? { local: true } : {}),
392
+ });
393
+ }
394
+ return rows;
395
+ }
396
+ /**
397
+ * Aggregated brief across a resolved multi-project workspace (pln#631 PR2). Resolves
398
+ * the target in EVERY store (shared budget), then contributes reading lists ONLY from
399
+ * the stores at the HIGHEST match tier present (exact > path > fuzzy) — so a symbol
400
+ * defined exactly in one package is never diluted by fuzzy token-noise from siblings,
401
+ * while a name defined exactly in two packages briefs both. Reading-list paths are
402
+ * workspace-relative + project-tagged; the source-reserve + memory attach run on the
403
+ * MERGED list. Related memory comes from the ROOT project only in PR2 (cross-package
404
+ * memory attach = follow-up). The badge merges per-store (worst-status + coverage,
405
+ * per-store HEAD drift) exactly like aggregateFind.
406
+ */
407
+ export function aggregateBrief(target, limit, resolved, currentHead, memoryReader) {
408
+ const checker = makeLazyChecker(aggregateBudget(resolved.stores.length));
409
+ const perStore = resolved.stores.map((ref) => {
410
+ const acc = newAccumulator();
411
+ const r = briefInStore(target, { cwd: ref.cwd }, checker, acc);
412
+ let badge = deriveBadge(r.base, acc, false, r.confident.length > 0, r.emptyRanked);
413
+ badge = applyGitHeadDrift(badge, ref.gitHead, currentHead);
414
+ return { ref, r, badge };
415
+ });
416
+ // Contribute reading lists from the stores at the highest match TIER present (no
417
+ // fuzzy dilution). But when NO store DEFINES the target (bestTier 0), fall back to
418
+ // any store with a non-empty reading list — rankFiles' import-specifier heuristic can
419
+ // surface relevant IMPORTER files even with no defining symbol (review F1: preserves
420
+ // single-store brief parity for an imported-but-not-locally-defined name like `axios`).
421
+ const bestTier = Math.max(0, ...perStore.map((p) => briefMatchTier(p.r.matchKind)));
422
+ const contributing = bestTier > 0
423
+ ? perStore.filter((p) => briefMatchTier(p.r.matchKind) === bestTier)
424
+ : perStore.filter((p) => p.r.confident.length > 0);
425
+ const merged = [];
426
+ const seen = new Set();
427
+ const mergedDefiningPaths = new Set();
428
+ const symbolNames = new Set();
429
+ for (const p of contributing) {
430
+ for (const e of p.r.defining)
431
+ symbolNames.add(e.name);
432
+ for (const dp of p.r.definingPaths)
433
+ mergedDefiningPaths.add(p.ref.relPath ? `${p.ref.relPath}/${dp}` : dp);
434
+ for (const rf of p.r.confident) {
435
+ const key = `${p.ref.cwd}::${rf.path}`; // store-scoped dedup (never project_id)
436
+ if (seen.has(key))
437
+ continue;
438
+ seen.add(key);
439
+ merged.push({
440
+ ...rf,
441
+ path: p.ref.relPath ? `${p.ref.relPath}/${rf.path}` : rf.path,
442
+ project: p.ref.relPath,
443
+ ...(p.ref.isLocal ? { local: true } : {}),
444
+ });
445
+ }
446
+ }
447
+ // pln#631 PR3 — cross-package reverse dependents: sibling packages importing the
448
+ // defining package's public name. Only when a store genuinely DEFINES the target
449
+ // (bestTier > 0) — the heuristic fallback has no "defining package" to find importers
450
+ // of. Rows are flagged cross_package; keyed by their own store so they never collide
451
+ // with the intra-package rows above.
452
+ if (bestTier > 0) {
453
+ // crossPackageReverseDeps dedups internally, and its rows come from NON-contributing
454
+ // stores (distinct workspace-relative paths from the intra-package rows above), so a
455
+ // direct append cannot collide.
456
+ merged.push(...crossPackageReverseDeps(contributing.map((p) => p.ref), resolved.stores, symbolNames, checker));
457
+ }
458
+ merged.sort((a, b) => b.score - a.score || (b.local ? 1 : 0) - (a.local ? 1 : 0) || a.path.localeCompare(b.path));
459
+ const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
460
+ const capped = reserveSourceSlots(merged, cap, mergedDefiningPaths);
461
+ if (symbolNames.size === 0)
462
+ symbolNames.add(target);
463
+ const related = attachRelatedMemory(memoryReader({ cwd: resolved.root }), capped.map((f) => f.path), [...symbolNames]);
464
+ const baseEntries = attachMemoryIds(capped, related);
465
+ const suggested = baseEntries.map((s, i) => ({
466
+ ...s,
467
+ project: capped[i].project,
468
+ ...(capped[i].cross_package ? { cross_package: true } : {}),
469
+ ...(capped[i].local ? { local: true } : {}),
470
+ }));
471
+ return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: mergeBadges(perStore.map((p) => ({ ref: p.ref, badge: p.badge, hasIndex: p.r.hasIndex }))) };
472
+ }
473
+ //# sourceMappingURL=aggregate.js.map
@@ -12,15 +12,18 @@ import { execFileSync } from 'node:child_process';
12
12
  import path from 'node:path';
13
13
  import { readManifest, storeExists } from './store.js';
14
14
  import { refresh as runRefresh } from './refresh.js';
15
- import { applyGitHeadDrift } from './freshness.js';
15
+ import { applyGitHeadDrift, withCoarse } from './freshness.js';
16
16
  import { brief as runBrief, find as runFind } from './query.js';
17
+ import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js';
17
18
  import { defaultMemoryReader } from './memory-reader.js';
18
19
  import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
19
20
  import { loadConfig } from '../config.js';
20
21
  /** spec §9 caps the brief reading list at 12 files. */
21
22
  export const BRIEF_FILE_CAP = 12;
22
23
  function badge(status, details = {}) {
23
- return { status, details };
24
+ // pln#601 stamp the coarse rollup at construction so every backend-built badge
25
+ // (status, missing_index fallbacks, find/brief base) carries it uniformly.
26
+ return withCoarse({ status, details });
24
27
  }
25
28
  /**
26
29
  * Read the working tree's current commit at `root` (read-path git-HEAD drift,
@@ -185,13 +188,25 @@ export class JsonlBackend {
185
188
  * as confident (§6.1); the response badge reflects any detected drift.
186
189
  */
187
190
  async find(input) {
191
+ const cwd = input.cwd ?? process.cwd();
192
+ const resolved = resolveTraversal(cwd, input.traversal ?? 'auto');
193
+ if (resolved.workspace) {
194
+ // pln#631 — root-aggregated find across the workspace's per-project stores.
195
+ // git-HEAD drift is resolved ONCE at the workspace root (one working tree) and
196
+ // compared PER STORE inside aggregateFind (review F3) — so a child whose index
197
+ // lags the working tree is flagged even under an otherwise-fresh root.
198
+ const currentHead = this.gitHeadReader(resolved.root);
199
+ const agg = aggregateFind(input.query, input.limit, resolved, currentHead);
200
+ return {
201
+ query: agg.query,
202
+ matches: agg.matches,
203
+ freshness_badge: agg.freshness_badge,
204
+ };
205
+ }
188
206
  const ctx = this.queryContext(input);
189
207
  const out = runFind(input.query, input.limit, ctx);
190
208
  const manifest = readManifest(input.cwd, input.preferredDirName);
191
- const base = {
192
- status: out.freshness_badge.status,
193
- details: out.freshness_badge.details,
194
- };
209
+ const base = badge(out.freshness_badge.status, out.freshness_badge.details);
195
210
  return {
196
211
  query: out.query,
197
212
  matches: out.matches,
@@ -204,13 +219,24 @@ export class JsonlBackend {
204
219
  * and carries a §6.1 lazy-validated freshness badge.
205
220
  */
206
221
  async brief(input) {
222
+ const cwd = input.cwd ?? process.cwd();
223
+ const resolved = resolveTraversal(cwd, input.traversal ?? 'auto');
224
+ if (resolved.workspace) {
225
+ // pln#631 PR2 — root-aggregated brief across the per-project stores (per-store
226
+ // HEAD drift resolved against ONE workspace HEAD, like aggregateFind).
227
+ const currentHead = this.gitHeadReader(resolved.root);
228
+ const agg = aggregateBrief(input.target, input.limit, resolved, currentHead, this.memoryReader);
229
+ return {
230
+ target: agg.target,
231
+ suggested_files_to_read: agg.suggested_files_to_read,
232
+ related_memory: agg.related_memory,
233
+ freshness_badge: agg.freshness_badge,
234
+ };
235
+ }
207
236
  const ctx = this.queryContext(input);
208
237
  const out = runBrief(input.target, input.limit, ctx, this.memoryReader);
209
238
  const manifest = readManifest(input.cwd, input.preferredDirName);
210
- const base = {
211
- status: out.freshness_badge.status,
212
- details: out.freshness_badge.details,
213
- };
239
+ const base = badge(out.freshness_badge.status, out.freshness_badge.details);
214
240
  return {
215
241
  target: out.target,
216
242
  suggested_files_to_read: out.suggested_files_to_read,
@@ -15,6 +15,40 @@
15
15
  * extractor_config_hash + per-language grammar hashes.
16
16
  */
17
17
  import crypto from 'node:crypto';
18
+ /**
19
+ * pln#601 — collapse the detailed 7-value {@link FreshnessStatus} into the coarse,
20
+ * surface-uniform signal (`fresh|stale|partial|missing`). Every `stale_*` variant
21
+ * rolls up to `stale`; `missing_index` → `missing`; `partial`/`fresh` pass through.
22
+ * This is the SINGLE definition of the rollup so no surface can disagree.
23
+ */
24
+ export function coarseFreshness(status) {
25
+ switch (status) {
26
+ case 'fresh':
27
+ return 'fresh';
28
+ case 'partial':
29
+ return 'partial';
30
+ case 'missing_index':
31
+ return 'missing';
32
+ case 'stale_changed_files':
33
+ case 'stale_extractor':
34
+ case 'stale_grammar':
35
+ case 'stale_git_head':
36
+ return 'stale';
37
+ default: {
38
+ // Exhaustiveness guard (pln#601 review F4): every FreshnessStatus is mapped
39
+ // explicitly above. If the enum grows, this `never` assignment fails to
40
+ // compile — forcing a deliberate classification instead of a new status
41
+ // silently rolling up to 'stale'.
42
+ const _exhaustive = status;
43
+ void _exhaustive;
44
+ return 'stale';
45
+ }
46
+ }
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) };
51
+ }
18
52
  /** Stable serialization: sort object keys recursively so hashing is order-independent. */
19
53
  function stableStringify(value) {
20
54
  if (value === null || typeof value !== 'object')
@@ -131,10 +165,11 @@ export function summarizeFreshness(shards) {
131
165
  */
132
166
  export function applyGitHeadDrift(badge, indexHead, currentHead) {
133
167
  if (!indexHead || !currentHead || indexHead === currentHead)
134
- return badge;
168
+ return withCoarse(badge);
135
169
  const status = badge.status === 'fresh' ? 'stale_git_head' : badge.status;
136
170
  return {
137
171
  status,
172
+ coarse: coarseFreshness(status),
138
173
  details: {
139
174
  ...badge.details,
140
175
  git_head_changed: { index_head: indexHead, current_head: currentHead },
@@ -0,0 +1,12 @@
1
+ ; Code Map — C imports (imports.scm). Provider #6 (langs batch 2).
2
+ ;
3
+ ; enclosingStatementNodeTypes = [preproc_include] (the import span/ordinal anchor).
4
+ ; A `#include` path comes in two grammar shapes:
5
+ ; #include <stdio.h> -> path: (system_lib_string) text = "<stdio.h>"
6
+ ; #include "config.h" -> path: (string_literal) text = "\"config.h\""
7
+ ; Both are captured as @import.source; the provider's refine() strips the angle
8
+ ; brackets (`<...>`) or the surrounding double quotes (`"..."`) to the bare header
9
+ ; path. C has no imported-name bindings, so imported_names stays empty.
10
+
11
+ (preproc_include
12
+ path: [(system_lib_string) (string_literal)] @import.source)