brainclaw 1.28.2 → 1.28.3

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.
Binary file
@@ -374,7 +374,7 @@ export const MCP_READ_TOOLS = [
374
374
  },
375
375
  {
376
376
  name: 'bclaw_code_status',
377
- description: 'Code Map status for this project: store presence, freshness badge (fresh / stale_changed_files / stale_extractor / stale_grammar / stale_git_head / partial / missing_index), and index stats (files, nodes, edges). Read-only; never refreshes. Pair with bclaw_code_refresh when freshness is missing_index or stale. In a multi-project workspace, cascade=true adds a per-child recap (which nested projects have a built index vs missing_index).',
377
+ description: 'Code Map status for the active session project: store presence, freshness badge, and index stats. Read-only; never refreshes. In a multi-project workspace, cascade=true adds per-child coverage plus progress/terminal diagnostics for the latest durable cascade job.',
378
378
  annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'auto' },
379
379
  inputSchema: {
380
380
  type: 'object',
@@ -459,7 +459,7 @@ export const MCP_READ_TOOLS = [
459
459
  const MCP_WRITE_TOOLS = [
460
460
  {
461
461
  name: 'bclaw_code_refresh',
462
- description: 'Rebuild the Code Map index for this project (Tree-sitter parse + shards + indexes, behind the per-project lock). scope="changed" (default) reparses changed files; scope="all" does a full refresh + compaction. A live competing lock fails fast with a clear status — refresh never blocks. Returns the resulting freshness_badge. In a multi-project workspace, cascade=true refreshes EVERY nested project into its own store + the root store scoped to files no child owns (zero double-indexing) so one call at the root indexes the whole monorepo per-project.',
462
+ description: 'Rebuild the Code Map index for the active session project. scope="changed" (default) reparses changed files; scope="all" does a full refresh + compaction. In a multi-project workspace, cascade=true starts a durable background job immediately; follow it with bclaw_code_status(cascade=true), which reports progress and terminal per-project diagnostics without an MCP timeout.',
463
463
  annotations: { tier: 'standard', category: 'discovery', headlessApproval: 'prompt' },
464
464
  inputSchema: {
465
465
  type: 'object',
@@ -12,6 +12,7 @@
12
12
  * @module
13
13
  */
14
14
  import crypto from 'node:crypto';
15
+ import path from 'node:path';
15
16
  import { spawnSync } from 'node:child_process';
16
17
  import { buildClaimEnvPrefix } from '../core/execution-profile.js';
17
18
  import { resolveProjectCwd } from '../core/cross-project.js';
@@ -387,9 +388,17 @@ export async function handleBclawCoordinate(args, ctx) {
387
388
  // for state-mutating helpers; the outer `cwd` (source) stays in scope
388
389
  // for the few cases that genuinely need source attribution.
389
390
  const dispatchCwd = resolveProjectCwd(req.project, cwd);
390
- const isCrossProject = dispatchCwd !== cwd;
391
+ const isCrossProject = path.resolve(dispatchCwd) !== path.resolve(cwd);
391
392
  if (isCrossProject && req.autoExecute !== false) {
392
- warnings.push(`cross-project dispatch (project='${req.project}') — auto-spawn disabled; the target agent picks up the brief async via its own bclaw_work.`);
393
+ return {
394
+ response: createToolErrorResponse('cross_project_auto_execute_unsupported', `cross-project dispatch (project='${req.project}') cannot auto-execute from the source process; admission refused before creating a claim, assignment, or loop.`, {
395
+ next_actions: [{
396
+ tool: 'bclaw_coordinate',
397
+ args: { ...req, autoExecute: false },
398
+ when: 'create an inbox-only cross-project assignment that the target agent will pick up with bclaw_work',
399
+ }],
400
+ }),
401
+ };
393
402
  }
394
403
  const effectiveAutoExecute = isCrossProject ? false : req.autoExecute;
395
404
  // pln#692 P0 — an explicit checkout ref is part of admission, not worktree
@@ -1008,7 +1017,7 @@ export async function handleBclawCoordinate(args, ctx) {
1008
1017
  // existing length===0 guard skips loop creation. Skipped when open_loop
1009
1018
  // is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
1010
1019
  // preflightAgents). Cross-project dispatch never auto-spawns, so skip.
1011
- if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
1020
+ if (req.open_loop === true && req.preflight !== false && !isCrossProject && loopReviewerAgents.length > 0) {
1012
1021
  try {
1013
1022
  const { preflightAgents } = await import('../core/spawn-check.js');
1014
1023
  const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
@@ -1158,7 +1167,7 @@ export async function handleBclawCoordinate(args, ctx) {
1158
1167
  // (that is the double-spawn hole), and do NOT release the (possibly shared) claim; leave
1159
1168
  // the slot for reconcile/self-heal. LEGACY: the unchanged inline mint runs.
1160
1169
  let usedTurnOwned = false;
1161
- if (turnOwnedReviewEnabled() && !req.project) {
1170
+ if (turnOwnedReviewEnabled() && !isCrossProject) {
1162
1171
  const prep = prepareTurnOwnedReviewDispatch({
1163
1172
  loopId: loop.id,
1164
1173
  slotId: slot.slot_id,
@@ -479,9 +479,12 @@ export function handleBclawCreate(payload, ctx) {
479
479
  const result = createEntity(entity, data, targetCwd);
480
480
  appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
481
481
  const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
482
+ const proximityText = result.nearby_items?.length
483
+ ? `Nearby existing ${entity} item(s): ${result.nearby_items.map((item) => `${item.id} (${item.reason})`).join(', ')}. Creation was kept; review before adding another duplicate.`
484
+ : undefined;
482
485
  const createContent = autoRepair
483
- ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
484
- : [{ type: 'text', text: createText }];
486
+ ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])]
487
+ : [{ type: 'text', text: createText }, ...(proximityText ? [{ type: 'text', text: proximityText }] : [])];
485
488
  // pln#634 — a freshly created plan whose steps are never added is the most
486
489
  // common half-finished shape in the store; a sequence with no readiness
487
490
  // check is the second. Only those two emit a follow-up.
@@ -1096,8 +1096,10 @@ async function _executeMcpToolCallInner(payload) {
1096
1096
  if (name === 'bclaw_code_status' || name === 'bclaw_code_find' || name === 'bclaw_code_brief' || name === 'bclaw_code_impact' || name === 'bclaw_code_export' || name === 'bclaw_code_outline' || name === 'bclaw_code_refresh') {
1097
1097
  const { JsonlBackend } = await import('../core/code-map/backend.js');
1098
1098
  const be = new JsonlBackend();
1099
+ // Session-scoped project selection is authoritative for Code Map too.
1100
+ const codeCwd = scopeInfo.cwd;
1099
1101
  if (name === 'bclaw_code_status') {
1100
- const status = await be.status({ cwd, cascade: args.cascade === true });
1102
+ const status = await be.status({ cwd: codeCwd, cascade: args.cascade === true });
1101
1103
  const diskVersion = readDiskBrainclawVersion();
1102
1104
  return {
1103
1105
  response: toolResponse({
@@ -1118,7 +1120,23 @@ async function _executeMcpToolCallInner(payload) {
1118
1120
  }
1119
1121
  if (name === 'bclaw_code_refresh') {
1120
1122
  const scope = args.scope === 'all' ? 'all' : 'changed';
1121
- const result = await be.refresh({ scope, cwd, cascade: args.cascade === true });
1123
+ if (args.cascade === true) {
1124
+ const { startCascadeRefreshJob, summarizeCascadeRefreshJob } = await import('../core/code-map/cascade-jobs.js');
1125
+ const job = startCascadeRefreshJob(codeCwd, scope);
1126
+ if (job) {
1127
+ return {
1128
+ response: toolResponse({
1129
+ content: [{ type: 'text', text: `Code Map cascade started: job=${job.job_id}, projects=${job.projects_total}. Follow with bclaw_code_status(cascade=true).` }],
1130
+ structuredContent: {
1131
+ started: true,
1132
+ ...summarizeCascadeRefreshJob(job),
1133
+ next_actions: [{ tool: 'bclaw_code_status', args: { cascade: true }, when: 'follow progress and terminal per-project diagnostics' }],
1134
+ },
1135
+ }),
1136
+ };
1137
+ }
1138
+ }
1139
+ const result = await be.refresh({ scope, cwd: codeCwd, cascade: args.cascade === true });
1122
1140
  const cascadeNote = result.cascade ? ` cascade=${result.cascade.children_refreshed} child(ren)+root` : '';
1123
1141
  return {
1124
1142
  response: toolResponse({
@@ -1133,7 +1151,7 @@ async function _executeMcpToolCallInner(payload) {
1133
1151
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_find requires a non-empty query.') };
1134
1152
  }
1135
1153
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1136
- const result = await be.find({ query, limit, cwd });
1154
+ const result = await be.find({ query, limit, cwd: codeCwd });
1137
1155
  return {
1138
1156
  response: toolResponse({
1139
1157
  content: [{ type: 'text', text: `Code Map find "${result.query}": ${result.matches.length} match(es), freshness=${result.freshness_badge.freshness}` }],
@@ -1154,7 +1172,7 @@ async function _executeMcpToolCallInner(payload) {
1154
1172
  const maxEdges = typeof args.maxEdges === 'number' ? args.maxEdges : undefined;
1155
1173
  const minConfidence = typeof args.minConfidence === 'number' ? args.minConfidence : undefined;
1156
1174
  const format = args.format === 'mermaid' ? 'mermaid' : args.format === 'json' ? 'json' : undefined;
1157
- const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd });
1175
+ const result = await be.exportGraph({ target, targetKind, direction, depth, maxNodes, maxEdges, minConfidence, format, cwd: codeCwd });
1158
1176
  return {
1159
1177
  response: toolResponse({
1160
1178
  content: [{ type: 'text', text: `Code Map export "${result.target}": ${result.nodes.length} node(s), ${result.edges.length} edge(s), depth=${result.limits.max_depth}, freshness=${result.freshness_badge.freshness}` }],
@@ -1170,7 +1188,7 @@ async function _executeMcpToolCallInner(payload) {
1170
1188
  }
1171
1189
  const depth = typeof args.depth === 'number' ? args.depth : undefined;
1172
1190
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1173
- const result = await be.impact({ target, depth, limit, cwd });
1191
+ const result = await be.impact({ target, depth, limit, cwd: codeCwd });
1174
1192
  return {
1175
1193
  response: toolResponse({
1176
1194
  content: [{ type: 'text', text: `Code Map impact "${result.target}": ${result.risk.counters.direct_dependents} direct, ${result.risk.counters.transitive_dependents} transitive dependent(s), risk=${result.risk.score}, freshness=${result.freshness_badge.freshness}` }],
@@ -1185,7 +1203,7 @@ async function _executeMcpToolCallInner(payload) {
1185
1203
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_outline requires a non-empty path.') };
1186
1204
  }
1187
1205
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1188
- const result = await be.outline({ path: outlinePath, limit, cwd });
1206
+ const result = await be.outline({ path: outlinePath, limit, cwd: codeCwd });
1189
1207
  return {
1190
1208
  response: toolResponse({
1191
1209
  content: [{ type: 'text', text: `Code Map outline "${result.path}": ${result.symbols.length}/${result.symbol_count} symbol(s), index=${result.index_status}, freshness=${result.freshness_badge.freshness}` }],
@@ -1199,7 +1217,7 @@ async function _executeMcpToolCallInner(payload) {
1199
1217
  return { response: createToolErrorResponse('validation_error', 'bclaw_code_brief requires a non-empty target.') };
1200
1218
  }
1201
1219
  const limit = typeof args.limit === 'number' ? args.limit : undefined;
1202
- const result = await be.brief({ target, limit, cwd });
1220
+ const result = await be.brief({ target, limit, cwd: codeCwd });
1203
1221
  return {
1204
1222
  response: toolResponse({
1205
1223
  content: [{ type: 'text', text: `Code Map brief "${result.target}": ${result.suggested_files_to_read.length} file(s) to read, freshness=${result.freshness_badge.freshness}` }],
@@ -183,10 +183,9 @@ function statusRank(s) {
183
183
  }
184
184
  }
185
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`.
186
+ * Merge per-store badges into one workspace badge. Missing child indexes are a
187
+ * PARTIAL workspace, never a fresh one: serving the indexed subset is useful,
188
+ * but the top-line signal must describe the coverage actually searched.
190
189
  */
191
190
  function mergeBadges(perStore) {
192
191
  const total = perStore.length;
@@ -202,14 +201,28 @@ function mergeBadges(perStore) {
202
201
  if (statusRank(p.badge.status) > statusRank(worst))
203
202
  worst = p.badge.status;
204
203
  }
204
+ if (unindexed.length > 0)
205
+ worst = 'partial';
206
+ const statusCounts = {};
207
+ for (const p of perStore) {
208
+ const status = p.hasIndex ? p.badge.status : 'missing_index';
209
+ statusCounts[status] = (statusCounts[status] ?? 0) + 1;
210
+ }
211
+ const exceptionalProjects = perStore
212
+ .filter((p) => p.hasIndex && p.badge.status !== 'fresh')
213
+ .map((p) => ({ path: p.ref.relPath || '.', status: p.badge.status }));
205
214
  const details = {
206
215
  traversal: 'workspace',
207
216
  projects_indexed: indexed.length,
208
217
  projects_total: total,
209
- per_project: Object.fromEntries(perStore.map((p) => [p.ref.relPath || '.', p.badge.status])),
218
+ project_status_counts: statusCounts,
210
219
  };
211
- if (unindexed.length)
220
+ if (unindexed.length) {
221
+ details.unindexed_project_count = unindexed.length;
212
222
  details.unindexed_projects = unindexed;
223
+ }
224
+ if (exceptionalProjects.length)
225
+ details.non_fresh_projects = exceptionalProjects;
213
226
  const prefixMerge = (key) => {
214
227
  const out = [];
215
228
  for (const p of indexed) {
@@ -259,7 +272,7 @@ export function aggregateFind(query, limit, resolved, currentHead) {
259
272
  const r = findInStore(query, { cwd: ref.cwd }, checker, acc);
260
273
  // Per-store badge: drive `partial` from THIS store's own budget-skips (review F2),
261
274
  // NOT the shared checker.exhausted flag — else an early store spending the budget
262
- // would mislabel every fully-fresh later store as `partial` in per_project. Then
275
+ // would mislabel every fully-fresh later store as `partial` in diagnostics. Then
263
276
  // apply per-store HEAD drift against the one workspace HEAD (review F3) so a child
264
277
  // whose index lags the working tree is flagged even under an otherwise-fresh root.
265
278
  let badge = deriveBadge(r.base, acc, false, r.matches.length > 0, r.emptyCandidates);
@@ -19,7 +19,8 @@ import { exportSubgraph } from './export.js';
19
19
  import { fileId } from './ids.js';
20
20
  import { resolveTraversal, aggregateFind, aggregateBrief } from './aggregate.js';
21
21
  import { defaultMemoryReader } from './memory-reader.js';
22
- import { listNestedProjects, refreshWorkspaceCascade } from './cascade.js';
22
+ import { inspectNestedProjects, refreshWorkspaceCascade } from './cascade.js';
23
+ import { latestCascadeRefreshJob, summarizeCascadeRefreshJob } from './cascade-jobs.js';
23
24
  import { loadConfig } from '../config.js';
24
25
  import { codeMapDir } from './paths.js';
25
26
  /** spec §9 caps the brief reading list at 12 files. */
@@ -112,17 +113,26 @@ function isMultiProjectWorkspace(cwd) {
112
113
  /** Per-child store recap for `status(cascade)` in a multi-project workspace. */
113
114
  function buildCascadeStatus(rootCwd) {
114
115
  const root = rootCwd ?? process.cwd();
115
- const children = listNestedProjects(root).map((abs) => {
116
+ const discovery = inspectNestedProjects(root);
117
+ const children = discovery.projects.map((abs) => {
116
118
  const m = readManifest(abs);
117
119
  return {
118
120
  path: path.relative(root, abs).replace(/\\/g, '/') || '.',
119
121
  store_exists: m ? true : storeExists(abs),
120
122
  freshness: m ? m.freshness.status : 'missing_index',
121
123
  files_indexed: m ? m.stats.files_indexed : null,
124
+ ...(m && m.stats.files_indexed === 0 ? { reason: 'no_eligible_files' } : {}),
122
125
  };
123
126
  });
124
127
  const indexed = children.filter((c) => c.freshness !== 'missing_index').length;
125
- return { children, indexed_children: indexed, total_children: children.length };
128
+ const latestJob = latestCascadeRefreshJob(root);
129
+ return {
130
+ children,
131
+ indexed_children: indexed,
132
+ total_children: children.length,
133
+ discovery_truncated: discovery.truncated,
134
+ ...(latestJob ? { refresh_job: summarizeCascadeRefreshJob(latestJob) } : {}),
135
+ };
126
136
  }
127
137
  /**
128
138
  * P0 JSONL-backed query backend. Reads the durable file store (manifest +
@@ -198,17 +208,17 @@ export class JsonlBackend {
198
208
  // A cascade is only fully "acquired" when EVERY project got its lock; if a
199
209
  // child or the root was skipped under a live writer, surface that instead
200
210
  // of reporting a clean lock_acquired=true over a partial cascade (codex review).
201
- const skipped = allProjects.filter((p) => !p.lock_acquired);
211
+ const incomplete = allProjects.filter((p) => p.outcome === 'locked' || p.outcome === 'failed');
202
212
  return {
203
213
  ran: allProjects.some((p) => p.ran),
204
214
  scope,
205
- lock_acquired: skipped.length === 0,
215
+ lock_acquired: incomplete.length === 0,
206
216
  freshness_badge: badge(root.freshness, {
207
217
  files_parsed: root.files_parsed,
208
218
  children_refreshed: cascade.children_refreshed,
209
219
  }),
210
- ...(skipped.length > 0
211
- ? { lock_status: `${skipped.length} project(s) skipped (lock held): ${skipped.map((p) => p.path).join(', ')}` }
220
+ ...(incomplete.length > 0
221
+ ? { lock_status: `${incomplete.length} project(s) incomplete: ${incomplete.map((p) => `${p.path} (${p.outcome})`).join(', ')}` }
212
222
  : {}),
213
223
  cascade,
214
224
  };
@@ -0,0 +1,174 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import { loadConfig } from '../config.js';
7
+ import { writeFileAtomic } from '../io.js';
8
+ import { codeMapDir } from './paths.js';
9
+ import { inspectNestedProjects, refreshWorkspaceCascade } from './cascade.js';
10
+ export function summarizeCascadeRefreshJob(job) {
11
+ const { result, ...summary } = job;
12
+ if (!result)
13
+ return summary;
14
+ const projects = [result.root_result, ...result.children];
15
+ const outcomeCounts = {};
16
+ for (const project of projects)
17
+ outcomeCounts[project.outcome] = (outcomeCounts[project.outcome] ?? 0) + 1;
18
+ const problemProjects = projects
19
+ .filter((project) => project.outcome !== 'indexed')
20
+ .map((project) => ({ path: project.path, outcome: project.outcome, ...(project.reason ? { reason: project.reason } : {}), ...(project.error ? { error: project.error } : {}) }));
21
+ return { ...summary, outcome_counts: outcomeCounts, ...(problemProjects.length ? { problem_projects: problemProjects } : {}) };
22
+ }
23
+ function jobsDir(root) {
24
+ return path.join(codeMapDir(root), 'cascade-jobs');
25
+ }
26
+ function jobPath(root, jobId) {
27
+ return path.join(jobsDir(root), `${jobId}.json`);
28
+ }
29
+ function writeJob(job) {
30
+ const dir = jobsDir(job.root);
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ const target = jobPath(job.root, job.job_id);
33
+ writeFileAtomic(target, `${JSON.stringify(job, null, 2)}\n`);
34
+ }
35
+ export function readCascadeRefreshJob(root, jobId) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(jobPath(path.resolve(root), jobId), 'utf8'));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function latestCascadeRefreshJob(root) {
44
+ const dir = jobsDir(path.resolve(root));
45
+ try {
46
+ const jobs = fs.readdirSync(dir)
47
+ .filter((name) => name.endsWith('.json'))
48
+ .map((name) => {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ })
56
+ .filter((job) => job !== null)
57
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
58
+ return jobs[0] ?? null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function processAlive(pid) {
65
+ if (!pid)
66
+ return false;
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ export function startCascadeRefreshJob(root, scope) {
76
+ const resolvedRoot = path.resolve(root);
77
+ try {
78
+ if (loadConfig(resolvedRoot).project_mode !== 'multi-project')
79
+ return null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const previous = latestCascadeRefreshJob(resolvedRoot);
85
+ if (previous && (previous.status === 'queued' || previous.status === 'running') && processAlive(previous.pid)) {
86
+ return previous;
87
+ }
88
+ const now = new Date().toISOString();
89
+ const job = {
90
+ job_id: `cmj_${crypto.randomBytes(8).toString('hex')}`,
91
+ root: resolvedRoot,
92
+ scope,
93
+ status: 'queued',
94
+ created_at: now,
95
+ updated_at: now,
96
+ projects_total: inspectNestedProjects(resolvedRoot).projects.length + 1,
97
+ projects_completed: 0,
98
+ current_project: null,
99
+ };
100
+ writeJob(job);
101
+ const worker = fileURLToPath(new URL('./cascade-worker.js', import.meta.url));
102
+ const child = spawn(process.execPath, [worker, resolvedRoot, job.job_id, scope], {
103
+ cwd: resolvedRoot,
104
+ detached: true,
105
+ stdio: 'ignore',
106
+ windowsHide: true,
107
+ });
108
+ const markWorkerExit = (detail) => {
109
+ const current = readCascadeRefreshJob(resolvedRoot, job.job_id);
110
+ if (!current || current.status === 'completed' || current.status === 'failed')
111
+ return;
112
+ const completed = new Date().toISOString();
113
+ writeJob({
114
+ ...current,
115
+ status: 'failed',
116
+ error: detail,
117
+ current_project: null,
118
+ completed_at: completed,
119
+ updated_at: completed,
120
+ });
121
+ };
122
+ child.once('error', (error) => {
123
+ markWorkerExit(`cascade worker failed to start: ${error.message}`);
124
+ });
125
+ child.once('exit', (code, signal) => {
126
+ // The worker writes its terminal state synchronously before exiting. If no
127
+ // terminal state exists here, the launch/runtime failed outside its guard.
128
+ markWorkerExit(`cascade worker exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
129
+ });
130
+ child.unref();
131
+ const current = readCascadeRefreshJob(resolvedRoot, job.job_id) ?? job;
132
+ if (current.status === 'queued') {
133
+ current.pid = child.pid;
134
+ current.updated_at = new Date().toISOString();
135
+ writeJob(current);
136
+ }
137
+ return current;
138
+ }
139
+ /** Worker entry seam, exported for deterministic tests and the tiny worker file. */
140
+ export async function runCascadeRefreshJob(root, jobId, scope) {
141
+ const job = readCascadeRefreshJob(root, jobId);
142
+ if (!job)
143
+ throw new Error(`cascade job not found: ${jobId}`);
144
+ const started = new Date().toISOString();
145
+ writeJob({ ...job, status: 'running', pid: process.pid, started_at: started, updated_at: started });
146
+ try {
147
+ const result = await refreshWorkspaceCascade({
148
+ rootCwd: root,
149
+ scope,
150
+ onProgress: (progress) => {
151
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
152
+ writeJob({
153
+ ...current,
154
+ status: 'running',
155
+ pid: process.pid,
156
+ projects_total: progress.total,
157
+ projects_completed: progress.completed,
158
+ current_project: progress.current_project,
159
+ ...(progress.last_result ? { last_result: progress.last_result } : {}),
160
+ updated_at: new Date().toISOString(),
161
+ });
162
+ },
163
+ });
164
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
165
+ const completed = new Date().toISOString();
166
+ writeJob({ ...current, status: 'completed', result, current_project: null, projects_completed: current.projects_total, completed_at: completed, updated_at: completed });
167
+ }
168
+ catch (error) {
169
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
170
+ const completed = new Date().toISOString();
171
+ writeJob({ ...current, status: 'failed', error: error instanceof Error ? error.message : String(error), current_project: null, completed_at: completed, updated_at: completed });
172
+ }
173
+ }
174
+ //# sourceMappingURL=cascade-jobs.js.map
@@ -0,0 +1,15 @@
1
+ import { runCascadeRefreshJob } from './cascade-jobs.js';
2
+ const [root, jobId, rawScope] = process.argv.slice(2);
3
+ if (!root || !jobId)
4
+ process.exit(2);
5
+ const scope = rawScope === 'all' ? 'all' : 'changed';
6
+ try {
7
+ await runCascadeRefreshJob(root, jobId, scope);
8
+ // Tree-sitter/native handles can keep Node's event loop alive after the
9
+ // durable terminal record has been flushed. This process owns no other work.
10
+ process.exit(0);
11
+ }
12
+ catch {
13
+ process.exit(1);
14
+ }
15
+ //# sourceMappingURL=cascade-worker.js.map
@@ -20,7 +20,7 @@
20
20
  * refreshes existing brainclaw projects, it does not initialise new ones.
21
21
  */
22
22
  import path from 'node:path';
23
- import { scanNestedBrainclawProjects } from '../workspace-projects.js';
23
+ import { scanNestedBrainclawProjectsDetailed } from '../workspace-projects.js';
24
24
  import { loadConfig } from '../config.js';
25
25
  import { refresh as runRefresh } from './refresh.js';
26
26
  import { readManifest } from './store.js';
@@ -54,10 +54,14 @@ function projectIdFor(cwd, fallbackId) {
54
54
  * cascade and the `status --cascade` recap so both agree on the project set.
55
55
  */
56
56
  export function listNestedProjects(rootCwd) {
57
+ return inspectNestedProjects(rootCwd).projects;
58
+ }
59
+ export function inspectNestedProjects(rootCwd) {
57
60
  const root = path.resolve(rootCwd);
58
- return Array.from(new Set(scanNestedBrainclawProjects(root)
59
- .map((c) => path.resolve(c.path))
60
- .filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort();
61
+ const discovered = scanNestedBrainclawProjectsDetailed(root);
62
+ return { projects: Array.from(new Set(discovered.projects
63
+ .map((c) => path.resolve(c.path))
64
+ .filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort(), truncated: discovered.truncated };
61
65
  }
62
66
  /**
63
67
  * Refresh the whole multi-project workspace: every nested brainclaw project +
@@ -68,7 +72,8 @@ export async function refreshWorkspaceCascade(input) {
68
72
  const rootCwd = path.resolve(input.rootCwd);
69
73
  // Enumerate nested brainclaw projects strictly under the root (FS scan, so it
70
74
  // is strategy-agnostic). De-dup + sort by path for deterministic output.
71
- const childAbsPaths = listNestedProjects(rootCwd);
75
+ const discovery = inspectNestedProjects(rootCwd);
76
+ const childAbsPaths = discovery.projects;
72
77
  // Every project to refresh, root first.
73
78
  const allProjects = [rootCwd, ...childAbsPaths];
74
79
  const refreshOne = async (projectCwd, isRoot) => {
@@ -77,40 +82,72 @@ export async function refreshWorkspaceCascade(input) {
77
82
  const nestedUnder = allProjects.filter((p) => p !== projectCwd && isStrictlyUnder(p, projectCwd));
78
83
  const extraIgnorePatterns = nestedUnder.map((p) => `${toPosix(path.relative(projectCwd, p))}/**`);
79
84
  const projectId = projectIdFor(projectCwd);
80
- const result = await runRefresh({
81
- projectId,
82
- projectRoot: projectCwd,
83
- scope: input.scope,
84
- cwd: projectCwd,
85
- extraIgnorePatterns,
86
- ownerAgent: input.ownerAgent ?? null,
87
- ownerAgentId: input.ownerAgentId ?? null,
88
- });
89
- return {
90
- path: isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd)),
91
- project_id: projectId,
92
- is_root: isRoot,
93
- ran: result.ran,
94
- lock_acquired: result.lock_acquired,
95
- files_parsed: result.files_parsed,
96
- files_compacted: result.files_compacted,
97
- freshness: result.freshness.status,
98
- ...(result.lock_status ? { lock_status: result.lock_status } : {}),
99
- };
85
+ const projectPath = isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd));
86
+ try {
87
+ const result = await runRefresh({
88
+ projectId,
89
+ projectRoot: projectCwd,
90
+ scope: input.scope,
91
+ cwd: projectCwd,
92
+ extraIgnorePatterns,
93
+ ownerAgent: input.ownerAgent ?? null,
94
+ ownerAgentId: input.ownerAgentId ?? null,
95
+ });
96
+ const filesIndexed = readManifest(projectCwd)?.stats.files_indexed ?? 0;
97
+ const outcome = !result.lock_acquired
98
+ ? 'locked'
99
+ : filesIndexed === 0 ? 'no_eligible_files' : 'indexed';
100
+ return {
101
+ path: projectPath,
102
+ project_id: projectId,
103
+ is_root: isRoot,
104
+ ran: result.ran,
105
+ lock_acquired: result.lock_acquired,
106
+ files_parsed: result.files_parsed,
107
+ files_compacted: result.files_compacted,
108
+ files_indexed: filesIndexed,
109
+ freshness: result.freshness.status,
110
+ outcome,
111
+ ...(outcome === 'no_eligible_files' ? { reason: 'no eligible source files found' } : {}),
112
+ ...(result.lock_status ? { lock_status: result.lock_status, reason: result.lock_status } : {}),
113
+ };
114
+ }
115
+ catch (error) {
116
+ return {
117
+ path: projectPath,
118
+ project_id: projectId,
119
+ is_root: isRoot,
120
+ ran: false,
121
+ lock_acquired: false,
122
+ files_parsed: 0,
123
+ files_compacted: 0,
124
+ files_indexed: readManifest(projectCwd)?.stats.files_indexed ?? null,
125
+ freshness: 'partial',
126
+ outcome: 'failed',
127
+ reason: 'refresh_failed',
128
+ error: error instanceof Error ? error.message : String(error),
129
+ };
130
+ }
100
131
  };
101
132
  // Children first, then the root (sequential — each holds its own project lock
102
133
  // briefly; never blocks bclaw_work, rule 8).
103
134
  const children = [];
135
+ const total = childAbsPaths.length + 1;
136
+ input.onProgress?.({ completed: 0, total, current_project: childAbsPaths[0] ? toPosix(path.relative(rootCwd, childAbsPaths[0])) : '.' });
104
137
  for (const childCwd of childAbsPaths) {
105
- children.push(await refreshOne(childCwd, false));
138
+ const result = await refreshOne(childCwd, false);
139
+ children.push(result);
140
+ input.onProgress?.({ completed: children.length, total, current_project: children.length < childAbsPaths.length ? toPosix(path.relative(rootCwd, childAbsPaths[children.length])) : '.', last_result: result });
106
141
  }
107
142
  const rootResult = await refreshOne(rootCwd, true);
143
+ input.onProgress?.({ completed: total, total, current_project: null, last_result: rootResult });
108
144
  return {
109
145
  is_cascade: true,
110
146
  root: rootCwd,
111
147
  root_result: rootResult,
112
148
  children,
113
149
  children_refreshed: children.length,
150
+ discovery_truncated: discovery.truncated,
114
151
  };
115
152
  }
116
153
  //# sourceMappingURL=cascade.js.map
@@ -232,7 +232,7 @@ export function isTestPath(p) {
232
232
  /**
233
233
  * Score a symbol index entry against the query. Matching is separator/case
234
234
  * INSENSITIVE (pln#601): an exact NORMALIZED match scores highest, then prefix,
235
- * then substring, then the sub-token floor. Exported symbols + components/hooks
235
+ * then substring; sub-token-only candidates score zero. Exported symbols + components/hooks
236
236
  * get a small boost; test-file symbols are biased DOWN so a test helper never
237
237
  * outranks the real definition of the same name (the Fable-audit brief-noise
238
238
  * companion to the find defect). Exported for focused ranking tests.
@@ -250,7 +250,7 @@ export function scoreEntry(entry, query) {
250
250
  else if (name.includes(q))
251
251
  score += 3; // substring
252
252
  else
253
- score += 1; // matched only via a sub-token bucket
253
+ return 0; // shared-token noise is not a match
254
254
  score *= entry.score_hint; // exported (1.0) vs internal (0.8)
255
255
  if (entry.subtype === 'component' || entry.subtype === 'hook')
256
256
  score += 1;
@@ -367,6 +367,9 @@ export function findInStore(query, ctx, checker, acc) {
367
367
  const confident = validateEntry(entry, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
368
368
  if (!confident)
369
369
  continue;
370
+ const score = scoreEntry(entry, query);
371
+ if (score <= 0)
372
+ continue;
370
373
  ranked.push({
371
374
  match: {
372
375
  node_id: entry.node_id,
@@ -375,7 +378,7 @@ export function findInStore(query, ctx, checker, acc) {
375
378
  file_id: entry.file_id,
376
379
  kind: entry.kind,
377
380
  subtype: entry.subtype ?? null,
378
- score: scoreEntry(entry, query),
381
+ score,
379
382
  },
380
383
  centrality: importCentrality(entry, resolutionIndex),
381
384
  });
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import path from 'node:path';
17
17
  import { loadState, mutateState } from './state.js';
18
+ import { detectDuplicates } from './duplicates.js';
18
19
  import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
19
20
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
20
21
  import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
@@ -511,6 +512,19 @@ export function getEntity(name, idOrShortLabel, cwd) {
511
512
  // ─── CREATE ────────────────────────────────────────────────────────────
512
513
  export function createEntity(name, data, cwd) {
513
514
  assertKnownEntity(name, 'create');
515
+ const proximityKinds = new Set(['decision', 'constraint', 'trap']);
516
+ const nearby = proximityKinds.has(name) && typeof data.text === 'string'
517
+ ? detectDuplicates(data.text, name, loadState(cwd), listCandidates(undefined, cwd).filter((candidate) => candidate.status === 'pending')).slice(0, 3).map((match) => ({
518
+ id: match.id,
519
+ source: match.source,
520
+ reason: match.reason,
521
+ preview: match.text.length > 160 ? `${match.text.slice(0, 157)}…` : match.text,
522
+ }))
523
+ : [];
524
+ const result = (base) => ({
525
+ ...base,
526
+ ...(nearby.length ? { nearby_items: nearby } : {}),
527
+ });
514
528
  switch (name) {
515
529
  case 'plan': {
516
530
  // Explicit field whitelist + required-author check brings plan create in line
@@ -530,7 +544,7 @@ export function createEntity(name, data, cwd) {
530
544
  estimatedEffort: data.estimated_effort,
531
545
  }, cwd);
532
546
  stampProvenanceOnStateItem('plan', res.id, defaultProvenance(data), cwd);
533
- return { entity: name, id: res.id, short_label: res.shortLabel };
547
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
534
548
  }
535
549
  case 'decision': {
536
550
  const res = createDecision({
@@ -542,7 +556,7 @@ export function createEntity(name, data, cwd) {
542
556
  planId: data.plan_id,
543
557
  }, cwd);
544
558
  stampProvenanceOnStateItem('decision', res.id, defaultProvenance(data), cwd);
545
- return { entity: name, id: res.id, short_label: res.shortLabel };
559
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
546
560
  }
547
561
  case 'constraint': {
548
562
  const res = createConstraint({
@@ -553,7 +567,7 @@ export function createEntity(name, data, cwd) {
553
567
  relatedPaths: data.related_paths,
554
568
  }, cwd);
555
569
  stampProvenanceOnStateItem('constraint', res.id, defaultProvenance(data), cwd);
556
- return { entity: name, id: res.id, short_label: res.shortLabel };
570
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
557
571
  }
558
572
  case 'trap': {
559
573
  const res = createTrap({
@@ -564,7 +578,7 @@ export function createEntity(name, data, cwd) {
564
578
  relatedPaths: data.related_paths,
565
579
  }, cwd);
566
580
  stampProvenanceOnStateItem('trap', res.id, defaultProvenance(data), cwd);
567
- return { entity: name, id: res.id, short_label: res.shortLabel };
581
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
568
582
  }
569
583
  case 'runtime_note': {
570
584
  const id = generateId('runtime_note');
@@ -213,6 +213,12 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
213
213
  }
214
214
  return { ...invoke, args, bashCommand };
215
215
  }
216
+ /** Refuse a stdin-delivery invoke that would otherwise inherit `/dev/null`. */
217
+ export function assertPromptDelivery(invoke) {
218
+ if (invoke.promptDelivery === 'stdin_pipe' && !invoke.promptText?.trim()) {
219
+ throw new Error('Invalid stdin_pipe invocation: promptText is empty; refusing to spawn an agent with ignored stdin.');
220
+ }
221
+ }
216
222
  export class CliExecutionAdapter {
217
223
  id = 'cli';
218
224
  canSpawn(agentName) {
@@ -281,6 +287,7 @@ export class CliExecutionAdapter {
281
287
  };
282
288
  }
283
289
  start(invoke, options) {
290
+ assertPromptDelivery(invoke);
284
291
  const isWin32 = process.platform === 'win32';
285
292
  invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
286
293
  // F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
@@ -317,7 +324,7 @@ export class CliExecutionAdapter {
317
324
  }
318
325
  const spawnExecutable = resolvedExecutable ?? invoke.executable;
319
326
  const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
320
- const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
327
+ const needsStdin = invoke.promptDelivery === 'stdin_pipe';
321
328
  // pln#520 step 4: when we ack-wrap, the SHELL redirects stdout/stderr to the
322
329
  // per-assignment log files (fds passed via stdio are NOT inherited through
323
330
  // the cmd.exe → .cmd → node shim — the empty-logs bug of can_f792cacd), and
@@ -96,7 +96,15 @@ export async function checkAgentSpawn(agent, options = {}) {
96
96
  const stderrRaw = readLogTail(root, assignmentId, 'stderr', 800).trim();
97
97
  const stderrTail = stderrRaw ? stderrRaw.split(/\r?\n/).filter(Boolean) : undefined;
98
98
  if (completed) {
99
- return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
99
+ return {
100
+ agent,
101
+ binary,
102
+ status: 'ok',
103
+ delivered,
104
+ completed: true,
105
+ duration_ms,
106
+ detail: `validation probe: ack + completed round-trip (${invoke.promptDelivery}, ${invoke.promptText?.length ?? 0} prompt bytes)`,
107
+ };
100
108
  }
101
109
  if (failed) {
102
110
  const tail = stderrRaw || readLogTail(root, assignmentId, 'stdout', 400).trim();
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.28.2 on 2026-08-25T18:22:09.950Z
2
+ // Source: brainclaw v1.28.3 on 2026-08-25T23:07:33.877Z
3
3
  export const FACTS = {
4
- "version": "1.28.2",
5
- "generated_at": "2026-08-25T18:22:09.950Z",
4
+ "version": "1.28.3",
5
+ "generated_at": "2026-08-25T23:07:33.877Z",
6
6
  "tools": {
7
7
  "count": 70,
8
8
  "published_count": 68,
@@ -478,7 +478,7 @@ export const FACTS = {
478
478
  },
479
479
  "bench": {
480
480
  "schema": "brainclaw.bench.v1",
481
- "generated_at": "2026-08-25T18:22:07.761Z",
481
+ "generated_at": "2026-08-25T23:07:31.792Z",
482
482
  "node_version": "v24.19.0",
483
483
  "platform": "linux-x64",
484
484
  "repeats": 3,
@@ -487,7 +487,7 @@ export const FACTS = {
487
487
  "name": "cold_onboard",
488
488
  "volume": "empty",
489
489
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
490
- "duration_ms_median": 82,
490
+ "duration_ms_median": 79,
491
491
  "payload_chars_median": 1640,
492
492
  "payload_tokens_est_median": 410
493
493
  },
@@ -495,7 +495,7 @@ export const FACTS = {
495
495
  "name": "warm_work",
496
496
  "volume": "medium",
497
497
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
498
- "duration_ms_median": 132,
498
+ "duration_ms_median": 122,
499
499
  "payload_chars_median": 2626,
500
500
  "payload_tokens_est_median": 657
501
501
  },
@@ -503,7 +503,7 @@ export const FACTS = {
503
503
  "name": "first_edit",
504
504
  "volume": "medium",
505
505
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
506
- "duration_ms_median": 13,
506
+ "duration_ms_median": 11,
507
507
  "payload_chars_median": 1629,
508
508
  "payload_tokens_est_median": 407
509
509
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.28.2",
3
- "generated_at": "2026-08-25T18:22:09.950Z",
2
+ "version": "1.28.3",
3
+ "generated_at": "2026-08-25T23:07:33.877Z",
4
4
  "tools": {
5
5
  "count": 70,
6
6
  "published_count": 68,
@@ -476,7 +476,7 @@
476
476
  },
477
477
  "bench": {
478
478
  "schema": "brainclaw.bench.v1",
479
- "generated_at": "2026-08-25T18:22:07.761Z",
479
+ "generated_at": "2026-08-25T23:07:31.792Z",
480
480
  "node_version": "v24.19.0",
481
481
  "platform": "linux-x64",
482
482
  "repeats": 3,
@@ -485,7 +485,7 @@
485
485
  "name": "cold_onboard",
486
486
  "volume": "empty",
487
487
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
488
- "duration_ms_median": 82,
488
+ "duration_ms_median": 79,
489
489
  "payload_chars_median": 1640,
490
490
  "payload_tokens_est_median": 410
491
491
  },
@@ -493,7 +493,7 @@
493
493
  "name": "warm_work",
494
494
  "volume": "medium",
495
495
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
496
- "duration_ms_median": 132,
496
+ "duration_ms_median": 122,
497
497
  "payload_chars_median": 2626,
498
498
  "payload_tokens_est_median": 657
499
499
  },
@@ -501,7 +501,7 @@
501
501
  "name": "first_edit",
502
502
  "volume": "medium",
503
503
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
504
- "duration_ms_median": 13,
504
+ "duration_ms_median": 11,
505
505
  "payload_chars_median": 1629,
506
506
  "payload_tokens_est_median": 407
507
507
  }
package/docs/cli.md CHANGED
@@ -639,11 +639,11 @@ Full reference (freshness model, supported languages, WASM bundling): [docs/code
639
639
 
640
640
  ### `brainclaw code-map status [--cascade]`
641
641
 
642
- Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only. In a multi-project workspace, `--cascade` adds a per-child recap (which nested projects have a built index vs `missing_index`, plus an aggregate count).
642
+ Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only. In a multi-project workspace, `--cascade` adds compact coverage counts and names only non-fresh projects. On the MCP surface, the equivalent `bclaw_code_status(cascade=true)` also follows the latest durable cascade job.
643
643
 
644
644
  ### `brainclaw code-map refresh [--all|--changed] [--cascade]`
645
645
 
646
- Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock. In a multi-project workspace, `--cascade` refreshes **every nested project** into its own store plus a root store scoped to the files no child owns (zero double-indexing) one command at the root indexes the whole monorepo per-project. See [docs/code-map.md](code-map.md#cascading-a-multi-project-workspace---cascade).
646
+ Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock. In a multi-project workspace, `--cascade` synchronously refreshes each discovered project into its own store plus a root store scoped to files no child owns (zero double-indexing). The MCP equivalent starts a durable background job instead; follow it with `bclaw_code_status(cascade=true)`. See [docs/code-map.md](code-map.md#cascading-a-multi-project-workspace---cascade).
647
647
 
648
648
  ### `brainclaw code-map find <query> [--limit <n>]`
649
649
 
package/docs/code-map.md CHANGED
@@ -126,11 +126,11 @@ all return a `freshness_badge`:
126
126
 
127
127
  | Tool | Kind | Purpose |
128
128
  |---|---|---|
129
- | `bclaw_code_status` | read | Store presence, freshness badge, index stats. Never refreshes. |
129
+ | `bclaw_code_status` | read | Active-session project store, freshness, index stats; `cascade=true` also follows the latest cascade job. Never refreshes. |
130
130
  | `bclaw_code_find` | read | Ranked symbol-index search (`query`, optional `limit`). Never refreshes. |
131
131
  | `bclaw_code_brief` | read | Reading brief for a symbol/path (`target`, optional `limit`, files capped at 12). Never refreshes. |
132
132
  | `bclaw_code_export` | read | Bounded local subgraph around required `target`; direction/depth/node/edge caps, confidence filtering, and optional Mermaid projection. Never refreshes. |
133
- | `bclaw_code_refresh` | write | Rebuild the index. `scope` = `"changed"` (default) or `"all"`. Fails fast on a live lock. |
133
+ | `bclaw_code_refresh` | write | Rebuild the index. `scope` = `"changed"` (default) or `"all"`; MCP `cascade=true` starts a durable background job and returns immediately. |
134
134
 
135
135
  The read tools never trigger a parse — if `bclaw_code_status` /
136
136
  `bclaw_code_find` / `bclaw_code_brief` report `missing_index` or a stale badge,
@@ -178,7 +178,7 @@ No read command parses files or refreshes the index. `bclaw_work` can suggest
178
178
  that explicit refresh, but never performs it lazily.
179
179
  ## Lifecycle — pull-based, no daemon
180
180
 
181
- Code Map never runs in the background and never auto-reindexes. The model is lazy
181
+ Code Map never auto-reindexes and has no daemon. The model is lazy
182
182
  reconciliation at the read path:
183
183
 
184
184
  1. You edit or pull code — the index does not change.
@@ -186,7 +186,9 @@ reconciliation at the read path:
186
186
  file-hash diff vs the stored shards), so a stale index is always *visible*,
187
187
  never silently wrong.
188
188
  3. `refresh --changed` re-parses only the changed files (incremental); `--all` does
189
- a full rebuild + orphan compaction.
189
+ a full rebuild + orphan compaction. The one bounded background path is an
190
+ explicitly requested MCP monorepo cascade, whose durable progress is read
191
+ through `bclaw_code_status(cascade=true)`.
190
192
  4. `bclaw_work` nudges a refresh when the badge is `missing_index` or stale, so an
191
193
  agent knows to reconcile before trusting the map.
192
194
 
@@ -240,9 +242,15 @@ double-indexing**, even when projects nest inside one another. `--cascade` is
240
242
  opt-in; without it, the root refresh keeps its single-tree behaviour (above), and
241
243
  single-project repos ignore the flag entirely.
242
244
 
243
- `status --cascade` (or `bclaw_code_status(cascade=true)`) adds a per-child recap —
244
- which nested projects have a built index vs `missing_index`, plus an aggregate
245
- count so you can see workspace-wide freshness from the root.
245
+ The CLI cascade stays synchronous. MCP `bclaw_code_refresh(cascade=true)` returns
246
+ a durable `job_id` immediately, avoiding the client timeout that a large workspace
247
+ can hit; follow it with `bclaw_code_status(cascade=true)`. Status reports completed
248
+ and total project counts, the project currently being indexed, and terminal
249
+ outcomes. Successful rows are aggregated; only exceptions are named. A project
250
+ with a valid empty index is labeled `no_eligible_files`, while lock contention and
251
+ refresh failures remain distinct (`locked` / `failed`). `discovery_truncated=true`
252
+ warns that the bounded nested-project scan could not inspect deeper branches, so
253
+ the reported project total must not be treated as complete.
246
254
 
247
255
  ### Workspace-wide `find` / `brief`
248
256
 
@@ -250,8 +258,11 @@ Once the per-child indexes exist (built by `--cascade`), `find` and `brief` run
250
258
  at a multi-project workspace **root** automatically aggregate across every child
251
259
  project's store — no flag needed. Matches are project-tagged with
252
260
  workspace-relative paths, and the freshness badge merges per-store status (worst
253
- status wins) plus coverage (how many projects are indexed, listing any unindexed
254
- children). An aggregated `brief` also surfaces **cross-package reverse
261
+ status wins) plus coverage. Missing child stores make the top-line badge
262
+ `partial`, never `fresh`; diagnostics carry status counts and only the non-fresh
263
+ exceptions instead of repeating every project. Weak shared-token candidates that
264
+ do not contain the normalized query are omitted rather than returned as plausible
265
+ score-1/2 noise. An aggregated `brief` also surfaces **cross-package reverse
255
266
  dependents**: sibling packages that import the defining package's public name
256
267
  rank into the reading list, flagged `cross_package`.
257
268
 
@@ -108,13 +108,13 @@ Each tool also has an `annotations.category` field: `session`, `context`, `memor
108
108
  | `bclaw_remove` | memory | Archive or purge a canonical entity |
109
109
  | `bclaw_transition` | memory | Move an entity through its validated state machine |
110
110
  | `bclaw_move` | memory | Relocate an item to another project, id-preserving (multi-project) |
111
- | `bclaw_code_status` | discovery | Code Map freshness badge + index stats (store presence, files/nodes/edges) |
111
+ | `bclaw_code_status` | discovery | Active-session Code Map freshness + stats; `cascade:true` follows durable monorepo refresh progress and exceptions |
112
112
  | `bclaw_code_find` | discovery | Search the Code Map symbol index by name (function/class/component/hook/type) |
113
113
  | `bclaw_code_brief` | discovery | Ranked reading list + related decisions/traps before editing a symbol or path |
114
114
  | `bclaw_code_impact` | discovery | Explainable local blast radius from resolved imports: definition, direct dependents, opt-in bounded transitives, tests, and count-based risk |
115
115
  | `bclaw_code_export` | discovery | Compact bounded local nodes/edges around one symbol or file; preserves edge kind/source/confidence, with optional Mermaid projection |
116
116
  | `bclaw_code_outline` | discovery | Source-ordered symbols of one indexed file (span, exported, confidence) — no reparse |
117
- | `bclaw_code_refresh` | discovery | Rebuild the Code Map index (`scope: changed \| all`) |
117
+ | `bclaw_code_refresh` | discovery | Rebuild the Code Map index (`scope: changed \| all`); `cascade:true` starts a durable background job |
118
118
 
119
119
  See [code map](../code-map.md) for the full Code Map reference (CLI, freshness model, supported languages).
120
120
 
@@ -8,6 +8,36 @@ guarantees this changelog follows.
8
8
 
9
9
  ---
10
10
 
11
+ ## [1.28.3] — 2026-08-26
12
+
13
+ **Changed — durable Code Map cascade execution**
14
+
15
+ - `bclaw_code_refresh({ cascade: true })` now returns a durable `job_id`
16
+ immediately for multi-project workspaces instead of keeping the MCP request
17
+ open for the whole synchronous cascade.
18
+ - `bclaw_code_status({ cascade: true })` adds the latest job's lifecycle and
19
+ progress (`queued | running | completed | failed`, project counts and current
20
+ project), then a bounded terminal summary with outcome counts and problem
21
+ projects. Discovery truncation and `no_eligible_files`, `locked`, and `failed`
22
+ outcomes remain explicit.
23
+ - All Code Map MCP tools now resolve against the active session project selected
24
+ by `bclaw_work` / `bclaw_switch`; this corrects routing behavior without
25
+ changing their input schemas.
26
+
27
+ **Added — non-blocking proximity hints on canonical memory creation**
28
+
29
+ - Successful `bclaw_create` calls for decisions, constraints, and traps may add
30
+ `nearby_items` (at most three bounded previews with ids and match reasons).
31
+ The requested creation is never rejected solely because a nearby item exists.
32
+
33
+ **Changed — admission failures become pre-mutation**
34
+
35
+ - Unsupported true cross-project auto-execution and empty `stdin_pipe` prompt
36
+ delivery now fail before claims, assignments, loops, or worker processes are
37
+ created. Existing successful response shapes and input schemas are unchanged.
38
+
39
+ No tool was added, removed, or renamed in this release.
40
+
11
41
  ## [1.20.3] — 2026-08-03
12
42
 
13
43
  **Changed — `bclaw_dispatch_status` diagnosis values under the fs-activity veto (#170)**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.28.2",
3
+ "version": "1.28.3",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {