brainclaw 1.26.1 → 1.27.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 (91) hide show
  1. package/README.md +41 -12
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +87 -14
  8. package/dist/commands/mcp-catalog.js +42 -18
  9. package/dist/commands/mcp-schemas.generated.js +44 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +146 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agent-files.js +21 -21
  14. package/dist/core/agentrun-reconciler.js +148 -22
  15. package/dist/core/agentruns.js +254 -29
  16. package/dist/core/assignment-request-schema.js +7 -0
  17. package/dist/core/assignment-sweeper.js +5 -3
  18. package/dist/core/assignments.js +131 -33
  19. package/dist/core/claim-request-schema.js +7 -0
  20. package/dist/core/claims.js +53 -2
  21. package/dist/core/dispatch-status.js +16 -6
  22. package/dist/core/dispatcher.js +51 -51
  23. package/dist/core/entity-operations.js +20 -0
  24. package/dist/core/events.js +4 -0
  25. package/dist/core/execution-adapters.js +160 -14
  26. package/dist/core/execution-contract.js +345 -0
  27. package/dist/core/execution.js +130 -16
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +207 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +476 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/evidence.js +187 -0
  48. package/dist/core/loops/facade-schema.js +41 -10
  49. package/dist/core/loops/gate-policy.js +485 -0
  50. package/dist/core/loops/impl-bind.js +37 -79
  51. package/dist/core/loops/index.js +9 -0
  52. package/dist/core/loops/iteration-engine.js +31 -19
  53. package/dist/core/loops/kind-policies.js +90 -0
  54. package/dist/core/loops/lock.js +71 -13
  55. package/dist/core/loops/reconcile-turn.js +235 -18
  56. package/dist/core/loops/result-reducers.js +99 -10
  57. package/dist/core/loops/store.js +30 -3
  58. package/dist/core/loops/turn-execution.js +480 -0
  59. package/dist/core/loops/types.js +113 -2
  60. package/dist/core/loops/verbs.js +332 -99
  61. package/dist/core/loops/verify-command.js +31 -8
  62. package/dist/core/loops/workspace-digest.js +54 -0
  63. package/dist/core/protocol-tool-policy.js +44 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +35 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/PROTOCOL.md +6 -4
  73. package/docs/cli.md +49 -1
  74. package/docs/concepts/attempt-authority.md +407 -0
  75. package/docs/concepts/evidence-attestations.md +135 -0
  76. package/docs/concepts/execution-contract.md +166 -0
  77. package/docs/concepts/harness-adapters.md +166 -0
  78. package/docs/concepts/ideation-loop.md +5 -4
  79. package/docs/concepts/loop-engine.md +348 -133
  80. package/docs/index.md +4 -1
  81. package/docs/integrations/codex.md +3 -3
  82. package/docs/integrations/hermes.md +42 -3
  83. package/docs/integrations/mcp.md +75 -9
  84. package/docs/loops/debug.md +144 -0
  85. package/docs/loops/ideation.md +158 -0
  86. package/docs/loops/implementation.md +154 -0
  87. package/docs/loops/research.md +136 -0
  88. package/docs/loops/review.md +200 -0
  89. package/docs/mcp-schema-changelog.md +14 -5
  90. package/docs/product/agent-first-model.md +33 -33
  91. package/package.json +1 -1
package/README.md CHANGED
@@ -46,7 +46,7 @@ It sits alongside your coding agents and gives them a shared state layer they ca
46
46
  | **Agent-ready context** | compact, prompt-sized context built from real workspace state instead of stale instructions |
47
47
  | **Code Map** | a Tree-sitter symbol + import index (11 languages — JS/TS, Python, PHP, Java, Go, Rust, C#, Ruby, C, C++) so agents ask "where is X / what should I read first" before editing, with related decisions/traps attached — `bclaw_code_find` / `bclaw_code_brief`, see [code map](docs/code-map.md) |
48
48
  | **Native agent files** | auto-writes `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules/`, `.windsurfrules`, and similar local guidance |
49
- | **Multi-turn loops** | review and ideation loops with structured phases, iteration semantics, and per-phase memory filters — see[loop engine](docs/concepts/loop-engine.md) and [ideation loop](docs/concepts/ideation-loop.md) |
49
+ | **Multi-turn loops** | review, ideation, implementation, research, and debug workflows with structured phases, iteration semantics, verification gates, and per-phase memory filters — see [loop engine](docs/concepts/loop-engine.md) |
50
50
  | **Machine AI surface discovery** | detects local coding agents plus desktop AI work surfaces such as ChatGPT Desktop and Gemini CLI |
51
51
  | **Queued surface tasks** | stores project-scoped requests for other local AI surfaces, such as visual generation, drafting, summaries, or research |
52
52
  | **Local-first storage** | plain text + JSON, Git-friendly, no mandatory cloud, no telemetry by default |
@@ -263,14 +263,42 @@ bclaw_loop({ intent: "get", loop_id: "lop_abc" }); // inspect status any time
263
263
 
264
264
  ## The Loop Engine (Multi-Turn Workflows)
265
265
 
266
- Brainclaw's Loop Engine moves beyond manual ping-pong by formalizing multi-turn workflows (review, ideation, testing). It features two distinct review modes:
267
-
268
- - **Asymmetric Mode**: The classic author→reviewer handoff. The reviewer creates findings, and the original author must apply the fixes.
269
- - **Symmetric Mode**: Eliminates unnecessary round-trips. Both the author and reviewer slots can apply fixes directly, drastically speeding up spec and documentation reviews.
270
-
271
- Each loop maintains a structured lifecycle, explicit phases, iteration bounds, and per-phase memory filters, executed seamlessly via `bclaw_loop`.
272
-
273
- **Autonomous convergence (pln#628 Focus 4B + pln#630):** a dispatched reviewer doesn't need to be driven by hand. It writes its verdict (`review_verdict: approve | request_changes`) into its `LANE-RESULT.json`; when the coordinator harvests the lane, brainclaw records the verdict on the loop and **auto-closes it on approve** — the review loop reaches `reviewer_green` with no human ping-pong. On `request_changes`, brainclaw **runs the fix→re-review cycle autonomously**: it bumps the round, retains the worktree, and re-dispatches — through an exactly-once turn-attempt state machine (immutable attempt records behind an atomic launch fence, on by default; kill-switch `BRAINCLAW_TURN_OWNED_REVIEW=0`) so a turn is never double-spawned, with a bounded round cap that lands on `blocked` instead of looping forever.
266
+ Brainclaw's Loop Engine formalizes repeated multi-turn work so agents can
267
+ resume, automate, and audit it rather than relying on manual ping-pong. It is
268
+ one engine with five shipped default workflows: **review, ideation,
269
+ implementation, research, and debug**.
270
+
271
+ | Workflow | Typical outcome | Normal entry point |
272
+ | --- | --- | --- |
273
+ | Review | accepted verdict or bounded fix cycle | `bclaw_coordinate(intent="review", open_loop=true)` |
274
+ | Ideation | memory-confronted plan draft or synthesis | `bclaw_coordinate(intent="ideate")` |
275
+ | Implementation | green verification and handoff | `bclaw_loop(intent="open", kind="implementation", allow_orphan=true)`, then `bind` |
276
+ | Research | evidence-backed synthesis | `bclaw_loop(intent="open", kind="research", allow_orphan=true)` |
277
+ | Debug | reproduced, verified fix and handoff | `bclaw_loop(intent="open", kind="debug", allow_orphan=true)` |
278
+
279
+ Every loop has structured phases, bounded iteration, explicit artifacts, and
280
+ per-phase memory filters. The shared controls are `open`, `turn`,
281
+ `complete_turn`, `advance`, `add_artifact`, `pause`, `resume`, and `close`;
282
+ implementation also adds `bind` and `verify`. `request_input` /
283
+ `provide_input` are cross-cutting clarification primitives for any workflow.
284
+
285
+ Every worker-backed phase, across all five workflows, is launched from one
286
+ immutable [execution contract](docs/concepts/execution-contract.md): exact
287
+ identity, artifact expectations, workspace policy, capability snapshot and
288
+ evidence rules are persisted before the launch fence crosses. This reuses the
289
+ existing TurnReservation, Assignment and AgentRun records; it does not add a
290
+ second event journal.
291
+
292
+ Gate-driving artifacts are also bound to a server-sealed
293
+ [evidence envelope](docs/concepts/evidence-attestations.md). Review approval,
294
+ command verification, claim binding, and ordinary observations are separate
295
+ attestations rather than one confidence score; the same policy mechanism
296
+ protects every workflow above.
297
+
298
+ Review is a useful specialized path, not the definition of the engine. It has
299
+ asymmetric and symmetric modes and can auto-close on an approved verdict; the
300
+ other workflows use the same lifecycle to converge on a plan, synthesis,
301
+ handoff, or verified fix. See the [Loop Engine guide](docs/concepts/loop-engine.md).
274
302
 
275
303
  ## Enterprise Ready: Mono-repo & Micro-services
276
304
 
@@ -310,7 +338,7 @@ Recent releases have moved a lot of multi-agent parallel work from "risky" to "s
310
338
 
311
339
  - **Per-claim auto-worktree** — each dispatched lane gets its own isolated git worktree; the coordinator integrates with an octopus merge.
312
340
  - **Sequenced parallel execute** — `bclaw_dispatch(intent="execute")` fans out independent lanes across several agent instances and integrates the result.
313
- - **Symmetric review-fix loops** — `bclaw_coordinate(intent="review", open_loop=true, review_mode="symmetric")` runs an alternating review-and-fix conversation across two slots without shared-checkout collisions. The reviewer's verdict is harvested from `LANE-RESULT.json` and the loop **auto-closes on approve** no manual round-trip to converge the approve path.
341
+ - **Loop Engine protocols** — review, ideation, implementation, research, and debug workflows share a persisted lifecycle. Review offers a symmetric auto-fix shortcut; implementation and debug bind verification to the work; ideation and research converge on durable syntheses.
314
342
  - **Cross-platform spawn** — OS-aware prompt delivery (stdin pipe / inline arg) plus a brief-ack file handshake, so spawned workers can be detected and timed out reliably on Windows and Unix.
315
343
  - **Worktree GC is scope-bounded** — symlinks and junctions are no longer followed during cleanup, so post-merge sweeps can't wipe `node_modules` or other neighboring directories.
316
344
  - **MCP runtime self-heal** — when the runtime is corrupted, the server logs an actionable repair pointer; `brainclaw doctor --repair` rebuilds dist in one step.
@@ -327,8 +355,9 @@ Recommended use today:
327
355
 
328
356
  1. for parallel work, dispatch a sequence with `bclaw_dispatch(intent="execute")` — each lane gets its own worktree
329
357
  2. for sequential work in the same project, let one agent claim at a time and rely on handoffs
330
- 3. when reviewing or fixing across agents, prefer symmetric review loops over manual ping-pong
331
- 4. keep multi-machine workflows on a single source of truth until federation lands
358
+ 3. choose the loop by outcome: ideation for a plan, implementation or debug for a verified handoff, research for a synthesis, and review for a verdict
359
+ 4. when reviewing or fixing across agents, prefer symmetric review loops over manual ping-pong
360
+ 5. keep multi-machine workflows on a single source of truth until federation lands
332
361
 
333
362
  ---
334
363
 
Binary file
@@ -388,7 +388,7 @@ export function registerCoordinationCommands(program) {
388
388
  // --- loop (drive loop turn verbs; pln#517 step 2) ---
389
389
  const loopCmd = program
390
390
  .command('loop')
391
- .description('Drive a loop turn (turn / complete-turn / advance / add-artifact)');
391
+ .description('Drive loop turns, fenced takeovers, phase advances, and artifacts');
392
392
  loopCmd
393
393
  .command('turn <loop_id>')
394
394
  .description('Issue a turn assignment on a slot')
@@ -407,6 +407,13 @@ export function registerCoordinationCommands(program) {
407
407
  .description('Complete a slot turn')
408
408
  .requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
409
409
  .requiredOption('--outcome <outcome>', 'Turn outcome: done, failed, or cancelled')
410
+ .option('--assignment-id <id>', 'Attempt fence: stable assignment id')
411
+ .option('--turn-id <id>', 'Attempt fence: stable logical turn id')
412
+ .option('--run-id <id>', 'Attempt fence: physical run id')
413
+ .option('--nonce <value>', 'Attempt fence: generation launch nonce')
414
+ .option('--attempt-epoch <n>', 'Attempt fence: non-negative generation epoch')
415
+ .option('--execution-contract-hash <sha256>', 'Attempt fence: execution contract hash')
416
+ .option('--workspace-digest <sha256>', 'Attempt fence: canonical workspace digest')
410
417
  .option('--failure-reason <text>', 'Reason when outcome is failed')
411
418
  .option('--artifact <json>', 'JSON object payload for an artifact to attach')
412
419
  .option('--json', 'Machine-readable output')
@@ -415,6 +422,24 @@ export function registerCoordinationCommands(program) {
415
422
  const { runLoopCommand } = await import('../commands/loop.js');
416
423
  await runLoopCommand('complete-turn', { loop_id }, options, globalOpts.cwd);
417
424
  });
425
+ loopCmd
426
+ .command('takeover <loop_id>')
427
+ .description('Fence the active physical run and arm a fresh generation')
428
+ .requiredOption('--slot <slot_id>', 'Target slot id (lsl_...)')
429
+ .requiredOption('--turn-id <turn_id>', 'Stable logical turn id')
430
+ .requiredOption('--expected-epoch <n>', 'Currently active generation epoch')
431
+ .requiredOption('--cause <text>', 'Audited takeover cause')
432
+ .requiredOption('--liveness-evidence <text>', 'Evidence that the prior producer cannot safely continue')
433
+ .requiredOption('--external-effect-policy <policy>', 'none, idempotent, or externally_fenced')
434
+ .requiredOption('--next-workspace-path <path>', 'Existing isolated workspace for the new generation')
435
+ .requiredOption('--agent <agent>', 'Loop coordinator identity')
436
+ .option('--mode <mode>', 'takeover or retry', 'takeover')
437
+ .option('--json', 'Machine-readable output')
438
+ .action(async (loop_id, options) => {
439
+ const globalOpts = program.opts();
440
+ const { runLoopCommand } = await import('../commands/loop.js');
441
+ await runLoopCommand('takeover', { loop_id }, options, globalOpts.cwd);
442
+ });
418
443
  loopCmd
419
444
  .command('advance <loop_id>')
420
445
  .description('Advance a loop to its next phase')
@@ -441,6 +466,45 @@ export function registerCoordinationCommands(program) {
441
466
  const { runLoopCommand } = await import('../commands/loop.js');
442
467
  await runLoopCommand('add-artifact', { loop_id }, options, globalOpts.cwd);
443
468
  });
469
+ // --- attempt-authority (two-release writer guard; P4) ---
470
+ const attemptAuthorityCmd = program
471
+ .command('attempt-authority')
472
+ .description('Prepare, acknowledge and activate AttemptAuthority v2 writer compatibility');
473
+ attemptAuthorityCmd
474
+ .command('status')
475
+ .option('--json', 'Machine-readable output')
476
+ .action((options) => {
477
+ const globalOpts = program.opts();
478
+ return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('status', { ...options, cwd: globalOpts.cwd }));
479
+ });
480
+ attemptAuthorityCmd
481
+ .command('prepare')
482
+ .requiredOption('--writers <agent_ids...>', 'Complete Release-A writer membership (registered agent ids)')
483
+ .option('--membership-epoch <n>', 'Membership epoch; defaults to active+1')
484
+ .option('--prepared-by <actor>', 'Audited operator/coordinator identity', 'operator')
485
+ .option('--json', 'Machine-readable output')
486
+ .action((options) => {
487
+ const globalOpts = program.opts();
488
+ return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('prepare', { ...options, cwd: globalOpts.cwd }));
489
+ });
490
+ attemptAuthorityCmd
491
+ .command('ack')
492
+ .requiredOption('--membership-epoch <n>', 'Prepared membership epoch')
493
+ .requiredOption('--agent-id <agent_id>', 'Writer signing this ACK')
494
+ .option('--json', 'Machine-readable output')
495
+ .action((options) => {
496
+ const globalOpts = program.opts();
497
+ return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('ack', { ...options, cwd: globalOpts.cwd }));
498
+ });
499
+ attemptAuthorityCmd
500
+ .command('activate')
501
+ .requiredOption('--membership-epoch <n>', 'Fully acknowledged membership epoch')
502
+ .option('--activated-by <actor>', 'Audited operator/coordinator identity', 'operator')
503
+ .option('--json', 'Machine-readable output')
504
+ .action((options) => {
505
+ const globalOpts = program.opts();
506
+ return import('../commands/attempt-authority.js').then(({ runAttemptAuthorityCommand }) => runAttemptAuthorityCommand('activate', { ...options, cwd: globalOpts.cwd }));
507
+ });
444
508
  // --- reply (provide_input to an operator_question; pln#508 step 4) ---
445
509
  program
446
510
  .command('reply <qst_id>')
@@ -0,0 +1,80 @@
1
+ import { loadAgentIdentity, loadAgentSigningKey } from '../core/agent-registry.js';
2
+ import { loadConnectionState } from '../core/federation-state.js';
3
+ import { ATTEMPT_AUTHORITY_WRITER_VERSION, activateAttemptAuthorityV2, attemptRolloutActivationDigest, ensureLocalAuthorityHome, prepareAttemptAuthorityRollout, publishAttemptRolloutAck, readLocalAuthorityHome, resolveActiveAttemptRollout, } from '../core/loops/attempt-rollout.js';
4
+ function epoch(value, fallback = 1) {
5
+ const parsed = value === undefined ? fallback : Number(value);
6
+ if (!Number.isInteger(parsed) || parsed <= 0)
7
+ throw new Error('membership epoch must be a positive integer');
8
+ return parsed;
9
+ }
10
+ function print(value, json) {
11
+ if (json)
12
+ console.log(JSON.stringify(value, null, 2));
13
+ else
14
+ console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));
15
+ }
16
+ export function runAttemptAuthorityCommand(subcommand, options = {}) {
17
+ const cwd = options.cwd ?? process.cwd();
18
+ if (subcommand === 'status') {
19
+ const active = resolveActiveAttemptRollout(cwd);
20
+ print({
21
+ writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
22
+ local_authority_home: readLocalAuthorityHome(cwd),
23
+ active,
24
+ }, options.json);
25
+ return;
26
+ }
27
+ if (subcommand === 'prepare') {
28
+ const writerIds = options.writers ?? [];
29
+ if (writerIds.length === 0)
30
+ throw new Error('prepare requires at least one --writers <agent_id>');
31
+ const participants = writerIds.map((writerId) => {
32
+ const identity = loadAgentIdentity(writerId, cwd);
33
+ if (!identity.identity_key) {
34
+ throw new Error(`agent ${writerId} has no Ed25519 identity key; re-register it before preparing rollout`);
35
+ }
36
+ return {
37
+ writer_id: writerId,
38
+ public_key_pem: identity.identity_key.public_key,
39
+ key_fingerprint: identity.identity_key.fingerprint,
40
+ status: 'active',
41
+ };
42
+ });
43
+ const active = resolveActiveAttemptRollout(cwd);
44
+ const membershipEpoch = epoch(options.membershipEpoch, (active?.guard.membership_epoch ?? 0) + 1);
45
+ const connection = loadConnectionState(cwd);
46
+ const home = ensureLocalAuthorityHome(cwd, { device_id: connection?.device.device_id });
47
+ const guard = prepareAttemptAuthorityRollout(cwd, {
48
+ membership_epoch: membershipEpoch,
49
+ authority_home: home,
50
+ participants,
51
+ previous_activation_digest: active ? attemptRolloutActivationDigest(active.activation) : undefined,
52
+ prepared_by: options.preparedBy ?? 'operator',
53
+ });
54
+ print(guard, options.json);
55
+ return;
56
+ }
57
+ if (subcommand === 'ack') {
58
+ const writerId = options.agentId;
59
+ if (!writerId)
60
+ throw new Error('ack requires --agent-id <agent_id>');
61
+ const signing = loadAgentSigningKey(writerId);
62
+ if (!signing)
63
+ throw new Error(`no local Ed25519 signing key for ${writerId}`);
64
+ const ack = publishAttemptRolloutAck(cwd, {
65
+ membership_epoch: epoch(options.membershipEpoch),
66
+ writer_id: writerId,
67
+ writer_version: ATTEMPT_AUTHORITY_WRITER_VERSION,
68
+ private_key_pem: signing.privateKeyPem,
69
+ });
70
+ print(ack, options.json);
71
+ return;
72
+ }
73
+ if (subcommand === 'activate') {
74
+ const activation = activateAttemptAuthorityV2(cwd, epoch(options.membershipEpoch), options.activatedBy ?? 'operator');
75
+ print(activation, options.json);
76
+ return;
77
+ }
78
+ throw new Error(`unknown attempt-authority subcommand: ${subcommand}`);
79
+ }
80
+ //# sourceMappingURL=attempt-authority.js.map
@@ -24,54 +24,33 @@ import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capabilit
24
24
  import { commitWorktreeOnBehalf, worktreesBaseDir, resolveGitToplevel } from '../core/worktree.js';
25
25
  import { closeReviewLoopFromLaneResult } from '../core/review-loop-close.js';
26
26
  import { closeIdeationLoopFromLaneResult } from '../core/ideation-loop-close.js';
27
- import { dispatchReviewLoopTurn, turnOwnedReviewEnabled } from '../core/review-loop-turn-dispatch.js';
27
+ import { dispatchReviewLoopTurn, turnOwnedLoopEnabled } from '../core/review-loop-turn-dispatch.js';
28
28
  import { reconcileTurn } from '../core/loops/reconcile-turn.js';
29
29
  import { findReservationByAssignmentId } from '../core/loops/attempt-reservation.js';
30
+ import { resolveTurnGenerationChain } from '../core/loops/attempt-generations.js';
30
31
  import { getLoop } from '../core/loops/store.js';
32
+ import { phasePolicy } from '../core/loops/kind-policies.js';
31
33
  import { readCompletionSignals } from '../core/runtime-signals.js';
32
34
  import { reconcileClaimConformity } from '../core/claim-conformity.js';
33
35
  import { toWarningDetail } from '../core/warnings.js';
34
- /**
35
- * pln#630 PR3a — finalize a TURN-OWNED review lane via the exactly-once `reconcileTurn`
36
- * instead of the legacy `closeReviewLoopFromLaneResult`. Returns `undefined` for a legacy
37
- * (non-reserved) lane so the caller runs the unchanged legacy path — this is the
38
- * exactly-one-finalizer discriminator: a lane is turn-owned iff a reservation OWNS its
39
- * assignment_id (only the turn-owned dispatch writes a reservation file).
40
- *
41
- * Evidence sourcing (the load-bearing subtlety): a real reviewer's LANE-RESULT.json is
42
- * KEYLESS — the review brief never asks the worker to echo turn_id/run_id/nonce — so
43
- * read-strict `reconcileTurn` (which matches lane.{turn_id,run_id,nonce} against the
44
- * attempt) would REJECT it. We source the keys authoritatively: turn_id + run_id are
45
- * deterministic from the reservation, and the NONCE — the non-derivable proof that THIS
46
- * launch generation actually ran — comes from the coordinator's completion SENTINEL
47
- * (written mechanically by the ack-wrapper with the launch-grant token). A caller/test
48
- * that already supplies keyed lanes is honored (lane.* wins); a stale generation's
49
- * sentinel carries the old token → still rejected, preserving the anti-stale guarantee.
50
- */
51
- /**
52
- * The turn-owned FINALIZATION discriminator (pln#630, review Finding 1). A lane finalizes via
53
- * the exactly-once reconcileTurn ONLY if a committed reservation OWNS it AND turn-keyed evidence
54
- * (the nonce) is available — from the lane or the coordinator's completion SENTINEL. Without the
55
- * nonce, reconcileTurn's read-strict gate can NEVER converge: this is reachable in production
56
- * when a turn-owned dispatch WON the fence but did not ack-wrap-spawn (inbox_only / IDE-only
57
- * reviewer, command_ready_manual, capacity cap, BRAINCLAW_NO_SPAWN, worktree-creation failure) —
58
- * it minted a reservation but no sentinel will ever be written. Returning undefined there routes
59
- * the lane to the LEGACY presence-based closer so the loop still converges instead of stalling
60
- * forever. This is SAFE: the exactly-once SPAWN guarantee is enforced at DISPATCH by the launch
61
- * fence (already run), so using legacy FINALIZATION for a sentinel-less lane reintroduces no
62
- * double-spawn; and a sentinel that lands after a legacy close makes a later reconcile a
63
- * terminal-loop idempotent no-op.
64
- */
36
+ import { harvestHarnessObservation } from '../core/harness-adapters/index.js';
65
37
  function turnOwnedLaneEvidence(lane, cwd) {
66
38
  const reservation = findReservationByAssignmentId(lane.assignment_id, cwd);
67
39
  if (!reservation)
68
40
  return undefined; // legacy lane (no reservation)
69
- const nonce = lane.nonce ?? readCompletionSignals(cwd, reservation.child_ids.assignment_id).completed?.nonce;
70
- if (!nonce)
71
- return undefined; // reservation but NO turn-keyed evidence → legacy finalization
72
- return { reservation, nonce };
41
+ const chain = resolveTurnGenerationChain(cwd, reservation.turn_id);
42
+ const completion = readCompletionSignals(cwd, reservation.child_ids.assignment_id, chain?.latest_generation.run_id).completed;
43
+ const nonce = lane.nonce ?? completion?.nonce;
44
+ if (!nonce && !reservation.execution_contract_ref)
45
+ return undefined;
46
+ return {
47
+ reservation,
48
+ nonce,
49
+ contract_hash: lane.execution_contract_hash ?? completion?.contract_hash,
50
+ capability_snapshot_hash: lane.capability_snapshot_hash ?? completion?.capability_snapshot_hash,
51
+ };
73
52
  }
74
- function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
53
+ function reconcileTurnOwnedLane(lane, cwd, evidence) {
75
54
  const ev = evidence ?? turnOwnedLaneEvidence(lane, cwd);
76
55
  if (!ev)
77
56
  return undefined; // legacy lane OR no turn-keyed evidence — caller runs the legacy path
@@ -81,8 +60,17 @@ function reconcileTurnOwnedReviewLane(lane, cwd, evidence) {
81
60
  turn_id: lane.turn_id ?? reservation.turn_id,
82
61
  run_id: lane.run_id ?? reservation.child_ids.run_id,
83
62
  nonce,
63
+ execution_contract_hash: lane.execution_contract_hash ?? ev.contract_hash,
64
+ capability_snapshot_hash: lane.capability_snapshot_hash ?? ev.capability_snapshot_hash,
84
65
  };
85
- const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd });
66
+ const loop = getLoop(reservation.loop_id, cwd);
67
+ const critiques = loop?.kind === 'ideation'
68
+ && reservation.phase === 'critique'
69
+ && lane.artifact_type === 'critique'
70
+ && (lane.body ?? '').trim().length > 0
71
+ ? [{ body: lane.body.trim() }]
72
+ : undefined;
73
+ const result = reconcileTurn({ turn_id: reservation.turn_id, lane: enrichedLane, cwd, critiques });
86
74
  return { reservation, result };
87
75
  }
88
76
  /**
@@ -392,15 +380,31 @@ export function harvestLaneResults(options = {}) {
392
380
  const worktreePaths = resolveLaneScanPaths(options, cwd);
393
381
  for (const worktreePath of worktreePaths) {
394
382
  const file = getLaneResultPath(worktreePath);
395
- if (!fs.existsSync(file))
383
+ const fileExists = fs.existsSync(file);
384
+ let nativeObservation;
385
+ if (options.assignmentId) {
386
+ try {
387
+ nativeObservation = harvestHarnessObservation(options.assignmentId, cwd, !options.dryRun);
388
+ }
389
+ catch (err) {
390
+ result.errors.push(`Failed to harvest native harness output for ${options.assignmentId}: ${err instanceof Error ? err.message : String(err)}`);
391
+ continue;
392
+ }
393
+ }
394
+ if (!fileExists && !nativeObservation)
396
395
  continue;
397
396
  let lane;
398
- try {
399
- lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
397
+ if (fileExists) {
398
+ try {
399
+ lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
400
+ }
401
+ catch (err) {
402
+ result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
403
+ continue;
404
+ }
400
405
  }
401
- catch (err) {
402
- result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
403
- continue;
406
+ else {
407
+ lane = nativeObservation.lane;
404
408
  }
405
409
  // Assignment filter (when harvesting a specific lane).
406
410
  if (options.assignmentId && lane.assignment_id !== options.assignmentId)
@@ -438,14 +442,66 @@ export function harvestLaneResults(options = {}) {
438
442
  // Kill-switch (=0), a legacy lane (no reservation), OR a reservation WITHOUT evidence
439
443
  // (review Finding 1: an inbox_only/non-ack-wrapped dispatch that never wrote a
440
444
  // sentinel) → the lane takes the unchanged legacy close so it still converges.
441
- // Ideation stays legacy (review-only).
442
- const laneTurnEvidence = turnOwnedReviewEnabled() ? turnOwnedLaneEvidence(lane, cwd) : undefined;
443
- turnEvidenceForCatch = laneTurnEvidence;
445
+ // P0C: the same evidence gate now serves every LoopKind. Review keeps
446
+ // its report-only request_changes deferral because that path cannot
447
+ // re-dispatch a fix cycle; other kinds can safely record their result.
448
+ const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
449
+ // Set before reading the loop: a corrupt loop store may throw below,
450
+ // and the catch must still know this was a turn-owned lane to warn.
451
+ turnEvidenceForCatch = candidateEvidence;
452
+ const candidateLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
453
+ const laneTurnEvidence = candidateEvidence && candidateLoop && turnOwnedLoopEnabled(candidateLoop.kind)
454
+ ? candidateEvidence
455
+ : undefined;
456
+ turnEvidenceForCatch = laneTurnEvidence ?? candidateEvidence;
457
+ const ownedLoop = laneTurnEvidence ? candidateLoop : undefined;
458
+ const ownedPhasePolicy = ownedLoop
459
+ ? phasePolicy(ownedLoop.kind, laneTurnEvidence.reservation.phase)
460
+ : undefined;
444
461
  if (!laneTurnEvidence) {
445
462
  closeReviewLoopFromLaneResult(laneAssignment, lane, agent, cwd, { cycleOnRequestChanges: false });
446
463
  }
464
+ else if (ownedPhasePolicy?.finalization === 'integrate') {
465
+ result.warnings.push(toWarningDetail({
466
+ code: 'loop_turn_not_converged',
467
+ message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} requires harvest --integrate before convergence; claim retained.`,
468
+ data: {
469
+ assignment_id: lane.assignment_id,
470
+ loop_id: laneTurnEvidence.reservation.loop_id,
471
+ turn_id: laneTurnEvidence.reservation.turn_id,
472
+ phase: laneTurnEvidence.reservation.phase,
473
+ },
474
+ }));
475
+ }
476
+ else if (ownedLoop?.kind !== 'review') {
477
+ const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
478
+ if (ownedLoop?.kind === 'ideation'
479
+ && laneTurnEvidence.reservation.phase === 'critique'
480
+ && (lane.artifact_type !== 'critique' || !(lane.body ?? '').trim())) {
481
+ result.warnings.push(toWarningDetail({
482
+ code: 'loop_turn_not_converged',
483
+ message: `Ideation critique lane ${lane.assignment_id} did not provide artifact_type='critique' with a non-empty body; no critique artifact was accepted.`,
484
+ data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
485
+ }));
486
+ }
487
+ if (rr && !rr.result.reconciled && !/superseded/.test(rr.result.reason ?? '')) {
488
+ result.warnings.push(toWarningDetail({
489
+ code: 'loop_turn_not_converged',
490
+ message: `Turn-owned ${ownedLoop?.kind ?? 'loop'} lane ${lane.assignment_id} did not converge: ${rr.result.reason}.`,
491
+ data: { assignment_id: lane.assignment_id, loop_id: laneTurnEvidence.reservation.loop_id, turn_id: laneTurnEvidence.reservation.turn_id },
492
+ }));
493
+ }
494
+ if (ownedLoop?.kind === 'ideation' && rr) {
495
+ ideationLoop = {
496
+ loop_id: laneTurnEvidence.reservation.loop_id,
497
+ action: rr.result.auto_closed ? 'closed' : rr.result.reconciled ? 'advanced' : 'noop',
498
+ reason: rr.result.reason,
499
+ loop_status: rr.result.loop_status,
500
+ };
501
+ }
502
+ }
447
503
  else if (lane.review_verdict === 'approve') {
448
- const rr = reconcileTurnOwnedReviewLane(lane, cwd, laneTurnEvidence);
504
+ const rr = reconcileTurnOwnedLane(lane, cwd, laneTurnEvidence);
449
505
  // Reason-based quietness (PR #171 review P2-1 refinement): a terminal loop
450
506
  // returns reconciled:true (idempotent no-op) and a superseded turn is the one
451
507
  // healthy decline (`harvest --all` over a prior round's lane) — everything
@@ -465,7 +521,9 @@ export function harvestLaneResults(options = {}) {
465
521
  }
466
522
  // pln#521 P2-bis — the ideation analog: a critic lane records its critique +
467
523
  // advances the ideation loop. Returns undefined for non-ideate scopes (no-op here).
468
- ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
524
+ if (!laneTurnEvidence) {
525
+ ideationLoop = closeIdeationLoopFromLaneResult(laneAssignment, lane, agent, cwd);
526
+ }
469
527
  // pln#636 C2 (review F3) — the universal net's most important trigger.
470
528
  // A file-fallback worker declares its own footprint in `files_changed`,
471
529
  // which is BOTH cheaper and more reliable than a git diff here: by
@@ -522,6 +580,8 @@ export function harvestLaneResults(options = {}) {
522
580
  ideation_loop: ideationLoop ?? null,
523
581
  files_changed: lane.files_changed ?? [],
524
582
  source_worktree: worktreePath,
583
+ harness_stdout_log: nativeObservation?.stdout_log ?? null,
584
+ harness_stderr_log: nativeObservation?.stderr_log ?? null,
525
585
  },
526
586
  }, cwd);
527
587
  fs.mkdirSync(path.dirname(marker), { recursive: true });
@@ -684,27 +744,46 @@ export function integrateLaneResults(options = {}) {
684
744
  // next_turn) unless the iteration cap is hit. This is the --integrate
685
745
  // path, so it MAY cycle (it can re-dispatch AND retain the claim). No-op
686
746
  // for non-review lanes / lanes without a verdict; never throws.
687
- // pln#521 P2-bis the ideation analog (record critique + advance the ideation
688
- // loop). Turn-owned is REVIEW-ONLY (pln#630), so ideation always uses the legacy
689
- // path. It is independent of the review-loop close (a lane's scope is review-loop
690
- // XOR ideate-loop), so it runs once here regardless of the branch below.
691
- const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
692
- if (ideationClose) {
693
- reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
694
- entry.ideation_loop = ideationClose;
747
+ // Legacy ideation lanes still use the historical closer. A turn-owned
748
+ // lane of any kind is finalized exactly once by reconcileTurn below.
749
+ const candidateEvidence = turnOwnedLaneEvidence(lane, cwd);
750
+ const ownedLoop = candidateEvidence ? getLoop(candidateEvidence.reservation.loop_id, cwd) : undefined;
751
+ const turnOwnedEvidence = candidateEvidence && ownedLoop && turnOwnedLoopEnabled(ownedLoop.kind)
752
+ ? candidateEvidence
753
+ : undefined;
754
+ if (!turnOwnedEvidence) {
755
+ const ideationClose = closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd);
756
+ if (ideationClose) {
757
+ reasons.push(`ideate-loop ${ideationClose.loop_id}: ${ideationClose.action} — ${ideationClose.reason}`);
758
+ entry.ideation_loop = ideationClose;
759
+ }
695
760
  }
696
761
  // pln#630 PR3a — a TURN-OWNED review lane finalizes via the exactly-once
697
762
  // reconcileTurn, which REPLACES the legacy closer + teardown gate for this lane
698
763
  // (exactly-one finalizer per lane). Kill-switch (=0), a legacy (non-reserved) lane, OR a
699
764
  // reservation with NO turn-keyed evidence (review Finding 1) → `turnOwned` is undefined
700
765
  // and the unchanged legacy `else` block runs so the loop still converges.
701
- const turnOwned = turnOwnedReviewEnabled()
702
- ? reconcileTurnOwnedReviewLane(lane, cwd)
766
+ const turnOwned = turnOwnedEvidence
767
+ ? reconcileTurnOwnedLane(lane, cwd, turnOwnedEvidence)
703
768
  : undefined;
704
769
  if (turnOwned) {
705
770
  const { reservation, result: rr } = turnOwned;
706
- entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
707
- reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
771
+ if (ownedLoop?.kind === 'review') {
772
+ entry.review_loop = reconcileToReviewLoopResult(reservation, rr, lane);
773
+ reasons.push(`turn-owned reconcile ${reservation.loop_id}: ${entry.review_loop.action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
774
+ }
775
+ else {
776
+ const action = rr.auto_closed ? 'closed' : rr.reconciled ? 'advanced' : 'noop';
777
+ reasons.push(`turn-owned ${ownedLoop?.kind ?? 'loop'} reconcile ${reservation.loop_id}: ${action} — ${rr.reason}${rr.conflict ? ' [CONFLICT — held]' : ''}`);
778
+ if (ownedLoop?.kind === 'ideation') {
779
+ entry.ideation_loop = {
780
+ loop_id: reservation.loop_id,
781
+ action,
782
+ reason: rr.reason,
783
+ loop_status: rr.loop_status,
784
+ };
785
+ }
786
+ }
708
787
  // pln#630 PR3b — a symmetric request_changes bumped the round + retained the claim
709
788
  // and handed back the next fix-cycle turn. Push it exactly like the legacy path so
710
789
  // the existing async re-dispatch loop spawns round N+1 into the reused worktree. The
@@ -60,6 +60,14 @@ function parseOutcome(opts) {
60
60
  }
61
61
  return outcome;
62
62
  }
63
+ function parseOptionalEpoch(value, opts) {
64
+ if (value === undefined)
65
+ return undefined;
66
+ const epoch = Number(value);
67
+ if (!Number.isInteger(epoch) || epoch < 0)
68
+ fail('--attempt-epoch must be a non-negative integer', 1, opts);
69
+ return epoch;
70
+ }
63
71
  function formatNextExpected(hint) {
64
72
  if (!hint)
65
73
  return ' (loop has no further expected action)';
@@ -95,11 +103,37 @@ function buildRequest(subcommand, loopId, opts) {
95
103
  intent: 'complete_turn',
96
104
  loop_id: loopId,
97
105
  slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
106
+ assignment_id: opts.assignmentId,
107
+ turn_id: opts.turnId,
108
+ run_id: opts.runId,
109
+ nonce: opts.nonce,
110
+ attempt_epoch: parseOptionalEpoch(opts.attemptEpoch, opts),
111
+ execution_contract_hash: opts.executionContractHash,
112
+ workspace_digest: opts.workspaceDigest,
98
113
  outcome: parseOutcome(opts),
99
114
  failure_reason: opts.failureReason,
100
115
  artifact,
101
116
  };
102
117
  }
118
+ case 'takeover': {
119
+ const epoch = Number(opts.expectedEpoch);
120
+ if (!Number.isInteger(epoch) || epoch < 0)
121
+ fail('--expected-epoch must be a non-negative integer', 1, opts);
122
+ return {
123
+ intent: 'takeover',
124
+ loop_id: loopId,
125
+ slot_id: requireOption(opts.slot, '--slot <slot_id>', opts),
126
+ turn_id: requireOption(opts.turnId, '--turn-id <turn_id>', opts),
127
+ expected_epoch: epoch,
128
+ cause: requireOption(opts.cause, '--cause <text>', opts),
129
+ liveness_evidence: requireOption(opts.livenessEvidence, '--liveness-evidence <text>', opts),
130
+ external_effect_policy: requireOption(opts.externalEffectPolicy, '--external-effect-policy <policy>', opts),
131
+ next_workspace_path: requireOption(opts.nextWorkspacePath, '--next-workspace-path <path>', opts),
132
+ takeover_mode: opts.mode,
133
+ agent: opts.agent,
134
+ agentId: opts.agent,
135
+ };
136
+ }
103
137
  case 'advance':
104
138
  return {
105
139
  intent: 'advance',