brainclaw 1.28.2 → 1.28.4

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 (49) hide show
  1. package/README.md +6 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/harvest.js +67 -25
  4. package/dist/commands/loops-handlers.js +28 -1
  5. package/dist/commands/mcp-catalog.js +25 -4
  6. package/dist/commands/mcp-read-handlers.js +15 -1
  7. package/dist/commands/mcp-schemas.generated.js +13 -0
  8. package/dist/commands/mcp-write-coordination.js +97 -26
  9. package/dist/commands/mcp-write-entities.js +5 -2
  10. package/dist/commands/mcp-write-memory.js +87 -1
  11. package/dist/commands/mcp.js +41 -9
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +17 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/context.js +16 -3
  19. package/dist/core/dispatch-status.js +36 -14
  20. package/dist/core/dispatcher.js +28 -20
  21. package/dist/core/entity-operations.js +80 -8
  22. package/dist/core/entity-registry.js +3 -3
  23. package/dist/core/execution-adapters.js +18 -1
  24. package/dist/core/facade-schema.js +10 -0
  25. package/dist/core/ideation-loop-close.js +3 -1
  26. package/dist/core/lane-result-file.js +72 -0
  27. package/dist/core/loop-turn-dispatch.js +2 -0
  28. package/dist/core/loops/brief-assembly.js +19 -11
  29. package/dist/core/loops/next-expected.js +56 -1
  30. package/dist/core/loops/reconcile-turn.js +8 -0
  31. package/dist/core/loops/result-reducers.js +14 -12
  32. package/dist/core/loops/store.js +4 -0
  33. package/dist/core/loops/types.js +14 -2
  34. package/dist/core/loops/verbs.js +8 -1
  35. package/dist/core/loops/worker-reply-contract.js +1 -1
  36. package/dist/core/protocol-tool-policy.js +1 -0
  37. package/dist/core/review-loop-turn-dispatch.js +1 -0
  38. package/dist/core/schema.js +24 -1
  39. package/dist/core/search.js +3 -2
  40. package/dist/core/spawn-check.js +9 -1
  41. package/dist/core/worktree.js +14 -7
  42. package/dist/facts.js +10 -9
  43. package/dist/facts.json +9 -8
  44. package/docs/cli.md +35 -2
  45. package/docs/code-map.md +20 -9
  46. package/docs/concepts/ideation-loop.md +35 -14
  47. package/docs/integrations/mcp.md +15 -5
  48. package/docs/mcp-schema-changelog.md +72 -6
  49. package/package.json +1 -1
@@ -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
  });
@@ -29,6 +29,19 @@ import { isTrapActive, listOperationalTraps } from './traps.js';
29
29
  import { buildEstimationReport } from '../commands/estimation-report.js';
30
30
  import { detectStaleness } from './staleness.js';
31
31
  export const CONTEXT_SCHEMA_VERSION = '1.2';
