brainclaw 1.17.0 → 1.18.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 (80) hide show
  1. package/README.md +5 -5
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/code-map.js +4 -1
  4. package/dist/commands/codev.js +61 -30
  5. package/dist/commands/doctor.js +14 -1
  6. package/dist/commands/harvest.js +196 -42
  7. package/dist/commands/inbox.js +10 -4
  8. package/dist/commands/loop.js +2 -2
  9. package/dist/commands/loops-handlers.js +82 -1
  10. package/dist/commands/mcp-catalog.js +12 -4
  11. package/dist/commands/mcp-read-handlers.js +90 -7
  12. package/dist/commands/mcp-schemas.generated.js +3 -0
  13. package/dist/commands/mcp-write-coordination.js +159 -40
  14. package/dist/commands/mcp.js +11 -2
  15. package/dist/core/agentrun-reconciler.js +171 -7
  16. package/dist/core/agentruns.js +6 -1
  17. package/dist/core/code-map/aggregate.js +473 -0
  18. package/dist/core/code-map/backend.js +36 -10
  19. package/dist/core/code-map/freshness.js +36 -1
  20. package/dist/core/code-map/lang/c/imports.scm +12 -0
  21. package/dist/core/code-map/lang/c/index.js +150 -0
  22. package/dist/core/code-map/lang/c/tags.scm +68 -0
  23. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  24. package/dist/core/code-map/lang/cpp/index.js +149 -0
  25. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  26. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  27. package/dist/core/code-map/lang/csharp/index.js +224 -0
  28. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  29. package/dist/core/code-map/lang/go/imports.scm +13 -0
  30. package/dist/core/code-map/lang/go/index.js +139 -0
  31. package/dist/core/code-map/lang/go/tags.scm +36 -0
  32. package/dist/core/code-map/lang/providers.js +12 -1
  33. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  34. package/dist/core/code-map/lang/ruby/index.js +198 -0
  35. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  36. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  37. package/dist/core/code-map/lang/rust/index.js +136 -0
  38. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  39. package/dist/core/code-map/query.js +229 -80
  40. package/dist/core/code-map/types.js +18 -0
  41. package/dist/core/code-map/work-section.js +8 -7
  42. package/dist/core/codev-responses.js +16 -0
  43. package/dist/core/dispatcher.js +176 -22
  44. package/dist/core/execution-adapters.js +29 -3
  45. package/dist/core/ideation-loop-close.js +124 -0
  46. package/dist/core/loops/artifact-resolver.js +197 -0
  47. package/dist/core/loops/attempt-reservation.js +576 -0
  48. package/dist/core/loops/commit-intent.js +494 -0
  49. package/dist/core/loops/facade-schema.js +48 -0
  50. package/dist/core/loops/impl-bind.js +144 -0
  51. package/dist/core/loops/index.js +1 -1
  52. package/dist/core/loops/iteration-engine.js +29 -0
  53. package/dist/core/loops/lock.js +14 -0
  54. package/dist/core/loops/project-resolution.js +157 -0
  55. package/dist/core/loops/reconcile-turn.js +369 -0
  56. package/dist/core/loops/result-reducers.js +88 -0
  57. package/dist/core/loops/store.js +46 -7
  58. package/dist/core/loops/types.js +139 -11
  59. package/dist/core/loops/verbs.js +9 -3
  60. package/dist/core/loops/verify-command.js +209 -0
  61. package/dist/core/messaging.js +58 -5
  62. package/dist/core/review-loop-close.js +5 -2
  63. package/dist/core/review-loop-turn-dispatch.js +290 -28
  64. package/dist/core/runtime-signals.js +68 -0
  65. package/dist/core/schema.js +24 -0
  66. package/dist/core/worktree.js +24 -0
  67. package/dist/facts.js +9 -9
  68. package/dist/facts.json +8 -8
  69. package/dist/wasm/tree-sitter-c.wasm +0 -0
  70. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  71. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  72. package/dist/wasm/tree-sitter-go.wasm +0 -0
  73. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  74. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  75. package/docs/cli.md +1 -1
  76. package/docs/code-map.md +22 -6
  77. package/docs/concepts/loop-engine.md +24 -0
  78. package/docs/concepts/observer-protocol.md +22 -0
  79. package/docs/mcp-schema-changelog.md +43 -1
  80. package/package.json +1 -1
@@ -19,12 +19,205 @@
19
19
  * here imports harvest or review-loop-close, so no import cycle is introduced.
20
20
  */
21
21
  import { createCoordinatorClaim, attachAssignmentMessageToClaim, linkClaimToAssignment } from './claims.js';
