brainclaw 1.7.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -20,14 +20,44 @@
20
20
  */
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
+ import { execFileSync } from 'node:child_process';
23
24
  import { loadAssignment, listAssignments } from './assignments.js';
25
+ import { logger } from './logger.js';
24
26
  import { loadAgentRun, listAgentRuns } from './agentruns.js';
25
27
  import { loadClaim } from './claims.js';
26
28
  import { getLoop, listLoops } from './loops/store.js';
27
29
  import { isProcessAlive } from './agentrun-reconciler.js';
28
- import { latestActivityMs } from './runtime-signals.js';
30
+ import { findRuntimeNoteById } from './runtime.js';
31
+ import { latestActivityMs, decodeOemAwareBuffer } from './runtime-signals.js';
32
+ import { LaneResultSchema } from './schema.js';
29
33
  const DEFAULT_TAIL = 20;
30
34
  const DEFAULT_STALL_MS = 5 * 60_000;
35
+ const DEFAULT_BASE_REF = 'master';
36
+ /**
37
+ * pln#554 — worktree git evidence, the signal that beats process/administrative
38
+ * status: a worker that committed everything to its lane branch has DELIVERED,
39
+ * whatever its pid/heartbeat/assignment.status say. Shared by dispatch-status
40
+ * and `brainclaw dispatch watch`. Returns undefined when there is no worktree
41
+ * or git could not be queried (never conclude "no commits" from a failed read).
42
+ */
43
+ export function gitEvidence(worktreePath, baseRef) {
44
+ if (!worktreePath)
45
+ return undefined;
46
+ try {
47
+ const ahead = execFileSync('git', ['-C', worktreePath, 'rev-list', '--count', `${baseRef}..HEAD`], {
48
+ encoding: 'utf-8', timeout: 15000,
49
+ }).trim();
50
+ const status = execFileSync('git', ['-C', worktreePath, 'status', '--short'], {
51
+ encoding: 'utf-8', timeout: 15000,
52
+ });
53
+ const dirty = status.split('\n').filter((l) => l.trim() && !l.startsWith('??')).length;
54
+ return { commitsAhead: Number.parseInt(ahead, 10) || 0, dirtyTracked: dirty };
55
+ }
56
+ catch (err) {
57
+ logger.debug('dispatch status: git evidence unavailable:', err);
58
+ return undefined;
59
+ }
60
+ }
31
61
  // ── Internal helpers ──────────────────────────────────────────────────────
