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
@@ -17,6 +17,7 @@ import { spawnSync } from 'node:child_process';
17
17
  import { listAssignments, transitionAssignment } from './assignments.js';
18
18
  import { signalExists, readHeartbeat, latestActivityMs } from './runtime-signals.js';
19
19
  import { DEFAULT_HYGIENE_POLICY } from './hygiene-policy.js';
20
+ import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
20
21
  function lastCommitAgeMs(worktreePath, nowMs) {
21
22
  if (!worktreePath)
22
23
  return undefined;
@@ -46,6 +47,7 @@ function lastCommitAgeMs(worktreePath, nowMs) {
46
47
  */
47
48
  function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
48
49
  const root = cwd ?? process.cwd();
50
+ const runId = currentAttemptRunIdForAssignment(assignment.id, cwd);
49
51
  const parts = [];
50
52
  let freshest;
51
53
  const bump = (ageMs) => {
@@ -55,12 +57,12 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
55
57
  freshest = ageMs;
56
58
  };
57
59
  try {
58
- if (signalExists(root, assignment.id, 'ack'))
60
+ if (signalExists(root, assignment.id, 'ack', runId))
59
61
  parts.push('ack sentinel');
60
62
  }
61
63
  catch { /* defensive */ }
62
64
  try {
63
- const hb = readHeartbeat(root, assignment.id, assignment.worktree_path);
65
+ const hb = readHeartbeat(root, assignment.id, assignment.worktree_path, runId);
64
66
  if (hb.exists && hb.mtimeMs !== undefined) {
65
67
  const age = nowMs - hb.mtimeMs;
66
68
  parts.push(`heartbeat ${Math.round(age / 1000)}s old`);
@@ -69,7 +71,7 @@ function collectImplicitEvidence(assignment, cwd, nowMs, sinceMs, freshTtlMs) {
69
71
  }
70
72
  catch { /* defensive */ }
71
73
  try {
72
- const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path);
74
+ const lastFs = latestActivityMs(root, assignment.id, assignment.worktree_path, runId);
73
75
  if (lastFs !== undefined) {
74
76
  const age = nowMs - lastFs;
75
77
  parts.push(`fs activity ${Math.round(age / 1000)}s old`);
@@ -48,37 +48,41 @@ function assignmentStoreForDir(dirPath) {
48
48
  // ── CRUD ─────────────────────────────────────────────────────
49
49
  export function saveAssignment(assignment, cwd) {
50
50
  mutate({ cwd }, () => {
51
- ensureAssignmentsDir(cwd);
52
- const store = new JsonStore({
53
- dirPath: assignmentsDir(cwd, 'write'),
54
- documentType: 'assignment',
55
- getId: (a) => a.id,
56
- sort: (a, b) => a.created_at.localeCompare(b.created_at),
57
- });
58
- const parsed = AssignmentSchema.parse(assignment);
59
- // pln#568 (I2): journal the post-image BEFORE the projection write.
60
- const created = !store.exists(parsed.id);
61
- emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
62
- registryFaultPoint('after_registry_journal');
63
- store.save(parsed);
64
- // CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
65
- // canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
66
- // canonical first so the record looked right, but `deleteAssignment` removed only the
67
- // canonical one and the stale copy became the record again — a zombie resurrection.
68
- // Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
69
- // visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
70
- const writeDir = assignmentsDir(cwd, 'write');
71
- for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
72
- if (dirPath === writeDir)
73
- continue;
74
- const legacyPath = path.join(dirPath, `${parsed.id}.json`);
75
- try {
76
- if (fs.existsSync(legacyPath))
77
- fs.unlinkSync(legacyPath);
78
- }
79
- catch { /* best effort — the dual-layout list keeps it visible */ }
80
- }
51
+ saveAssignmentUnlocked(assignment, cwd);
52
+ });
53
+ }
54
+ /** Store-lock caller variant used by create-or-validate projection repair. */
55
+ function saveAssignmentUnlocked(assignment, cwd) {
56
+ ensureAssignmentsDir(cwd);
57
+ const store = new JsonStore({
58
+ dirPath: assignmentsDir(cwd, 'write'),
59
+ documentType: 'assignment',
60
+ getId: (a) => a.id,
61
+ sort: (a, b) => a.created_at.localeCompare(b.created_at),
81
62
  });
63
+ const parsed = AssignmentSchema.parse(assignment);
64
+ // pln#568 (I2): journal the post-image BEFORE the projection write.
65
+ const created = !store.exists(parsed.id);
66
+ emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
67
+ registryFaultPoint('after_registry_journal');
68
+ store.save(parsed);
69
+ // CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
70
+ // canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
71
+ // canonical first so the record looked right, but `deleteAssignment` removed only the
72
+ // canonical one and the stale copy became the record again — a zombie resurrection.
73
+ // Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
74
+ // visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
75
+ const writeDir = assignmentsDir(cwd, 'write');
76
+ for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
77
+ if (dirPath === writeDir)
78
+ continue;
79
+ const legacyPath = path.join(dirPath, `${parsed.id}.json`);
80
+ try {
81
+ if (fs.existsSync(legacyPath))
82
+ fs.unlinkSync(legacyPath);
83
+ }
84
+ catch { /* best effort — the dual-layout list keeps it visible */ }
85
+ }
82
86
  }
83
87
  export function loadAssignment(id, cwd) {
84
88
  // JsonStore.load throws when the id is missing; honor the declared
@@ -357,14 +361,14 @@ export function recordProgress(id, options, cwd) {
357
361
  * Create a new assignment. Called by the dispatcher after creating a claim
358
362
  * and sending an inbox message.
359
363
  */
360
- export function createAssignment(options, cwd) {
364
+ function buildAssignment(options, cwd) {
361
365
  const generated = options.id ? undefined : generateAssignmentId(cwd);
362
366
  const id = options.id ?? generated.id;
363
367
  // `generated` is undefined whenever the caller supplied an id, so the old
364
368
  // `generated!.short_label` threw a TypeError on the perfectly reasonable call
365
369
  // "give me this id, derive the rest". Found while pinning the layout primitive.
366
370
  const short_label = options.short_label ?? generated?.short_label ?? id;
367
- const assignment = AssignmentSchema.parse({
371
+ return AssignmentSchema.parse({
368
372
  schema_version: 1,
369
373
  id,
370
374
  short_label,
@@ -388,6 +392,8 @@ export function createAssignment(options, cwd) {
388
392
  description: options.description,
389
393
  lane: options.lane,
390
394
  worktree_path: options.worktree_path,
395
+ execution_contract_ref: options.execution_contract_ref,
396
+ capability_snapshot: options.capability_snapshot,
391
397
  status: 'created',
392
398
  created_at: nowISO(),
393
399
  heartbeat_ttl_ms: options.heartbeat_ttl_ms,
@@ -397,7 +403,8 @@ export function createAssignment(options, cwd) {
397
403
  artifacts: [],
398
404
  tags: options.tags ?? [],
399
405
  });
400
- saveAssignment(assignment, cwd);
406
+ }
407
+ function emitAssignmentCreatedSideEffects(assignment, options, cwd) {
401
408
  emitAssignmentEvent(assignment, 'assignment_created', options.dispatcher_agent, cwd);
402
409
  appendAuditEntry({
403
410
  actor: options.dispatcher_agent,
@@ -406,8 +413,99 @@ export function createAssignment(options, cwd) {
406
413
  item_type: 'assignment',
407
414
  after: { agent: options.agent, scope: options.scope, claim_id: options.claim_id },
408
415
  }, cwd);
416
+ }
417
+ export function createAssignment(options, cwd) {
418
+ const assignment = buildAssignment(options, cwd);
419
+ saveAssignment(assignment, cwd);
420
+ emitAssignmentCreatedSideEffects(assignment, options, cwd);
409
421
  return assignment;
410
422
  }
423
+ export class AssignmentProjectionConflictError extends Error {
424
+ assignmentId;
425
+ constructor(assignmentId, detail) {
426
+ super(`Assignment projection conflict for ${assignmentId}: ${detail}`);
427
+ this.assignmentId = assignmentId;
428
+ this.name = 'AssignmentProjectionConflictError';
429
+ }
430
+ }
431
+ const TERMINAL_PROJECTION_ASSIGNMENT_STATUSES = new Set([
432
+ 'completed', 'cancelled', 'failed', 'blocked', 'timed_out', 'expired', 'rerouted',
433
+ ]);
434
+ function assertAssignmentProjectionMatches(existing, expected) {
435
+ const fields = [
436
+ 'id', 'claim_id', 'agent', 'dispatcher_agent', 'scope', 'description',
437
+ ];
438
+ for (const field of fields) {
439
+ if (existing[field] !== expected[field]) {
440
+ throw new AssignmentProjectionConflictError(expected.id, `${String(field)} differs (existing=${String(existing[field])}, expected=${String(expected[field])})`);
441
+ }
442
+ }
443
+ // project_id was added after the entity shipped. An absent legacy owner remains readable;
444
+ // when present it must identify the same authoritative store.
445
+ if (existing.project_id !== undefined && existing.project_id !== expected.project_id) {
446
+ throw new AssignmentProjectionConflictError(expected.id, 'project_id differs');
447
+ }
448
+ // Turn-owned Assignments created before P0B did not persist agent_id. Accept and
449
+ // enrich that legacy omission when the named agent still matches; a present,
450
+ // divergent identity remains a hard conflict.
451
+ if (existing.agent_id !== undefined && existing.agent_id !== expected.agent_id) {
452
+ throw new AssignmentProjectionConflictError(expected.id, 'agent_id differs');
453
+ }
454
+ if (existing.execution_contract_ref !== undefined
455
+ && expected.execution_contract_ref !== undefined
456
+ && JSON.stringify(existing.execution_contract_ref) !== JSON.stringify(expected.execution_contract_ref)) {
457
+ throw new AssignmentProjectionConflictError(expected.id, 'execution_contract_ref differs');
458
+ }
459
+ if (existing.capability_snapshot !== undefined
460
+ && expected.capability_snapshot !== undefined
461
+ && JSON.stringify(existing.capability_snapshot) !== JSON.stringify(expected.capability_snapshot)) {
462
+ throw new AssignmentProjectionConflictError(expected.id, 'capability_snapshot differs');
463
+ }
464
+ if (TERMINAL_PROJECTION_ASSIGNMENT_STATUSES.has(existing.status)) {
465
+ throw new AssignmentProjectionConflictError(expected.id, `existing status is terminal (${existing.status})`);
466
+ }
467
+ }
468
+ /**
469
+ * Create-or-validate the deterministic Assignment projection for one logical turn.
470
+ *
471
+ * The read/decision/write sequence is serialized by the store lock. Identical recovery is
472
+ * a strict no-op (no registry/runtime/audit duplicate); any divergent projection fails closed.
473
+ */
474
+ export function ensureAssignmentProjection(options, cwd) {
475
+ let created = false;
476
+ let repaired = false;
477
+ const expected = buildAssignment(options, cwd);
478
+ const assignment = mutate({ cwd }, () => {
479
+ const existing = loadAssignment(options.id, cwd);
480
+ if (existing) {
481
+ assertAssignmentProjectionMatches(existing, expected);
482
+ const requiredTags = options.tags ?? [];
483
+ const missingAgentId = existing.agent_id === undefined && expected.agent_id !== undefined;
484
+ const missingContractRef = existing.execution_contract_ref === undefined && expected.execution_contract_ref !== undefined;
485
+ const missingCapabilitySnapshot = existing.capability_snapshot === undefined && expected.capability_snapshot !== undefined;
486
+ const missingTags = !requiredTags.every((tag) => existing.tags.includes(tag));
487
+ if (!missingAgentId && !missingContractRef && !missingCapabilitySnapshot && !missingTags)
488
+ return existing;
489
+ const enriched = AssignmentSchema.parse({
490
+ ...existing,
491
+ ...(missingAgentId ? { agent_id: expected.agent_id } : {}),
492
+ ...(missingContractRef ? { execution_contract_ref: expected.execution_contract_ref } : {}),
493
+ ...(missingCapabilitySnapshot ? { capability_snapshot: expected.capability_snapshot } : {}),
494
+ tags: [...new Set([...existing.tags, ...requiredTags])],
495
+ updated_at: nowISO(),
496
+ });
497
+ saveAssignmentUnlocked(enriched, cwd);
498
+ repaired = true;
499
+ return enriched;
500
+ }
501
+ saveAssignmentUnlocked(expected, cwd);
502
+ created = true;
503
+ return expected;
504
+ });
505
+ if (created)
506
+ emitAssignmentCreatedSideEffects(assignment, options, cwd);
507
+ return { assignment, created, ...(repaired ? { repaired: true } : {}) };
508
+ }
411
509
  // ── Active Assignment Lookup ─────────────────────────────────
412
510
  /** Statuses that indicate a finished assignment (no longer active). */
413
511
  const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'rerouted']);
@@ -68,5 +68,12 @@ export const ReleaseClaimRequestSchema = z.object({
68
68
  .boolean()
69
69
  .describe('Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928.')
70
70
  .optional(),
71
+ /** AttemptAuthority v2 fence; mandatory for a worker-owned claim linked to a v2 Assignment. */
72
+ turn_id: z.string().optional(),
73
+ run_id: z.string().optional(),
74
+ nonce: z.string().optional(),
75
+ attempt_epoch: z.number().int().nonnegative().optional(),
76
+ execution_contract_hash: z.string().regex(/^[a-f0-9]{64}$/).optional(),
77
+ workspace_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
71
78
  });
72
79
  //# sourceMappingURL=claim-request-schema.js.map
@@ -16,6 +16,8 @@ import { loadState, persistState } from './state.js';
16
16
  import { createRuntimeEvent } from './events.js';
17
17
  import { latestActivityMs, readHeartbeat } from './runtime-signals.js';
18
18
  import { emitRegistryPostImage, registryFaultPoint } from './events/registry-post-image.js';
19
+ import { loadAssignment } from './assignments.js';
20
+ import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
19
21
  /** Parse duration string like '4h', '30m' to ms. */
20
22
  function parseTtl(value) {
21
23
  const match = /^(\d+)([mhd])$/i.exec(value.trim());
@@ -632,6 +634,7 @@ function freshestEvidenceAgeMs(claim, nowMs, cwd) {
632
634
  if (!claim.assignment_id)
633
635
  return undefined;
634
636
  const root = cwd ?? process.cwd();
637
+ const runId = currentAttemptRunIdForAssignment(claim.assignment_id, cwd);
635
638
  let freshest;
636
639
  const consider = (ms) => {
637
640
  if (ms === undefined)
@@ -647,13 +650,13 @@ function freshestEvidenceAgeMs(claim, nowMs, cwd) {
647
650
  freshest = normalised;
648
651
  };
649
652
  try {
650
- const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path);
653
+ const hb = readHeartbeat(root, claim.assignment_id, claim.worktree_path, runId);
651
654
  if (hb.exists)
652
655
  consider(hb.mtimeMs);
653
656
  }
654
657
  catch { /* evidence is best-effort */ }
655
658
  try {
656
- consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path));
659
+ consider(latestActivityMs(root, claim.assignment_id, claim.worktree_path, runId));
657
660
  }
658
661
  catch { /* evidence is best-effort */ }
659
662
  return freshest;
@@ -1063,6 +1066,54 @@ export function linkClaimToAssignment(claimId, assignmentId, cwd) {
1063
1066
  saveClaimUnlocked(claim, cwd);
1064
1067
  });
1065
1068
  }
1069
+ export class ClaimAssignmentConflictError extends Error {
1070
+ claimId;
1071
+ constructor(claimId, detail) {
1072
+ super(`Claim/Assignment projection conflict for ${claimId}: ${detail}`);
1073
+ this.claimId = claimId;
1074
+ this.name = 'ClaimAssignmentConflictError';
1075
+ }
1076
+ }
1077
+ /**
1078
+ * Idempotently bind an active claim to the deterministic Assignment of its turn.
1079
+ * Unlike the legacy patch helper, this never overwrites a divergent binding.
1080
+ */
1081
+ export function ensureClaimAssignmentBinding(claimId, assignmentId, cwd, options = {}) {
1082
+ return mutate({ cwd }, () => {
1083
+ const claim = loadClaim(claimId, cwd);
1084
+ if (claim.status !== 'active') {
1085
+ throw new ClaimAssignmentConflictError(claimId, `claim is ${claim.status}, expected active`);
1086
+ }
1087
+ if (claim.assignment_id !== undefined && claim.assignment_id !== assignmentId) {
1088
+ // Symmetric review/fix cycles intentionally retain one active coordinator
1089
+ // claim across rounds. A later deterministic turn may advance that pointer,
1090
+ // but only after the previous Assignment is durably terminal.
1091
+ const prior = loadAssignment(claim.assignment_id, cwd);
1092
+ const priorTerminal = prior && [
1093
+ 'completed', 'cancelled', 'expired', 'rerouted',
1094
+ ].includes(prior.status);
1095
+ if (!priorTerminal) {
1096
+ throw new ClaimAssignmentConflictError(claimId, `already bound to non-terminal ${claim.assignment_id}, cannot bind ${assignmentId}`);
1097
+ }
1098
+ }
1099
+ let changed = false;
1100
+ if (claim.assignment_id !== assignmentId) {
1101
+ claim.assignment_id = assignmentId;
1102
+ changed = true;
1103
+ }
1104
+ // AttemptAuthority keeps one logical Assignment/claim across physical
1105
+ // generations. Rebind the claim's liveness and GC root to the current
1106
+ // generation workspace in the SAME mutation as its Assignment pointer.
1107
+ if (options.worktreePath !== undefined && claim.worktree_path !== options.worktreePath) {
1108
+ claim.worktree_path = options.worktreePath;
1109
+ changed = true;
1110
+ }
1111
+ if (!changed)
1112
+ return claim;
1113
+ saveClaimUnlocked(claim, cwd);
1114
+ return claim;
1115
+ });
1116
+ }
1066
1117
  /**
1067
1118
  * Adopt a claim from a spawned instance's session.
1068
1119
  * Sets session_id + adopted_at on the claim. Refuses if the claim is already
@@ -28,7 +28,8 @@ import { loadClaim } from './claims.js';
28
28
  import { getLoop, listLoops } from './loops/store.js';
29
29
  import { isProcessAlive } from './agentrun-reconciler.js';
30
30
  import { findRuntimeNoteById } from './runtime.js';
31
- import { latestActivityMs, decodeOemAwareBuffer } from './runtime-signals.js';
31
+ import { latestActivityMs, decodeOemAwareBuffer, getRuntimeLogPath, getRuntimeSignalPath } from './runtime-signals.js';
32
+ import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
32
33
  import { LaneResultSchema } from './schema.js';
33
34
  const DEFAULT_TAIL = 20;
34
35
  const DEFAULT_STALL_MS = 5 * 60_000;
@@ -422,10 +423,19 @@ export function getDispatchStatus(options) {
422
423
  // coordination root. Use the cwd or the runtime cwd as the anchor; the
423
424
  // dispatcher writes them under cwd/.brainclaw/coordination/runtime/...
424
425
  const projectRoot = cwd ?? process.cwd();
425
- const runtimeRoot = path.join(projectRoot, '.brainclaw', 'coordination', 'runtime');
426
- const ackPath = assignmentId ? path.join(runtimeRoot, 'ack', `${assignmentId}.ack`) : undefined;
427
- const stdoutPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stdout.log`) : undefined;
428
- const stderrPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stderr.log`) : undefined;
426
+ const currentAttemptRunId = assignmentId
427
+ ? currentAttemptRunIdForAssignment(assignmentId, cwd)
428
+ : undefined;
429
+ const runtimeRunId = currentAttemptRunId ? (agentRun?.id ?? currentAttemptRunId) : undefined;
430
+ const ackPath = assignmentId
431
+ ? getRuntimeSignalPath(projectRoot, assignmentId, 'ack', runtimeRunId)
432
+ : undefined;
433
+ const stdoutPath = assignmentId
434
+ ? getRuntimeLogPath(projectRoot, assignmentId, 'stdout', runtimeRunId)
435
+ : undefined;
436
+ const stderrPath = assignmentId
437
+ ? getRuntimeLogPath(projectRoot, assignmentId, 'stderr', runtimeRunId)
438
+ : undefined;
429
439
  // pln#527 — filesystem-activity age: max mtime across the captured logs + the
430
440
  // run's worktree files (skipping junctions). The truer liveness signal when
431
441
  // the heartbeat / last_event_at is stale during a long single operation.
@@ -436,7 +446,7 @@ export function getDispatchStatus(options) {
436
446
  const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path ?? assignment?.worktree_path;
437
447
  let lastFsActivityMs;
438
448
  if (assignmentId) {
439
- const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs);
449
+ const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs, runtimeRunId);
440
450
  if (lastFs !== undefined)
441
451
  lastFsActivityMs = nowMs - lastFs;
442
452
  }
@@ -44,7 +44,8 @@ import { memoryDir } from './io.js';
44
44
  import { loadVersionedJsonFile } from './migration.js';
45
45
  import fs from 'node:fs';
46
46
  import path from 'node:path';
47
- import { buildInvokeCommand, resolveBriefMode, getCapabilityProfile, dispatchHasMcp, dispatchCanCommit, isSandboxedSpawn, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
47
+ import { resolveBriefMode, getCapabilityProfile, dispatchHasMcp, dispatchCanCommit, isSandboxedSpawn, resolveConcurrencyLimit, resolveResourceKey, resolveModel, serializeConcurrencyLimit } from './agent-capability.js';
48
+ import { buildHarnessInvocation } from './harness-adapters/index.js';
48
49
  import { getRuntimeSignalPath, getWorktreeHeartbeatPath } from './runtime-signals.js';
49
50
  import { attemptExecution } from './execution.js';
50
51
  import { createAssignment, transitionAssignment, generateAssignmentId, patchAssignmentMessageId } from './assignments.js';
@@ -67,9 +68,8 @@ function buildEnvPrefix(claimId) {
67
68
  /**
68
69
  * Analyze a sequence and categorize each item as ready, active, blocked, or done.
69
70
  *
70
- * `sequenceId` (pln#632 impl-loop bind) targets a SPECIFIC sequence by id instead of
71
- * the project's active one so an implementation loop can dispatch its own linked
72
- * sequence without hijacking the global active-sequence pointer. Omitted → the active
71
+ * `sequenceId` targets a SPECIFIC sequence by id instead of the project's
72
+ * active one, without hijacking the global active-sequence pointer. Omitted the active
73
73
  * sequence (byte-identical to the historical behaviour; the resolver is non-throwing,
74
74
  * so an unknown id yields `null` exactly like "no active sequence").
75
75
  */
@@ -283,9 +283,11 @@ export function buildLivenessSection(cwd, assignmentId, worktreePath, opts) {
283
283
  // cwd is the worktree root) — same file, sandbox-proof spelling.
284
284
  const sandboxRelative = opts?.sandboxed === true && !!worktreePath;
285
285
  const hbPath = worktreePath
286
- ? getWorktreeHeartbeatPath(worktreePath, assignmentId)
287
- : getRuntimeSignalPath(cwd, assignmentId, 'heartbeat');
288
- const targetPath = sandboxRelative ? `.brainclaw-heartbeat-${assignmentId}` : hbPath;
286
+ ? getWorktreeHeartbeatPath(worktreePath, assignmentId, opts?.runId)
287
+ : getRuntimeSignalPath(cwd, assignmentId, 'heartbeat', opts?.runId);
288
+ const targetPath = sandboxRelative
289
+ ? `.brainclaw-heartbeat-${assignmentId}${opts?.runId ? `-${opts.runId}` : ''}`
290
+ : hbPath;
289
291
  const isWin = process.platform === 'win32';
290
292
  const writeCmd = isWin
291
293
  ? `echo work_loop_reached ${assignmentId} > "${targetPath}"`
@@ -326,50 +328,35 @@ export function buildWorkingDefaultsSection(opts) {
326
328
  '',
327
329
  ].join('\n');
328
330
  }
329
- /**
330
- * pln#638 PR-4 — the transport section, for BOTH declared-MCP and MCP-less agents.
331
- *
332
- * WHY THIS EXISTS AS ONE FUNCTION. The MCP-less block was duplicated verbatim in
333
- * `generateBrief` and `generateDispatchBrief`; that duplication is exactly what
334
- * lets two brief paths drift, which is the class of bug PR-3 just fixed one layer
335
- * up. One function, two callers.
336
- *
337
- * WHY A DECLARED-MCP AGENT ALSO GETS A SECTION — the part that was missing. A
338
- * profile's `runtime.mcp_direct` is a STATIC flag: it asserts nothing about
339
- * whether the config exists on this machine, whether the server started, or
340
- * whether stdio came up. Proven in production during pln#638's own ideation: a
341
- * codex critic ran with `mcp_direct=true` and NO reachable MCP. Its 3654-character
342
- * critique survived only because the brief happened to spell out a file fallback
343
- * by hand. So the brief must never ASSERT the capability — it states the
344
- * expectation and names the fallback, and the worker decides from what it
345
- * actually observes.
346
- *
347
- * The store path is deliberately NOT mentioned. `.brainclaw/` is gitignored
348
- * (.gitignore:10), so it does not exist in a worker's worktree — the previous
349
- * wording told workers to write candidates into a directory they cannot see.
350
- */
351
- /**
352
- * The ONE LANE-RESULT shape every brief quotes.
353
- *
354
- * There used to be two. `buildProtocolSection` has emitted a fallback since
355
- * pln#526 with `{summary, files_changed, artifacts}` and NO `body`, while the
356
- * transport section below asked for `body`. A full-mode worker with an assignment
357
- * id received both and had to pick — and a worker that followed the older one
358
- * recreated trp_8efdbf9d (a substantial review collapsed into a one-line summary
359
- * because the contract had nowhere to put the reasoning). Caught in review by
360
- * Fable before this shipped.
361
- */
362
- export function laneResultShape(assignmentId) {
331
+ export function laneResultShape(assignmentId, contractRef, fence) {
363
332
  const asgn = assignmentId ?? '<assignment_id>';
364
- return `{"assignment_id":"${asgn}","status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
333
+ const generation = fence
334
+ ? `,"turn_id":"${fence.turn_id}","run_id":"${fence.run_id}","nonce":"${fence.nonce}","attempt_epoch":${fence.attempt_epoch},"workspace_digest":"${fence.workspace_digest}"`
335
+ : '';
336
+ const contract = contractRef
337
+ ? `,"execution_contract_hash":"${contractRef.hash}","capability_snapshot_hash":"${contractRef.snapshot_hash}"`
338
+ : '';
339
+ return `{"assignment_id":"${asgn}"${generation}${contract},"status":"completed|blocked|failed","summary":"<one line>","body":"<your full output — the reasoning, not just a label>","files_changed":["..."],"artifacts":["..."]}`;
365
340
  }
366
341
  export function buildTransportSection(opts) {
367
- const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId)}`;
342
+ const laneResult = `write LANE-RESULT.json at the worktree ROOT: ${laneResultShape(opts.assignmentId, opts.executionContractRef, opts.attemptFence)}`;
343
+ const acceptance = opts.executionContractRef
344
+ ? [
345
+ `Execution contract: ${opts.executionContractRef.hash}`,
346
+ `Capability snapshot: ${opts.executionContractRef.snapshot_hash}`,
347
+ 'These values are also available as BRAINCLAW_EXECUTION_CONTRACT_HASH and BRAINCLAW_CAPABILITY_SNAPSHOT_HASH. Echo both unchanged in LANE-RESULT.json. If either differs from what your runtime accepted, report blocked immediately; Brainclaw withholds convergence and never respawns this crossed generation.',
348
+ ...(opts.attemptFence ? [
349
+ `Attempt generation fence: turn=${opts.attemptFence.turn_id}, run=${opts.attemptFence.run_id}, nonce=${opts.attemptFence.nonce}, epoch=${opts.attemptFence.attempt_epoch}, workspace_digest=${opts.attemptFence.workspace_digest}.`,
350
+ 'Include every fence field in bclaw_assignment_update as well as LANE-RESULT.json; an incomplete or stale update is rejected before mutating Assignment, AgentRun, or Claim.',
351
+ ] : []),
352
+ ]
353
+ : [];
368
354
  if (!opts.hasMcp) {
369
355
  return [
370
356
  '## ⚠ Transport: no MCP (file protocol only)',
371
357
  'Your runtime has no brainclaw MCP access — any `bclaw_*` instruction above does NOT apply to you. Report your outcome via the FILE protocol only; it is authoritative for this run:',
372
358
  `- When done, ${laneResult}.`,
359
+ ...acceptance,
373
360
  // RESTORED after review. Removing this orphaned a real, shipped consumer:
374
361
  // `collectWorktreeCandidateFiles` (harvest.ts:191-205) scans exactly this
375
362
  // directory inside WORKER worktrees, and `bclaw_harvest_candidates` exposes
@@ -386,6 +373,7 @@ export function buildTransportSection(opts) {
386
373
  'Your profile declares brainclaw MCP access, but that is a DECLARATION, not a verified fact — the config may be absent on this machine or the server may not have started. Decide from what you actually observe:',
387
374
  '- If `bclaw_*` tools respond: use them, as instructed above.',
388
375
  `- If they are unavailable or error: do not stop and do not discard your work — ${laneResult}. The coordinator harvests it.`,
376
+ ...acceptance,
389
377
  '',
390
378
  ].join('\n');
391
379
  }
@@ -463,11 +451,17 @@ export function buildProtocolSection(options) {
463
451
  parts.push(`${options.worktreePath ? '3' : '2'}. Call bclaw_assignment_update(assignment_id: "${options.assignmentId}", status: "started")`);
464
452
  parts.push(`${options.worktreePath ? '4' : '3'}. Work on the assigned scope`);
465
453
  parts.push(`${options.worktreePath ? '5' : '4'}. Periodically call bclaw_assignment_update(status: "progress", message: "...") as heartbeat`);
466
- parts.push(`${options.worktreePath ? '6' : '5'}. When done: bclaw_assignment_update(status: "completed", artifacts: [...])`);
467
- const claimRef = options?.claimId ? `id: "${options.claimId}"` : 'id: "<claim_id>"';
468
- parts.push(`${options.worktreePath ? '7' : '6'}. Release the claim: bclaw_release_claim(${claimRef}, planStatus: "done") required for hard_after gating to unblock downstream tasks`);
469
- parts.push(`${options.worktreePath ? '8' : '7'}. If blocked: bclaw_assignment_update(status: "blocked", blocker: "...")`);
470
- parts.push(`${options.worktreePath ? '9' : '8'}. If failed: bclaw_assignment_update(status: "failed", error_message: "...")`);
454
+ if (options.attemptFence) {
455
+ parts.push(`${options.worktreePath ? '6' : '5'}. Report the terminal outcome in full-fence LANE-RESULT.json; do NOT terminalize the logical Assignment or release its Claim. Brainclaw settles close(epoch) first, then replays those projections.`);
456
+ parts.push(`${options.worktreePath ? '7' : '6'}. If blocked or failed, encode that status and explanation in LANE-RESULT.json with the same complete generation fence.`);
457
+ }
458
+ else {
459
+ parts.push(`${options.worktreePath ? '6' : '5'}. When done: bclaw_assignment_update(status: "completed", artifacts: [...])`);
460
+ const claimRef = options?.claimId ? `id: "${options.claimId}"` : 'id: "<claim_id>"';
461
+ parts.push(`${options.worktreePath ? '7' : '6'}. Release the claim: bclaw_release_claim(${claimRef}, planStatus: "done") — required for hard_after gating to unblock downstream tasks`);
462
+ parts.push(`${options.worktreePath ? '8' : '7'}. If blocked: bclaw_assignment_update(status: "blocked", blocker: "...")`);
463
+ parts.push(`${options.worktreePath ? '9' : '8'}. If failed: bclaw_assignment_update(status: "failed", error_message: "...")`);
464
+ }
471
465
  // pln#479: compile-check contract for code workers — a per-worktree
472
466
  // pre-commit gate may HARD-block a commit that fails tsc (opt-in).
473
467
  if (options.worktreePath) {
@@ -763,7 +757,10 @@ export function generateDispatchBrief(options) {
763
757
  // sprint 1.5 — task-based briefs get the same step-0 liveness contract as
764
758
  // plan-based briefs (worktree-local heartbeat, writable from any sandbox).
765
759
  if (options.assignmentId && options.worktreePath) {
766
- parts.push(buildLivenessSection(options.worktreePath, options.assignmentId, options.worktreePath, { sandboxed: taskSandboxed }));
760
+ parts.push(buildLivenessSection(options.worktreePath, options.assignmentId, options.worktreePath, {
761
+ sandboxed: taskSandboxed,
762
+ runId: options.attemptFence?.run_id,
763
+ }));
767
764
  }
768
765
  // pln#554 step 4 — working defaults (incremental commits + validation bar).
769
766
  parts.push(buildWorkingDefaultsSection({ canCommit: taskBriefProfile ? dispatchCanCommit(taskBriefProfile) : true }));
@@ -779,6 +776,7 @@ export function generateDispatchBrief(options) {
779
776
  claimId: options.claimId,
780
777
  worktreePath: options.worktreePath,
781
778
  assignmentId: options.assignmentId,
779
+ attemptFence: options.attemptFence,
782
780
  }));
783
781
  }
784
782
  // pln#628 Focus 4A — transport addendum keyed to the ACTUAL missing capability
@@ -792,6 +790,8 @@ export function generateDispatchBrief(options) {
792
790
  parts.push(buildTransportSection({
793
791
  hasMcp: taskBriefProfile ? dispatchHasMcp(taskBriefProfile) : true,
794
792
  assignmentId: options.assignmentId,
793
+ executionContractRef: options.executionContractRef,
794
+ attemptFence: options.attemptFence,
795
795
  }));
796
796
  }
797
797
  // Codex-specific constraints: focus and speed guidance for sandboxed runs
@@ -1087,7 +1087,7 @@ export async function dispatch(options, cwd) {
1087
1087
  // `sandboxed` flag. A --dry-run that previews a DIFFERENT brief than the one
1088
1088
  // that ships is worse than no preview.
1089
1089
  const brief = generateBrief(readyItem.plan, readyItem.item, cwd, briefMode, { claimId, worktreePath, agent: targetAgent });
1090
- const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
1090
+ const invokeCmd = buildHarnessInvocation(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) })?.invoke;
1091
1091
  if (invokeCmd) {
1092
1092
  const cmdPrefix = buildEnvPrefix(claimId);
1093
1093
  result.commands.push({ agent: targetAgent, lane: readyItem.lane, plan_id: readyItem.plan.id, command: `${cmdPrefix}${invokeCmd.bashCommand}`, shell: process.platform === 'win32' ? 'cmd' : (invokeCmd.shell ? 'bash' : 'sh') });
@@ -1143,7 +1143,7 @@ export async function dispatch(options, cwd) {
1143
1143
  agent: targetAgent,
1144
1144
  });
1145
1145
  // Step 3: Build invoke command
1146
- const invokeCmd = buildInvokeCommand(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) });
1146
+ const invokeCmd = buildHarnessInvocation(targetAgent, brief, { model: resolveModel(targetAgent, { override: options.model }) })?.invoke;
1147
1147
  if (invokeCmd) {
1148
1148
  const cmdPrefix = buildEnvPrefix(claimId);
1149
1149
  result.commands.push({
@@ -26,6 +26,9 @@ import { loadAllSessions } from './identity.js';
26
26
  import { loadInstructions } from './instructions.js';
27
27
  import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
28
28
  import { listAgentRuns } from './agentruns.js';
29
+ import { currentAttemptRunIdForAssignment } from './loops/attempt-reservation.js';
30
+ import { findReservationByAssignmentId } from './loops/attempt-reservation.js';
31
+ import { getLoop } from './loops/store.js';
29
32
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, reconcileStrandedFailureClaimAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
30
33
  import { isObserverMode } from './observer-mode.js';
31
34
  import { deleteRuntimeNote, listRuntimeNotes, parkRuntimeNoteBackup, saveRuntimeNote, } from './runtime.js';
@@ -709,6 +712,9 @@ export function updateEntity(name, id, patch, cwd) {
709
712
  const assignment = loadAssignment(id, cwd);
710
713
  if (!assignment)
711
714
  throw new EntityNotFoundError(name, id);
715
+ if (currentAttemptRunIdForAssignment(id, cwd)) {
716
+ throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; generic update is fenced until authoritative loop settlement/recovery`);
717
+ }
712
718
  const patched = { ...assignment, ...patch };
713
719
  saveAssignment(patched, cwd);
714
720
  return { entity: name, id };
@@ -894,6 +900,9 @@ export function removeEntity(name, id, cwd, purge = false) {
894
900
  const assignment = loadAssignment(id, cwd);
895
901
  if (!assignment)
896
902
  throw new EntityNotFoundError(name, id);
903
+ if (currentAttemptRunIdForAssignment(id, cwd)) {
904
+ throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; remove is fenced until authoritative loop settlement/recovery`);
905
+ }
897
906
  if (purge) {
898
907
  const deleted = deleteAssignment(id, cwd);
899
908
  if (!deleted)
@@ -987,6 +996,9 @@ export function transitionEntity(name, id, to, cwd, _reason, auth) {
987
996
  throw new InvalidTransitionError(name, from, to);
988
997
  }
989
998
  case 'assignment': {
999
+ if (currentAttemptRunIdForAssignment(id, cwd)) {
1000
+ throw new Error(`assignment '${id}' is managed by AttemptAuthority v2; use full-fence bclaw_assignment_update or loop reconciliation`);
1001
+ }
990
1002
  transitionAssignment(id, to, {
991
1003
  actor: 'brainclaw',
992
1004
  status_reason: _reason,
@@ -1000,6 +1012,14 @@ export function transitionEntity(name, id, to, cwd, _reason, auth) {
1000
1012
  return { entity: name, id, from, to, side_effects: sideEffects };
1001
1013
  }
1002
1014
  case 'claim': {
1015
+ const claim = loadClaim(id, cwd);
1016
+ if (claim.assignment_id && currentAttemptRunIdForAssignment(claim.assignment_id, cwd)) {
1017
+ const reservation = findReservationByAssignmentId(claim.assignment_id, cwd);
1018
+ const loop = reservation ? getLoop(reservation.loop_id, cwd) : undefined;
1019
+ if (!auth?.override || !loop || auth.agent_id !== loop.created_by) {
1020
+ throw new Error(`claim '${id}' belongs to an AttemptAuthority v2 assignment; override requires authenticated loop creator ${loop?.created_by ?? 'unknown'}`);
1021
+ }
1022
+ }
1003
1023
  // trp#928 — the entity registry advertised `active → released|stale` but
1004
1024
  // transitionEntity never routed for entity=claim. The isValidTransition
1005
1025
  // check above passed for anyone calling `bclaw_transition(entity='claim',
@@ -121,6 +121,10 @@ export function createRuntimeEvent(options, cwd) {
121
121
  tags: options.tags ?? [],
122
122
  assignment_id: options.assignment_id,
123
123
  run_id: options.run_id,
124
+ turn_id: options.turn_id,
125
+ nonce: options.nonce,
126
+ attempt_epoch: options.attempt_epoch,
127
+ workspace_digest: options.workspace_digest,
124
128
  claim_id: options.claim_id,
125
129
  message_id: options.message_id,
126
130
  plan_id: options.plan_id,