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.
- package/README.md +5 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +4 -1
- package/dist/commands/codev.js +61 -30
- package/dist/commands/doctor.js +14 -1
- package/dist/commands/harvest.js +196 -42
- package/dist/commands/inbox.js +10 -4
- package/dist/commands/loop.js +2 -2
- package/dist/commands/loops-handlers.js +82 -1
- package/dist/commands/mcp-catalog.js +12 -4
- package/dist/commands/mcp-read-handlers.js +90 -7
- package/dist/commands/mcp-schemas.generated.js +3 -0
- package/dist/commands/mcp-write-coordination.js +159 -40
- package/dist/commands/mcp.js +11 -2
- package/dist/core/agentrun-reconciler.js +171 -7
- package/dist/core/agentruns.js +6 -1
- package/dist/core/code-map/aggregate.js +473 -0
- package/dist/core/code-map/backend.js +36 -10
- package/dist/core/code-map/freshness.js +36 -1
- package/dist/core/code-map/lang/c/imports.scm +12 -0
- package/dist/core/code-map/lang/c/index.js +150 -0
- package/dist/core/code-map/lang/c/tags.scm +68 -0
- package/dist/core/code-map/lang/cpp/imports.scm +14 -0
- package/dist/core/code-map/lang/cpp/index.js +149 -0
- package/dist/core/code-map/lang/cpp/tags.scm +87 -0
- package/dist/core/code-map/lang/csharp/imports.scm +20 -0
- package/dist/core/code-map/lang/csharp/index.js +224 -0
- package/dist/core/code-map/lang/csharp/tags.scm +63 -0
- package/dist/core/code-map/lang/go/imports.scm +13 -0
- package/dist/core/code-map/lang/go/index.js +139 -0
- package/dist/core/code-map/lang/go/tags.scm +36 -0
- package/dist/core/code-map/lang/providers.js +12 -1
- package/dist/core/code-map/lang/ruby/imports.scm +24 -0
- package/dist/core/code-map/lang/ruby/index.js +198 -0
- package/dist/core/code-map/lang/ruby/tags.scm +49 -0
- package/dist/core/code-map/lang/rust/imports.scm +44 -0
- package/dist/core/code-map/lang/rust/index.js +136 -0
- package/dist/core/code-map/lang/rust/tags.scm +47 -0
- package/dist/core/code-map/query.js +229 -80
- package/dist/core/code-map/types.js +18 -0
- package/dist/core/code-map/work-section.js +8 -7
- package/dist/core/codev-responses.js +16 -0
- package/dist/core/dispatcher.js +176 -22
- package/dist/core/execution-adapters.js +29 -3
- package/dist/core/ideation-loop-close.js +124 -0
- package/dist/core/loops/artifact-resolver.js +197 -0
- package/dist/core/loops/attempt-reservation.js +576 -0
- package/dist/core/loops/commit-intent.js +494 -0
- package/dist/core/loops/facade-schema.js +48 -0
- package/dist/core/loops/impl-bind.js +144 -0
- package/dist/core/loops/index.js +1 -1
- package/dist/core/loops/iteration-engine.js +29 -0
- package/dist/core/loops/lock.js +14 -0
- package/dist/core/loops/project-resolution.js +157 -0
- package/dist/core/loops/reconcile-turn.js +369 -0
- package/dist/core/loops/result-reducers.js +88 -0
- package/dist/core/loops/store.js +46 -7
- package/dist/core/loops/types.js +139 -11
- package/dist/core/loops/verbs.js +9 -3
- package/dist/core/loops/verify-command.js +209 -0
- package/dist/core/messaging.js +58 -5
- package/dist/core/review-loop-close.js +5 -2
- package/dist/core/review-loop-turn-dispatch.js +290 -28
- package/dist/core/runtime-signals.js +68 -0
- package/dist/core/schema.js +24 -0
- package/dist/core/worktree.js +24 -0
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/dist/wasm/tree-sitter-c.wasm +0 -0
- package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/dist/wasm/tree-sitter-cpp.wasm +0 -0
- package/dist/wasm/tree-sitter-go.wasm +0 -0
- package/dist/wasm/tree-sitter-ruby.wasm +0 -0
- package/dist/wasm/tree-sitter-rust.wasm +0 -0
- package/docs/cli.md +1 -1
- package/docs/code-map.md +22 -6
- package/docs/concepts/loop-engine.md +24 -0
- package/docs/concepts/observer-protocol.md +22 -0
- package/docs/mcp-schema-changelog.md +43 -1
- 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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
281
|
+
agentId: slot.agent_id,
|
|
282
|
+
phase,
|
|
283
|
+
task: input.task,
|
|
88
284
|
description,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
package/dist/core/schema.js
CHANGED
|
@@ -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(),
|
package/dist/core/worktree.js
CHANGED
|
@@ -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.
|
|
2
|
+
// Source: brainclaw v1.18.0 on 2026-07-31T14:51:44.969Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-07-
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
503
|
-
"payload_chars_median":
|
|
504
|
-
"payload_tokens_est_median":
|
|
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.
|
|
3
|
-
"generated_at": "2026-07-
|
|
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-
|
|
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":
|
|
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":
|
|
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":
|
|
501
|
-
"payload_chars_median":
|
|
502
|
-
"payload_tokens_est_median":
|
|
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
|
|
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
|
|