32
62
  function readLogTail(filePath, lines) {
33
63
  try {
@@ -35,7 +65,9 @@ function readLogTail(filePath, lines) {
35
65
  if (lines <= 0) {
36
66
  return { path: filePath, exists: true, size_bytes: stat.size };
37
67
  }
38
- const content = fs.readFileSync(filePath, 'utf-8');
68
+ // can_c39f0961: Windows-native tools write OEM cp850 — decode-aware read
69
+ // instead of blind utf-8 so the tail is human-readable.
70
+ const content = decodeOemAwareBuffer(fs.readFileSync(filePath));
39
71
  const all = content.split(/\r?\n/);
40
72
  // Strip trailing empty line from final \n
41
73
  if (all.length > 0 && all[all.length - 1] === '')
@@ -136,6 +168,36 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
136
168
  recommended_next_action: 'Verify the target_id is correct (asgn_/clm_/lop_/run_). Use bclaw_find(entity="assignment") to list available assignments.',
137
169
  };
138
170
  }
171
+ // pln#532 — RESULT is the #1 verdict signal. If the worker wrote LANE-RESULT.json
172
+ // it has FINISHED — regardless of pid / heartbeat / agent_run.status (a sandboxed
173
+ // worker frequently cannot self-update the run). This sits above every other
174
+ // signal, including the agent_run terminal/running checks below.
175
+ if (runtime.lane_result) {
176
+ const lr = runtime.lane_result;
177
+ const ok = lr.status === 'completed';
178
+ const stale = agentRun && agentRun.status !== 'completed'
179
+ ? ` (agent_run still ${agentRun.status}; the worker could not self-update — harvest reconciles it)`
180
+ : '';
181
+ return {
182
+ health: 'terminal',
183
+ summary: `worker reported done via LANE-RESULT.json: status=${lr.status} — ${lr.summary.slice(0, 140)}${stale}`,
184
+ recommended_next_action: ok
185
+ ? 'Worker finished. `brainclaw harvest <assignment_id>` to ingest the result, then commit/integrate its worktree diff and converge the lane.'
186
+ : `Worker reported "${lr.status}". Read the LANE-RESULT summary + stderr; address the blocker or reroute.`,
187
+ };
188
+ }
189
+ // pln#554 — git evidence is the #2 signal, ABOVE process sentinels and
190
+ // administrative status: commits ahead of base with a clean tracked tree
191
+ // means the worker delivered everything to the branch, even if its pid is
192
+ // dead, its heartbeat stale, or the run was relabeled interrupted by a TTL
193
+ // sweep (can_948acfd6). The verdict is "harvest it" — never "kill and reroute".
194
+ if ((runtime.commits_ahead ?? 0) > 0 && runtime.dirty_tracked === 0) {
195
+ return {
196
+ health: 'terminal',
197
+ summary: `worker delivered: ${runtime.commits_ahead} commit(s) ahead of base with a clean tracked tree — everything is on the lane branch${agentRun && !TERMINAL_RUN_STATUSES.has(agentRun.status) ? ` (agent_run still ${agentRun.status}; exit formalities missing — harvest reconciles it)` : ''}`,
198
+ recommended_next_action: 'Worker delivered; harvest it: `brainclaw harvest <assignment_id>` to ingest and merge the lane branch. Do NOT kill or reroute.',
199
+ };
200
+ }
139
201
  if (!agentRun) {
140
202
  return {
141
203
  health: 'not_dispatched',
@@ -215,7 +277,15 @@ export function getDispatchStatus(options) {
215
277
  const resolved = resolveTarget(options.target_id, cwd);
216
278
  const assignmentId = resolved.assignment_id;
217
279
  const assignment = assignmentId ? loadAssignment(assignmentId, cwd) : undefined;
218
- const claim = assignment?.claim_id ? loadClaim(assignment.claim_id, cwd) : undefined;
280
+ // loadClaim THROWS on a missing id a GC'd/never-created claim must not
281
+ // crash the whole diagnostic (sprint 1.5).
282
+ let claim;
283
+ if (assignment?.claim_id) {
284
+ try {
285
+ claim = loadClaim(assignment.claim_id, cwd);
286
+ }
287
+ catch { /* claim gone — diagnose without it */ }
288
+ }
219
289
  // Prefer the pre-resolved agent_run (when target_id was a run_…); otherwise
220
290
  // look up by assignment_id and pick the most recent attempt.
221
291
  let agentRun = resolved.agent_run;
@@ -241,13 +311,29 @@ export function getDispatchStatus(options) {
241
311
  // pln#527 — filesystem-activity age: max mtime across the captured logs + the
242
312
  // run's worktree files (skipping junctions). The truer liveness signal when
243
313
  // the heartbeat / last_event_at is stale during a long single operation.
244
- const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path;
314
+ // can_948acfd6: also fall back to assignment.worktree_path without it a
315
+ // LANE-RESULT.json sitting in the assignment's worktree was invisible when
316
+ // neither the run nor the claim carried the path, and the verdict degraded
317
+ // to 'read stderr for failure detail' despite a completed lane result.
318
+ const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path ?? assignment?.worktree_path;
245
319
  let lastFsActivityMs;
246
320
  if (assignmentId) {
247
321
  const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs);
248
322
  if (lastFs !== undefined)
249
323
  lastFsActivityMs = nowMs - lastFs;
250
324
  }
325
+ // pln#532 — the #1 verdict signal: a LANE-RESULT.json at the worktree root means
326
+ // the worker FINISHED (even if it couldn't self-update the run). Read + validate it.
327
+ let laneResult;
328
+ if (worktreeForFs) {
329
+ try {
330
+ const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
331
+ laneResult = { status: parsed.status, summary: parsed.summary };
332
+ }
333
+ catch { /* no / invalid LANE-RESULT.json */ }
334
+ }
335
+ // pln#554 — worktree git evidence (commits ahead of base + dirty tracked files).
336
+ const evidence = gitEvidence(worktreeForFs, options.base_ref ?? DEFAULT_BASE_REF);
251
337
  const runtime = {
252
338
  pid: agentRun?.pid,
253
339
  pid_alive: isProcessAlive(agentRun?.pid),
@@ -260,8 +346,26 @@ export function getDispatchStatus(options) {
260
346
  stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
261
347
  },
262
348
  last_fs_activity_ms: lastFsActivityMs,
349
+ lane_result: laneResult,
350
+ commits_ahead: evidence?.commitsAhead,
351
+ dirty_tracked: evidence?.dirtyTracked,
263
352
  };
264
- const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
353
+ let diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
354
+ // can_b8d53d18 — a `run_` target that resolves to nothing may be a LEGACY
355
+ // runtime_note id (pre-rtn_ prefix collision). Say so precisely instead of
356
+ // the generic "verify the target_id" message.
357
+ if (resolved.resolved_from === 'unresolved' && options.target_id.startsWith('run_')) {
358
+ try {
359
+ if (findRuntimeNoteById(options.target_id, {}, cwd)) {
360
+ diagnosis = {
361
+ health: 'unknown',
362
+ summary: `${options.target_id} is a runtime_note (legacy run_ id prefix), not an agent_run — nothing to dispatch-diagnose`,
363
+ recommended_next_action: 'Read it with bclaw_get(entity="runtime_note"). Run `brainclaw repair` to migrate legacy run_ note ids to rtn_.',
364
+ };
365
+ }
366
+ }
367
+ catch { /* diagnosis stays generic */ }
368
+ }
265
369
  return {
266
370
  target_id: options.target_id,
267
371
  resolved_from: resolved.resolved_from,
@@ -43,8 +43,8 @@ import { memoryDir } from './io.js';
43
43
  import { loadVersionedJsonFile } from './migration.js';
44
44
  import fs from 'node:fs';
45
45
  import path from 'node:path';
46
- import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, dispatchHasMcp, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
47
- import { getRuntimeSignalPath } from './runtime-signals.js';
46
+ import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, dispatchHasMcp, dispatchCanCommit, isSandboxedSpawn, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
47
+ import { getRuntimeSignalPath, getWorktreeHeartbeatPath } from './runtime-signals.js';
48
48
  import { attemptExecution } from './execution.js';
49
49
  import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
50
50
  import { createAgentRun, transitionAgentRun } from './agentruns.js';
@@ -211,12 +211,28 @@ export function analyzeSequence(cwd) {
211
211
  * heartbeat. This is the worker-side half of the liveness contract whose
212
212
  * engine-side floor is the wrapper + reconciler (steps 4 + 1).
213
213
  */
214
- export function buildLivenessSection(cwd, assignmentId) {
215
- const hbPath = getRuntimeSignalPath(cwd, assignmentId, 'heartbeat');
214
+ export function buildLivenessSection(cwd, assignmentId, worktreePath, opts) {
215
+ // sprint 1.5 (dogfooding): the project-root signal path is NOT writable from
216
+ // inside worker sandboxes (Claude Code restricts writes to its working dirs;
217
+ // codex workspace-write roots exclude the project root) — the brief was
218
+ // demanding a heartbeat the worker could not write. When the worker has a
219
+ // worktree, point step 0 at a worktree-local heartbeat instead; every reader
220
+ // (reconciler, sweeper, dispatch_status fs-activity) checks both locations.
221
+ //
222
+ // pln#554 step 4 — sandbox-aware: codex workspace-write refuses even absolute
223
+ // paths in some configurations (cnd_asgn_7336aa79_heartbeat_sandbox /
224
+ // can_asgn_b0169fd8_heartbeat). When the execution adapter KNOWS the worker is
225
+ // sandboxed, point the write command at a worktree-RELATIVE path (the sandbox
226
+ // cwd is the worktree root) — same file, sandbox-proof spelling.
227
+ const sandboxRelative = opts?.sandboxed === true && !!worktreePath;
228
+ const hbPath = worktreePath
229
+ ? getWorktreeHeartbeatPath(worktreePath, assignmentId)
230
+ : getRuntimeSignalPath(cwd, assignmentId, 'heartbeat');
231
+ const targetPath = sandboxRelative ? `.brainclaw-heartbeat-${assignmentId}` : hbPath;
216
232
  const isWin = process.platform === 'win32';
217
233
  const writeCmd = isWin
218
- ? `echo work_loop_reached ${assignmentId} > "${hbPath}"`
219
- : `printf 'work_loop_reached ${assignmentId} %s' "$(date +%s)" > "${hbPath}"`;
234
+ ? `echo work_loop_reached ${assignmentId} > "${targetPath}"`
235
+ : `printf 'work_loop_reached ${assignmentId} %s' "$(date +%s)" > "${targetPath}"`;
220
236
  return [
221
237
  '## Liveness — DO THIS FIRST (step 0)',
222
238
  'Before ANY other action, prove you reached your work loop by writing a heartbeat,',
@@ -228,7 +244,28 @@ export function buildLivenessSection(cwd, assignmentId) {
228
244
  '```sh',
229
245
  writeCmd,
230
246
  '```',
231
- `Heartbeat file (absolute, writable): ${hbPath}`,
247
+ sandboxRelative
248
+ ? `Heartbeat file (worktree-RELATIVE — run it from the worktree root, your sandbox cwd; sandboxes refuse the absolute coordination path): ${targetPath}`
249
+ : `Heartbeat file (absolute, writable from your sandbox): ${hbPath}`,
250
+ ...(worktreePath ? ['If that write is denied, use any file edit in your worktree as your liveness signal and continue — do NOT stall on the heartbeat.'] : []),
251
+ '',
252
+ ].join('\n');
253
+ }
254
+ /**
255
+ * pln#554 step 4 — working defaults baked into every generated brief, distilled
256
+ * from the 2026-06-10 session: (a) incremental commits so a worker death costs
257
+ * one step max (the orphaned recoveries that night lost zero work ONLY because
258
+ * the diff was still on disk); (b) a split validation bar so parallel workers
259
+ * don't pile full test suites onto a memory-pressured machine.
260
+ */
261
+ export function buildWorkingDefaultsSection(opts) {
262
+ const commitRule = opts.canCommit
263
+ ? '- **Incremental commits**: commit after EACH completed step (conventional message). Never hold more than one step uncommitted — a worker death then costs at most one step, and the coordinator can harvest everything already on the branch.'
264
+ : '- **Incremental delivery**: your sandbox cannot `git commit` — finish steps in order and keep every file saved as you complete each step; the coordinator commits the worktree on your behalf at harvest. Never leave a step half-edited.';
265
+ return [
266
+ '## Working defaults',
267
+ commitRule,
268
+ '- **Validation bar**: run `tsc --noEmit` (or the project typecheck) + the targeted unit tests for the files you touched ONLY. Do NOT run the full default test suite — the coordinator runs the full gate after harvest (prevents test-suite pileups when several workers run in parallel).',
232
269
  '',
233
270
  ].join('\n');
234
271
  }
@@ -364,12 +401,18 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
364
401
  if (plan.estimated_effort)
365
402
  parts.push(`Estimated effort: ${plan.estimated_effort} minutes`);
366
403
  parts.push('');
404
+ // Capability profile drives the sandbox-aware liveness path + the working
405
+ // defaults' commit rule (pln#554 step 4) and the transport addendum below.
406
+ const briefProfile = options?.agent ? getCapabilityProfile(options.agent) : undefined;
407
+ const briefSandboxed = briefProfile ? isSandboxedSpawn(briefProfile) : false;
367
408
  // pln#520 step 5 — liveness heartbeat instruction, first actionable block so
368
409
  // the worker writes work_loop_reached before anything else. Only when an
369
410
  // assignment id is known (the heartbeat is keyed by it).
370
411
  if (options?.assignmentId) {
371
- parts.push(buildLivenessSection(cwd, options.assignmentId));
412
+ parts.push(buildLivenessSection(cwd, options.assignmentId, options.worktreePath, { sandboxed: briefSandboxed }));
372
413
  }
414
+ // pln#554 step 4 — working defaults (incremental commits + validation bar).
415
+ parts.push(buildWorkingDefaultsSection({ canCommit: briefProfile ? dispatchCanCommit(briefProfile) : true }));
373
416
  // Steps if any
374
417
  if (plan.steps?.length) {
375
418
  parts.push('## Steps');
@@ -435,7 +478,6 @@ export function generateBrief(plan, item, cwd, briefMode, options) {
435
478
  // so the reconciler-independent path is preserved; this addendum disambiguates
436
479
  // the transport rather than stripping the section — the full compact reversal
437
480
  // is a separate human-owned call on the May-vs-June MCP-availability conflict.)
438
- const briefProfile = options?.agent ? getCapabilityProfile(options.agent) : undefined;
439
481
  if (briefProfile && !dispatchHasMcp(briefProfile)) {
440
482
  parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
441
483
  parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
@@ -468,6 +510,15 @@ export function generateDispatchBrief(options) {
468
510
  if (options.worktreePath)
469
511
  parts.push(`Worktree: ${options.worktreePath}`);
470
512
  parts.push('');
513
+ const taskBriefProfile = options.agent ? getCapabilityProfile(options.agent) : undefined;
514
+ const taskSandboxed = taskBriefProfile ? isSandboxedSpawn(taskBriefProfile) : false;
515
+ // sprint 1.5 — task-based briefs get the same step-0 liveness contract as
516
+ // plan-based briefs (worktree-local heartbeat, writable from any sandbox).
517
+ if (options.assignmentId && options.worktreePath) {
518
+ parts.push(buildLivenessSection(options.worktreePath, options.assignmentId, options.worktreePath, { sandboxed: taskSandboxed }));
519
+ }
520
+ // pln#554 step 4 — working defaults (incremental commits + validation bar).
521
+ parts.push(buildWorkingDefaultsSection({ canCommit: taskBriefProfile ? dispatchCanCommit(taskBriefProfile) : true }));
471
522
  if (briefMode === 'full') {
472
523
  parts.push(buildProtocolSection({
473
524
  claimId: options.claimId,
@@ -476,7 +527,6 @@ export function generateDispatchBrief(options) {
476
527
  }));
477
528
  }
478
529
  // pln#528 — transport-aware addendum for sandboxed agents (see generateBrief).
479
- const taskBriefProfile = options.agent ? getCapabilityProfile(options.agent) : undefined;
480
530
  if (taskBriefProfile && !dispatchHasMcp(taskBriefProfile)) {
481
531
  parts.push('## ⚠ Transport: sandboxed run (no MCP, no commit)');
482
532
  parts.push('Your runtime is sandboxed — the brainclaw MCP server is NOT reachable and `git commit` is unavailable (.git is outside the sandbox root). Any `bclaw_*` MCP instruction above does NOT apply to you. Report your outcome via the FILE protocol only — it is authoritative for this run:');
@@ -679,7 +729,7 @@ export async function dispatch(options, cwd) {
679
729
  const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
680
730
  if (invokeCmd) {
681
731
  const cmdPrefix = buildEnvPrefix(claimId);
682
- result.commands.push({ agent: targetAgent, lane: readyItem.lane, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
732
+ result.commands.push({ agent: targetAgent, lane: readyItem.lane, plan_id: readyItem.plan.id, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
683
733
  }
684
734
  const deliveryEntry = { agent: targetAgent, plan_id: readyItem.plan.id, message_id: '(dry-run)', lane: readyItem.lane, channel: 'inbox', claim_id: claimId };
685
735
  result.delivery_plan.push(deliveryEntry);
@@ -738,6 +788,7 @@ export async function dispatch(options, cwd) {
738
788
  result.commands.push({
739
789
  agent: targetAgent,
740
790
  lane: readyItem.lane,
791
+ plan_id: readyItem.plan.id,
741
792
  command: `${cmdPrefix}${invokeCmd.bashCommand}`,
742
793
  shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh'),
743
794
  });
@@ -850,6 +901,7 @@ export async function dispatch(options, cwd) {
850
901
  dispatcherAgentId: options.dispatcherAgentId,
851
902
  cwd,
852
903
  handshakeTimeoutMs: options.handshakeTimeoutMs,
904
+ requireWorktree: true, // pln#531: never spawn a worker in the integration repo
853
905
  });
854
906
  entry.execution_status = execResult.execution_status;
855
907
  if (execResult.pid)
@@ -880,6 +932,7 @@ export async function dispatch(options, cwd) {
880
932
  status_reason: 'CLI spawn launched by dispatcher',
881
933
  tags: ['dispatch-run', ...(entry.lane ? [`lane:${entry.lane}`] : [])],
882
934
  }, cwd);
935
+ entry.run_id = run.id;
883
936
  transitionAgentRun(run.id, 'failed', {
884
937
  actor: options.dispatcherAgent,
885
938
  actor_id: options.dispatcherAgentId,
@@ -927,6 +980,7 @@ export async function dispatch(options, cwd) {
927
980
  status_reason: execResult.error,
928
981
  tags: ['dispatch-run', ...(entry.lane ? [`lane:${entry.lane}`] : [])],
929
982
  }, cwd);
983
+ entry.run_id = run.id;
930
984
  if (execResult.execution_status === 'delivered_and_started') {
931
985
  transitionAgentRun(run.id, 'launching', {
932
986
  actor: options.dispatcherAgent,
@@ -12,14 +12,15 @@
12
12
  * until later slices wire them in.
13
13
  */
14
14
  import path from 'node:path';
15
- import { loadState, persistState } from './state.js';
15
+ import { loadState, mutateState } from './state.js';
16
16
  import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
17
17
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
18
- import { listClaims } from './claims.js';
18
+ import { listClaims, loadClaim, saveClaim } from './claims.js';
19
19
  import { listActionRequired } from './actions.js';
20
20
  import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
21
21
  import { listAgentRuns } from './agentruns.js';
22
22
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
23
+ import { isObserverMode } from './observer-mode.js';
23
24
  import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
24
25
  import { createSequence, deleteSequence, listSequences, updateSequence, } from './sequence.js';
25
26
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
@@ -102,6 +103,12 @@ export class InvalidTransitionError extends Error {
102
103
  */
103
104
  function loadAgentRunsWithReconciliation(cwd) {
104
105
  const runs = listAgentRuns(cwd);
106
+ // Observer mode (BRAINCLAW_OBSERVER=1) suppresses the lazy reconciliation
107
+ // pass. A dashboard reading agent_run records must never transition them —
108
+ // that loop drove the 2026-06-10 lock storm (every poll could mutate every
109
+ // non-terminal run, holding the mutation lock under each transition).
110
+ if (isObserverMode())
111
+ return runs;
105
112
  for (const run of runs) {
106
113
  if (run.status === 'running') {
107
114
  try {
@@ -441,6 +448,20 @@ export function updateEntity(name, id, patch, cwd) {
441
448
  saveAssignment(patched, cwd);
442
449
  return { entity: name, id };
443
450
  }
451
+ case 'claim': {
452
+ // sprint 1.5 — description + worktree_path (manual-worktree registration).
453
+ // Status changes still go through bclaw_transition / release flows.
454
+ let claim;
455
+ try {
456
+ claim = loadClaim(id, cwd);
457
+ }
458
+ catch {
459
+ throw new EntityNotFoundError(name, id);
460
+ }
461
+ const patched = { ...claim, ...patch };
462
+ saveClaim(patched, cwd);
463
+ return { entity: name, id };
464
+ }
444
465
  case 'candidate': {
445
466
  // Note: `candidate.type` is intentionally create-only (not in
446
467
  // candidate.updatable at entity-registry.ts) — no validation needed.
@@ -561,8 +582,9 @@ export function transitionEntity(name, id, to, cwd, _reason) {
561
582
  if (!spec.statusField) {
562
583
  throw new Error(`${name} has no lifecycle (statusField is undefined)`);
563
584
  }
585
+ const statusField = spec.statusField;
564
586
  const current = getEntity(name, id, cwd);
565
- const from = current[spec.statusField];
587
+ const from = current[statusField];
566
588
  if (!from) {
567
589
  throw new Error(`${name} '${id}' has no '${spec.statusField}' field set`);
568
590
  }
@@ -579,15 +601,15 @@ export function transitionEntity(name, id, to, cwd, _reason) {
579
601
  case 'decision':
580
602
  case 'constraint':
581
603
  case 'trap': {
582
- const state = loadState(cwd);
583
- const bucket = name === 'decision' ? state.recent_decisions
584
- : name === 'constraint' ? state.active_constraints
585
- : state.known_traps;
586
- const item = bucket.find((x) => x.id === id);
587
- if (!item)
588
- throw new EntityNotFoundError(name, id);
589
- item[spec.statusField] = to;
590
- persistState(state, cwd);
604
+ mutateState((state) => {
605
+ const bucket = name === 'decision' ? state.recent_decisions
606
+ : name === 'constraint' ? state.active_constraints
607
+ : state.known_traps;
608
+ const item = bucket.find((x) => x.id === id);
609
+ if (!item)
610
+ throw new EntityNotFoundError(name, id);
611
+ item[statusField] = to;
612
+ }, cwd);
591
613
  return { entity: name, id, from, to, side_effects: sideEffects };
592
614
  }
593
615
  case 'candidate': {
@@ -618,20 +640,19 @@ export function transitionEntity(name, id, to, cwd, _reason) {
618
640
  // ─── Helpers ──────────────────────────────────────────────────────────
619
641
  /**
620
642
  * Stamp provenance on a state-resident record (plan, decision, constraint, trap)
621
- * immediately after create. Writes one extra persistState call; acceptable for
622
- * v1 since create is infrequent compared to reads.
643
+ * immediately after create.
623
644
  */
624
645
  function stampProvenanceOnStateItem(name, id, provenance, cwd) {
625
- const state = loadState(cwd);
626
- const bucket = name === 'plan' ? state.plan_items
627
- : name === 'decision' ? state.recent_decisions
628
- : name === 'constraint' ? state.active_constraints
629
- : state.known_traps;
630
- const item = bucket.find((x) => x.id === id);
631
- if (!item)
632
- return;
633
- item.provenance = provenance;
634
- persistState(state, cwd);
646
+ mutateState((state) => {
647
+ const bucket = name === 'plan' ? state.plan_items
648
+ : name === 'decision' ? state.recent_decisions
649
+ : name === 'constraint' ? state.active_constraints
650
+ : state.known_traps;
651
+ const item = bucket.find((x) => x.id === id);
652
+ if (!item)
653
+ return;
654
+ item.provenance = provenance;
655
+ }, cwd);
635
656
  }
636
657
  function requireString(data, field) {
637
658
  const value = data[field];
@@ -44,7 +44,7 @@ const step = {
44
44
  name: 'step',
45
45
  shortLabelPrefix: 'stp',
46
46
  schema: PlanStepSchema,
47
- updatable: ['text', 'assignee'],
47
+ updatable: ['text', 'assignee', 'estimated_effort', 'actual_effort'],
48
48
  statusField: 'status',
49
49
  transitions: {
50
50
  todo: ['in_progress', 'blocked', 'done'],
@@ -63,7 +63,9 @@ const claim = {
63
63
  name: 'claim',
64
64
  shortLabelPrefix: 'clm',
65
65
  schema: ClaimSchema,
66
- updatable: ['description'],
66
+ // worktree_path: sprint 1.5 — coordinators register manual worktrees (or fix
67
+ // stale paths) so harvest/dispatch_status can resolve LANE-RESULT locations.
68
+ updatable: ['description', 'worktree_path'],
67
69
  statusField: 'status',
68
70
  transitions: {
69
71
  active: ['released', 'stale'],
@@ -107,7 +109,16 @@ const decision = {
107
109
  name: 'decision',
108
110
  shortLabelPrefix: 'dec',
109
111
  schema: DecisionSchema,
110
- updatable: ['text', 'tags', 'outcome', 'scope', 'related_paths'],
112
+ updatable: [
113
+ 'text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd',
114
+ // pln#544 lifecycle (touched via memory-lifecycle.ts recordMemoryEvent;
115
+ // exposing them here keeps bclaw_update straight-through for tests and
116
+ // operator backfills).
117
+ 'last_confirmed_at', 'last_infirmed_at',
118
+ 'confirmation_count', 'infirmation_count',
119
+ 'saved_me_count', 'misled_me_count',
120
+ 'confirmations',
121
+ ],
111
122
  statusField: 'outcome',
112
123
  transitions: {
113
124
  pending: ['approved', 'rejected', 'deferred'],
@@ -121,7 +132,14 @@ const constraint = {
121
132
  name: 'constraint',
122
133
  shortLabelPrefix: 'cst',
123
134
  schema: ConstraintSchema,
124
- updatable: ['text', 'tags', 'category', 'scope', 'related_paths', 'expires_at'],
135
+ updatable: [
136
+ 'text', 'tags', 'category', 'scope', 'related_paths', 'expires_at',
137
+ // pln#544 lifecycle — see decision.updatable.
138
+ 'last_confirmed_at', 'last_infirmed_at',
139
+ 'confirmation_count', 'infirmation_count',
140
+ 'saved_me_count', 'misled_me_count',
141
+ 'confirmations',
142
+ ],
125
143
  statusField: 'status',
126
144
  transitions: {
127
145
  active: ['resolved', 'expired'],
@@ -137,7 +155,15 @@ const trap = {
137
155
  name: 'trap',
138
156
  shortLabelPrefix: 'trp',
139
157
  schema: TrapSchema,
140
- updatable: ['text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope'],
158
+ updatable: [
159
+ 'text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope',
160
+ 'verified_at', 'verify_cmd',
161
+ // pln#544 lifecycle — see decision.updatable.
162
+ 'last_confirmed_at', 'last_infirmed_at',
163
+ 'confirmation_count', 'infirmation_count',
164
+ 'saved_me_count', 'misled_me_count',
165
+ 'confirmations',
166
+ ],
141
167
  statusField: 'status',
142
168
  transitions: {
143
169
  active: ['resolved', 'expired'],