32
+ function verificationStatus(item) {
33
+ const verification = item.verification;
34
+ if (!verification)
35
+ return undefined;
36
+ if (verification.outcome === 'fail')
37
+ return 'verification:fail';
38
+ if (verification.max_age_days !== undefined) {
39
+ const ageMs = Date.now() - new Date(verification.verified_at).getTime();
40
+ if (Number.isFinite(ageMs) && ageMs > verification.max_age_days * 86_400_000)
41
+ return 'verification:stale';
42
+ }
43
+ return 'verification:pass';
44
+ }
32
45
  export function buildContext(options = {}) {
33
46
  const requestedCwd = options.cwd ?? process.cwd();
34
47
  const contextCwd = resolveContextStoreCwd(requestedCwd, options.target);
@@ -95,7 +108,7 @@ export function buildContext(options = {}) {
95
108
  related_paths: c.related_paths,
96
109
  score: 0,
97
110
  reasons: [],
98
- extra: c.status,
111
+ extra: [c.status, verificationStatus(c)].filter(Boolean).join(', '),
99
112
  plan_id: c.plan_id,
100
113
  provenance: {
101
114
  actor: c.author,
@@ -125,7 +138,7 @@ export function buildContext(options = {}) {
125
138
  related_paths: d.related_paths,
126
139
  score: 0,
127
140
  reasons: [],
128
- extra: d.related_paths?.join(', '),
141
+ extra: [d.related_paths?.join(', '), verificationStatus(d)].filter(Boolean).join(', ') || undefined,
129
142
  plan_id: d.plan_id,
130
143
  provenance: {
131
144
  actor: d.author,
@@ -155,7 +168,7 @@ export function buildContext(options = {}) {
155
168
  related_paths: t.related_paths,
156
169
  score: 0,
157
170
  reasons: [],
158
- extra: `${t.severity}, visibility:${t.visibility ?? 'shared'}`,
171
+ extra: [`${t.severity}, visibility:${t.visibility ?? 'shared'}, status:${t.status}`, verificationStatus(t)].filter(Boolean).join(', '),
159
172
  plan_id: t.plan_id,
160
173
  provenance: {
161
174
  actor: t.author,
@@ -28,9 +28,9 @@ import { loadClaim } from './claims.js';
28
28
  import { getLoop, listLoops } from './loops/store.js';
29
29
  import { isProcessAlive } from './agentrun-reconciler.js';
30
30
  import { findRuntimeNoteById } from './runtime.js';
31
- import { latestActivityMs, decodeOemAwareBuffer, getRuntimeLogPath, getRuntimeSignalPath } from './runtime-signals.js';
31
+ import { latestActivityMs, decodeOemAwareBuffer, getRuntimeLogPath, getRuntimeSignalPath, readCompletionSignals } from './runtime-signals.js';
32
32
  import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
33
- import { LaneResultSchema } from './schema.js';
33
+ import { resolveLaneResultFile } from './lane-result-file.js';
34
34
  const DEFAULT_TAIL = 20;
35
35
  const DEFAULT_STALL_MS = 5 * 60_000;
36
36
  const DEFAULT_BASE_REF = 'master';
@@ -291,6 +291,23 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
291
291
  : `Worker reported "${lr.status}". Read the LANE-RESULT summary + stderr; address the blocker or reroute.`,
292
292
  };
293
293
  }
294
+ if (runtime.terminal_signal) {
295
+ const signal = runtime.terminal_signal;
296
+ if (signal.status === 'contradictory') {
297
+ return {
298
+ health: 'unknown',
299
+ summary: 'both completed and failed terminal sentinels exist; outcome is contradictory and no terminal projection was inferred',
300
+ recommended_next_action: 'Inspect LANE-RESULT.json and both log files; preserve the worktree and reconcile the contradiction before retrying.',
301
+ };
302
+ }
303
+ return {
304
+ health: 'terminal',
305
+ summary: `worker wrapper emitted the canonical ${signal.status} terminal signal${agentRun && !TERMINAL_RUN_STATUSES.has(agentRun.status) ? ` (agent_run still ${agentRun.status})` : ''}`,
306
+ recommended_next_action: signal.status === 'completed'
307
+ ? 'Run bclaw_harvest (or `brainclaw harvest <assignment_id>`) to ingest LANE-RESULT and converge the Assignment/Claim.'
308
+ : 'Read stderr and LANE-RESULT if present, then replay or reroute the failed slot.',
309
+ };
310
+ }
294
311
  // pln#554 — git evidence is the #2 signal, ABOVE process sentinels and
295
312
  // administrative status: commits ahead of base with a clean tracked tree
296
313
  // means the worker delivered everything to the branch, even if its pid is
@@ -359,18 +376,11 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
359
376
  ?? 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
360
377
  };
361
378
  }
362
- if (runtime.pid_alive === true && stallAge > options.stallMs && fsActive) {
363
- return {
364
- health: 'healthy',
365
- summary: `agent_run alive (pid=${runtime.pid}); last_event_at stale (${Math.round(stallAge / 1000)}s) but filesystem active ${Math.round((fsAge ?? 0) / 1000)}s ago — working through a long op without a heartbeat`,
366
- recommended_next_action: 'No action — the worker is actively writing to logs/worktree. Re-check periodically until terminal.',
367
- };
368
- }
369
379
  if (runtime.pid_alive === true && stallAge > options.stallMs) {
370
380
  return {
371
381
  health: 'stalled',
372
- summary: `agent_run alive (pid=${runtime.pid}) but no activity for ${Math.round(stallAge / 1000)}s AND no filesystem writes${fsAge !== undefined ? ` (last fs ${Math.round(fsAge / 1000)}s ago)` : ' (no logs/worktree mtime)'}; last_event_at=${agentRun.last_event_at ?? '(never)'}`,
373
- recommended_next_action: 'Worker appears genuinely hung (no log/file writes). Tail stderr to confirm, then kill the pid and reroute.',
382
+ summary: `agent_run pid=${runtime.pid} is alive but no explicit progress/heartbeat arrived for ${Math.round(stallAge / 1000)}s${fsActive ? `; filesystem activity ${Math.round((fsAge ?? 0) / 1000)}s ago is context, not proof of worker progress` : ''}`,
383
+ recommended_next_action: 'Inspect stdout/stderr and the expected artifact path. If no phase artifact or progress heartbeat is advancing, replay/reroute the slot; do not treat PID or unrelated filesystem activity as health.',
374
384
  };
375
385
  }
376
386
  if (runtime.pid_alive === true) {
@@ -436,6 +446,9 @@ export function getDispatchStatus(options) {
436
446
  const stderrPath = assignmentId
437
447
  ? getRuntimeLogPath(projectRoot, assignmentId, 'stderr', runtimeRunId)
438
448
  : undefined;
449
+ const completionSignals = assignmentId
450
+ ? readCompletionSignals(projectRoot, assignmentId, runtimeRunId)
451
+ : {};
439
452
  // pln#527 — filesystem-activity age: max mtime across the captured logs + the
440
453
  // run's worktree files (skipping junctions). The truer liveness signal when
441
454
  // the heartbeat / last_event_at is stale during a long single operation.
@@ -460,8 +473,9 @@ export function getDispatchStatus(options) {
460
473
  let laneResult;
461
474
  let laneResultStale;
462
475
  if (worktreeForFs) {
463
- try {
464
- const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
476
+ const resolvedLaneResult = resolveLaneResultFile(worktreeForFs, assignmentId);
477
+ if (resolvedLaneResult.kind === 'found') {
478
+ const parsed = resolvedLaneResult.lane;
465
479
  if (parsed.assignment_id === assignmentId) {
466
480
  laneResult = { status: parsed.status, summary: parsed.summary };
467
481
  }
@@ -469,7 +483,6 @@ export function getDispatchStatus(options) {
469
483
  laneResultStale = { assignment_id: parsed.assignment_id, status: parsed.status, summary: parsed.summary };
470
484
  }
471
485
  }
472
- catch { /* no / invalid LANE-RESULT.json */ }
473
486
  }
474
487
  // pln#554 — worktree git evidence (commits ahead of base + dirty tracked files).
475
488
  const evidence = gitEvidence(worktreeForFs, options.base_ref ?? DEFAULT_BASE_REF);
@@ -480,6 +493,15 @@ export function getDispatchStatus(options) {
480
493
  exists: ackPath ? fs.existsSync(ackPath) : false,
481
494
  path: ackPath,
482
495
  },
496
+ ...(assignmentId && (completionSignals.completed || completionSignals.failed) ? {
497
+ terminal_signal: {
498
+ status: completionSignals.completed && completionSignals.failed
499
+ ? 'contradictory'
500
+ : completionSignals.completed ? 'completed' : 'failed',
501
+ completed_path: getRuntimeSignalPath(projectRoot, assignmentId, 'completed', runtimeRunId),
502
+ failed_path: getRuntimeSignalPath(projectRoot, assignmentId, 'failed', runtimeRunId),
503
+ },
504
+ } : {}),
483
505
  log_files: {
484
506
  stdout: stdoutPath ? readLogTail(stdoutPath, tailLines) : undefined,
485
507
  stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,