22
- import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
22
+ import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId, loadAssignment } from './assignments.js';
23
23
  import { turn } from './loops/verbs.js';
24
+ import { getLoop } from './loops/store.js';
24
25
  import { generateDispatchBrief } from './dispatcher.js';
25
26
  import { sendMessage } from './messaging.js';
26
27
  import { buildInvokeCommand, resolveModel } from './agent-capability.js';
27
28
  import { attemptExecution } from './execution.js';
29
+ import { createAgentRun, loadAgentRun, transitionAgentRun } from './agentruns.js';
30
+ import { reserve, commitReservation, armLaunch, consumeLaunchGrant, launchGrant, deriveTurnId, deriveChildIds, ReservationStateError, LaunchFenceError, } from './loops/attempt-reservation.js';
31
+ /**
32
+ * pln#630 — gate for the turn-owned (exactly-once) review dispatch path.
33
+ *
34
+ * NOW DEFAULT ON (the shipped default): the finalization (PR3a), autonomous fix-cycle (PR3b),
35
+ * strand self-heal (PR4), and revoked-grant recovery (R1) are all merged; the §9 conformance
36
+ * harness + a live end-to-end (real spawn → turn-keyed sentinel → harvest → reconcileTurn →
37
+ * close-on-approve) validated the path. `BRAINCLAW_TURN_OWNED_REVIEW=0` is the explicit
38
+ * KILL-SWITCH that reverts to the legacy closer (byte-identical) if a problem surfaces in prod.
39
+ * Note the routing is doubly-gated: even ON, harvest only reconcile-turns a lane that OWNS a
40
+ * turn reservation — a legacy-dispatched lane (no reservation) still uses the legacy path.
41
+ */
42
+ export function turnOwnedReviewEnabled() {
43
+ // Normalized kill-switch (review Finding 4): any of 0/false/off/no (case/space-insensitive)
44
+ // disables — so an operator reaching for it under pressure can't mis-set it. Anything else
45
+ // (including unset) keeps the shipped default ON.
46
+ const v = (process.env.BRAINCLAW_TURN_OWNED_REVIEW ?? '').trim().toLowerCase();
47
+ return !['0', 'false', 'off', 'no'].includes(v);
48
+ }
49
+ /** GRANT lease: bounds ONE launch generation's reserve→arm→consume→spawn→run-`running`
50
+ * window. Long enough that a genuinely launching worker never expires under the PR2c-lease
51
+ * pre-run reconciler; a live `running` run is out of that reconciler's scope. */
52
+ const TURN_OWNED_LEASE_MS = 10 * 60_000;
53
+ /** DISPATCH lease: how long the committed RESERVATION stays re-dispatchable. Strictly LONGER
54
+ * than the grant lease (pln#630 dec#149 R1 / review Finding 1): a reserved_never_launched
55
+ * round (grant lease expired → the reconciler revokes the grant) then still has a RECOVERY
56
+ * WINDOW to re-arm a fresh generation before the reservation itself expires. armLaunch
57
+ * enforces this bound, so a re-dispatch past it refuses arm and stays correctly stranded —
58
+ * no phantom-spawn-after-lease. Decoupling the two is what makes R1 recovery actually reachable
59
+ * (with a single shared lease, the grant is revoked exactly when the dispatch lease is already
60
+ * expired, so re-arm was always denied). */
61
+ const TURN_OWNED_DISPATCH_LEASE_MS = 30 * 60_000;
62
+ /**
63
+ * Prepare a turn-owned review dispatch (pln#630 PR2c-b, design dec#144). Runs the
64
+ * exactly-once machine INLINE in the coordinator (which has store access, unlike
65
+ * the sandboxed worktree — trp_26e9634b): deterministic turn_id → reserve/adopt →
66
+ * commit → arm/adopt → consume, spawning ONLY on the winning consume.
67
+ *
68
+ * FAIL-CLOSED after `reserve`: once identity is claimed, any error aborts as
69
+ * `denied` (never legacy) so an ungated legacy worker can never spawn beside a
70
+ * live reservation (the adversarial double-spawn hole, dec#144 MUST-FIX 1). Only
71
+ * a failure BEFORE identity is reserved degrades to `legacy`.
72
+ */
73
+ export function prepareTurnOwnedReviewDispatch(input) {
74
+ const { loopId, slotId, claimId, cwd } = input;
75
+ // ── Snapshot the loop BEFORE turn() bumps its version (dec#139 item 3). ──
76
+ let iteration;
77
+ let version;
78
+ try {
79
+ const thread = getLoop(loopId, cwd);
80
+ if (!thread)
81
+ return { kind: 'legacy' }; // loop not found — pre-identity, safe to degrade
82
+ iteration = thread.iteration_count;
83
+ version = thread.version;
84
+ }
85
+ catch {
86
+ return { kind: 'legacy' };
87
+ }
88
+ const turnId = deriveTurnId(loopId, slotId, iteration);
89
+ const { assignment_id: assignmentId, run_id: runId } = deriveChildIds(turnId);
90
+ // Decoupled leases (dec#149 R1): the reservation dispatch lease is longer than each grant's
91
+ // lease, giving a revoked (reserved_never_launched) round a window to re-arm. reserve() adopts
92
+ // on a re-dispatch, so the ORIGINAL (longer) dispatch lease governs re-arm eligibility.
93
+ const dispatchLease = new Date(Date.now() + TURN_OWNED_DISPATCH_LEASE_MS).toISOString();
94
+ const grantLease = new Date(Date.now() + TURN_OWNED_LEASE_MS).toISOString();
95
+ // ── Phase 1: claim identity. Fail-OPEN allowed ONLY here (nothing reserved yet). ──
96
+ try {
97
+ reserve({
98
+ turn_id: turnId,
99
+ loop_id: loopId,
100
+ slot_id: slotId,
101
+ target_slot_generation: iteration, // LoopSlot has no generation field — observational proxy (dec#144 #8)
102
+ loop_version_at_reserve: version,
103
+ agent: input.agent,
104
+ agent_id: input.agentId,
105
+ claim_id: claimId,
106
+ phase: input.phase,
107
+ iteration,
108
+ store_root: cwd,
109
+ cwd,
110
+ lease_deadline: dispatchLease,
111
+ }, cwd);
112
+ }
113
+ catch (err) {
114
+ if (err instanceof ReservationStateError && err.code === 'reservation_exists') {
115
+ // A concurrent dispatch already OWNS this turn_id — adopt it and fall
116
+ // through to the fail-closed consume path (we may still legitimately win
117
+ // the fence if the owner reserved-but-never-crossed; otherwise denied).
118
+ }
119
+ else {
120
+ // FAIL-CLOSED (review Finding 1): any other reserve outcome is
121
+ // INDETERMINATE — a lock timeout (a live holder mid-critical-section),
122
+ // lock-lost, or unknown error does NOT prove that no identity was claimed.
123
+ // Degrading to `legacy` here would spawn an ungated worker beside a
124
+ // reservation that may well exist — the exact concurrent-dispatch
125
+ // double-spawn MUST-FIX 1 closes. (A definitively pre-identity error is
126
+ // unreachable here: the lease we build is always parseable, and
127
+ // loop-not-found is handled before reserve.)
128
+ return { kind: 'denied', reason: `reserve indeterminate — fail-closed (no legacy fallback): ${err instanceof Error ? err.message : String(err)}` };
129
+ }
130
+ }
131
+ // ── Phase 2: FAIL-CLOSED. Identity is reserved; never legacy-spawn from here. ──
132
+ try {
133
+ commitReservation(turnId, cwd);
134
+ // Arm-or-adopt the launch grant. Arm when none exists OR when a prior generation was
135
+ // REVOKED (reserved_never_launched — a crash between arm and consume, then the expiry
136
+ // sweep): re-arm a FRESH generation at a strictly-higher epoch so a revoked round becomes
137
+ // re-dispatchable (pln#630 dec#149 R1 strand recovery). armLaunch permits re-arming a
138
+ // revoked grant and still enforces the dispatch lease, so a lease-expired reservation
139
+ // refuses arm and stays correctly stranded (never a phantom-spawn-after-lease). A
140
+ // concurrent arm surfaces as `already_armed` → adopt the incumbent grant.
141
+ let grant = launchGrant(turnId, cwd);
142
+ if (!grant || grant.status === 'revoked') {
143
+ const priorEpoch = grant?.epoch ?? -1; // fresh → epoch 0 (unchanged); revoked → epoch+1
144
+ try {
145
+ armLaunch(turnId, { epoch: priorEpoch + 1, lease_deadline: grantLease }, cwd);
146
+ }
147
+ catch (err) {
148
+ if (!(err instanceof LaunchFenceError && err.code === 'already_armed')) {
149
+ // dispatch_lease_expired / lease_invalid / not_committed / epoch_mismatch → do-not-spawn.
150
+ return { kind: 'denied', reason: `arm_refused: ${err instanceof Error ? err.message : String(err)}` };
151
+ }
152
+ }
153
+ grant = launchGrant(turnId, cwd);
154
+ }
155
+ // A crossed grant = launch_attempted_unknown (worker already invoked, never re-spawn);
156
+ // still-revoked = re-arm refused (lease expired) → never-launch; absent → all do-not-spawn.
157
+ if (!grant || grant.status !== 'armed') {
158
+ return { kind: 'denied', reason: `launch_denied: grant is ${grant?.status ?? 'absent'} (not armed)` };
159
+ }
160
+ // Consume the grant — the atomic exactly-once SPAWN authority.
161
+ let wonTransition;
162
+ try {
163
+ ({ wonTransition } = consumeLaunchGrant(turnId, grant.token, grant.epoch, cwd));
164
+ }
165
+ catch (err) {
166
+ return { kind: 'denied', reason: `launch_denied: consume refused (${err instanceof Error ? err.message : String(err)})` };
167
+ }
168
+ if (!wonTransition) {
169
+ // Adopted — another invocation crossed the fence. MUST NOT spawn.
170
+ return { kind: 'denied', reason: 'launch_denied: grant already crossed by a concurrent dispatch' };
171
+ }
172
+ // ── WON: this dispatch is the SOLE spawner. Bind slot + run to MY live claim
173
+ // (claimId), NOT the reservation's first-reserver claim (dec#144 #3) — else a
174
+ // recovery-winner would bind the slot to a dead claim and break complete_turn
175
+ // auth. Mints are idempotent (save overwrites, so guard on load). ──
176
+ if (!loadAssignment(assignmentId, cwd)) {
177
+ createAssignment({
178
+ id: assignmentId,
179
+ short_label: assignmentId,
180
+ claim_id: claimId,
181
+ agent: input.agent,
182
+ dispatcher_agent: input.dispatcherAgent,
183
+ dispatcher_session_id: input.sessionId,
184
+ scope: input.scope,
185
+ description: input.description,
186
+ tags: ['coordinate', 'review', 'loop', 'turn-owned', input.isReviewer ? 're-review' : 'author-fix'],
187
+ }, cwd);
188
+ }
189
+ if (!loadAgentRun(runId, cwd)) {
190
+ createAgentRun({
191
+ id: runId,
192
+ short_label: runId,
193
+ assignment_id: assignmentId,
194
+ claim_id: claimId,
195
+ agent: input.agent,
196
+ agent_id: input.agentId,
197
+ transport: 'cli_spawn',
198
+ status: 'created',
199
+ scope: input.scope,
200
+ description: input.description,
201
+ worktree_path: input.worktreePath,
202
+ tags: ['turn-owned', 'review', 'loop'],
203
+ }, cwd);
204
+ }
205
+ turn({
206
+ id: loopId,
207
+ slot_id: slotId,
208
+ actor: input.dispatcherAgentId ?? input.dispatcherAgent,
209
+ input: input.task,
210
+ turn_id: turnId,
211
+ assignment_id: assignmentId,
212
+ claim_id: claimId,
213
+ }, cwd);
214
+ return { kind: 'won', assignmentId, runId, turnId, nonce: grant.token };
215
+ }
216
+ catch (err) {
217
+ // FAIL-CLOSED: identity reserved; degrade to denied, NEVER legacy.
218
+ return { kind: 'denied', reason: `turn-owned prep aborted after reserve: ${err instanceof Error ? err.message : String(err)}` };
219
+ }
220
+ }
28
221
  /**
29
222
  * The structured signal a reviewer must emit in LANE-RESULT.json so harvest can
30
223
  * map its lane back onto the loop. Shared by the initial dispatch and every
@@ -75,37 +268,94 @@ export async function dispatchReviewLoopTurn(input) {
75
268
  result.claim_id = claimResult.claimId;
76
269
  result.worktree_path = claimResult.worktreePath;
77
270
  let assignmentId;
78
- try {
79
- const preId = generateAssignmentId(cwd);
80
- const assignment = createAssignment({
81
- id: preId.id,
82
- short_label: preId.short_label,
83
- claim_id: claimResult.claimId,
271
+ let turnEcho;
272
+ let runLegacyProjection = true;
273
+ // pln#630 — turn-owned (exactly-once) dispatch, now the DEFAULT + FAIL-CLOSED after
274
+ // reserve. Kill-switch off (BRAINCLAW_TURN_OWNED_REVIEW=0) → runLegacyProjection stays
275
+ // true and this branch is a byte-identical no-op (the legacy projection below runs).
276
+ if (turnOwnedReviewEnabled()) {
277
+ const prep = prepareTurnOwnedReviewDispatch({
278
+ loopId,
279
+ slotId: slot.slot_id,
84
280
  agent,
85
- dispatcher_agent: input.dispatcherAgent,
86
- dispatcher_session_id: input.sessionId,
87
- scope,
281
+ agentId: slot.agent_id,
282
+ phase,
283
+ task: input.task,
88
284
  description,
89
- tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
90
- }, cwd);
91
- assignmentId = assignment.id;
92
- result.assignment_id = assignment.id;
285
+ scope,
286
+ claimId: claimResult.claimId,
287
+ worktreePath: claimResult.worktreePath,
288
+ dispatcherAgent: input.dispatcherAgent,
289
+ dispatcherAgentId: input.dispatcherAgentId,
290
+ sessionId: input.sessionId,
291
+ isReviewer,
292
+ cwd,
293
+ });
294
+ if (prep.kind === 'denied') {
295
+ // The exactly-once fence says this dispatch is NOT the spawner (adopted /
296
+ // crossed / revoked / lease-expired). MUST NOT spawn AND MUST NOT fall back
297
+ // to legacy — a legacy spawn beside the live reservation is the double-spawn
298
+ // hole the fence exists to close (dec#144 MUST-FIX 1).
299
+ //
300
+ // Do NOT release the coordinator claim here (review Finding 2, round 2):
301
+ // createCoordinatorClaim dedups on scope+agent, so a same-turn concurrent
302
+ // dispatch SHARES one claim C1 — and claim-creation order is uncoupled from
303
+ // fence-crossing order (different locks), so the loser can be C1's creator
304
+ // while the WINNER merely reused it and bound its slot/run/assignment to C1.
305
+ // releaseClaim() runs unauthenticated with no active-binding guard, so ANY
306
+ // release in the denied path can flip a claim a live winner depends on to
307
+ // `released` (re-opening the slot.claim_id divergence MUST-FIX 3 closed). A
308
+ // genuine orphan — the rare double-failure where no dispatch wins — is
309
+ // low-harm and reaped by the claim staleness sweep (auto_release_after_hours);
310
+ // sabotaging a live winner is high-harm and not self-healing. So we leave it.
311
+ result.execution_status = 'inbox_only';
312
+ result.error = prep.reason;
313
+ return result;
314
+ }
315
+ if (prep.kind === 'won') {
316
+ // Deterministic assignment mint + slot binding already happened inside
317
+ // prepare; skip the legacy projection and carry the turn-keyed echo so the
318
+ // ack-wrapper writes a turn-keyed completion sentinel.
319
+ assignmentId = prep.assignmentId;
320
+ result.assignment_id = prep.assignmentId;
321
+ turnEcho = { turn_id: prep.turnId, run_id: prep.runId, nonce: prep.nonce };
322
+ runLegacyProjection = false;
323
+ }
324
+ // prep.kind === 'legacy' (fail-open BEFORE identity) → fall through unchanged.
93
325
  }
94
- catch (asgErr) {
95
- result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
326
+ if (runLegacyProjection) {
327
+ try {
328
+ const preId = generateAssignmentId(cwd);
329
+ const assignment = createAssignment({
330
+ id: preId.id,
331
+ short_label: preId.short_label,
332
+ claim_id: claimResult.claimId,
333
+ agent,
334
+ dispatcher_agent: input.dispatcherAgent,
335
+ dispatcher_session_id: input.sessionId,
336
+ scope,
337
+ description,
338
+ tags: ['coordinate', 'review', 'loop', isReviewer ? 're-review' : 'author-fix'],
339
+ }, cwd);
340
+ assignmentId = assignment.id;
341
+ result.assignment_id = assignment.id;
342
+ }
343
+ catch (asgErr) {
344
+ result.error = `assignment creation failed: ${asgErr instanceof Error ? asgErr.message : String(asgErr)}`;
345
+ }
346
+ // Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
347
+ // later harvest must resolve THIS slot by assignment_id, not by agent name
348
+ // (which is ambiguous under symmetric multi-reviewer loops). Runs even if
349
+ // assignment creation failed (undefined id → legacy agent-match fallback).
350
+ turn({
351
+ id: loopId,
352
+ slot_id: slot.slot_id,
353
+ actor: input.dispatcherAgentId ?? input.dispatcherAgent,
354
+ input: input.task,
355
+ assignment_id: assignmentId,
356
+ claim_id: claimResult.claimId,
357
+ }, cwd);
96
358
  }
97
- // Bind the slot to the new claim/assignment (PR1 BLOCKING 2 invariant): a
98
- // later harvest must resolve THIS slot by assignment_id, not by agent name
99
- // (which is ambiguous under symmetric multi-reviewer loops). Runs even if
100
- // assignment creation failed (undefined id → legacy agent-match fallback).
101
- turn({
102
- id: loopId,
103
- slot_id: slot.slot_id,
104
- actor: input.dispatcherAgentId ?? input.dispatcherAgent,
105
- input: input.task,
106
- assignment_id: assignmentId,
107
- claim_id: claimResult.claimId,
108
- }, cwd);
109
359
  // Reviewer turns must carry the verdict contract; author-fix turns must not
110
360
  // (an author lane has no verdict — it's mapped by scope+slot instead).
111
361
  const briefTask = isReviewer ? input.task + REVIEW_VERDICT_BRIEF_SUFFIX : input.task;
@@ -167,12 +417,24 @@ export async function dispatchReviewLoopTurn(input) {
167
417
  dispatcherAgentId: input.dispatcherAgentId,
168
418
  cwd,
169
419
  requireWorktree: true, // never spawn a worker in the integration repo (pln#531)
420
+ turnEcho, // pln#630 PR2c-b — undefined on the legacy path (wrapper unchanged)
170
421
  });
171
422
  result.execution_status = execResult.execution_status;
172
423
  result.command = execResult.command;
173
424
  result.shell = execResult.shell;
174
425
  if (execResult.error && !result.error)
175
426
  result.error = execResult.error;
427
+ // pln#630 PR2c-b — a turn-owned run was preallocated `created`; once the
428
+ // worker actually spawned, move it to `running` so it leaves the PR2c-lease
429
+ // pre-run lease scope (created/launching) and is governed by the heartbeat
430
+ // reconciler instead. If it did NOT start, leave it `created` → the pre-run
431
+ // reconciler converges it (crossed → launch_attempted_unknown) at lease.
432
+ if (turnEcho && execResult.execution_status === 'delivered_and_started') {
433
+ try {
434
+ transitionAgentRun(turnEcho.run_id, 'running', { actor: input.dispatcherAgent, status_reason: 'turn-owned worker spawned' }, cwd);
435
+ }
436
+ catch { /* best-effort — the reconciler converges if this races */ }
437
+ }
176
438
  return result;
