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
@@ -0,0 +1,369 @@
1
+ import path from 'node:path';
2
+ import { getReservation, evidenceMatchesAttempt, currentNonce, deriveTurnId, launchGrant } from './attempt-reservation.js';
3
+ import { getLoop } from './store.js';
4
+ import { complete_turn, add_artifact, advance } from './verbs.js';
5
+ import { reducerForKind } from './result-reducers.js';
6
+ import { loadAgentRun, transitionAgentRun } from '../agentruns.js';
7
+ import { loadAssignment, transitionAssignment } from '../assignments.js';
8
+ import { loadClaim, releaseClaim } from '../claims.js';
9
+ import { createRuntimeEvent } from '../events.js';
10
+ import { readCompletionSignals } from '../runtime-signals.js';
11
+ import { buildFixCycleTask } from '../review-loop-close.js';
12
+ import { withLoopLock, LockTimeoutError, LockLostError } from './lock.js';
13
+ // The terminal loop statuses (LOOP_STATUSES = open|paused|completed|blocked|cancelled).
14
+ // 'blocked' is LOAD-BEARING (pln#630 PR3b): the iteration cap closes a fix cycle to
15
+ // `blocked`, and a blocked loop must be treated as terminal both by the idempotent
16
+ // early-return below and by the fix-cycle already-bumped branch. (The legacy 'closed'/
17
+ // 'abandoned' entries are not real loop statuses — kept as harmless historical aliases.)
18
+ const LOOP_TERMINAL = new Set(['closed', 'cancelled', 'completed', 'abandoned', 'blocked']);
19
+ /** Move a turn-owned run to `completed` via `running` if it never got there. Best-effort. */
20
+ function settleRunCompleted(runId, actor, cwd) {
21
+ try {
22
+ const run = loadAgentRun(runId, cwd);
23
+ if (!run || run.status === 'completed')
24
+ return;
25
+ // created/launching/waiting_input/blocked can't go straight to completed —
26
+ // route through running first (matrix allows created→running, running→completed).
27
+ if (run.status !== 'running') {
28
+ try {
29
+ transitionAgentRun(runId, 'running', { actor, status_reason: 'reconcileTurn: settling for completion' }, cwd);
30
+ }
31
+ catch { /* already past running, or terminal — fall through */ }
32
+ }
33
+ const after = loadAgentRun(runId, cwd);
34
+ if (after && after.status === 'running') {
35
+ transitionAgentRun(runId, 'completed', { actor, status_reason: 'reconcileTurn: turn-keyed completion accepted' }, cwd);
36
+ }
37
+ }
38
+ catch { /* best-effort — loop convergence does not depend on run status */ }
39
+ }
40
+ /** Complete this turn's assignment (idempotent, best-effort). The re-dispatch of a fix
41
+ * cycle mints a FRESH assignment, so completing the old one is always correct. */
42
+ function settleAssignment(assignmentId, actor, cwd) {
43
+ try {
44
+ const asg = loadAssignment(assignmentId, cwd);
45
+ if (asg && asg.status !== 'completed' && asg.status !== 'cancelled') {
46
+ try {
47
+ transitionAssignment(assignmentId, 'completed', { actor }, cwd);
48
+ }
49
+ catch { /* transition may be illegal from current state — best-effort */ }
50
+ }
51
+ }
52
+ catch { /* best-effort */ }
53
+ }
54
+ /**
55
+ * Release the coordinator claim (idempotent, best-effort). pln#630 PR3b: DEFERRED until
56
+ * after the advance decision and skipped when the fix cycle retains the claim/worktree. The
57
+ * caller passes the AUTHORITATIVE claim the slot/assignment is bound to (dec#149 #3) — NOT
58
+ * reservation.claim_id, which is the dead first-reserver claim in the recovery-winner path.
59
+ */
60
+ function releaseCoordinatorClaim(claimId, cwd) {
61
+ if (!claimId)
62
+ return;
63
+ try {
64
+ const claim = loadClaim(claimId, cwd);
65
+ if (claim && claim.status === 'active')
66
+ releaseClaim(claimId, cwd);
67
+ }
68
+ catch { /* best-effort */ }
69
+ }
70
+ export function reconcileTurn(input) {
71
+ const { turn_id, lane, cwd } = input;
72
+ const actor = input.actor ?? 'reconciler';
73
+ const reservation = getReservation(turn_id, cwd);
74
+ if (!reservation)
75
+ return { reconciled: false, reason: `unknown turn_id ${turn_id}` };
76
+ // ── Containment gate (§8 Q6): the reservation must belong to the store we are
77
+ // operating on. Before any mutation / on-behalf convergence, refuse to reconcile
78
+ // a reservation whose store_root resolves to a DIFFERENT project than `cwd` — a
79
+ // cross-project reconcile must never converge another store's loop/claim.
80
+ // Case-folded on win32 (review Finding 2) so a `C:\`-vs-`c:\` casing difference
81
+ // between the reserve-time cwd and the reconcile cwd never false-rejects on the
82
+ // Windows-primary target. ──
83
+ const operatingRoot = path.resolve(cwd ?? process.cwd());
84
+ const reservationRoot = path.resolve(reservation.store_root);
85
+ const sameStore = process.platform === 'win32'
86
+ ? reservationRoot.toLowerCase() === operatingRoot.toLowerCase()
87
+ : reservationRoot === operatingRoot;
88
+ if (!sameStore) {
89
+ return { reconciled: false, reason: `containment: reservation store_root ${reservation.store_root} != operating store ${operatingRoot}` };
90
+ }
91
+ // ── §2 read-strict evidence gate: the LANE must be turn-keyed to THIS attempt's
92
+ // current launch generation. A stale/mismatched result never converges the loop. ──
93
+ if (!evidenceMatchesAttempt(reservation, { turn_id: lane.turn_id, run_id: lane.run_id, nonce: lane.nonce })) {
94
+ const nonce = currentNonce(reservation);
95
+ return {
96
+ reconciled: false,
97
+ reason: nonce === undefined
98
+ ? 'no live launch generation (revoked/never-armed) — cannot accept evidence'
99
+ : `lane evidence (turn=${lane.turn_id} run=${lane.run_id} nonce=${lane.nonce}) does not match attempt ${turn_id}`,
100
+ };
101
+ }
102
+ // ── §13 R4 contradiction: a turn-keyed FAILED sentinel present alongside a
103
+ // completed result (the lane or a completed sentinel) → WITHHOLD convergence,
104
+ // journal a conflict (never silently accept). Compared against the LANE's
105
+ // completed status too (review Finding 5): a worker that wrote a completed
106
+ // LANE-RESULT then exited non-zero (turn-keyed failed sentinel) is a conflict,
107
+ // not a clean close. ──
108
+ try {
109
+ const bodies = readCompletionSignals(cwd ?? process.cwd(), reservation.child_ids.assignment_id);
110
+ const matchedCompleted = bodies.completed?.status === 'completed' && evidenceMatchesAttempt(reservation, bodies.completed);
111
+ const matchedFailed = bodies.failed?.status === 'failed' && evidenceMatchesAttempt(reservation, bodies.failed);
112
+ if (matchedFailed && (matchedCompleted || lane.status === 'completed')) {
113
+ try {
114
+ createRuntimeEvent({
115
+ agent: actor,
116
+ event_type: 'run_blocked',
117
+ text: `reconcileTurn: turn ${turn_id} has a completed(lane/sentinel)+failed(sentinel) contradiction — auto-stop WITHHELD (§13 R4), escalating to human`,
118
+ tags: ['loops', 'reconcile', 'conflict', 'turn-attempt'],
119
+ assignment_id: reservation.child_ids.assignment_id,
120
+ run_id: reservation.child_ids.run_id,
121
+ status_reason: 'turn_evidence_contradiction',
122
+ }, cwd);
123
+ }
124
+ catch { /* conflict-journal best-effort */ }
125
+ return { reconciled: false, conflict: true, reason: 'completed+failed contradiction — convergence withheld (§13 R4)' };
126
+ }
127
+ }
128
+ catch { /* signal read best-effort — absence of sentinels is not a contradiction */ }
129
+ // pln#630 PR3b (adversarial review Finding 1) — the guard-read + advance + release compound
130
+ // MUST be atomic + serialized. Without a lock two concurrent reconciles of the same turn each
131
+ // pass the iteration-equality bump guard on a STALE snapshot and each advance from a fresh
132
+ // read → i→i+1 and i+1→i+2 → two turn_ids → the launch fence spawns BOTH rounds. Run the
133
+ // mutation under the loop lock (re-reading the loop INSIDE), mirroring the legacy closer's
134
+ // BLOCKING-3 fix. Lock contention → reconciled:false (a later trigger retries); a REAL error
135
+ // still propagates (Finding 6), never silently swallowed.
136
+ try {
137
+ return withLoopLock({
138
+ cwd,
139
+ intent: 'reconcile-turn',
140
+ agentId: actor,
141
+ scope: { kind: 'loop', loopId: reservation.loop_id },
142
+ work: () => convergeLockedTurn(reservation, input, actor, cwd),
143
+ });
144
+ }
145
+ catch (err) {
146
+ if (err instanceof LockTimeoutError || err instanceof LockLostError) {
147
+ return { reconciled: false, reason: `reconcile deferred (${err.name}); a later trigger retries` };
148
+ }
149
+ throw err;
150
+ }
151
+ }
152
+ /**
153
+ * The locked convergence body of reconcileTurn (pln#630 PR3b). Runs INSIDE withLoopLock so the
154
+ * iteration-equality bump guard and the advance observe ONE serialized snapshot — the loop is
155
+ * re-read here as the fresh in-lock read. Logic is otherwise identical to the pre-lock inline
156
+ * version (plus the terminal-early-return claim release, review Finding 2).
157
+ */
158
+ function convergeLockedTurn(reservation, input, actor, cwd) {
159
+ const { turn_id, lane } = input;
160
+ const loop = getLoop(reservation.loop_id, cwd);
161
+ if (!loop)
162
+ return { reconciled: false, reason: `loop ${reservation.loop_id} not found` };
163
+ const slot = loop.slots.find((s) => s.slot_id === reservation.slot_id);
164
+ if (!slot)
165
+ return { reconciled: false, reason: `slot ${reservation.slot_id} not in loop ${reservation.loop_id}` };
166
+ // ── Superseded-turn guard (PR4 / review round-2 follow-up). A NEWER turn has
167
+ // taken over this slot when `slot.current_turn_id` is set and points elsewhere
168
+ // (each dispatch's turn() rebinds the slot pointer). A late/duplicate reconcile
169
+ // of the OLD turn must then NOT re-terminalize the slot or advance on a stale
170
+ // outcome — the slot has moved on. No-op. (When current_turn_id is unset — a
171
+ // direct reconcile with no preceding turn() — this does not fire, so the
172
+ // primitive stays testable in isolation.) This is safe against
173
+ // crossed_not_revocable: we never try to revoke the old crossed grant; we just
174
+ // decline to converge a superseded turn. ──
175
+ if (slot.current_turn_id !== undefined && slot.current_turn_id !== turn_id) {
176
+ return { reconciled: false, reason: `turn ${turn_id} superseded by current turn ${slot.current_turn_id} on slot ${slot.slot_id}` };
177
+ }
178
+ // A terminal loop already converged → idempotent no-op (any trigger may fire us). Still
179
+ // release the claim (review Finding 2): a cap-blocked loop that crashed AFTER the block
180
+ // transition but BEFORE its own deferred release would otherwise leak the retained claim
181
+ // until the staleness sweep — releaseCoordinatorClaim is idempotent (no-op if not active).
182
+ if (LOOP_TERMINAL.has(loop.status)) {
183
+ releaseCoordinatorClaim(loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id, cwd);
184
+ return { reconciled: true, reason: `loop already ${loop.status} (idempotent no-op)`, artifacts_added: 0, loop_status: loop.status };
185
+ }
186
+ // ── Idempotency (review Findings 1+2): a TERMINAL slot durably means this turn's
187
+ // verdict was already recorded — complete_turn sets the slot terminal ATOMICALLY
188
+ // with its artifact via the crash-atomic WAL, so a terminal slot is the
189
+ // recorded-marker (each new turn resets its slot to `assigned` first, so a
190
+ // terminal slot can only be THIS turn's own convergence). Re-running the reducer
191
+ // /complete_turn would double-record (Finding 2). So SKIP recording when
192
+ // terminal — but STILL fall through to advance below (Finding 1: a crash between
193
+ // complete_turn and advance must still close the loop on the next trigger). ──
194
+ const slotTerminal = slot.status === 'done' || slot.status === 'failed' || slot.status === 'cancelled';
195
+ let slot_outcome;
196
+ let artifacts_added = 0;
197
+ if (slotTerminal) {
198
+ slot_outcome = slot.status === 'done' ? 'done' : 'failed';
199
+ }
200
+ else {
201
+ // ── §6 reducer: validated result → loop artifacts + slot outcome. ──
202
+ const reducerInput = { lane, phase: reservation.phase, critiques: input.critiques };
203
+ const reduced = reducerForKind(loop.kind)(reducerInput, reservation);
204
+ artifacts_added = reduced.artifacts.length;
205
+ slot_outcome = reduced.slot_outcome;
206
+ // Record artifacts + complete the turn (crash-atomic WAL via complete_turn).
207
+ // Guarded: a body-cap/schema error becomes a graceful reconciled:false rather
208
+ // than an unhandled throw out of reconcileTurn (review Finding 3 defense).
209
+ try {
210
+ const [primary, ...extras] = reduced.artifacts;
211
+ for (const a of extras) {
212
+ try {
213
+ add_artifact({ id: loop.id, actor, artifact: { phase: a.phase, type: a.type, body: a.body, produced_by: a.produced_by } }, cwd);
214
+ }
215
+ catch { /* an extra artifact failing must not abort convergence */ }
216
+ }
217
+ complete_turn({
218
+ id: loop.id,
219
+ slot_id: slot.slot_id,
220
+ actor,
221
+ outcome: reduced.slot_outcome,
222
+ failure_reason: reduced.failure_reason,
223
+ ...(primary ? { artifact: { phase: primary.phase, type: primary.type, body: primary.body } } : {}),
224
+ }, cwd);
225
+ }
226
+ catch (err) {
227
+ return { reconciled: false, reason: `complete_turn failed: ${err instanceof Error ? err.message : String(err)}` };
228
+ }
229
+ // ── pln#521 P4 — observability: the turn's artifact was harvested + integrated
230
+ // into the loop. Best-effort (never aborts a successful convergence). ──
231
+ try {
232
+ createRuntimeEvent({
233
+ agent: actor,
234
+ event_type: 'loop_artifact_harvested',
235
+ text: `reconcileTurn: harvested turn ${turn_id} on slot ${slot.slot_id} → loop ${loop.id} (${slot_outcome}, ${artifacts_added} artifact(s), phase ${reservation.phase})`,
236
+ tags: ['loops', 'reconcile', 'harvest', 'turn-attempt'],
237
+ assignment_id: reservation.child_ids.assignment_id,
238
+ run_id: reservation.child_ids.run_id,
239
+ status_reason: `harvested_${slot_outcome}`,
240
+ }, cwd);
241
+ }
242
+ catch {
243
+ /* observability best-effort — a telemetry failure must not undo the harvest */
244
+ }
245
+ }
246
+ // ── Secondary convergence (best-effort + idempotent): run + assignment. The CLAIM
247
+ // release is DEFERRED to after the advance decision (pln#630 PR3b) so a fix-cycle round
248
+ // can RETAIN the claim/worktree. Run + assignment settle unconditionally on both paths
249
+ // (a crash that recorded the turn but not the settle still converges on replay; the
250
+ // fix-cycle re-dispatch mints a fresh run/assignment, so completing the old is correct). ──
251
+ settleRunCompleted(reservation.child_ids.run_id, actor, cwd);
252
+ settleAssignment(reservation.child_ids.assignment_id, actor, cwd);
253
+ // ── Advance / stop decision. On a `done` outcome we either drive a deterministic stop
254
+ // (reviewer_green / gate → close), continue a symmetric fix cycle (bump the round + retain
255
+ // the claim + emit next_turn), or leave the loop open (asymmetric / no successor). ──
256
+ let auto_closed = false;
257
+ let retainClaim = false;
258
+ let next_turn;
259
+ // Build the fix-cycle re-dispatch descriptor for a given round (shared by the bump arm and
260
+ // the strand self-heal below so the reviewer brief + slot binding never drift).
261
+ const mkNextTurn = (phase, iteration) => ({
262
+ slot_id: slot.slot_id,
263
+ role: slot.role ?? 'reviewer',
264
+ agent: slot.agent ?? '',
265
+ ...(slot.agent_id ? { agent_id: slot.agent_id } : {}),
266
+ phase,
267
+ iteration,
268
+ task: buildFixCycleTask(lane.review_summary ?? lane.summary ?? '', iteration),
269
+ });
270
+ if (slot_outcome === 'done') {
271
+ // symmetricRC detection is INDEPENDENT of the bump guard below (safety race 2): a
272
+ // re-reconcile in the pre-redispatch window must NOT fall into the approve/asymmetric
273
+ // arm, which would advance the phase FORWARD (to author_response) and corrupt the cycle.
274
+ const symmetricRC = loop.kind === 'review' &&
275
+ lane.review_verdict === 'request_changes' &&
276
+ loop.protocol?.review_mode === 'symmetric';
277
+ if (symmetricRC) {
278
+ // EXACTLY-ONCE bump (the one non-negotiable safety guard): each bump changes
279
+ // deriveTurnId(loop, slot, iteration), so a DOUBLE bump would mint two turn_ids and
280
+ // the launch fence would spawn BOTH rounds. Bump only when this turn's round is still
281
+ // current; a re-reconcile after the bump takes the else-branch (no re-bump, no re-emit).
282
+ if (loop.iteration_count === reservation.iteration) {
283
+ // Legacy backward-bump (advance to the SAME phase → iteration_count += 1). The
284
+ // post-advance stop check closes to `blocked` when the max_iterations cap is hit.
285
+ const adv = advance({ id: loop.id, to_phase: loop.current_phase, actor }, cwd);
286
+ if (adv.auto_closed || LOOP_TERMINAL.has(adv.loop.status)) {
287
+ auto_closed = true; // iteration cap → blocked/terminal → release the claim below
288
+ }
289
+ else {
290
+ retainClaim = true; // keep the coordinator claim + worktree for the re-dispatch
291
+ next_turn = mkNextTurn(adv.loop.current_phase, adv.loop.iteration_count);
292
+ }
293
+ }
294
+ else {
295
+ // Already bumped by a prior pass. Distinguish a benign re-reconcile (the next round
296
+ // WAS dispatched) from a genuine STRAND (pln#630 PR4, closes dec#149 #2/F3): the pass
297
+ // that bumped crashed BEFORE harvest re-dispatched, so the loop sits open with the
298
+ // claim retained and NO worker in flight. Detect it precisely — is there a reservation
299
+ // for the bumped round's deterministic turn_id? If NOT → strand → RE-EMIT next_turn to
300
+ // self-heal (the launch fence dedups, so a benign duplicate is DENIED, never a
301
+ // double-spawn) and journal a recovery event. If a reservation exists → the next round
302
+ // is already in flight → just retain, no re-emit (no churn on benign re-reconciles).
303
+ const cur = getLoop(loop.id, cwd);
304
+ if (cur && !LOOP_TERMINAL.has(cur.status)) {
305
+ retainClaim = true;
306
+ // The bumped round is LIVE only if its reservation exists AND its launch grant is
307
+ // armed (dispatch in flight, pre-spawn) or crossed (spawned). A REVOKED grant
308
+ // (reserved_never_launched — crash between arm and consume + the expiry sweep) or an
309
+ // absent reservation is a STRAND (dec#149 R1): re-emit to self-heal. The re-dispatch's
310
+ // prepare re-arms a revoked grant at a higher epoch, so this round can actually relaunch.
311
+ const bumpedTurnId = deriveTurnId(loop.id, slot.slot_id, cur.iteration_count);
312
+ const bumpedGrant = launchGrant(bumpedTurnId, cwd);
313
+ const bumpedLive = getReservation(bumpedTurnId, cwd) !== undefined &&
314
+ (bumpedGrant?.status === 'armed' || bumpedGrant?.status === 'crossed');
315
+ if (!bumpedLive) {
316
+ next_turn = mkNextTurn(cur.current_phase, cur.iteration_count);
317
+ try {
318
+ createRuntimeEvent({
319
+ agent: actor,
320
+ event_type: 'run_blocked',
321
+ text: `reconcileTurn: fix-cycle round ${cur.iteration_count} of loop ${loop.id} was bumped but never dispatched (turn ${turn_id} strand) — re-emitting next_turn to self-heal`,
322
+ tags: ['loops', 'reconcile', 'turn-owned', 'strand-recovery'],
323
+ assignment_id: reservation.child_ids.assignment_id,
324
+ run_id: reservation.child_ids.run_id,
325
+ status_reason: 'fix_cycle_strand_reemit',
326
+ }, cwd);
327
+ }
328
+ catch { /* observability best-effort */ }
329
+ }
330
+ }
331
+ else {
332
+ auto_closed = true;
333
+ }
334
+ }
335
+ }
336
+ else {
337
+ // approve OR asymmetric request_changes — unchanged PR3a / legacy-asymmetric behavior.
338
+ // Benign "cannot advance/close now" → loop stays open awaiting the next turn; any
339
+ // OTHER throw is a REAL error and must propagate, never be silently swallowed (Finding 6).
340
+ try {
341
+ auto_closed = advance({ id: loop.id, actor }, cwd).auto_closed;
342
+ }
343
+ catch (err) {
344
+ const msg = err instanceof Error ? err.message : String(err);
345
+ if (!/phase_advance_blocked|already at last phase|no post-cycle successor/.test(msg))
346
+ throw err;
347
+ }
348
+ }
349
+ }
350
+ // Release the coordinator claim now — UNLESS a fix-cycle round retained it. Target the
351
+ // authoritative claim the assignment is bound to, not reservation.claim_id (dec#149 #3).
352
+ if (!retainClaim) {
353
+ const authoritativeClaimId = loadAssignment(reservation.child_ids.assignment_id, cwd)?.claim_id ?? reservation.claim_id;
354
+ releaseCoordinatorClaim(authoritativeClaimId, cwd);
355
+ }
356
+ const loop_status = getLoop(loop.id, cwd)?.status ?? loop.status;
357
+ return {
358
+ reconciled: true,
359
+ reason: next_turn
360
+ ? `turn ${turn_id} → request_changes round ${next_turn.iteration}: claim retained, next fix turn emitted for re-dispatch`
361
+ : slotTerminal ? `turn ${turn_id} already recorded; advance re-attempted (${slot_outcome})` : `turn ${turn_id} reconciled (${slot_outcome})`,
362
+ slot_outcome,
363
+ artifacts_added,
364
+ auto_closed,
365
+ loop_status,
366
+ ...(next_turn ? { next_turn } : {}),
367
+ };
368
+ }
369
+ //# sourceMappingURL=reconcile-turn.js.map
@@ -0,0 +1,88 @@
1
+ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './types.js';
2
+ /**
3
+ * Cap an artifact body to LOOP_ARTIFACT_BODY_MAX_BYTES (review Finding 3).
4
+ * `review_summary`/`summary` are worker-controlled and unbounded, but
5
+ * LoopArtifactSchema hard-rejects a body over the cap — an un-truncated body
6
+ * would make complete_turn throw and crash reconcileTurn. Byte-aware, drops any
7
+ * partial trailing multibyte char.
8
+ */
9
+ function capBody(s) {
10
+ if (Buffer.byteLength(s, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
11
+ return s;
12
+ const marker = '…[truncated]';
13
+ const room = LOOP_ARTIFACT_BODY_MAX_BYTES - Buffer.byteLength(marker, 'utf8');
14
+ return Buffer.from(s, 'utf8').subarray(0, room).toString('utf8').replace(/�+$/, '') + marker;
15
+ }
16
+ /**
17
+ * review reducer (§6). `approve` → one `verdict` artifact whose body begins
18
+ * `accepted…` — EXACTLY what `isVerdictAccepted`/`reviewer_green` matches
19
+ * (verbs.ts) so the loop auto-closes; `request_changes` → a `changes-requested`
20
+ * verdict that does NOT fire reviewer_green (the symmetric fix cycle continues).
21
+ * A lane that did not complete, or completed with no verdict, fails the slot
22
+ * (no fake green).
23
+ */
24
+ export const reviewReducer = (input, attempt) => {
25
+ const { lane, phase } = input;
26
+ if (lane.status !== 'completed') {
27
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `review lane status is ${lane.status}, not completed` };
28
+ }
29
+ if (!lane.review_verdict) {
30
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
31
+ }
32
+ const summary = (lane.review_summary ?? '').trim();
33
+ const body = capBody(lane.review_verdict === 'approve'
34
+ ? `accepted${summary ? `: ${summary}` : ''}`
35
+ : `changes-requested${summary ? `: ${summary}` : ''}`);
36
+ return {
37
+ artifacts: [{ phase, type: 'verdict', body, produced_by: attempt.agent }],
38
+ slot_outcome: 'done',
39
+ };
40
+ };
41
+ /**
42
+ * ideation reducer (§6). A `critique_batch` → N `critique` artifacts (so
43
+ * `min_artifacts_by_type` can open the next phase). A bare summary with no
44
+ * critique body → slot `failed`, gate stays shut (correct: no fake progress from
45
+ * a lane that produced no critiques).
46
+ */
47
+ export const ideationReducer = (input, attempt) => {
48
+ const { lane, phase, critiques } = input;
49
+ if (lane.status !== 'completed') {
50
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation lane status is ${lane.status}, not completed` };
51
+ }
52
+ if (!critiques || critiques.length === 0) {
53
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation lane produced no critiques (bare summary) — gate stays shut' };
54
+ }
55
+ return {
56
+ artifacts: critiques.map((c) => ({
57
+ phase, type: 'critique', body: capBody(c.body), produced_by: attempt.agent,
58
+ ...(c.addresses_critique ? { addresses_critique: c.addresses_critique } : {}),
59
+ })),
60
+ slot_outcome: 'done',
61
+ };
62
+ };
63
+ /**
64
+ * Default reducer for loop kinds without a specialized one (implementation /
65
+ * research / debug / bootstrap): a completed lane → one generic `lane_result`
66
+ * artifact carrying the summary; a non-completed lane → slot failed. Keeps
67
+ * convergence sensible without fabricating structured artifacts a kind never
68
+ * declared.
69
+ */
70
+ export const defaultReducer = (input, attempt) => {
71
+ const { lane, phase } = input;
72
+ if (lane.status !== 'completed') {
73
+ return { artifacts: [], slot_outcome: 'failed', failure_reason: `lane status is ${lane.status}, not completed` };
74
+ }
75
+ return {
76
+ artifacts: [{ phase, type: 'lane_result', body: capBody(lane.summary), produced_by: attempt.agent }],
77
+ slot_outcome: 'done',
78
+ };
79
+ };
80
+ const RESULT_REDUCERS = {
81
+ review: reviewReducer,
82
+ ideation: ideationReducer,
83
+ };
84
+ /** The reducer for a loop kind — a specialized one when registered, else the default. */
85
+ export function reducerForKind(kind) {
86
+ return RESULT_REDUCERS[kind] ?? defaultReducer;
87
+ }
88
+ //# sourceMappingURL=result-reducers.js.map
@@ -9,6 +9,7 @@ import { logCascadeReleaseResult, releaseClaimsCascade } from '../claims.js';
9
9
  import { gcWorktreeIfHarvested } from '../worktree.js';
10
10
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
11
11
  import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
12
+ import { reconstructConsistentThread } from './commit-intent.js';
12
13
  import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
13
14
  function loopsDir(cwd) {
14
15
  return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
@@ -86,7 +87,21 @@ export function appendEvent(loopId, event, cwd,
86
87
  threadSnapshot) {
87
88
  const parsed = LoopEventSchema.parse(event);
88
89
  ensureLoopsDir(cwd);
89
- fs.appendFileSync(eventsPath(loopId, cwd), `${JSON.stringify(parsed)}\n`);
90
+ // pln#630 PR1b — fsync the journal append so a materialized projection can
91
+ // never be durable ahead of the event that explains it (trp_8b17c2d0).
92
+ const fd = fs.openSync(eventsPath(loopId, cwd), 'a');
93
+ try {
94
+ // Loop the write to completion so a short/partial write never leaves a torn
95
+ // final record for the next append to fuse with (PR #102 review round 2).
96
+ const buf = Buffer.from(`${JSON.stringify(parsed)}\n`, 'utf8');
97
+ let off = 0;
98
+ while (off < buf.length)
99
+ off += fs.writeSync(fd, buf, off, buf.length - off);
100
+ fs.fsyncSync(fd);
101
+ }
102
+ finally {
103
+ fs.closeSync(fd);
104
+ }
90
105
  // pln#513 step 4 — best-effort OS notification on input_requested events.
91
106
  // Prefer the in-memory snapshot from the caller (carries the freshly-
92
107
  // added operator_question). Fall back to a disk read so any direct
@@ -125,7 +140,12 @@ export function openLoop(input, cwd) {
125
140
  // presets) wins over the kind/mode-derived default. When no override
126
141
  // is supplied, fall back to the legacy resolveProtocol() path so
127
142
  // existing callers (review, default ideation) are unaffected.
128
- const protocol = input.protocol ?? resolveProtocol(input.kind, input.mode);
143
+ let protocol = input.protocol ?? resolveProtocol(input.kind, input.mode);
144
+ // pln#632 — merge an opener-provided verify command onto the resolved protocol (adds
145
+ // the engine-run command_green gate without discarding the kind-default iteration).
146
+ if (input.verify && !protocol?.verify) {
147
+ protocol = { ...(protocol ?? {}), verify: input.verify };
148
+ }
129
149
  const thread = {
130
150
  schema_version: 1,
131
151
  id,
@@ -166,10 +186,14 @@ export function openLoop(input, cwd) {
166
186
  }
167
187
  export function getLoop(id, cwd) {
168
188
  const filePath = threadPath(id, cwd);
169
- if (!fs.existsSync(filePath))
170
- return undefined;
171
- const raw = fs.readFileSync(filePath, 'utf8');
172
- return LoopThreadSchema.parse(JSON.parse(raw));
189
+ const onDisk = fs.existsSync(filePath)
190
+ ? LoopThreadSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')))
191
+ : undefined;
192
+ // pln#630 PR1b — if a durable-but-not-yet-applied commit intent is ahead of
193
+ // the on-disk thread, return the reconstructed consistent view. The mutation
194
+ // is durable in the intent; persistence catches up at the next lock-entry
195
+ // recovery (never a write on this read path — cf. trp_fdf3e590 / dec#137).
196
+ return reconstructConsistentThread(id, onDisk, cwd);
173
197
  }
174
198
  export function listLoops(filters = {}, cwd) {
175
199
  const dir = threadsDir(cwd);
@@ -199,7 +223,22 @@ export function listLoopEvents(id, cwd) {
199
223
  if (!fs.existsSync(filePath))
200
224
  return [];
201
225
  const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
202
- return lines.map((line) => LoopEventSchema.parse(JSON.parse(line)));
226
+ const events = [];
227
+ for (let i = 0; i < lines.length; i++) {
228
+ try {
229
+ events.push(LoopEventSchema.parse(JSON.parse(lines[i])));
230
+ }
231
+ catch (err) {
232
+ // pln#630 PR1b — a non-empty unparseable FINAL line is an uncommitted
233
+ // torn-append fragment (durably repaired at the next lock-entry recovery,
234
+ // commit-intent.ts). Tolerate it on read so seq allocation / reads never
235
+ // wedge on a torn tail. An EARLIER unparseable line is real corruption.
236
+ if (i === lines.length - 1)
237
+ break;
238
+ throw err;
239
+ }
240
+ }
241
+ return events;
203
242
  }
204
243
  /**
205
244
  * pln#512 step 2 — sentinel thrown by closeLoop when the bootstrap close