brainclaw 1.28.3 → 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 (40) 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 +23 -2
  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 +84 -22
  9. package/dist/commands/mcp-write-memory.js +87 -1
  10. package/dist/commands/mcp.js +16 -2
  11. package/dist/core/context.js +16 -3
  12. package/dist/core/dispatch-status.js +36 -14
  13. package/dist/core/dispatcher.js +28 -20
  14. package/dist/core/entity-operations.js +62 -4
  15. package/dist/core/entity-registry.js +3 -3
  16. package/dist/core/execution-adapters.js +10 -0
  17. package/dist/core/facade-schema.js +10 -0
  18. package/dist/core/ideation-loop-close.js +3 -1
  19. package/dist/core/lane-result-file.js +72 -0
  20. package/dist/core/loop-turn-dispatch.js +2 -0
  21. package/dist/core/loops/brief-assembly.js +19 -11
  22. package/dist/core/loops/next-expected.js +56 -1
  23. package/dist/core/loops/reconcile-turn.js +8 -0
  24. package/dist/core/loops/result-reducers.js +14 -12
  25. package/dist/core/loops/store.js +4 -0
  26. package/dist/core/loops/types.js +14 -2
  27. package/dist/core/loops/verbs.js +8 -1
  28. package/dist/core/loops/worker-reply-contract.js +1 -1
  29. package/dist/core/protocol-tool-policy.js +1 -0
  30. package/dist/core/review-loop-turn-dispatch.js +1 -0
  31. package/dist/core/schema.js +24 -1
  32. package/dist/core/search.js +3 -2
  33. package/dist/core/worktree.js +14 -7
  34. package/dist/facts.js +9 -8
  35. package/dist/facts.json +8 -7
  36. package/docs/cli.md +33 -0
  37. package/docs/concepts/ideation-loop.md +35 -14
  38. package/docs/integrations/mcp.md +13 -3
  39. package/docs/mcp-schema-changelog.md +42 -6
  40. package/package.json +1 -1
@@ -20,7 +20,8 @@ import { deleteMemoryItem, updateMemoryItem } from '../core/operations/memory-mu
20
20
  import { assessMemoryPressure, buildCompactionTemplate, applyCompaction } from '../core/gc-semantic.js';
21
21
  import { createRuntimeNote } from './runtime-note.js';
22
22
  import { createCandidateFromInput } from './reflect.js';
23
- import { harvestCandidates } from './harvest.js';
23
+ import { harvestCandidates, harvestLaneResults, integrateLaneResults } from './harvest.js';
24
+ import { dispatchReviewLoopTurn } from '../core/review-loop-turn-dispatch.js';
24
25
  import { ensureTrust, scanMcpWriteText, appendSecurityWarnings } from './mcp-write-support.js';
25
26
  import { toolResponse, createToolErrorResponse, } from './mcp-contract.js';
26
27
  function scoreKeywordMatches(text, patterns) {
@@ -448,4 +449,89 @@ export function handleBclawHarvestCandidates(payload, _ctx) {
448
449
  }),
449
450
  };
450
451
  }
