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.
- package/README.md +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +196 -42
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +5 -2
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +24 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/mcp-schema-changelog.md +43 -1
- 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
|
-
|
|
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)
|