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
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { hashContent } from './extractor.js';
15
+ import { coarseFreshness } from './freshness.js';
15
16
  import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
16
17
  // --- lazy read-path freshness budget (spec §6.1) ---
17
18
  /** Default per-query lazy-check budget (spec §6.1). */
@@ -26,7 +27,7 @@ export const LAZY_BUDGET = {
26
27
  * per-path memoization so a brief() that touches one file from several ranking
27
28
  * signals spends a single budget slot.
28
29
  */
29
- function makeLazyChecker(budget = LAZY_BUDGET) {
30
+ export function makeLazyChecker(budget = LAZY_BUDGET) {
30
31
  return {
31
32
  budget,
32
33
  startedAt: Date.now(),
@@ -47,7 +48,7 @@ function budgetExhausted(checker) {
47
48
  }
48
49
  return checker.exhausted;
49
50
  }
50
- function newAccumulator() {
51
+ export function newAccumulator() {
51
52
  return {
52
53
  staleChangedPaths: new Set(),
53
54
  missingPaths: new Set(),
@@ -62,7 +63,16 @@ function newAccumulator() {
62
63
  * served as a *confident* (fresh) result.
63
64
  */
64
65
  function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd, preferredDirName) {
65
- const cached = checker.memo.get(entry.path);
66
+ // pln#631 memo key scoped by the store's cwd. Two collision hazards must both be
67
+ // avoided when a shared checker spans a workspace: keying by PATH collides two
68
+ // packages' same-named `src/index.ts`; keying by file_id ALONE collides when two
69
+ // stores share a project_id (the `prj_${basename}` fallback, or a copied
70
+ // `.brainclaw/config.yaml`) — file_id = sha256(project_id + rel_path), so a shared
71
+ // id makes the file_id identical too (review F1). Scoping by the store's cwd (unique
72
+ // per store) is collision-proof either way; single-store keeps cwd constant, so
73
+ // behavior is identical to before.
74
+ const memoKey = `${cwd ?? ''} ${entry.file_id}`;
75
+ const cached = checker.memo.get(memoKey);
66
76
  if (cached !== undefined)
67
77
  return cached;
68
78
  const abs = path.join(projectRoot, entry.path);
@@ -72,19 +82,19 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
72
82
  }
73
83
  catch {
74
84
  acc.missingPaths.add(entry.path); // §6.1.2 — deletion.
75
- checker.memo.set(entry.path, false);
85
+ checker.memo.set(memoKey, false);
76
86
  return false;
77
87
  }
78
88
  const shard = readShard(entry.file_id, cwd, preferredDirName);
79
89
  if (!shard) {
80
90
  // No backing shard to compare against — treat as unchecked, not confident.
81
91
  acc.uncheckedPaths.add(entry.path);
82
- checker.memo.set(entry.path, false);
92
+ checker.memo.set(memoKey, false);
83
93
  return false;
84
94
  }
85
95
  // §6.1.3 — cheap gate: mtime + size match => fresh for this read.
86
96
  if (stat.mtimeMs === shard.mtime_ms && stat.size === shard.size_bytes) {
87
- checker.memo.set(entry.path, true);
97
+ checker.memo.set(memoKey, true);
88
98
  return true;
89
99
  }
90
100
  // §6.1.4/§6.1.6 — gate tripped: hash only when within budget AND not oversized.
@@ -93,13 +103,13 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
93
103
  // `partial`. Keep them separable so the badge reason is accurate.
94
104
  if (stat.size > maxParseFileBytes) {
95
105
  acc.uncheckedPaths.add(entry.path); // structurally unverifiable, not budget.
96
- checker.memo.set(entry.path, false);
106
+ checker.memo.set(memoKey, false);
97
107
  return false;
98
108
  }
99
109
  if (budgetExhausted(checker)) {
100
110
  acc.uncheckedPaths.add(entry.path);
101
111
  acc.budgetSkippedPaths.add(entry.path);
102
- checker.memo.set(entry.path, false);
112
+ checker.memo.set(memoKey, false);
103
113
  return false;
104
114
  }
105
115
  checker.filesChecked++;
@@ -109,17 +119,30 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
109
119
  }
110
120
  catch {
111
121
  acc.uncheckedPaths.add(entry.path);
112
- checker.memo.set(entry.path, false);
122
+ checker.memo.set(memoKey, false);
113
123
  return false;
114
124
  }
115
125
  if (hashContent(live) === shard.file_hash) {
116
- checker.memo.set(entry.path, true); // §6.1 — identical despite mtime touch.
126
+ checker.memo.set(memoKey, true); // §6.1 — identical despite mtime touch.
117
127
  return true;
118
128
  }
119
129
  acc.staleChangedPaths.add(entry.path); // §6.1.5 — confirmed content change.
120
- checker.memo.set(entry.path, false);
130
+ checker.memo.set(memoKey, false);
121
131
  return false;
122
132
  }
133
+ /**
134
+ * pln#631 PR3 — validate a single file entry against a SPECIFIC store's live tree
135
+ * (resolves that store's root + parse budget from its manifest). Lets the cross-package
136
+ * scan lazy-validate importer rows from a sibling store and drop deleted/stale ones,
137
+ * upholding the same "no silent stale graph hints" rule intra-package graph rows obey.
138
+ * Shares the caller's checker (one budget) + records into the caller's acc.
139
+ */
140
+ export function validateStoreEntry(entry, checker, acc, cwd, preferredDirName) {
141
+ const manifest = readManifest(cwd, preferredDirName);
142
+ const root = manifest?.project_root ?? cwd ?? process.cwd();
143
+ const maxBytes = manifest?.extractor_config.max_parse_file_bytes ?? 1024 * 1024;
144
+ return validateEntry(entry, checker, acc, root, maxBytes, cwd, preferredDirName);
145
+ }
123
146
  /**
124
147
  * Derive the response freshness badge from the base manifest status + the
125
148
  * outcomes recorded during this query's lazy check (spec §6.1, §9).
@@ -127,7 +150,7 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
127
150
  * Precedence: an exhausted budget yields `partial`; otherwise any detected
128
151
  * change/deletion yields `stale_changed_files`; else the manifest base status.
129
152
  */
130
- function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
153
+ export function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
131
154
  const details = {};
132
155
  if (acc.staleChangedPaths.size > 0) {
133
156
  details.stale_changed_files = [...acc.staleChangedPaths].sort();
@@ -167,7 +190,7 @@ function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex)
167
190
  if (status !== base)
168
191
  details.index_status = base;
169
192
  void hadConfidentMatch;
170
- return { status, details };
193
+ return { status, coarse: coarseFreshness(status), details };
171
194
  }
172
195
  const DEFAULT_FIND_LIMIT = 20;
173
196
  /** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
@@ -185,25 +208,73 @@ function queryTokens(query) {
185
208
  return [...tokens];
186
209
  }
187
210
  /**
188
- * Score a symbol index entry against the query. Exact (full-query) token match
189
- * scores highest; a prefix/substring match scores lower. Exported symbols and
190
- * components/hooks get a small boost (these are what agents most want to find).
211
+ * pln#601 normalize an identifier to a separator/case-INSENSITIVE canonical
212
+ * form (strip `_`, `-`, `.`, camelCase boundaries collapse to nothing; lowercase).
213
+ * `EntityRegistry`, `ENTITY_REGISTRY`, and `entity-registry` all `entityregistry`.
214
+ * Without this, `scoreEntry` compared raw-lowercased strings, so a Pascal-case
215
+ * query `EntityRegistry` failed to exact/prefix/substring-match the snake_case
216
+ * `ENTITY_REGISTRY` and dropped it to the sub-token floor (score 1) alongside 19
217
+ * unrelated `*Registry` symbols — the Fable-audit "all results score 1 → the agent
218
+ * re-greps" defect that undercuts the whole "stop grepping blind" value prop.
219
+ */
220
+ function normIdent(s) {
221
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '');
222
+ }
223
+ /**
224
+ * pln#601 — a path that is a test/spec file (its symbols must not outrank the real
225
+ * def, and it should not crowd source out of a brief). Recognizes conventions across
226
+ * ALL supported languages (JS/TS + py/php/java/go/rust/c#/ruby/c/c++), so the
227
+ * source-over-test bias actually works for the polyglot langs, not just JS/TS.
228
+ * Exported for the classification regression tests.
191
229
  */
192
- function scoreEntry(entry, query) {
193
- const q = query.toLowerCase();
194
- const name = entry.name.toLowerCase();
230
+ export function isTestPath(p) {
231
+ // Directory conventions (all langs): tests/, test/, __tests__/, spec(s)/ (Ruby
232
+ // RSpec), __mocks__/. NOTE: a source dir literally named `spec/` (e.g. an OpenAPI
233
+ // `spec/` folder) is an accepted false-positive — RSpec `spec/` is the commoner
234
+ // meaning now that Ruby is supported, and path alone can't disambiguate.
235
+ if (/(?:^|[\\/])(?:tests?|__tests__|specs?|__mocks__)[\\/]/i.test(p))
236
+ return true;
237
+ const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
238
+ return (
239
+ // separator-suffixed: foo.test.ts, foo_test.go, foo-spec.js, foo_spec.rb, foo_test.py
240
+ /[._-](?:test|spec)\.(?:[cm]?[jt]sx?|py|rb|go|php|java|cs|rs)$/i.test(base) ||
241
+ // pytest / minitest prefix: test_foo.py, test_foo.rb
242
+ /^test_.+\.(?:py|rb)$/i.test(base) ||
243
+ // bare test/spec file: test.ts, spec.rb
244
+ /^(?:test|spec)\.(?:[cm]?[jt]sx?|py|rb|go)$/i.test(base) ||
245
+ // xUnit / JUnit PascalCase suffix: FooTest.cs, BarTests.cs, BazTest.java
246
+ // (case-SENSITIVE capital T so `contest.cs` / `latest.cs` are not false hits)
247
+ /Tests?\.(?:cs|java)$/.test(base));
248
+ }
249
+ /**
250
+ * Score a symbol index entry against the query. Matching is separator/case
251
+ * INSENSITIVE (pln#601): an exact NORMALIZED match scores highest, then prefix,
252
+ * then substring, then the sub-token floor. Exported symbols + components/hooks
253
+ * get a small boost; test-file symbols are biased DOWN so a test helper never
254
+ * outranks the real definition of the same name (the Fable-audit brief-noise
255
+ * companion to the find defect). Exported for focused ranking tests.
256
+ */
257
+ export function scoreEntry(entry, query) {
258
+ const q = normIdent(query);
259
+ const name = normIdent(entry.name);
195
260
  let score = 0;
196
- if (name === q)
197
- score += 10;
261
+ if (q.length === 0)
262
+ score += 1;
263
+ else if (name === q)
264
+ score += 10; // exact, style-insensitive
198
265
  else if (name.startsWith(q))
199
- score += 6;
266
+ score += 6; // prefix
200
267
  else if (name.includes(q))
201
- score += 3;
268
+ score += 3; // substring
202
269
  else
203
270
  score += 1; // matched only via a sub-token bucket
204
271
  score *= entry.score_hint; // exported (1.0) vs internal (0.8)
205
272
  if (entry.subtype === 'component' || entry.subtype === 'hook')
206
273
  score += 1;
274
+ // pln#601 — source over test: a test/spec symbol of the same name must not
275
+ // outrank the real definition (keeps find/brief pointing at source first).
276
+ if (isTestPath(entry.path))
277
+ score *= 0.4;
207
278
  return score;
208
279
  }
209
280
  function resolveRoot(ctx) {
@@ -237,20 +308,22 @@ function gatherSymbolEntries(index, query) {
237
308
  }
238
309
  return out;
239
310
  }
240
- export function find(query, limit, ctx) {
241
- const base = baseStatus(ctx);
311
+ /**
312
+ * Store-local find CORE (pln#631): gather → lazy-validate → score → sort, with NO
313
+ * cap and NO badge. Accepts an INJECTED checker + acc so a workspace aggregation can
314
+ * share ONE lazy budget across stores (the checker's memo is file_id-keyed, unique
315
+ * per store, so sharing never collides same-named files across packages). `find()`
316
+ * wraps this with a fresh checker/acc + cap + badge for the unchanged single-store
317
+ * path; `aggregate.ts` fans it out across stores with a shared checker.
318
+ */
319
+ export function findInStore(query, ctx, checker, acc) {
242
320
  const index = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
243
321
  if (!index) {
244
- return {
245
- query,
246
- matches: [],
247
- freshness_badge: { status: 'missing_index', details: { hint: 'run refresh' } },
248
- };
322
+ return { matches: [], base: 'missing_index', hasIndex: false, emptyCandidates: true, acc };
249
323
  }
324
+ const base = baseStatus(ctx);
250
325
  const root = resolveRoot(ctx);
251
326
  const maxBytes = maxParseBytes(ctx);
252
- const checker = makeLazyChecker();
253
- const acc = newAccumulator();
254
327
  const candidates = gatherSymbolEntries(index, query);
255
328
  const ranked = [];
256
329
  for (const entry of candidates) {
@@ -269,8 +342,21 @@ export function find(query, limit, ctx) {
269
342
  });
270
343
  }
271
344
  ranked.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
272
- const capped = ranked.slice(0, limit ?? DEFAULT_FIND_LIMIT);
273
- const badge = deriveBadge(base, acc, checker.exhausted, capped.length > 0, candidates.length === 0);
345
+ return { matches: ranked, base, hasIndex: true, emptyCandidates: candidates.length === 0, acc };
346
+ }
347
+ export function find(query, limit, ctx) {
348
+ const checker = makeLazyChecker();
349
+ const acc = newAccumulator();
350
+ const r = findInStore(query, ctx, checker, acc);
351
+ if (!r.hasIndex) {
352
+ return {
353
+ query,
354
+ matches: [],
355
+ freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
356
+ };
357
+ }
358
+ const capped = r.matches.slice(0, limit ?? DEFAULT_FIND_LIMIT);
359
+ const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyCandidates);
274
360
  return { query, matches: capped, freshness_badge: badge };
275
361
  }
276
362
  /** spec §11 — cap related memory at top 5 by relevance. */
@@ -399,6 +485,42 @@ function rankFiles(defining, forwardRows, reverseRows, symbolsIndex, importsInde
399
485
  }
400
486
  return [...byPath.values()].sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
401
487
  }
488
+ /**
489
+ * pln#601 — reserve most of the §9 reading list for SOURCE files. On a symbol with
490
+ * many test importers, the reverse-dependent test files (blast radius, +5 each) can
491
+ * fill the entire {@link BRIEF_FILE_CAP} and crowd out the source files an agent needs
492
+ * to understand the symbol — the Fable-audit brief-noise defect. At most `maxTest`
493
+ * test files (~1/4 of the cap, min 2) are admitted UNLESS there aren't enough non-test
494
+ * files to fill the cap, in which case the deferred tests backfill the empty slots so
495
+ * the list is never artificially short. Defining files ARE the target and are never
496
+ * counted as noise (a symbol legitimately defined in a test file still leads the list).
497
+ * Input is pre-sorted by score; output preserves that order within each bucket.
498
+ */
499
+ export function reserveSourceSlots(ranked, cap, definingPaths) {
500
+ const maxTest = Math.max(2, Math.floor(cap / 4)); // e.g. 3 of 12
501
+ const out = [];
502
+ const overflowTests = [];
503
+ let testsIncluded = 0;
504
+ for (const rf of ranked) {
505
+ if (out.length >= cap)
506
+ break;
507
+ const isNoise = isTestPath(rf.path) && !definingPaths.has(rf.path);
508
+ if (isNoise && testsIncluded >= maxTest) {
509
+ overflowTests.push(rf);
510
+ continue;
511
+ }
512
+ out.push(rf);
513
+ if (isNoise)
514
+ testsIncluded++;
515
+ }
516
+ // Non-test ran out before the cap → backfill the empty slots with deferred tests.
517
+ for (const rf of overflowTests) {
518
+ if (out.length >= cap)
519
+ break;
520
+ out.push(rf);
521
+ }
522
+ return out;
523
+ }
402
524
  /** Build a node-id → symbol index entry map (deduped; entries repeat across token buckets). */
403
525
  function buildNodeIdIndex(symbolsIndex) {
404
526
  const out = new Map();
@@ -495,49 +617,64 @@ function filesMatchingPath(symbolsIndex, target) {
495
617
  }
496
618
  return out;
497
619
  }
498
- export function brief(target, limit, ctx, memoryReader) {
499
- const base = baseStatus(ctx);
620
+ /**
621
+ * Store-local brief CORE (pln#631 PR2): resolve the target → graph signals →
622
+ * rankFiles → confident list, with NO cap, NO badge, NO related-memory attach.
623
+ * Accepts an INJECTED checker + acc so a workspace aggregation can share ONE lazy
624
+ * budget across stores (memo is cwd-scoped, so sharing is collision-safe). `brief()`
625
+ * wraps this with a fresh checker/acc + reserve + memory + badge for the unchanged
626
+ * single-store path; `aggregate.ts` fans it out and merges target-defining stores.
627
+ */
628
+ export function briefInStore(target, ctx, checker, acc) {
500
629
  const symbolsIndex = readSymbolsIndex(ctx.cwd, ctx.preferredDirName);
501
630
  if (!symbolsIndex) {
502
631
  return {
503
- target,
504
- suggested_files_to_read: [],
505
- related_memory: [],
506
- freshness_badge: { status: 'missing_index', details: { hint: 'run refresh' } },
632
+ defining: [],
633
+ definingPaths: new Set(),
634
+ matchKind: 'none',
635
+ confident: [],
636
+ base: 'missing_index',
637
+ hasIndex: false,
638
+ emptyRanked: true,
639
+ acc,
507
640
  };
508
641
  }
642
+ const base = baseStatus(ctx);
509
643
  const importsIndex = readImportsIndex(ctx.cwd, ctx.preferredDirName);
510
644
  const resolutionIndex = readResolutionIndex(ctx.cwd, ctx.preferredDirName);
511
645
  // Resolve target -> defining symbol entries. A brief orients on a SPECIFIC target,
512
646
  // so prefer EXACT name matches when present — otherwise the token index floods the
513
- // result with unrelated same-token symbols (e.g. `resolveProjectImports` would pull
514
- // in every `resolve*`), burying the real defining file + its graph signals. Fall
515
- // back to the fuzzy token set, then to a path match. (find() stays fuzzy by design.)
647
+ // result with unrelated same-token symbols. Fall back to the fuzzy token set, then
648
+ // a path match. `matchKind` records which path won so an aggregation can prefer the
649
+ // stores that actually DEFINE the target over stores that only fuzzy-match a token.
516
650
  let defining;
651
+ let matchKind;
517
652
  if (looksLikePathTarget(target)) {
518
- // PATH target (pln#593 1b): resolve the exact file; the graph signals (its
519
- // imports / dependents / direct tests) then rank below it via rankFiles. Skip
520
- // the fuzzy token gather entirely — it floods a path brief with same-token
521
- // noise. Degrade to the fuzzy set only if the path resolves to nothing indexed.
522
653
  defining = filesMatchingPath(symbolsIndex, target);
523
- if (defining.length === 0)
654
+ if (defining.length > 0)
655
+ matchKind = 'path';
656
+ else {
524
657
  defining = gatherSymbolEntries(symbolsIndex, target);
658
+ matchKind = defining.length > 0 ? 'fuzzy' : 'none';
659
+ }
525
660
  }
526
661
  else {
527
662
  defining = gatherSymbolEntries(symbolsIndex, target);
528
663
  const exact = defining.filter((e) => e.name.toLowerCase() === target.toLowerCase());
529
- if (exact.length > 0)
664
+ if (exact.length > 0) {
530
665
  defining = exact;
531
- else if (defining.length === 0)
666
+ matchKind = 'exact';
667
+ }
668
+ else if (defining.length > 0) {
669
+ matchKind = 'fuzzy';
670
+ }
671
+ else {
532
672
  defining = filesMatchingPath(symbolsIndex, target);
673
+ matchKind = defining.length > 0 ? 'path' : 'none';
674
+ }
533
675
  }
534
676
  const root = resolveRoot(ctx);
535
677
  const maxBytes = maxParseBytes(ctx);
536
- const checker = makeLazyChecker();
537
- const acc = newAccumulator();
538
- // P1d graph signals. FORWARD: read from defining shards — but only CONFIDENT ones
539
- // (validate first; a stale importer shard's edge list is not trusted). REVERSE: from
540
- // the resolution index (each importer row is lazy-validated below like any other).
541
678
  const definingPaths = new Set(defining.map((e) => e.path));
542
679
  const definingByNodeId = new Map(defining.map((e) => [e.node_id, e]));
543
680
  const confidentDefiningFileIds = new Map();
@@ -552,31 +689,25 @@ export function brief(target, limit, ctx, memoryReader) {
552
689
  const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
553
690
  const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
554
691
  const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
555
- // §6.1 — lazy validate each suggested file; exclude deletions from the confident
556
- // list (still recorded in the badge). P1d: a GRAPH-ONLY row that fails validation
557
- // (stale / unchecked / deleted) is SUPPRESSED — no silent stale graph hints (Codex).
692
+ // §6.1 — lazy validate each suggested file; exclude deletions; suppress a graph-only
693
+ // row that fails validation (no silent stale graph hints).
558
694
  const confident = [];
559
695
  for (const rf of ranked) {
560
696
  const ok = validateEntry({ path: rf.path, file_id: rf.file_id }, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
561
697
  if (acc.missingPaths.has(rf.path))
562
- continue; // deletion: exclude entirely.
698
+ continue;
563
699
  if (rf.graphDerived && !ok)
564
- continue; // graph-only + not confident → suppress.
565
- // Non-graph stale/unchecked rows still appear (badge flags them) so the agent
566
- // knows the file exists but may be out of date.
700
+ continue;
567
701
  confident.push(rf);
568
702
  }
569
- const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
570
- const capped = confident.slice(0, cap);
571
- // Related memory (spec §11): match by the candidate paths + symbol names.
572
- const candidatePaths = capped.map((f) => f.path);
573
- const symbolNames = [...new Set(defining.map((e) => e.name))];
574
- if (symbolNames.length === 0)
575
- symbolNames.push(target);
576
- const memoryItems = memoryReader(ctx);
577
- const related = attachRelatedMemory(memoryItems, candidatePaths, symbolNames);
578
- // Attach matching memory ids per file (those whose related_paths/text name it).
579
- const suggested = capped.map((f) => {
703
+ return { defining, definingPaths, matchKind, confident, base, hasIndex: true, emptyRanked: ranked.length === 0, acc };
704
+ }
705
+ /**
706
+ * Attach related-memory ids per reading-list entry (spec §11). Shared by the
707
+ * single-store brief() and the workspace aggregation so both surface memory identically.
708
+ */
709
+ export function attachMemoryIds(capped, related) {
710
+ return capped.map((f) => {
580
711
  const ids = related
581
712
  .filter((m) => {
582
713
  const fileNorm = f.path.replace(/\\/g, '/');
@@ -588,12 +719,30 @@ export function brief(target, limit, ctx, memoryReader) {
588
719
  .map((m) => m.id);
589
720
  return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
590
721
  });
591
- const badge = deriveBadge(base, acc, checker.exhausted, capped.length > 0, ranked.length === 0);
592
- return {
593
- target,
594
- suggested_files_to_read: suggested,
595
- related_memory: related,
596
- freshness_badge: badge,
597
- };
722
+ }
723
+ export function brief(target, limit, ctx, memoryReader) {
724
+ const checker = makeLazyChecker();
725
+ const acc = newAccumulator();
726
+ const r = briefInStore(target, ctx, checker, acc);
727
+ if (!r.hasIndex) {
728
+ return {
729
+ target,
730
+ suggested_files_to_read: [],
731
+ related_memory: [],
732
+ freshness_badge: { status: 'missing_index', coarse: 'missing', details: { hint: 'run refresh' } },
733
+ };
734
+ }
735
+ const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
736
+ // pln#601 — reserve source slots so test importers can't crowd out source files.
737
+ const capped = reserveSourceSlots(r.confident, cap, r.definingPaths);
738
+ // Related memory (spec §11): match by the candidate paths + symbol names.
739
+ const candidatePaths = capped.map((f) => f.path);
740
+ const symbolNames = [...new Set(r.defining.map((e) => e.name))];
741
+ if (symbolNames.length === 0)
742
+ symbolNames.push(target);
743
+ const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames);
744
+ const suggested = attachMemoryIds(capped, related);
745
+ const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyRanked);
746
+ return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: badge };
598
747
  }
599
748
  //# sourceMappingURL=query.js.map
@@ -61,6 +61,12 @@ const KNOWN_CODE_LANGS = new Set([
61
61
  'python',
62
62
  'php',
63
63
  'java',
64
+ 'go',
65
+ 'rust',
66
+ 'csharp',
67
+ 'ruby',
68
+ 'c',
69
+ 'cpp',
64
70
  ]);
65
71
  /** A well-formed (lowercase) language id a future provider could register. */
66
72
  const CODE_LANG_RE = /^[a-z][a-z0-9_]*$/;
@@ -285,9 +291,21 @@ export const CodeLockSchema = z.object({
285
291
  heartbeat_at: z.string(),
286
292
  stale_after_ms: z.number().int(),
287
293
  });
294
+ /**
295
+ * spec §9 (pln#601) — the COARSE, surface-uniform rollup of the detailed 7-value
296
+ * {@link FreshnessStatus}. find/brief/status/work each expose their own precise
297
+ * status, but an agent wants one consistent top-line signal to decide "trust this
298
+ * or refresh first" without memorizing which `stale_*` variant applies. `coarse`
299
+ * collapses the detail: every `stale_*` → `stale`, `missing_index` → `missing`,
300
+ * `partial`/`fresh` unchanged. Derived (never independently authored) via
301
+ * `coarseFreshness()` so it can never contradict `status`.
302
+ */
303
+ export const CoarseFreshnessSchema = z.enum(['fresh', 'stale', 'partial', 'missing']);
288
304
  /** Freshness badge attached to every agent-facing read response (spec §9). */
289
305
  export const FreshnessBadgeSchema = z.object({
290
306
  status: FreshnessStatusSchema,
307
+ /** pln#601 — coarse rollup of `status`, uniform across all read surfaces. */
308
+ coarse: CoarseFreshnessSchema.optional(),
291
309
  details: z.record(z.string(), z.unknown()).default({}),
292
310
  });
293
311
  //# sourceMappingURL=types.js.map
@@ -18,6 +18,7 @@
18
18
  * bclaw_work beyond that bounded wait (rule §6 rule 8).
19
19
  */
20
20
  import { readManifest } from './store.js';
21
+ import { withCoarse } from './freshness.js';
21
22
  import { readCodeLock, isLockAbandoned } from './lock.js';
22
23
  import { codeMapDir, lockPath } from './paths.js';
23
24
  import { JsonlBackend } from './backend.js';
@@ -107,10 +108,10 @@ export async function codeMapWorkSection(cwd, opts = {}) {
107
108
  return {
108
109
  enabled: true,
109
110
  matches: out.matches,
110
- freshness_badge: {
111
+ freshness_badge: withCoarse({
111
112
  status: 'partial',
112
113
  details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
113
- },
114
+ }),
114
115
  lock_wait_ms: lockWaitMs,
115
116
  };
116
117
  }
@@ -121,10 +122,10 @@ export async function codeMapWorkSection(cwd, opts = {}) {
121
122
  return {
122
123
  enabled: true,
123
124
  matches: [],
124
- freshness_badge: {
125
+ freshness_badge: withCoarse({
125
126
  status: 'partial',
126
127
  details: { partial_reason: 'code_map_lock_active', lock_wait_ms: lockWaitMs },
127
- },
128
+ }),
128
129
  lock_wait_ms: lockWaitMs,
129
130
  };
130
131
  }
@@ -135,7 +136,7 @@ export async function codeMapWorkSection(cwd, opts = {}) {
135
136
  enabled: true,
136
137
  missing_index: 'Code Map index is empty for this project. Run `brainclaw code-map refresh --all` (or bclaw_code_refresh) before relying on find/brief.',
137
138
  matches: [],
138
- freshness_badge: { status: 'missing_index', details: {} },
139
+ freshness_badge: withCoarse({ status: 'missing_index', details: {} }),
139
140
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
140
141
  };
141
142
  }
@@ -146,13 +147,13 @@ export async function codeMapWorkSection(cwd, opts = {}) {
146
147
  return {
147
148
  enabled: true,
148
149
  matches: [],
149
- freshness_badge: {
150
+ freshness_badge: withCoarse({
150
151
  status: manifest.freshness.status,
151
152
  details: {
152
153
  stale_file_count: manifest.freshness.stale_file_count,
153
154
  partial_reason: manifest.freshness.partial_reason,
154
155
  },
155
- },
156
+ }),
156
157
  ...(lockWaitMs !== undefined ? { lock_wait_ms: lockWaitMs } : {}),
157
158
  };
158
159
  }
@@ -15,6 +15,22 @@ export function responseDir(threadSlug, cwd) {
15
15
  export function responseFilePath(threadSlug, roundNumber, persona, cwd) {
16
16
  return path.join(responseDir(threadSlug, cwd), `round_${roundNumber}_${persona}.json`);
17
17
  }
18
+ /**
19
+ * Persist a full CoDev/ideation phase body to the artifact store (pln#627
20
+ * Phase C) and return a repo-relative pointer. The inbox thread then carries
21
+ * only a bounded head + this pointer instead of a multi-hundred-KB rfc dump
22
+ * (root cause of the 3.8 MB inbox: full phase briefs persisted as messages).
23
+ * The `.brainclaw/coordination/ideation/<slug>/phases/` dir sits alongside the
24
+ * existing per-round `responses/` store so a thread's artifacts stay together.
25
+ */
26
+ export function writePhaseArtifact(threadSlug, label, text, cwd) {
27
+ const dir = path.join(memoryDir(cwd), 'coordination', 'ideation', sanitizeForPath(threadSlug), 'phases');
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ const filePath = path.join(dir, `${sanitizeForPath(label)}.md`);
30
+ fs.writeFileSync(filePath, text, 'utf8');
31
+ const relPath = path.relative(memoryDir(cwd), filePath).split(path.sep).join('/');
32
+ return { path: filePath, relPath, charCount: text.length };
33
+ }
18
34
  export function writeResponse(threadSlug, roundNumber, persona, agent, text, cwd) {
19
35
  const filePath = responseFilePath(threadSlug, roundNumber, persona, cwd);
20
36
  fs.mkdirSync(path.dirname(filePath), { recursive: true });