452
+ /** MCP parity for the CLI lane-result harvest path (distinct from candidates). */
453
+ export async function handleBclawHarvestLane(payload) {
454
+ const { args, cwd, connectionSessionId } = payload;
455
+ const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
456
+ if (resolved.error) {
457
+ return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
458
+ }
459
+ const assignmentId = typeof args.assignmentId === 'string' ? args.assignmentId : undefined;
460
+ const all = args.all === true;
461
+ if (!assignmentId && !all) {
462
+ return { response: createToolErrorResponse('validation_error', 'Provide assignmentId, or set all=true to scan every managed lane.') };
463
+ }
464
+ if (assignmentId && all) {
465
+ return { response: createToolErrorResponse('validation_error', 'assignmentId and all=true are mutually exclusive.') };
466
+ }
467
+ const worktreePaths = Array.isArray(args.worktreePaths) ? args.worktreePaths.filter((value) => typeof value === 'string') : undefined;
468
+ const dryRun = args.dryRun === true;
469
+ const actor = resolved.identity.agent_name;
470
+ if (args.integrate === true) {
471
+ const integrated = integrateLaneResults({ assignmentId, worktreePaths, dryRun, cwd, agent: actor });
472
+ const dispatchedTurns = [];
473
+ if (!dryRun) {
474
+ for (const next of integrated.next_turns) {
475
+ const dispatched = await dispatchReviewLoopTurn({
476
+ loopId: next.loop_id,
477
+ slot: { slot_id: next.slot_id, role: next.role, agent: next.agent, agent_id: next.agent_id },
478
+ phase: next.phase,
479
+ task: next.task,
480
+ dispatcherAgent: actor,
481
+ dispatcherAgentId: resolved.identity.agent_id,
482
+ cwd,
483
+ });
484
+ dispatchedTurns.push({
485
+ loop_id: next.loop_id,
486
+ agent: next.agent,
487
+ iteration: next.iteration,
488
+ execution_status: dispatched.execution_status,
489
+ error: dispatched.error,
490
+ });
491
+ }
492
+ }
493
+ return {
494
+ response: toolResponse({
495
+ content: [{ type: 'text', text: `✔ Lane integrate${dryRun ? ' (dry-run)' : ''}: ${integrated.integrated.length} integrated, ${dispatchedTurns.length} re-dispatched, ${integrated.errors.length} error(s).` }],
496
+ ...integrated,
497
+ dispatched_turns: dispatchedTurns,
498
+ dry_run: dryRun,
499
+ }),
500
+ };
501
+ }
502
+ const harvested = harvestLaneResults({ assignmentId, worktreePaths, dryRun, cwd, agent: actor });
503
+ const continuationActions = harvested.continuations.flatMap((continuation) => {
504
+ const next = continuation.next_expected;
505
+ if (!next)
506
+ return [];
507
+ if (next.action === 'turn' && next.slot_id) {
508
+ return [{
509
+ tool: 'bclaw_loop',
510
+ args: { intent: 'turn', loop_id: continuation.loop_id, slot_id: next.slot_id, dispatch: true },
511
+ when: next.reason ?? 'dispatch the next sequential loop participant',
512
+ }];
513
+ }
514
+ if (next.action === 'advance') {
515
+ return [{ tool: 'bclaw_loop', args: { intent: 'advance', loop_id: continuation.loop_id }, when: 'the current phase gate is satisfied' }];
516
+ }
517
+ return [{ tool: 'bclaw_loop', args: { intent: 'get', loop_id: continuation.loop_id }, when: next.reason ?? `inspect the expected ${next.action} action` }];
518
+ });
519
+ return {
520
+ response: toolResponse({
521
+ content: [{ type: 'text', text: `✔ Lane harvest${dryRun ? ' (dry-run)' : ''}: ${harvested.harvested.length} harvested, ${harvested.skipped.length} skipped, ${harvested.errors.length} error(s), ${harvested.warnings.length} warning(s).` }],
522
+ structuredContent: {
523
+ harvested: harvested.harvested,
524
+ skipped: harvested.skipped,
525
+ errors: harvested.errors,
526
+ warnings: harvested.warnings,
527
+ continuations: harvested.continuations,
528
+ dry_run: dryRun,
529
+ next_actions: [
530
+ ...continuationActions,
531
+ ...harvested.warnings.flatMap((warning) => warning.next_actions ?? []),
532
+ ],
533
+ },
534
+ }),
535
+ };
536
+ }
451
537
  //# sourceMappingURL=mcp-write-memory.js.map
