brainclaw 1.19.1 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -12,11 +12,9 @@
12
12
  *
13
13
  * @module
14
14
  */
15
- import fs from 'node:fs';
16
- import path from 'node:path';
17
15
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
18
16
  import { buildContext } from '../core/context.js';
19
- import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
17
+ import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
20
18
  import { loadConfig } from '../core/config.js';
21
19
  import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
22
20
  import { releaseClaimNextActions } from '../core/next-actions.js';
@@ -346,24 +344,14 @@ export async function handleBclawSessionStart(payload, ctx) {
346
344
  const sessionUpdateConfig = loadConfig(cwd);
347
345
  const sessionUpdateCheck = checkBrainclawInstallableUpdate(sessionUpdateConfig, cwd, { useDefaultNpmSource: true });
348
346
  const sessionUpdateNotice = renderBrainclawInstallableUpdateNotice(sessionUpdateCheck);
349
- // Stale instructions guardrail: check if agent files match current version
350
- let staleInstructionsWarn = '';
351
- {
352
- const currentVersion = getInstalledBrainclawVersion();
353
- const agentFilesToCheck = ['CLAUDE.md', 'AGENTS.md', '.github/copilot-instructions.md'];
354
- for (const relPath of agentFilesToCheck) {
355
- try {
356
- const fullPath = path.join(cwd, relPath);
357
- const content = fs.readFileSync(fullPath, 'utf-8').slice(0, 200);
358
- const match = content.match(/brainclaw v(\d+\.\d+\.\d+)/);
359
- if (match && match[1] !== currentVersion) {
360
- staleInstructionsWarn = `\n⚠️ Agent instruction files are stale (generated by v${match[1]}, current is v${currentVersion}). Run: brainclaw export --all`;
361
- break;
362
- }
363
- }
364
- catch { /* file doesn't exist, skip */ }
365
- }
366
- }
347
+ // Stale-surfaces advisory. This REPLACES a hand-rolled guardrail that lived
348
+ // here: a regex over the first 200 chars of 3 hardcoded files. That was a
349
+ // second, divergent freshness check running next to the proper one — the same
350
+ // dual-source drift class as the two LANE-RESULT shapes (pln#638 PR-4). The
351
+ // pln#638 2b reconcile in startSession covers ~25 registry-derived paths with
352
+ // a real parser; its result was computed by this very call and then dropped
353
+ // from the response (Fable audit P0.2). Now it IS the guardrail.
354
+ const staleInstructionsWarn = result.stale_surfaces ? `\n⚠️ ${result.stale_surfaces.message}` : '';
367
355
  // Claim adoption: if BRAINCLAW_CLAIM_ID is set (spawned by dispatcher),
368
356
  // adopt the claim by writing session_id into it. This links claim→session.
369
357
  let adoptedClaimId;
@@ -383,6 +371,10 @@ export async function handleBclawSessionStart(payload, ctx) {
383
371
  }
384
372
  if (adoptedClaimId)
385
373
  sessionStartMsgParts.push(`\n🔗 Adopted claim ${adoptedClaimId} — use bclaw_read_inbox with claimId to see your assignment.`);
374
+ if (result.shared_checkout_warning) {
375
+ const others = result.shared_checkout_warning.other_sessions.map((s) => s.agent).join(', ');
376
+ sessionStartMsgParts.push(`\n⚠️ Shared checkout: ${others} also working in this worktree — claim your scope before editing.`);
377
+ }
386
378
  if (staleInstructionsWarn)
387
379
  sessionStartMsgParts.push(staleInstructionsWarn);
388
380
  if (sessionUpdateNotice)
@@ -410,6 +402,15 @@ export async function handleBclawSessionStart(payload, ctx) {
410
402
  inbox_pending: inboxPending,
411
403
  ...(result.auto_registered ? { auto_registered: true } : {}),
412
404
  ...(result.memory_pressure ? { memory_pressure: result.memory_pressure } : {}),
405
+ // pln#638 2b — the field startSession computes; it was previously dropped
406
+ // here, making the whole feature reachable only via CLI --json.
407
+ ...(result.stale_surfaces
408
+ ? { warnings: [result.stale_surfaces.message], warning_details: [result.stale_surfaces] }
409
+ : {}),
410
+ // Fifth computed-then-dropped field, caught by the seam guard: other live
411
+ // sessions on the same checkout. An agent about to edit needs this more
412
+ // than a human does.
413
+ ...(result.shared_checkout_warning ? { shared_checkout_warning: result.shared_checkout_warning } : {}),
413
414
  };
414
415
  if (args.includeContext) {
415
416
  const contextAgent = resolved.identity?.agent_name ?? result.agent;
@@ -502,6 +503,13 @@ export async function handleBclawSessionEnd(payload, ctx) {
502
503
  parts.push(endUpdateNotice);
503
504
  if (preSessionEndText)
504
505
  parts.push(preSessionEndText);
506
+ // pln#636 C2 session-end sweep — the backstop trigger. endSession computed
507
+ // these and this handler dropped them (Fable audit P1): of the four C2
508
+ // boundaries, this one emitted nowhere. Text part + structured channel below.
509
+ if (result.scope_warnings?.length) {
510
+ for (const w of result.scope_warnings)
511
+ parts.push(`\n⚠️ ${w.message}`);
512
+ }
505
513
  if (result.reflection_prompt) {
506
514
  parts.push('\n📝 Session reflection — please answer these questions:');
507
515
  for (let i = 0; i < result.reflection_prompt.questions.length; i++) {
@@ -519,6 +527,12 @@ export async function handleBclawSessionEnd(payload, ctx) {
519
527
  triggered_items: preSessionEndItems,
520
528
  ...(result.handoff ? { handoff: result.handoff } : {}),
521
529
  ...(result.reflection_prompt ? { reflection_prompt: result.reflection_prompt } : {}),
530
+ ...(result.scope_warnings?.length
531
+ ? {
532
+ warnings: result.scope_warnings.map((w) => w.message),
533
+ warning_details: result.scope_warnings,
534
+ }
535
+ : {}),
522
536
  }),
523
537
  nextConnectionSessionId: null,
524
538
  };
@@ -626,6 +626,11 @@ export async function handleBclawCoordinate(args, ctx) {
626
626
  scope: options?.scope,
627
627
  worktreePath: options?.worktreePath,
628
628
  assignmentId: options?.assignmentId,
629
+ // pln#638 PR-6b — the envelope must read the TARGET project's store: on a
630
+ // cross-project dispatch, defaulting to process.cwd() would inline the
631
+ // WRONG project's constraints/traps into the worker's brief.
632
+ cwd: dispatchCwd,
633
+ contextEnvelope: options?.contextEnvelope,
629
634
  });
630
635
  };
631
636
  const toMessageSummary = (deliveryPlan) => deliveryPlan.map((entry) => ({
@@ -1750,6 +1755,12 @@ export async function handleBclawCoordinate(args, ctx) {
1750
1755
  scope: criticScope,
1751
1756
  worktreePath: claimResult.worktreePath,
1752
1757
  assignmentId: criticAssignmentId,
1758
+ // The ideation brief above already inlines a BM25-selected,
1759
+ // budget-managed memory bundle (with its own truncation warning).
1760
+ // The generic context envelope would double-carry the same traps
1761
+ // and break the documented ~48K content cap + dispatch-envelope
1762
+ // math pinned by ideation-loop-e2e.
1763
+ contextEnvelope: false,
1753
1764
  });
1754
1765
  const queued = queueCoordinateMessage({
1755
1766
  agent: slot.agent,
@@ -1238,6 +1238,9 @@ async function _executeMcpToolCallInner(payload) {
1238
1238
  const targetCwd = resolveProjectCwd(workReq.project, cwd);
1239
1239
  const useCompact = workReq.compact !== false; // default true
1240
1240
  const warnings = [];
1241
+ // pln#635 structured channel — populated alongside `warnings` and attached
1242
+ // to the response only when non-empty.
1243
+ const warningDetails = [];
1241
1244
  // Step 1: implicit session start (handles auto-registration internally)
1242
1245
  let sessionResult;
1243
1246
  try {
@@ -1254,6 +1257,21 @@ async function _executeMcpToolCallInner(payload) {
1254
1257
  if (sessionResult.auto_registered) {
1255
1258
  warnings.push(`Agent '${sessionResult.agent}' was auto-registered (first use). Run \`brainclaw register-agent ${sessionResult.agent}\` to set capabilities and trust level.`);
1256
1259
  }
1260
+ // pln#638 2b — surface the stale-surfaces advisory on the CANONICAL entry
1261
+ // point. Until the Fable audit, bclaw_work could never even compute this
1262
+ // (startSession defaulted to 'fast' and the check was gated on 'full'),
1263
+ // so the feature shipped in 1.19.0 unreachable from the one call the
1264
+ // session protocol tells every agent to make first.
1265
+ if (sessionResult.stale_surfaces) {
1266
+ warnings.push(sessionResult.stale_surfaces.message);
1267
+ warningDetails.push(sessionResult.stale_surfaces);
1268
+ }
1269
+ // Same seam, same fix: other live sessions on this checkout is exactly
1270
+ // what an agent entering via bclaw_work must hear before it edits.
1271
+ if (sessionResult.shared_checkout_warning) {
1272
+ const others = sessionResult.shared_checkout_warning.other_sessions.map((s) => s.agent).join(', ');
1273
+ warnings.push(`Shared checkout: ${others} also working in ${sessionResult.shared_checkout_warning.worktree_path} — claim your scope before editing.`);
1274
+ }
1257
1275
  // Step 2: build context for requested scope. The "what's new" diff is
1258
1276
  // surfaced for ALL intents (pln#390 regression fix): intent='resume'
1259
1277
  // anchors it on the agent's previous session; every other intent gets
@@ -1530,6 +1548,7 @@ async function _executeMcpToolCallInner(payload) {
1530
1548
  claim_status: claimStatus,
1531
1549
  session_id: sessionResult.session_id,
1532
1550
  warnings,
1551
+ ...(warningDetails.length ? { warning_details: warningDetails } : {}),
1533
1552
  duration_ms: Date.now() - startMs,
1534
1553
  bootstrap_recommended: bootstrapRecommended,
1535
1554
  bootstrap_verdict: bootstrapVerdict,
@@ -107,6 +107,13 @@ export async function runSessionEnd(options = {}) {
107
107
  if (result.compaction_hint) {
108
108
  console.log(` 💡 ${result.compaction_hint}`);
109
109
  }
110
+ // pln#636 C2 backstop — computed since 1.19.0, printed nowhere until the
111
+ // Fable audit (P1): the human output skipped it, so it was --json only.
112
+ if (result.scope_warnings?.length) {
113
+ for (const w of result.scope_warnings) {
114
+ console.warn(`⚠ ${w.message}`);
115
+ }
116
+ }
110
117
  if (result.reflection_prompt) {
111
118
  console.log('\n📝 Session reflection:');
112
119
  for (let i = 0; i < result.reflection_prompt.questions.length; i++) {
@@ -97,6 +97,21 @@ export async function runSessionStart(options = {}) {
97
97
  console.warn(` ${c.agent} → ${c.scope}`);
98
98
  }
99
99
  }
100
+ // pln#638 2b, wired for humans too (Fable audit P0.3): the field existed on
101
+ // the result but the human output never printed it — visible only via --json.
102
+ if (snapshot.stale_surfaces) {
103
+ console.warn(`⚠ ${snapshot.stale_surfaces.message}`);
104
+ }
105
+ // Fifth instance of the computed-then-dropped class, caught by the new seam
106
+ // guard on its first run: built since the shared-checkout detection landed,
107
+ // read by nothing. Two agents editing one checkout is precisely what a human
108
+ // at the terminal needs to hear about.
109
+ if (snapshot.shared_checkout_warning) {
110
+ const others = snapshot.shared_checkout_warning.other_sessions
111
+ .map((s) => `${s.agent} (${s.session_id}${s.branch ? `, ${s.branch}` : ''})`)
112
+ .join(', ');
113
+ console.warn(`⚠ Shared checkout: ${others} ${snapshot.shared_checkout_warning.other_sessions.length === 1 ? 'is' : 'are'} also working in ${snapshot.shared_checkout_warning.worktree_path}. Claim your scope before editing.`);
114
+ }
100
115
  }
101
116
  catch (e) {
102
117
  const message = e instanceof Error ? e.message : String(e);
@@ -291,15 +306,20 @@ export async function startSession(options = {}) {
291
306
  // daemon, no watcher (feedback_lazy_reconcile_pattern). Advisory only: nothing
292
307
  // is regenerated here, because regeneration is an explicit act and silently
293
308
  // rewriting a file the operator may have edited would be worse than a warning.
309
+ // NOT gated on maintenanceMode — deliberately, and this is a fix, not an
310
+ // oversight (Fable audit P0). The check is ~25 existsSync + a few 4KB head
311
+ // reads, nothing like the sweeps/federation/GC that justify the 'full' gate.
312
+ // Gating it on 'full' made it unreachable from `bclaw_work`, which calls
313
+ // startSession without maintenanceMode (→ 'fast') and is the entry point the
314
+ // session protocol tells every agent to use — so the warning shipped in
315
+ // 1.19.0 and never fired for the population it was built for.
294
316
  let staleSurfaces;
295
- if (maintenanceMode === 'full') {
296
- try {
297
- const currentVersion = getInstalledBrainclawVersion();
298
- const freshness = reconcileSurfaceFreshness(options.cwd ?? process.cwd(), currentVersion);
299
- staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
300
- }
301
- catch { /* non-fatal */ }
317
+ try {
318
+ const currentVersion = getInstalledBrainclawVersion();
319
+ const freshness = reconcileSurfaceFreshness(options.cwd ?? process.cwd(), currentVersion);
320
+ staleSurfaces = staleSurfaceWarning(freshness, currentVersion);
302
321
  }
322
+ catch { /* non-fatal */ }
303
323
  // Materialize incoming federation signals from linked projects (Phase 0 — local)
304
324
  if (maintenanceMode === 'full') {
305
325
  try {
@@ -4,7 +4,7 @@ import { loadState } from '../core/state.js';
4
4
  import { listRuntimeNotes } from '../core/runtime.js';
5
5
  import { listCandidates } from '../core/candidates.js';
6
6
  import { readAuditLog } from '../core/audit.js';
7
- import { listClaims, saveClaim, generateClaimId, ensureClaimsDir } from '../core/claims.js';
7
+ import { listClaims, saveClaim, generateClaimId, ensureClaimsDir, claimBaselineFields } from '../core/claims.js';
8
8
  import { resolveCurrentAgentName } from '../core/agent-registry.js';
9
9
  function emit(event) {
10
10
  process.stdout.write(JSON.stringify(event) + '\n');
@@ -197,6 +197,11 @@ export function runWatch(options = {}) {
197
197
  description: `auto-claim: ${filename}`,
198
198
  created_at: new Date().toISOString(),
199
199
  status: 'active',
200
+ // pln#636 C0-b / trp#1292 — the FIFTH creation path, missed by the
201
+ // 1.19.1 fix and invisible to its guard test: this one assigns the
202
+ // literal to a variable, so a scan looking for `saveClaim({` never saw
203
+ // it. Found by the ideation critic that reviewed the guard.
204
+ ...claimBaselineFields(),
200
205
  };
201
206
  try {
202
207
  saveClaim(claim);
@@ -326,6 +326,88 @@ export function buildWorkingDefaultsSection(opts) {
326
326
  '',
327
327
  ].join('\n');
328
328
  }
329
+ /**
330
+ * pln#638 PR-4 — the transport section, for BOTH declared-MCP and MCP-less agents.
331
+ *
332
+ * WHY THIS EXISTS AS ONE FUNCTION. The MCP-less block was duplicated verbatim in
333
+ * `generateBrief` and `generateDispatchBrief`; that duplication is exactly what
334
+ * lets two brief paths drift, which is the class of bug PR-3 just fixed one layer
335
+ * up. One function, two callers.
336
+ *
337
+ * WHY A DECLARED-MCP AGENT ALSO GETS A SECTION — the part that was missing. A
338
+ * profile's `runtime.mcp_direct` is a STATIC flag: it asserts nothing about
339
+ * whether the config exists on this machine, whether the server started, or
340
+ * whether stdio came up. Proven in production during pln#638's own ideation: a
341
+ * codex critic ran with `mcp_direct=true` and NO reachable MCP. Its 3654-character
342
+ * critique survived only because the brief happened to spell out a file fallback
343
+ * by hand. So the brief must never ASSERT the capability — it states the
344
+ * expectation and names the fallback, and the worker decides from what it
345
+ * actually observes.
346
+ *
347
+ * The store path is deliberately NOT mentioned. `.brainclaw/` is gitignored
348
+ * (.gitignore:10), so it does not exist in a worker's worktree — the previous
349
+ * wording told workers to write candidates into a directory they cannot see.
350
+ */
351
+ /**
352
+ * The ONE LANE-RESULT shape every brief quotes.
353
+ *
354
+ * There used to be two. `buildProtocolSection` has emitted a fallback since
355
+ * pln#526 with `{summary, files_changed, artifacts}` and NO `body`, while the
356
+ * transport section below asked for `body`. A full-mode worker with an assignment
357
+ * id received both and had to pick — and a worker that followed the older one
358
+ * recreated trp_8efdbf9d (a substantial review collapsed into a one-line summary
359
+ * because the contract had nowhere to put the reasoning). Caught in review by
360
+ * Fable before this shipped.
361
+ */
362
+ export function laneResultShape(assignmentId) {
363
+ const asgn = assignmentId ?? '<assignment_id>';
364
+ return `{"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
365
+ }
366
+ export function buildTransportSection(opts) {
367
+ const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId)}`;
368
+ if (!opts.hasMcp) {
369
+ return [
370
+ '## ⚠ Transport: no MCP (file protocol only)',
371
+ 'Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:',
372
+ `- When done, ${laneResult}.`,
373
+ // RESTORED after review. Removing this orphaned a real, shipped consumer:
374
+ // `collectWorktreeCandidateFiles` (harvest.ts:191-205) scans exactly this
375
+ // directory inside WORKER worktrees, and `bclaw_harvest_candidates` exposes
376
+ // it. My removal rested on a wrong inference — `.gitignore:10` means the
377
+ // path is not CHECKED OUT, not that a worker cannot create it. Being
378
+ // gitignored is the feature: candidates never pollute worker commits.
379
+ '- Capture decisions/traps as candidate JSON under `.brainclaw/coordination/inbox/` in your worktree — create the directory if it does not exist (it is gitignored, so a fresh worktree will not have it). The coordinator harvests them.',
380
+ '- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.',
381
+ '',
382
+ ].join('\n');
383
+ }
384
+ return [
385
+ '## Transport: MCP expected, file fallback if not',
386
+ 'Your profile declares brainclaw MCP access, but that is a DECLARATION, not a verified fact — the config may be absent on this machine or the server may not have started. Decide from what you actually observe:',
387
+ '- If `bclaw_*` tools respond: use them, as instructed above.',
388
+ `- If they are unavailable or error: do not stop and do not discard your work — ${laneResult}. The coordinator harvests it.`,
389
+ '',
390
+ ].join('\n');
391
+ }
392
+ /**
393
+ * pln#638 PR-6 — lane lifecycle doctrine (settled by loop lop_2d838a638b1e2956):
394
+ *
395
+ * Native hooks = interactive agents. Dispatched workers = dispatcher-owned
396
+ * lifecycle. A lane's unit of identity is assignment + claim + AgentRun —
397
+ * NEVER a session. Sessions carry interactive side-effects (auto-release
398
+ * frees ALL of an agent's claims at session_end) that would leak across
399
+ * lanes, and N parallel lanes would contend on the session lock (observed:
400
+ * a worker's global hook stalled 5s on a session LOCK, not on a missing
401
+ * store).
402
+ *
403
+ * The three lifecycle needs hooks used to cover are met elsewhere:
404
+ * - shared context at start → inlined ContextEnvelope in the brief
405
+ * (buildContextEnvelopeSection — MCP is a refresh, not a prerequisite);
406
+ * - progress/closure → bclaw_assignment_update + the wrapper's
407
+ * mechanical completed/failed sentinels (see attemptExecution);
408
+ * - business closure → the coordinator's harvest/report path.
409
+ * Transport completion never releases claims or triggers review.
410
+ */
329
411
  export function buildProtocolSection(options) {
330
412
  const parts = [];
331
413
  parts.push('## Protocol');
@@ -395,30 +477,37 @@ export function buildProtocolSection(options) {
395
477
  // fails in your environment. pln#628 Focus 4A: sandbox is NO LONGER a reason
396
478
  // MCP is unavailable (dec#133), so this is framed as a generic fallback, not a
397
479
  // sandbox instruction. The coordinator ingests it with `brainclaw harvest`.
398
- parts.push(`Final fallback (if bclaw_assignment_update / MCP is unavailable in your environment): write LANE-RESULT.json at the worktree root — {"assignment_id":"${options.assignmentId}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."],"artifacts":["..."]}. The coordinator harvests it via \`brainclaw harvest ${options.assignmentId}\`.`);
480
+ // Quotes the SHARED shape (laneResultShape) so this and the transport section
481
+ // cannot disagree about what a worker should write — they used to.
482
+ parts.push(`Final fallback (if bclaw_assignment_update / MCP is unavailable in your environment): write LANE-RESULT.json at the worktree root — ${laneResultShape(options.assignmentId)}. The coordinator harvests it via \`brainclaw harvest ${options.assignmentId}\`.`);
399
483
  }
400
484
  else if (options?.claimId) {
401
- parts.push('1. Call bclaw_session_start to register your session');
485
+ // pln#638 PR-6a NO session lifecycle in a dispatched lane's brief. The
486
+ // ideation loop settled this (design of record: pln638-pr6 synthesis): a
487
+ // session is an AGENT's lifecycle and its effects overflow the lane —
488
+ // `session-end --auto-release` releases ALL of the agent's active claims and
489
+ // the handoff aggregates ALL its commits, so one finishing lane could tear
490
+ // down its siblings' work. The engine already agrees: SESSION_ID is
491
+ // deliberately scrubbed from the worker env (execution-profile.ts). The
492
+ // lane's lifecycle is the claim (+ assignment when present), nothing more.
402
493
  if (options.worktreePath) {
403
- parts.push(`2. cd into the worktree: ${options.worktreePath}`);
494
+ parts.push(`1. cd into the worktree: ${options.worktreePath}`);
404
495
  }
405
- parts.push(`${options.worktreePath ? '3' : '2'}. Work on the assigned scope (claim already active)`);
406
- parts.push(`${options.worktreePath ? '4' : '3'}. Release the claim: bclaw_release_claim(id: "${options.claimId}", planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
407
- parts.push(`${options.worktreePath ? '5' : '4'}. Call bclaw_session_end with a narrative when done`);
496
+ parts.push(`${options.worktreePath ? '2' : '1'}. Work on the assigned scope (claim already active)`);
497
+ parts.push(`${options.worktreePath ? '3' : '2'}. Release the claim: bclaw_release_claim(id: "${options.claimId}", planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
498
+ parts.push('Do NOT call bclaw_session_start / bclaw_session_end: sessions belong to interactive agents, and session-end tears down claims beyond this lane.');
408
499
  }
409
500
  else {
410
- parts.push('1. Call bclaw_session_start to register your session');
411
- parts.push('2. Call bclaw_claim to claim the scope before editing');
412
- parts.push('3. Work in the worktree created by the claim');
413
- parts.push('4. Release the claim when done: bclaw_release_claim(id: "clm_xxx", planStatus: "done") required for hard_after sequence gating to unlock the next step');
414
- parts.push('5. Call bclaw_session_end with a narrative when done');
501
+ parts.push('1. Call bclaw_claim to claim the scope before editing');
502
+ parts.push('2. Work in the worktree created by the claim');
503
+ parts.push('3. Release the claim when done: bclaw_release_claim(id: "clm_xxx", planStatus: "done") — required for hard_after sequence gating to unlock the next step');
504
+ parts.push('Do NOT call bclaw_session_start / bclaw_session_end: sessions belong to interactive agents, and session-end tears down claims beyond this lane.');
415
505
  }
416
506
  parts.push('');
417
507
  parts.push('## Available tools');
418
508
  if (options?.assignmentId) {
419
509
  parts.push('- bclaw_assignment_update (report lifecycle: accepted/started/progress/completed/failed/blocked)');
420
510
  }
421
- parts.push('- bclaw_session_start, bclaw_session_end (session lifecycle)');
422
511
  if (!options?.claimId) {
423
512
  parts.push('- bclaw_claim, bclaw_release_claim (scope ownership)');
424
513
  }
@@ -498,6 +587,10 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
498
587
  }
499
588
  // pln#554 step 4 — working defaults (incremental commits + validation bar).
500
589
  parts.push(buildWorkingDefaultsSection({ canCommit: briefProfile ? dispatchCanCommit(briefProfile) : true }));
590
+ // pln#638 PR-6b — survival context rides in the brief; MCP is a refresh.
591
+ const envelope = buildContextEnvelopeSection(cwd);
592
+ if (envelope)
593
+ parts.push(envelope);
501
594
  // Steps if any
502
595
  if (plan.steps?.length) {
503
596
  parts.push('## Steps');
@@ -566,14 +659,19 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
566
659
  // call bclaw_*" note: their coherent message is carried by the Protocol section
567
660
  // (MCP primary + LANE-RESULT.json fallback) and working-defaults (canCommit=
568
661
  // false → the coordinator commits their worktree at harvest).
569
- if (briefProfile && !dispatchHasMcp(briefProfile)) {
570
- parts.push('## Transport: no MCP (file protocol only)');
571
- parts.push('Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:');
572
- const asgn = options?.assignmentId ?? '<assignment_id>';
573
- parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
574
- parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
575
- parts.push('- Do NOT call bclaw_* tools they are unavailable here. The coordinator harvests your result and integrates it.');
576
- parts.push('');
662
+ // pln#638 PR-4 emitted for EVERY agent, not only MCP-less ones: a declared-MCP
663
+ // worker needs the conditional fallback too (see buildTransportSection for the
664
+ // production incident that proved it).
665
+ //
666
+ // An UNRESOLVED profile gets the conditional form rather than silence — review
667
+ // caught that gating on a truthy profile left an unknown agent with a
668
+ // fully-MCP-asserting brief and no fallback, which is the case the principle
669
+ // "never assert an unverifiable capability" exists for.
670
+ if (options?.agent) {
671
+ parts.push(buildTransportSection({
672
+ hasMcp: briefProfile ? dispatchHasMcp(briefProfile) : true,
673
+ assignmentId: options.assignmentId,
674
+ }));
577
675
  }
578
676
  // Codex-specific constraints: focus and speed guidance for sandboxed runs.
579
677
  // Gated on agent identity (not brief mode) so future non-codex compact consumers
@@ -586,6 +684,68 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
586
684
  }
587
685
  return parts.join('\n');
588
686
  }
687
+ /** Character budget for the inlined context envelope — bounded by design
688
+ * (trp#179: oversized payloads), and deliberately small: this is the survival
689
+ * kit, not the library. */
690
+ const CONTEXT_ENVELOPE_MAX_CHARS = 3000;
691
+ const CONTEXT_ENVELOPE_ITEM_MAX_CHARS = 220;
692
+ const CONTEXT_ENVELOPE_TOP_K = 6;
693
+ /**
694
+ * pln#638 PR-6b — the ContextEnvelope: constraints/traps/decisions INLINED into
695
+ * the dispatch brief, bounded and deterministic.
696
+ *
697
+ * Why inline instead of "call bclaw_context": that instruction assumes the MCP
698
+ * is reachable, and the whole PR-6 design was forced by a production run where
699
+ * the declared-MCP flag was true and the server was not there (critic A of
700
+ * lop_2d838a638b1e2956 lost its context channel and worked blind). The brief is
701
+ * the ONE artifact every tier demonstrably receives — so the survival context
702
+ * rides in it, and MCP remains an optional refresh, never a precondition.
703
+ *
704
+ * Deterministic: newest-first by created_at (tie-broken by id), fixed caps, no
705
+ * clock reads — the same store state always renders the same envelope.
706
+ * NOT scope-filtered, deliberately: relevance guessing risks hiding the one
707
+ * trap that mattered (the inverted-default lesson of claim-scope, applied to
708
+ * context selection). Bounded instead.
709
+ */
710
+ export function buildContextEnvelopeSection(cwd) {
711
+ let state;
712
+ try {
713
+ state = loadState(cwd);
714
+ }
715
+ catch {
716
+ return ''; // no store, no envelope — never block a brief on context
717
+ }
718
+ const newestFirst = (items) => [...items].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '') || (a.id ?? '').localeCompare(b.id ?? ''));
719
+ const clip = (text) => text.length > CONTEXT_ENVELOPE_ITEM_MAX_CHARS ? `${text.slice(0, CONTEXT_ENVELOPE_ITEM_MAX_CHARS - 1)}…` : text;
720
+ const constraints = newestFirst(state.active_constraints ?? []);
721
+ const traps = newestFirst(state.known_traps ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
722
+ const decisions = newestFirst(state.recent_decisions ?? []).slice(0, CONTEXT_ENVELOPE_TOP_K);
723
+ if (constraints.length === 0 && traps.length === 0 && decisions.length === 0)
724
+ return '';
725
+ const lines = [
726
+ '## Project context (inlined — MCP is a refresh, not a prerequisite)',
727
+ `Snapshot: ${constraints.length} constraint(s), ${traps.length}/${(state.known_traps ?? []).length} trap(s), ${decisions.length}/${(state.recent_decisions ?? []).length} decision(s), newest-first.`,
728
+ ];
729
+ const push = (title, items) => {
730
+ if (items.length === 0)
731
+ return;
732
+ lines.push(`### ${title}`);
733
+ for (const item of items)
734
+ lines.push(`- [${item.id ?? '?'}] ${clip((item.text ?? '').replace(/\s+/g, ' ').trim())}`);
735
+ };
736
+ push('Active constraints (binding)', constraints);
737
+ push('Known traps', traps);
738
+ push('Recent decisions', decisions);
739
+ lines.push('');
740
+ // Enforce the total budget by dropping whole trailing lines — a clipped list
741
+ // stays valid markdown, a mid-line cut does not.
742
+ let text = lines.join('\n');
743
+ while (text.length > CONTEXT_ENVELOPE_MAX_CHARS && lines.length > 2) {
744
+ lines.splice(lines.length - 2, 1); // drop the last content line, keep the trailing ''
745
+ text = lines.join('\n');
746
+ }
747
+ return text;
748
+ }
589
749
  export function generateDispatchBrief(options) {
590
750
  const briefMode = resolveBriefMode(options.agent);
591
751
  const parts = [];
@@ -607,6 +767,13 @@ export function generateDispatchBrief(options) {
607
767
  }
608
768
  // pln#554 step 4 — working defaults (incremental commits + validation bar).
609
769
  parts.push(buildWorkingDefaultsSection({ canCommit: taskBriefProfile ? dispatchCanCommit(taskBriefProfile) : true }));
770
+ // pln#638 PR-6b — same envelope as generateBrief (see buildContextEnvelopeSection).
771
+ // Suppressed only when the content curates its own memory (contextEnvelope: false).
772
+ if (options.contextEnvelope !== false) {
773
+ const taskEnvelope = buildContextEnvelopeSection(options.cwd);
774
+ if (taskEnvelope)
775
+ parts.push(taskEnvelope);
776
+ }
610
777
  if (briefMode === 'full') {
611
778
  parts.push(buildProtocolSection({
612
779
  claimId: options.claimId,
@@ -618,14 +785,14 @@ export function generateDispatchBrief(options) {
618
785
  // (see generateBrief for the full rationale + dec#133). Fires only for
619
786
  // genuinely MCP-less agents; sandboxed-but-MCP-capable codex no longer gets a
620
787
  // self-contradictory "no MCP / Do NOT call bclaw_*" note.
621
- if (taskBriefProfile && !dispatchHasMcp(taskBriefProfile)) {
622
- parts.push('## Transport: no MCP (file protocol only)');
623
- parts.push('Your runtime has no brainclaw MCP access any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:');
624
- const asgn = options.assignmentId ?? '<assignment_id>';
625
- parts.push(`- When done, write LANE-RESULT.json at the worktree root: {"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<what you did>","files_changed":["..."]}.`);
626
- parts.push('- Capture decisions/traps as candidate JSON under .brainclaw/coordination/inbox/ (the coordinator harvests them).');
627
- parts.push('- Do NOT call bclaw_* tools — they are unavailable here. The coordinator harvests your result and integrates it.');
628
- parts.push('');
788
+ // pln#638 PR-4 same shared section as generateBrief; the wording used to be
789
+ // duplicated verbatim in both builders, which is how they drift. Unresolved
790
+ // profile conditional form, never silence (see generateBrief for why).
791
+ if (options.agent) {
792
+ parts.push(buildTransportSection({
793
+ hasMcp: taskBriefProfile ? dispatchHasMcp(taskBriefProfile) : true,
794
+ assignmentId: options.assignmentId,
795
+ }));
629
796
  }
630
797
  // Codex-specific constraints: focus and speed guidance for sandboxed runs
631
798
  if (options.agent === 'codex') {
@@ -911,7 +1078,15 @@ export async function dispatch(options, cwd) {
911
1078
  // --- Dry-run path: skip assignment creation and message sending ---
912
1079
  if (options.dryRun) {
913
1080
  const briefMode = resolveBriefMode(targetAgent);
914
- const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath });
1081
+ // pln#638 PR-3 `agent` MUST be passed here, exactly as the real dispatch
1082
+ // path below does. Without it `generateBrief` cannot resolve the capability
1083
+ // profile, and THREE things silently diverge from what would actually be
1084
+ // sent: working-defaults claims `canCommit: true` (wrong, and dangerous, for
1085
+ // a sandboxed worker whose .git is read-only), the MCP-less LANE-RESULT
1086
+ // section is omitted for a tier-C agent, and the liveness section loses its
1087
+ // `sandboxed` flag. A --dry-run that previews a DIFFERENT brief than the one
1088
+ // that ships is worse than no preview.
1089
+ const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath, agent: targetAgent });
915
1090
  const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
916
1091
  if (invokeCmd) {
917
1092
  const cmdPrefix = buildEnvPrefix(claimId);
@@ -138,6 +138,14 @@ export function executeDispatchedCommand(invoke, options) {
138
138
  * - If autoExecute=true and agent is spawnable: spawn and return delivered_and_started
139
139
  * - If autoExecute=false or not spawnable: return command_ready_manual with command string
140
140
  * - If spawn fails: log warning, fallback to command_ready_manual
141
+ *
142
+ * pln#638 PR-6 — lifecycle boundary (the other half lives on
143
+ * buildProtocolSection in dispatcher.ts): the wrapper spawned here emits
144
+ * ack/heartbeat/completed/failed sentinels MECHANICALLY from the process exit
145
+ * code. That is TRANSPORT completion only. It never opens or closes sessions
146
+ * (lanes have none), never releases the lane's claim, and never triggers a
147
+ * review — business completion is proven exclusively by the coordinator's
148
+ * harvest/report path reading LANE-RESULT or bclaw_assignment_update.
141
149
  */
142
150
  export async function attemptExecution(invoke, options) {
143
151
  const adapter = options.adapter ?? defaultExecutionAdapter;
@@ -19,6 +19,7 @@
19
19
  * — N items dropped)" tail so the operator can see when content was
20
20
  * dropped.
21
21
  */
22
+ import { deriveWorkerReplyContract, renderWorkerReplyProse } from './worker-reply-contract.js';
22
23
  const DEFAULT_MAX_CHARS = 48_000;
23
24
  const DEFAULT_TOP_K_PER_CATEGORY = 8;
24
25
  /**
@@ -73,14 +74,24 @@ export function buildIdeationBrief(input) {
73
74
  const proposalBlock = renderProposalBlock(proposalText);
74
75
  const memoryBlock = renderMemoryBlock(fetchedItemsByCategory);
75
76
  const closing = renderClosingInstructions(slotRole, thread.current_phase);
76
- // Compose with truncation. The proposal seed and header are fixed;
77
- // memory + prior artifacts share the remaining budget. Memory before
78
- // prior-artifacts so the critic always sees fresh adversarial pressure.
79
- const fixedParts = [header, proposalBlock, closing];
77
+ // pln#638 PR-5 the deliverable contract, derived from the CURRENT phase's
78
+ // gate and frozen into the brief at dispatch time. This is the structural fix
79
+ // for proof #1 (a critic typed `coverage_gap`, invisible to the critique gate):
80
+ // the expected type was always known here; it just never travelled. FIXED
81
+ // part, never truncated — a brief that keeps its memory bundle but loses its
82
+ // reply contract would recreate the bug the section exists to prevent.
83
+ const contract = deriveWorkerReplyContract(thread);
84
+ const contractBlock = contract ? renderWorkerReplyProse(contract) : '';
85
+ // Compose with truncation. The proposal seed, header, closing and contract
86
+ // are fixed; memory + prior artifacts share the remaining budget. Memory
87
+ // before prior-artifacts so the critic always sees fresh adversarial pressure.
88
+ const fixedParts = [header, proposalBlock, closing, contractBlock];
80
89
  const fixedSize = fixedParts.reduce((n, s) => n + s.length, 0);
81
90
  const remainingBudget = Math.max(0, maxChars - fixedSize);
82
91
  const { text: truncatedMemory, truncated, droppedItems, includedItems } = truncateToBudget([memoryBlock, priorArtifactsBlock].filter((s) => s.length > 0), fetchedItemsByCategory, remainingBudget);
83
- const text = [header, proposalBlock, truncatedMemory, closing]
92
+ // The contract closes the brief: the last thing a worker reads is how to
93
+ // reply so its work counts.
94
+ const text = [header, proposalBlock, truncatedMemory, closing, contractBlock]
84
95
  .filter((s) => s.length > 0)
85
96
  .join('\n\n');
86
97
  return { text, truncated, includedItems, droppedItems, categoriesUsed };
@@ -11,4 +11,5 @@ export { readSurveySources, } from './hooks/survey-source-reader.js';
11
11
  export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js';
12
12
  export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
13
13
  export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
14
+ export { deriveWorkerReplyContract, renderWorkerReplyProse, workerReplyNextAction, } from './worker-reply-contract.js';
14
15
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,93 @@
1
+ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './types.js';
2
+ /** Recursively extract artifact requirements from a gate, preserving all/any. */
3
+ function extractFromGate(gate) {
4
+ switch (gate.kind) {
5
+ case 'min_artifacts_by_type':
6
+ return { requirements: [{ type: gate.type, n: gate.n, scope: gate.scope }], other: [], composition: 'single' };
7
+ case 'artifact_produced':
8
+ return { requirements: [{ type: gate.type }], other: [], composition: 'single' };
9
+ case 'all':
10
+ case 'any': {
11
+ const parts = gate.conditions.map(extractFromGate);
12
+ const requirements = parts.flatMap((p) => p.requirements);
13
+ const other = parts.flatMap((p) => p.other);
14
+ // Nested compositions collapse to the OUTER combinator for prose purposes;
15
+ // the exact tree is the engine's business, the worker only needs to know
16
+ // whether one deliverable suffices or all are needed.
17
+ return {
18
+ requirements,
19
+ other,
20
+ composition: requirements.length > 1 ? gate.kind : 'single',
21
+ };
22
+ }
23
+ default:
24
+ // reviewer_green, no_open_questions, iterations, manual, phase_reached —
25
+ // conditions a worker cannot satisfy by typing an artifact correctly.
26
+ return { requirements: [], other: [gate.kind], composition: 'single' };
27
+ }
28
+ }
29
+ /**
30
+ * Derive the contract for the loop's CURRENT phase, or undefined when the phase
31
+ * has no artifact-typed gate — in which case no section is emitted and no
32
+ * obligation is invented (silence over fabrication, as everywhere else).
33
+ */
34
+ export function deriveWorkerReplyContract(thread) {
35
+ const phaseDef = thread.phases.find((p) => p.name === thread.current_phase);
36
+ const gate = phaseDef?.advance_gate;
37
+ if (!gate)
38
+ return undefined;
39
+ const { requirements, other, composition } = extractFromGate(gate);
40
+ if (requirements.length === 0)
41
+ return undefined;
42
+ return {
43
+ loop_id: thread.id,
44
+ phase: thread.current_phase,
45
+ requirements,
46
+ composition,
47
+ other_conditions: [...new Set(other)],
48
+ loop_version: thread.version,
49
+ body_max_bytes: LOOP_ARTIFACT_BODY_MAX_BYTES,
50
+ };
51
+ }
52
+ /**
53
+ * The {tool, args, when} a worker with MCP should call — derived from the SAME
54
+ * contract the prose renders, so the two cannot disagree.
55
+ */
56
+ export function workerReplyNextAction(contract) {
57
+ const primary = contract.requirements[0];
58
+ return {
59
+ tool: 'bclaw_loop',
60
+ args: {
61
+ intent: 'add_artifact',
62
+ loop_id: contract.loop_id,
63
+ artifact: {
64
+ phase: contract.phase,
65
+ type: primary.type,
66
+ body: '<your full output — non-empty, or it does not count toward the gate>',
67
+ },
68
+ },
69
+ when: `your ${primary.type} is ready — the phase gate only counts artifacts of this exact type`,
70
+ };
71
+ }
72
+ /** Render the brief section. Every value comes off the contract object. */
73
+ export function renderWorkerReplyProse(contract) {
74
+ const action = workerReplyNextAction(contract);
75
+ const typeList = contract.requirements
76
+ .map((r) => `\`${r.type}\`${r.n ? ` (n≥${r.n}, ${r.scope ?? 'phase'} scope)` : ''}`)
77
+ .join(contract.composition === 'any' ? ' OR ' : ' AND ');
78
+ const lines = [
79
+ `## Deliverable contract — loop ${contract.loop_id}, phase "${contract.phase}"`,
80
+ `The phase gate counts artifact type(s): ${typeList}. Any other type — however good the content — is INVISIBLE to the gate and stalls the loop.`,
81
+ `- MCP path: call \`${action.tool}\` with ${JSON.stringify(action.args)}`,
82
+ `- The body must be NON-EMPTY: an artifact without usable content does not count toward the gate.`,
83
+ `- Body cap: ${contract.body_max_bytes} bytes. If your output is larger, write the full version to a markdown file in your worktree and put a dense summary plus the file path in the body.`,
84
+ `- File fallback (no MCP): in LANE-RESULT.json set "artifact_type":"${contract.requirements[0].type}" and put your full output in "body" — the harvester records it under this contract.`,
85
+ `- This contract is FROZEN for loop version ${contract.loop_version}, phase "${contract.phase}". If your submit reports a version conflict or the loop has advanced, your work is still recorded under phase "${contract.phase}" — do not re-target a newer phase.`,
86
+ ];
87
+ if (contract.other_conditions.length > 0) {
88
+ lines.push(`- Note: the gate also requires ${contract.other_conditions.join(', ')} — producing artifacts alone may not advance the phase; that part is the coordinator's to satisfy.`);
89
+ }
90
+ lines.push('');
91
+ return lines.join('\n');
92
+ }
93
+ //# sourceMappingURL=worker-reply-contract.js.map
@@ -366,6 +366,7 @@ export async function dispatchReviewLoopTurn(input) {
366
366
  scope,
367
367
  worktreePath: claimResult.worktreePath,
368
368
  assignmentId,
369
+ cwd, // pln#638 PR-6b — the context envelope reads the store
369
370
  });
370
371
  const msg = sendMessage({
371
372
  from: input.dispatcherAgent,
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.19.1 on 2026-08-02T07:16:39.143Z
2
+ // Source: brainclaw v1.20.0 on 2026-08-02T17:17:41.989Z
3
3
  export const FACTS = {
4
- "version": "1.19.1",
5
- "generated_at": "2026-08-02T07:16:39.143Z",
4
+ "version": "1.20.0",
5
+ "generated_at": "2026-08-02T17:17:41.989Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-08-02T07:16:37.042Z",
477
+ "generated_at": "2026-08-02T17:17:40.300Z",
478
478
  "node_version": "v24.18.0",
479
479
  "platform": "linux-x64",
480
480
  "repeats": 3,
@@ -483,7 +483,7 @@ export const FACTS = {
483
483
  "name": "cold_onboard",
484
484
  "volume": "empty",
485
485
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
486
- "duration_ms_median": 81,
486
+ "duration_ms_median": 65,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,15 +491,15 @@ export const FACTS = {
491
491
  "name": "warm_work",
492
492
  "volume": "medium",
493
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
494
- "duration_ms_median": 123,
495
- "payload_chars_median": 2626,
496
- "payload_tokens_est_median": 657
494
+ "duration_ms_median": 101,
495
+ "payload_chars_median": 2625,
496
+ "payload_tokens_est_median": 656
497
497
  },
498
498
  {
499
499
  "name": "first_edit",
500
500
  "volume": "medium",
501
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
502
- "duration_ms_median": 12,
502
+ "duration_ms_median": 10,
503
503
  "payload_chars_median": 499,
504
504
  "payload_tokens_est_median": 125
505
505
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.19.1",
3
- "generated_at": "2026-08-02T07:16:39.143Z",
2
+ "version": "1.20.0",
3
+ "generated_at": "2026-08-02T17:17:41.989Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-08-02T07:16:37.042Z",
475
+ "generated_at": "2026-08-02T17:17:40.300Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -481,7 +481,7 @@
481
481
  "name": "cold_onboard",
482
482
  "volume": "empty",
483
483
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
- "duration_ms_median": 81,
484
+ "duration_ms_median": 65,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,15 +489,15 @@
489
489
  "name": "warm_work",
490
490
  "volume": "medium",
491
491
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 123,
493
- "payload_chars_median": 2626,
494
- "payload_tokens_est_median": 657
492
+ "duration_ms_median": 101,
493
+ "payload_chars_median": 2625,
494
+ "payload_tokens_est_median": 656
495
495
  },
496
496
  {
497
497
  "name": "first_edit",
498
498
  "volume": "medium",
499
499
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 12,
500
+ "duration_ms_median": 10,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }
@@ -8,6 +8,20 @@ guarantees this changelog follows.
8
8
 
9
9
  ---
10
10
 
11
+ ## [1.20.0] — 2026-08-02
12
+
13
+ **Fixed — 1.19.0 contract entries now actually emitted (#158)**
14
+ - `generated_surfaces_stale` (surfaced as `stale_surfaces`) and session-end
15
+ `scope_warnings` were documented below in 1.19.0 but computed-then-dropped
16
+ before the MCP boundary on the surfaces agents actually call. They now reach
17
+ `bclaw_session_start`, `bclaw_session_end` and `bclaw_work` responses as
18
+ `warnings` + `warning_details`. `shared_checkout_warning` (session-start)
19
+ rides the same seam.
20
+ - Read contract only: no tool added/removed/renamed, no inputSchema change,
21
+ no surface-fingerprint movement. The dispatch-brief changes this release
22
+ (transport section, context envelope, worker reply contract, lane session
23
+ prohibition) are payload text, not protocol.
24
+
11
25
  ## [1.19.0] — 2026-08-01
12
26
 
13
27
  **Added — `warning_details` on the facade response contract (pln#635)**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.19.1",
3
+ "version": "1.20.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {