brainclaw 1.26.2 → 1.28.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 (88) hide show
  1. package/README.md +13 -0
  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 +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  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 +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -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/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -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 +40 -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/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
package/README.md CHANGED
@@ -282,6 +282,19 @@ per-phase memory filters. The shared controls are `open`, `turn`,
282
282
  implementation also adds `bind` and `verify`. `request_input` /
283
283
  `provide_input` are cross-cutting clarification primitives for any workflow.
284
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
+
285
298
  Review is a useful specialized path, not the definition of the engine. It has
286
299
  asymmetric and symmetric modes and can auto-close on an approved verdict; the
287
300
  other workflows use the same lifecycle to converge on a plan, synthesis,
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',