@@ -48,7 +48,7 @@ import { handleBclawClaim, handleBclawReleaseClaim, handleBclawSessionStart, han
48
48
  // Sequence write handlers extracted in pln#622 PR4.
49
49
  import { handleBclawCreateSequence, handleBclawUpdateSequence, handleBclawDeleteSequence, } from './mcp-write-sequences.js';
50
50
  // Memory write handlers extracted in pln#622 PR4.
51
- import { handleBclawWriteNote, handleBclawQuickCapture, handleBclawCompact, handleBclawDeleteMemory, handleBclawUpdateMemory, handleBclawHarvestCandidates, } from './mcp-write-memory.js';
51
+ import { handleBclawWriteNote, handleBclawQuickCapture, handleBclawCompact, handleBclawDeleteMemory, handleBclawUpdateMemory, handleBclawHarvestCandidates, handleBclawHarvestLane, } from './mcp-write-memory.js';
52
52
  // Admin / provisioning write handlers extracted in pln#622 PR4.
53
53
  import { handleBclawSetup, handleBclawInitProject, handleBclawAddCapability, handleBclawAddTool, } from './mcp-write-admin.js';
54
54
  // Entity write handlers extracted in pln#622 PR4.
@@ -1736,6 +1736,9 @@ async function _executeMcpToolCallInner(payload) {
1736
1736
  if (name === 'bclaw_harvest_candidates') {
1737
1737
  return handleBclawHarvestCandidates(payload, writeMemoryCtx);
1738
1738
  }
1739
+ if (name === 'bclaw_harvest') {
1740
+ return await handleBclawHarvestLane(payload);
1741
+ }
1739
1742
  // ── Canonical CRUD verbs (Phase 3 slice 3b) ──────────────────────
1740
1743
  //
1741
1744
  // Thin wrappers around src/core/entity-operations.ts. Behind
@@ -1835,6 +1838,17 @@ async function _executeMcpToolCallInner(payload) {
1835
1838
  };
1836
1839
  }
1837
1840
  const result = listEntities(entity, targetCwd, filter);
1841
+ const requestedFields = Array.isArray(args.fields)
1842
+ ? args.fields.filter((field) => typeof field === 'string' && field.length > 0)
1843
+ : [];
1844
+ if (requestedFields.length > 0) {
1845
+ result.items = result.items.map((item) => {
1846
+ if (!item || typeof item !== 'object')
1847
+ return item;
1848
+ const row = item;
1849
+ return Object.fromEntries(requestedFields.filter((field) => row[field] !== undefined).map((field) => [field, row[field]]));
1850
+ });
1851
+ }
1838
1852
  // pln#491 — bound the payload (count is already capped by applyPaging;
1839
1853
  // this caps SIZE) so a verbose result set never overflows the MCP token
1840
1854
  // cap and silently pushes the agent to the CLI (trp#449). Advertises
@@ -1850,7 +1864,7 @@ async function _executeMcpToolCallInner(payload) {
1850
1864
  { tool: 'bclaw_get', args: { entity, id: '<id from items>', ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to read one item in full' },
1851
1865
  ];
1852
1866
  if (bounded.has_more) {
1853
- nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset }, ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to fetch the next page' });
1867
+ nextActions.push({ tool: 'bclaw_find', args: { entity, filter: { ...filter, offset: bounded.next_offset }, ...(requestedFields.length ? { fields: requestedFields } : {}), ...(args.project ? { project: args.project } : {}), ...(args.budget_tokens ? { budget_tokens: args.budget_tokens } : {}) }, when: 'to fetch the next page' });
1854
1868
  }
1855
1869
  // structuredContent is the canonical MCP return channel that clients
1856
1870
  // (VS Code extension, Codex, etc.) read for machine-parseable data.
@@ -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,
@@ -37,7 +37,7 @@ import { buildClaimEnvPrefix } from './execution-profile.js';
37
37
  import { getActiveSequence, listSequences } from './sequence.js';
38
38
  import { loadState, persistState } from './state.js';
39
39
  import { listClaims, createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment, assessClaimLiveness } from './claims.js';
40
- import { sanitizeBranchComponent, isBranchMergedByContent, probeLocalBranch, isGitRepo } from './worktree.js';
40
+ import { sanitizeBranchComponent, isBranchMergedByContent, probeLocalBranch, isGitRepo, detectWorkspaceNodeModules } from './worktree.js';
41
41
  import { listAgentIdentities, ensureAgentRegisteredForDispatch } from './agent-registry.js';
42
42
  import { sendMessage, hasActiveAssignment } from './messaging.js';
43
43
  import { memoryDir } from './io.js';
@@ -328,7 +328,7 @@ export function buildWorkingDefaultsSection(opts) {
328
328
  '',
329
329
  ].join('\n');
330
330
  }
331
- export function laneResultShape(assignmentId, contractRef, fence) {
331
+ export function laneResultShape(assignmentId, contractRef, fence, artifactType) {
332
332
  const asgn = assignmentId ?? '<assignment_id>';
333
333
  const generation = fence
334
334
  ? `,"turn_id":"${fence.turn_id}","run_id":"${fence.run_id}","nonce":"${fence.nonce}","attempt_epoch":${fence.attempt_epoch},"workspace_digest":"${fence.workspace_digest}"`
@@ -336,10 +336,11 @@ export function laneResultShape(assignmentId, contractRef, fence) {
336
336
  const contract = contractRef
337
337
  ? `,"execution_contract_hash":"${contractRef.hash}","capability_snapshot_hash":"${contractRef.snapshot_hash}"`
338
338
  : '';
339
- return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
339
+ const artifact = artifactType ? `,"artifact_type":"${artifactType}"` : '';
340
+ return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>"${artifact},"body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["<ref>",{"type":"file|commit|artifact","ref":"<path-or-id>","description":"<optional>"}]}`;
340
341
  }
341
342
  export function buildTransportSection(opts) {
342
- const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence)}`;
343
+ const laneResult = `write the terminal envelope at the worktree ROOT with the exact filename LANE-RESULT.json: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence, opts.artifactType)}. The filename is protocol-significant: do not translate it, replace the hyphen, add a suffix, or place it in a subdirectory. The artifacts array accepts either string refs or {type,ref,description} objects; for a loop turn, artifact_type is required exactly as shown`;
343
344
  const acceptance = opts.executionContractRef
344
345
  ? [
345
346
  `Execution contract: ${opts.executionContractRef.hash}`,
@@ -408,8 +409,8 @@ export function buildProtocolSection(options) {
408
409
  if (options?.worktreePath) {
409
410
  parts.push(`Worktree: ${options.worktreePath}`);
410
411
  // pln#523 / trp_37b05a15: tell the worker how dependencies are provisioned so
411
- // it does not stall trying to (re)install them. The authoritative record is
412
- // the worktree's `.brainclaw-worktree.json` `deps_mode` (absent `link`).
412
+ // it does not stall trying to (re)install them. The sidecar records intent,
413
+ // but the brief only claims availability after checking the worktree itself.
413
414
  // - link (default): node_modules (incl. monorepo per-package) is
414
415
  // junction-linked from the main repo — build/typecheck directly; do NOT
415
416
  // `npm install`. An out-of-root symlink, so `next dev`/Turbopack rejects
@@ -417,28 +418,34 @@ export function buildProtocolSection(options) {
417
418
  // - install/copy: node_modules is a REAL in-root directory — everything,
418
419
  // including a dev server, works directly; no reinstall needed.
419
420
  // - none: no deps provisioned — run the project's install first.
420
- let depsMode = 'link';
421
+ let depsMode;
421
422
  let depsProvisioned;
423
+ let recordedPaths = [];
422
424
  try {
423
425
  const sidecar = JSON.parse(fs.readFileSync(path.join(options.worktreePath, '.brainclaw-worktree.json'), 'utf-8'));
424
- if (sidecar.deps_mode)
425
- depsMode = sidecar.deps_mode;
426
+ depsMode = sidecar.deps_mode;
426
427
  depsProvisioned = sidecar.deps_provisioned;
428
+ recordedPaths = Array.isArray(sidecar.deps_paths) ? sidecar.deps_paths : [];
427
429
  }
428
- catch { /* sidecar absent/unreadable — assume the default `link` */ }
429
- if ((depsMode === 'install' || depsMode === 'copy') && depsProvisioned === false) {
430
- // Codex review P1: provisioning was ATTEMPTED but FAILED (best-effort, non-fatal).
431
- // Do not claim node_modules is usable — tell the worker to install it.
432
- parts.push(`Dependencies: in-root provisioning was attempted (deps_mode=${depsMode}) but FAILED — node_modules may be missing or incomplete. Run the project's install (npm/pnpm/yarn/bun) in the worktree before building; see .brainclaw-worktree.json symlink_warnings for the failure.`);
430
+ catch { /* sidecar absent/unreadable — do not infer provisioning */ }
431
+ const detectedPaths = detectWorkspaceNodeModules(options.worktreePath)
432
+ .filter((relativePath) => fs.existsSync(path.join(options.worktreePath, relativePath)));
433
+ const verifiedPaths = [...new Set([...recordedPaths, ...detectedPaths])]
434
+ .filter((relativePath) => fs.existsSync(path.join(options.worktreePath, relativePath)));
435
+ if (verifiedPaths.length > 0 && (depsMode === 'install' || depsMode === 'copy')) {
436
+ parts.push(`Dependencies: verified in-root node_modules at ${verifiedPaths.join(', ')} (deps_mode=${depsMode}). Use the repository's local scripts/binaries; do NOT reinstall.`);
433
437
  }
434
- else if (depsMode === 'install' || depsMode === 'copy') {
435
- parts.push(`Dependencies: node_modules is a real in-root directory (deps_mode=${depsMode}) build, typecheck, and dev server all work directly; do NOT reinstall. If anything is missing, see .brainclaw-worktree.json symlink_warnings.`);
438
+ else if (verifiedPaths.length > 0 && depsMode === 'link') {
439
+ parts.push(`Dependencies: verified node_modules at ${verifiedPaths.join(', ')} (deps_mode=link). Use the repository's local scripts/binaries; do NOT reinstall. These may be out-of-root links, so next dev/Turbopack can require deps_mode=install.`);
436
440
  }
437
441
  else if (depsMode === 'none') {
438
- parts.push('Dependencies: none were provisioned (deps_mode=none) run the project\'s install (npm/pnpm/yarn/bun) in the worktree before building.');
442
+ parts.push('Dependencies: none were provisioned (deps_mode=none). Do not invoke npx or any network-backed install unless the task explicitly authorizes it; report local validation as blocked when required tools are unavailable.');
439
443
  }
440
444
  else {
441
- parts.push('Dependencies: node_modules is linked from the main repo (incl. monorepo per-package). Build/typecheck directly; if deps are missing, do NOT npm install here — see .brainclaw-worktree.json symlink_warnings and validate centrally. (Out-of-root symlink: next dev/Turbopack needs deps_mode=install.)');
445
+ const attempted = depsProvisioned === false && depsMode
446
+ ? ` Provisioning was attempted with deps_mode=${depsMode} but did not produce a usable node_modules tree.`
447
+ : '';
448
+ parts.push(`Dependencies: no node_modules directory was verified in this worktree.${attempted} Do not invoke npx or any network-backed install unless the task explicitly authorizes it; report local validation as blocked when required tools are unavailable. See .brainclaw-worktree.json symlink_warnings when present.`);
442
449
  }
443
450
  }
444
451
  parts.push('');
@@ -711,8 +718,8 @@ export function buildContextEnvelopeSection(cwd) {
711
718
  }
712
719
  const newestFirst = (items) => [...items].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || (a.id ?? '').localeCompare(b.id ?? ''));
713
720
  const clip = (text) => text.length > CONTEXT_ENVELOPE_ITEM_MAX_CHARS ? `${text.slice(0, CONTEXT_ENVELOPE_ITEM_MAX_CHARS - 1)}…` : text;
714
- const constraints = newestFirst(state.active_constraints ?? []);
715
- const traps = newestFirst(state.known_traps ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
721
+ const constraints = newestFirst((state.active_constraints ?? []).filter((item) => item.status === 'active'));
722
+ const traps = newestFirst((state.known_traps ?? []).filter((item) => item.status === 'active')).slice(0, CONTEXT_ENVELOPE_TOP_K);
716
723
  const decisions = newestFirst(state.recent_decisions ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
717
724
  if (constraints.length === 0 && traps.length === 0 && decisions.length === 0)
718
725
  return '';
@@ -777,6 +784,7 @@ export function generateDispatchBrief(options) {
777
784
  worktreePath: options.worktreePath,
778
785
  assignmentId: options.assignmentId,
779
786
  attemptFence: options.attemptFence,
787
+ artifactType: options.artifactType,
780
788
  }));
781
789
  }
782
790
  // pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
@@ -21,7 +21,7 @@ import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks,
21
21
  import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
22
22
  import { listActionRequired } from './actions.js';
23
23
  import { listAgentIdentities } from './agent-registry.js';
24
- import { getCapabilityProfile, getSpawnableAgents } from './agent-capability.js';
24
+ import { getCapabilityProfile, getSpawnableAgents, validateAgentForDispatch } from './agent-capability.js';
25
25
  import { buildReputationSnapshot, toPublicReputationSummary } from './reputation.js';
26
26
  import { loadAllSessions } from './identity.js';
27
27
  import { loadInstructions } from './instructions.js';
@@ -40,7 +40,7 @@ import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
40
40
  import { ENTITY_NAMES, ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
41
41
  import { generateId } from './ids.js';
42
42
  import { mergeHandoffReview } from './handoff-review.js';
43
- import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, HandoffContractSchema, HandoffReviewSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, } from './schema.js';
43
+ import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, HandoffContractSchema, HandoffReviewSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, MemoryVerificationSchema, } from './schema.js';
44
44
  /**
45
45
  * Default provenance stamp applied on create when the caller does not
46
46
  * supply one. `user` kind with whatever author is in the payload; the
@@ -272,7 +272,14 @@ export function listEntities(name, cwd, filter = {}) {
272
272
  : fieldFiltered.filter((item) => isLegacyProvenance(item)).length;
273
273
  const excludedLowConfidenceAutoReflect = fieldFiltered.filter((item) => isLowConfidenceAutoReflect(item, filter)).length;
274
274
  const filtered = fieldFiltered.filter((item) => passesProvenanceFilter(item, filter));
275
- const paged = applyPaging(filtered, filter);
275
+ const newestFirst = [...filtered].sort((a, b) => {
276
+ const left = a;
277
+ const right = b;
278
+ const leftDate = String(left.updated_at ?? left.created_at ?? '');
279
+ const rightDate = String(right.updated_at ?? right.created_at ?? '');
280
+ return rightDate.localeCompare(leftDate) || String(left.id ?? '').localeCompare(String(right.id ?? ''));
281
+ });
282
+ const paged = applyPaging(newestFirst, filter);
276
283
  return {
277
284
  entity: name,
278
285
  total: filtered.length,
@@ -301,6 +308,16 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
301
308
  items = items.slice(0, items.length - drop);
302
309
  omittedForSize = result.items.length - items.length;
303
310
  }
311
+ let oversizedItemProjected = false;
312
+ if (items.length === 1 && JSON.stringify(items).length > charBudget) {
313
+ const item = items[0];
314
+ if (item && typeof item === 'object') {
315
+ const row = item;
316
+ const compactKeys = ['id', 'short_label', 'status', 'created_at', 'updated_at', 'agent', 'scope', 'plan_id'];
317
+ items = [Object.fromEntries(compactKeys.filter((key) => row[key] !== undefined).map((key) => [key, row[key]]))];
318
+ oversizedItemProjected = true;
319
+ }
320
+ }
304
321
  const returned = items.length;
305
322
  const hasMore = offset + returned < result.total;
306
323
  const bounded = {
@@ -309,6 +326,7 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
309
326
  returned,
310
327
  has_more: hasMore,
311
328
  ...(omittedForSize > 0 ? { omitted_for_size: omittedForSize } : {}),
329
+ ...(oversizedItemProjected ? { oversized_item_projected: true } : {}),
312
330
  };
313
331
  if (hasMore) {
314
332
  bounded.next_offset = offset + returned;
@@ -316,6 +334,9 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
316
334
  ? `Payload size-bounded: returned ${returned} of ${result.total} ${result.entity} item(s). Fetch more with filter.offset=${bounded.next_offset}, or narrow the filter (status/tag/author).`
317
335
  : `Returned ${returned} of ${result.total} ${result.entity} item(s). Page with filter.offset=${bounded.next_offset}, or narrow the filter.`;
318
336
  }
337
+ else if (oversizedItemProjected) {
338
+ bounded.hint = 'The matching item exceeded budget_tokens and was projected to identity/status fields. Use bclaw_get for the full item or request explicit bclaw_find fields.';
339
+ }
319
340
  return bounded;
320
341
  }
321
342
  /**
@@ -388,6 +409,11 @@ function loadAgentsForRead(cwd, filter) {
388
409
  : undefined;
389
410
  const project = (doc) => {
390
411
  const row = projectAgentForRead(doc);
412
+ const availability = validateAgentForDispatch(doc.agent_name, { requireSpawnable: true });
413
+ row.declared_spawnable = getCapabilityProfile(doc.agent_name)?.runtime.canBeSpawnedCli ?? false;
414
+ row.executable_now = availability.valid;
415
+ row.availability_code = availability.code;
416
+ row.availability_reason = availability.reason;
391
417
  if (reputationById)
392
418
  row.reputation = reputationById.get(String(row.id));
393
419
  return row;
@@ -408,7 +434,13 @@ function loadAgentsForRead(cwd, filter) {
408
434
  existing.dispatchable = true;
409
435
  continue;
410
436
  }
411
- byName.set(name, projectCatalogAgentForRead(name));
437
+ const row = projectCatalogAgentForRead(name);
438
+ const availability = validateAgentForDispatch(name, { requireSpawnable: true });
439
+ row.declared_spawnable = true;
440
+ row.executable_now = availability.valid;
441
+ row.availability_code = availability.code;
442
+ row.availability_reason = availability.reason;
443
+ byName.set(name, row);
412
444
  }
413
445
  return [...byName.values()];
414
446
  }
@@ -556,6 +588,7 @@ export function createEntity(name, data, cwd) {
556
588
  planId: data.plan_id,
557
589
  }, cwd);
558
590
  stampProvenanceOnStateItem('decision', res.id, defaultProvenance(data), cwd);
591
+ stampMemoryVerification('decision', res.id, data, cwd);
559
592
  return result({ entity: name, id: res.id, short_label: res.shortLabel });
560
593
  }
561
594
  case 'constraint': {
@@ -567,6 +600,7 @@ export function createEntity(name, data, cwd) {
567
600
  relatedPaths: data.related_paths,
568
601
  }, cwd);
569
602
  stampProvenanceOnStateItem('constraint', res.id, defaultProvenance(data), cwd);
603
+ stampMemoryVerification('constraint', res.id, data, cwd);
570
604
  return result({ entity: name, id: res.id, short_label: res.shortLabel });
571
605
  }
572
606
  case 'trap': {
@@ -578,6 +612,7 @@ export function createEntity(name, data, cwd) {
578
612
  relatedPaths: data.related_paths,
579
613
  }, cwd);
580
614
  stampProvenanceOnStateItem('trap', res.id, defaultProvenance(data), cwd);
615
+ stampMemoryVerification('trap', res.id, data, cwd);
581
616
  return result({ entity: name, id: res.id, short_label: res.shortLabel });
582
617
  }
583
618
  case 'runtime_note': {
@@ -1104,6 +1139,29 @@ function stampProvenanceOnStateItem(name, id, provenance, cwd) {
1104
1139
  item.provenance = provenance;
1105
1140
  }, cwd);
1106
1141
  }
1142
+ function stampMemoryVerification(name, id, data, cwd) {
1143
+ const verification = data.verification === undefined
1144
+ ? undefined
1145
+ : MemoryVerificationSchema.parse(data.verification);
1146
+ const verifiedAt = typeof data.verified_at === 'string' ? data.verified_at : verification?.verified_at;
1147
+ const verifyCmd = typeof data.verify_cmd === 'string' ? data.verify_cmd : undefined;
1148
+ if (!verification && !verifiedAt && !verifyCmd)
1149
+ return;
1150
+ mutateState((state) => {
1151
+ const bucket = name === 'decision' ? state.recent_decisions
1152
+ : name === 'constraint' ? state.active_constraints
1153
+ : state.known_traps;
1154
+ const item = bucket.find((entry) => entry.id === id);
1155
+ if (!item)
1156
+ return;
1157
+ if (verification)
1158
+ item.verification = verification;
1159
+ if (verifiedAt)
1160
+ item.verified_at = verifiedAt;
1161
+ if (verifyCmd)
1162
+ item.verify_cmd = verifyCmd;
1163
+ }, cwd);
1164
+ }
1107
1165
  function requireString(data, field) {
1108
1166
  const value = data[field];
1109
1167
  if (typeof value !== 'string' || !value) {
@@ -117,7 +117,7 @@ const decision = {
117
117
  shortLabelPrefix: 'dec',
118
118
  schema: DecisionSchema,
119
119
  updatable: [
120
- 'text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd',
120
+ 'text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd', 'verification',
121
121
  // pln#544 lifecycle (touched via memory-lifecycle.ts recordMemoryEvent;
122
122
  // exposing them here keeps bclaw_update straight-through for tests and
123
123
  // operator backfills).
@@ -140,7 +140,7 @@ const constraint = {
140
140
  shortLabelPrefix: 'cst',
141
141
  schema: ConstraintSchema,
142
142
  updatable: [
143
- 'text', 'tags', 'category', 'scope', 'related_paths', 'expires_at',
143
+ 'text', 'tags', 'category', 'scope', 'related_paths', 'expires_at', 'verified_at', 'verify_cmd', 'verification',
144
144
  // pln#544 lifecycle — see decision.updatable.
145
145
  'last_confirmed_at', 'last_infirmed_at',
146
146
  'confirmation_count', 'infirmation_count',
@@ -164,7 +164,7 @@ const trap = {
164
164
  schema: TrapSchema,
165
165
  updatable: [
166
166
  'text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope',
167
- 'verified_at', 'verify_cmd',
167
+ 'verified_at', 'verify_cmd', 'verification',
168
168
  // pln#544 lifecycle — see decision.updatable.
169
169
  'last_confirmed_at', 'last_infirmed_at',
170
170
  'confirmation_count', 'infirmation_count',
@@ -365,6 +365,13 @@ export class CliExecutionAdapter {
365
365
  contractBootstrapPath,
366
366
  expectedWorkspacePath: options.worktreePath,
367
367
  }, isWin32, options.turnEcho);
368
+ // Materialize advertised log paths before spawn. A shell/bootstrap error
369
+ // can otherwise happen before redirection creates either file.
370
+ for (const stream of ['stdout', 'stderr']) {
371
+ const logPath = getRuntimeLogPath(signalRoot, options.assignmentId, stream, runtimeRunId);
372
+ if (!fs.existsSync(logPath))
373
+ fs.writeFileSync(logPath, '', { encoding: 'utf8', mode: 0o600 });
374
+ }
368
375
  child = spawn(wrappedCmd, [], {
369
376
  detached: !isWin32,
370
377
  shell: true,
@@ -395,6 +402,9 @@ export class CliExecutionAdapter {
395
402
  child.stdin.write(invoke.promptText);
396
403
  child.stdin.end();
397
404
  }
405
+ // Preserve fire-and-forget semantics while keeping an exit observer so
406
+ // libuv reaps the direct wrapper child on long-lived POSIX coordinators.
407
+ child.once('exit', () => { });
398
408
  child.unref();
399
409
  const pid = child.pid;
400
410
  if (!pid) {
@@ -36,6 +36,16 @@ export const CoordinateRequestSchema = z.object({
36
36
  task: z.string(),
37
37
  scope: z.string().optional(),
38
38
  targetAgents: z.array(z.string()).optional(),
39
+ /**
40
+ * Ideation critic scheduling. The default is deliberately sequential: the
41
+ * loop still requires every critic artifact, but only the first open critic
42
+ * is dispatched initially and the driver takes the next real turn after
43
+ * harvest. Parallel fan-out remains available as an explicit throughput
44
+ * trade-off.
45
+ */
46
+ ideation_schedule: z.enum(['sequential', 'parallel']).optional().default('sequential'),
47
+ /** Optional per-slot lenses, positionally aligned with targetAgents. */
48
+ criticPerspectives: z.array(z.string().min(1).max(1000)).optional(),
39
49
  constraints: z.record(z.string(), z.unknown()).optional(),
40
50
  threadId: z.string().optional(),
41
51
  /** Optional pipeline provenance persisted when open_loop creates a loop. */
@@ -107,7 +107,9 @@ export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
107
107
  // Prefer the typed envelope, but honor the legacy artifacts labels too:
108
108
  // coverage_gap used to be silently invisible to a critique gate.
109
109
  const reportedArtifactType = lane.artifact_type?.trim()
110
- ?? lane.artifacts?.find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
110
+ ?? lane.artifacts
111
+ ?.map((item) => typeof item === 'string' ? item : item.type)
112
+ .find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
111
113
  const body = lane.body?.trim();
112
114
  const critique = body || [lane.summary, lane.notes]
113
115
  .map((s) => (s ?? '').trim())