177
439
  }
178
440
  catch (err) {
@@ -114,6 +114,74 @@ export function readHeartbeat(root, assignmentId, worktreePath) {
114
114
  return projectInfo;
115
115
  return (worktreeInfo.mtimeMs ?? 0) > (projectInfo.mtimeMs ?? 0) ? worktreeInfo : projectInfo;
116
116
  }
117
+ /**
118
+ * Write a turn-keyed completion/failed sentinel body. Used when brainclaw itself
119
+ * (wrapper/reconcile) writes the sentinel; the shell `&& completed` fallback
120
+ * still produces a legacy presence-only marker, which stays a valid life-sign
121
+ * via signalExists but is NOT accepted as turn-owned evidence (PR2b-c).
122
+ */
123
+ export function writeCompletionSignal(root, assignmentId, body) {
124
+ const p = getRuntimeSignalPath(root, assignmentId, body.status);
125
+ fs.mkdirSync(path.dirname(p), { recursive: true });
126
+ fs.writeFileSync(p, JSON.stringify(body), 'utf-8');
127
+ }
128
+ /** Parse ONE turn-keyed sentinel body, or undefined if absent / legacy
129
+ * presence-only / non-JSON / missing correlation keys. Never throws. */
130
+ function readOneCompletionSignal(root, assignmentId, status) {
131
+ let raw;
132
+ try {
133
+ raw = fs.readFileSync(getRuntimeSignalPath(root, assignmentId, status), 'utf-8').trim();
134
+ }
135
+ catch {
136
+ return undefined; // sentinel absent
137
+ }
138
+ if (!raw)
139
+ return undefined; // legacy presence-only (empty) marker
140
+ try {
141
+ const parsed = JSON.parse(raw);
142
+ if (typeof parsed.turn_id === 'string' &&
143
+ typeof parsed.run_id === 'string' &&
144
+ typeof parsed.nonce === 'string' &&
145
+ (parsed.status === 'completed' || parsed.status === 'failed')) {
146
+ return {
147
+ turn_id: parsed.turn_id,
148
+ run_id: parsed.run_id,
149
+ nonce: parsed.nonce,
150
+ status: parsed.status,
151
+ at: typeof parsed.at === 'string' ? parsed.at : '',
152
+ };
153
+ }
154
+ }
155
+ catch { /* non-JSON legacy body */ }
156
+ return undefined;
157
+ }
158
+ /**
159
+ * Read BOTH turn-keyed completion sentinels for an attempt. This is the
160
+ * authoritative reader for the read-strict acceptance path: it surfaces a
161
+ * `completed`+`failed` contradiction so the caller can raise a conflict event
162
+ * and WITHHOLD an irreversible auto-stop (spec §13 R4), rather than silently
163
+ * collapsing to one. Legacy presence-only markers read as absent here.
164
+ */
165
+ export function readCompletionSignals(root, assignmentId) {
166
+ const out = {};
167
+ const completed = readOneCompletionSignal(root, assignmentId, 'completed');
168
+ const failed = readOneCompletionSignal(root, assignmentId, 'failed');
169
+ if (completed)
170
+ out.completed = completed;
171
+ if (failed)
172
+ out.failed = failed;
173
+ return out;
174
+ }
175
+ /**
176
+ * Convenience single-body reader (`completed` preferred over `failed`). Returns
177
+ * undefined for absent / legacy presence-only / non-JSON / missing-keys.
178
+ * CALLERS THAT ACT IRREVERSIBLY must use {@link readCompletionSignals} instead
179
+ * so a completed+failed contradiction is not hidden (spec §13 R4).
180
+ */
181
+ export function readCompletionSignal(root, assignmentId) {
182
+ const both = readCompletionSignals(root, assignmentId);
183
+ return both.completed ?? both.failed;
184
+ }
117
185
  /**
118
186
  * can_c39f0961 — CP850 high-byte table (0x80–0xFF). Windows-native console
119
187
  * tools write redirected stdout/stderr in the OEM codepage (cp850 on western
@@ -479,6 +479,13 @@ export const InboxMessageSchema = z.object({
479
479
  read_at: z.string().optional(),
480
480
  /** When the message was acknowledged */
481
481
  ack_at: z.string().optional(),
482
+ /** True when the body was truncated at WRITE time because it exceeded the
483
+ * inline size cap (pln#627 Phase B). Unlike read-time previews, the omitted
484
+ * tail is NOT stored inline — the full artifact belongs in a dedicated store
485
+ * (e.g. ideation responses), with the message carrying only a pointer. */
486
+ truncated_at_write: z.boolean().optional(),
487
+ /** Original body length in characters before write-time truncation. */
488
+ original_text_length: z.number().int().nonnegative().optional(),
482
489
  created_at: z.string(),
483
490
  updated_at: z.string(),
484
491
  author: z.string(),
@@ -956,6 +963,9 @@ export const RuntimeEventTypeSchema = z.enum([
956
963
  'candidate_harvested',
957
964
  'lane_result_harvested',
958
965
  'lane_integrated',
966
+ // pln#521 P4 — a turn-owned loop artifact was harvested + integrated into the loop
967
+ // by reconcileTurn (observability for the harvest path).
968
+ 'loop_artifact_harvested',
959
969
  ]);
960
970
  /**
961
971
  * pln#526 — LANE-RESULT convention. A dispatched worker writes a single
@@ -966,6 +976,16 @@ export const RuntimeEventTypeSchema = z.enum([
966
976
  */
967
977
  export const LaneResultSchema = z.object({
968
978
  assignment_id: z.string(),
979
+ /**
980
+ * pln#630 PR2b-a (§13 R2/R3) — turn-attempt correlation keys. Optional for
981
+ * backward compat (legacy lanes are assignment-keyed only); a loop-dispatched
982
+ * lane echoes all three so the read-strict acceptance path can prove WHICH
983
+ * attempt+generation produced this result. `nonce` == the consumed launch
984
+ * token (the epoch-unique generation id), NOT a turn_id-bound value.
985
+ */
986
+ turn_id: z.string().optional(),
987
+ run_id: z.string().optional(),
988
+ nonce: z.string().optional(),
969
989
  status: z.enum(['completed', 'blocked', 'failed']),
970
990
  summary: z.string(),
971
991
  /** Paths or refs the worker produced (commits, files, docs). */
@@ -999,6 +1019,10 @@ export const RuntimeEventSchema = z.object({
999
1019
  tags: TagsWithDefaultSchema,
1000
1020
  assignment_id: z.string().optional(),
1001
1021
  run_id: z.string().optional(),
1022
+ // pln#630 PR2b-a (§13 R2/R3) — turn-attempt correlation on runtime signals.
1023
+ // `run_id` already present above; `nonce` == launch-generation token.
1024
+ turn_id: z.string().optional(),
1025
+ nonce: z.string().optional(),
1002
1026
  claim_id: z.string().optional(),
1003
1027
  message_id: z.string().optional(),
1004
1028
  plan_id: z.string().optional(),
@@ -1360,6 +1360,30 @@ export function isBranchMergedByContent(mainWorktreePath, branchName, baseRef =
1360
1360
  }
1361
1361
  return true;
1362
1362
  }
1363
+ /**
1364
+ * True when a LOCAL git branch of this exact name exists (pln#529). Lets the
1365
+ * gated-sequence base selector distinguish "predecessor branch gone (merged +
1366
+ * cleaned → code is on HEAD)" from "branch present but not yet integrated →
1367
+ * fork the dependent lane from it". Returns false on any git failure.
1368
+ */
1369
+ export function localBranchExists(mainWorktreePath, branchName) {
1370
+ return probeLocalBranch(mainWorktreePath, branchName) === 'present';
1371
+ }
1372
+ export function probeLocalBranch(mainWorktreePath, branchName) {
1373
+ const r = runGit(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], mainWorktreePath);
1374
+ if (r.ok)
1375
+ return 'present';
1376
+ return r.stderr.trim() === '' ? 'absent' : 'unknown';
1377
+ }
1378
+ /**
1379
+ * True when `cwd` is inside a git work tree. pln#529 uses this to distinguish a
1380
+ * NON-git project (where branch/worktree propagation is inapplicable — fall back
1381
+ * to the legacy HEAD base) from a git repo whose branch probe transiently failed
1382
+ * (which must fail SAFE, not silently assume HEAD).
1383
+ */
1384
+ export function isGitRepo(cwd) {
1385
+ return runGit(['rev-parse', '--is-inside-work-tree'], cwd).ok;
1386
+ }
1363
1387
  /**
1364
1388
  * Removes worktrees whose branch has been fully merged into the current branch
1365
1389
  * (typically master/main after a merge). Also removes brainclaw-managed
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.17.0 on 2026-07-19T20:01:41.172Z
2
+ // Source: brainclaw v1.18.0 on 2026-07-31T14:51:44.969Z
3
3
  export const FACTS = {
4
- "version": "1.17.0",
5
- "generated_at": "2026-07-19T20:01:41.172Z",
4
+ "version": "1.18.0",
5
+ "generated_at": "2026-07-31T14:51:44.969Z",
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-07-19T20:01:39.038Z",
477
+ "generated_at": "2026-07-31T14:51:42.799Z",
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": 76,
486
+ "duration_ms_median": 81,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,7 +491,7 @@ 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": 135,
494
+ "duration_ms_median": 142,
495
495
  "payload_chars_median": 2626,
496
496
  "payload_tokens_est_median": 657
497
497
  },
@@ -499,9 +499,9 @@ export const FACTS = {
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": 7,
503
- "payload_chars_median": 442,
504
- "payload_tokens_est_median": 111
502
+ "duration_ms_median": 12,
503
+ "payload_chars_median": 499,
504
+ "payload_tokens_est_median": 125
505
505
  }
506
506
  ]
507
507
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.17.0",
3
- "generated_at": "2026-07-19T20:01:41.172Z",
2
+ "version": "1.18.0",
3
+ "generated_at": "2026-07-31T14:51:44.969Z",
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-07-19T20:01:39.038Z",
475
+ "generated_at": "2026-07-31T14:51:42.799Z",
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": 76,
484
+ "duration_ms_median": 81,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,7 +489,7 @@
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": 135,
492
+ "duration_ms_median": 142,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
@@ -497,9 +497,9 @@
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": 7,
501
- "payload_chars_median": 442,
502
- "payload_tokens_est_median": 111
500
+ "duration_ms_median": 12,
501
+ "payload_chars_median": 499,
502
+ "payload_tokens_est_median": 125
503
503
  }
504
504
  ]
505
505
  }
Binary file
Binary file
Binary file
Binary file
Binary file
package/docs/cli.md CHANGED
@@ -2011,7 +2011,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
2011
2011
  |---|---|
2012
2012
  | `bclaw_coordinate(intent)` | Assign, consult, review, reroute, or summarize across agents. Pass `open_loop: true` on `intent="review"` to also dispatch the reviewer turn. |
2013
2013
  | `bclaw_dispatch(intent)` | Parallelize execute across a sequence's lanes (analysis / execute / review). |
2014
- | `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
2014
+ | `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`; implementation loops add `bind` to dispatch the linked sequence and `verify` to run the opener-configured `command_green` check). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
2015
2015
 
2016
2016
  **Sequences**:
2017
2017