brainclaw 1.14.0 → 1.16.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 (63) hide show
  1. package/README.md +16 -263
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2015
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +31 -6
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5570
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +31 -14
  29. package/dist/core/agent-files.js +1 -1
  30. package/dist/core/agent-registry.js +51 -3
  31. package/dist/core/claims.js +18 -0
  32. package/dist/core/coordination.js +5 -2
  33. package/dist/core/cross-project.js +35 -1
  34. package/dist/core/dispatcher.js +34 -20
  35. package/dist/core/entity-operations.js +335 -12
  36. package/dist/core/entity-registry.js +72 -9
  37. package/dist/core/execution.js +28 -4
  38. package/dist/core/facade-schema.js +30 -4
  39. package/dist/core/federation-cloud.js +142 -11
  40. package/dist/core/federation-outbox.js +292 -0
  41. package/dist/core/federation-signing.js +115 -0
  42. package/dist/core/handoff-review.js +35 -0
  43. package/dist/core/io.js +6 -0
  44. package/dist/core/protocol-tool-policy.js +113 -0
  45. package/dist/core/review-loop-close.js +115 -0
  46. package/dist/core/schema.js +25 -2
  47. package/dist/core/security-detectors.js +35 -6
  48. package/dist/core/security.js +32 -12
  49. package/dist/core/worktree.js +98 -9
  50. package/dist/facts.js +13 -11
  51. package/dist/facts.json +12 -10
  52. package/docs/PROTOCOL.md +7 -3
  53. package/docs/concepts/coordinator-runbook.md +3 -0
  54. package/docs/concepts/dispatch-lifecycle.md +4 -4
  55. package/docs/concepts/loop-engine.md +3 -1
  56. package/docs/concepts/troubleshooting.md +1 -1
  57. package/docs/integrations/codex.md +3 -3
  58. package/docs/integrations/overview.md +1 -1
  59. package/docs/mcp-schema-changelog.md +153 -2
  60. package/docs/playbooks/orchestration.md +1 -1
  61. package/docs/product/entity-model-audit.md +3 -2
  62. package/docs/security.md +22 -1
  63. package/package.json +3 -1
@@ -143,17 +143,37 @@ export async function attemptExecution(invoke, options) {
143
143
  const adapter = options.adapter ?? defaultExecutionAdapter;
144
144
  // No invoke command available (IDE-only agents, etc.)
145
145
  if (!invoke) {
146
- return { execution_status: 'inbox_only' };
146
+ return { execution_status: 'inbox_only', execution_reason: 'no_invoke_command' };
147
147
  }
148
148
  const spawnCheck = adapter.canSpawn(options.agent);
149
- // Opt-out or can't spawn: return command for manual execution
150
- // Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes correctly
151
- if (!options.autoExecute || !spawnCheck.canSpawn) {
149
+ // pln#626 Phase 1 — split the old `(!autoExecute || !spawnCheck.canSpawn)`
150
+ // branch, which collapsed two very different outcomes into one silent
151
+ // command_ready_manual (no reason, no failure_kind — it even discarded
152
+ // spawnCheck.reason). Callers could not tell "you didn't ask me to spawn"
153
+ // apart from "I tried and can't".
154
+ // (1) autoExecute explicitly disabled: a deliberate manual handoff, NOT a
155
+ // failure. Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes.
156
+ if (!options.autoExecute) {
152
157
  const manual = adapter.prepareManualCommand(invoke, options);
153
158
  return {
154
159
  execution_status: 'command_ready_manual',
155
160
  command: manual.command,
156
161
  shell: manual.shell,
162
+ execution_reason: 'auto_execute_disabled',
163
+ };
164
+ }
165
+ // (2) autoExecute requested but the agent cannot be spawned here: this IS a
166
+ // failure of the caller's intent. Surface spawnCheck.reason (previously
167
+ // dropped) instead of returning a bare manual command.
168
+ if (!spawnCheck.canSpawn) {
169
+ const manual = adapter.prepareManualCommand(invoke, options);
170
+ return {
171
+ execution_status: 'command_ready_manual',
172
+ command: manual.command,
173
+ shell: manual.shell,
174
+ error: spawnCheck.reason,
175
+ failure_kind: 'not_spawnable',
176
+ execution_reason: 'not_spawnable',
157
177
  };
158
178
  }
159
179
  // pln#531 — isolation invariant: a spawned worker MUST run in its own
@@ -179,6 +199,7 @@ export async function attemptExecution(invoke, options) {
179
199
  shell: manual.shell,
180
200
  error: 'Refusing to spawn without an isolated worktree: with no worktree the worker would run in the integration repo and edit the main tree. Fix worktree creation (see claim worktreeWarning) or run the command manually inside a worktree.',
181
201
  failure_kind: 'spawn_no_worktree',
202
+ execution_reason: 'spawn_no_worktree',
182
203
  };
183
204
  }
184
205
  // Capacity guard: skip if agent is at max concurrent tasks
@@ -201,6 +222,7 @@ export async function attemptExecution(invoke, options) {
201
222
  shell: manual.shell,
202
223
  error: `Spawn skipped: ${instanceCheck.reason}. Use the command manually.`,
203
224
  failure_kind: 'spawn_capacity',
225
+ execution_reason: 'spawn_capacity',
204
226
  };
205
227
  }
206
228
  }
@@ -239,6 +261,7 @@ export async function attemptExecution(invoke, options) {
239
261
  shell: manual.shell,
240
262
  error: `Spawn launched (pid ${result.pid}) but assignment ${options.assignmentId} did not acknowledge within ${handshakeTimeoutMs}ms`,
241
263
  failure_kind: 'spawn_no_handshake',
264
+ execution_reason: 'spawn_no_handshake',
242
265
  pid: result.pid,
243
266
  };
244
267
  }
@@ -280,6 +303,7 @@ export async function attemptExecution(invoke, options) {
280
303
  shell: manual.shell,
281
304
  error: `Spawn failed (${errorMsg}), falling back to manual execution`,
282
305
  failure_kind: 'spawn_failed',
306
+ execution_reason: 'spawn_failed',
283
307
  };
284
308
  }
285
309
  }
@@ -1,10 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  export const ExecutionStatusSchema = z.enum(['delivered_and_started', 'command_ready_manual', 'inbox_only']);
3
3
  export const WorkIntentSchema = z.enum(['execute', 'consult', 'resume', 'review']);
4
- // pln#492 phase 2.c — 'ideate' opens an ideation loop with a proposal seed
5
- // artifact. The handler does NOT yet dispatch turns (driver wire-up is
6
- // phase 2.d). Callers receive loop_id and may drive the loop manually via
7
- // bclaw_loop intent='turn' / 'advance' until the dispatch path lands.
4
+ // pln#626 — coordinate intents split into three honest contracts:
5
+ // • SPAWNING (assign / review / reroute, + multi-agent ideate): create a claim
6
+ // + worktree and, when autoExecute is on, spawn a worker on the brief.
7
+ // 'ideate' with targetAgents advances to critique and spawns one
8
+ // worktree-isolated critic worker per target (Phase 2, Option B); single-
9
+ // agent ideate just opens the loop for the champion to drive manually via
10
+ // bclaw_loop intent='turn'/'advance'.
11
+ // • INBOX-ONLY (consult): delivers the brief to the target inbox; autoExecute
12
+ // is a no-op here (never silently ignored).
13
+ // • READ-ONLY (summarize): reads a thread and returns a summary — no claim,
14
+ // no dispatch, no execution_status; autoExecute is irrelevant.
8
15
  export const CoordinateIntentSchema = z.enum(['assign', 'consult', 'review', 'reroute', 'summarize', 'ideate']);
9
16
  export const WorkRequestSchema = z.object({
10
17
  intent: WorkIntentSchema,
@@ -109,6 +116,18 @@ export const CoordinateRequestSchema = z.object({
109
116
  * rejected as `preset_kind_mismatch`.
110
117
  */
111
118
  preset: z.string().min(1).optional(),
119
+ /**
120
+ * pln#520 step 3 / pln#606 — model to run on the spawned worker, decoupled
121
+ * from agent identity. Passed through to `resolveModel({ override })` and
122
+ * injected as `<model_flag> <model>` into the invoke command for agents that
123
+ * declare a `model_flag` (e.g. `claude-code --model sonnet`, `codex exec
124
+ * --model …`, `copilot --model …`). No-op for agents whose template already
125
+ * pins a model (e.g. the `claude-sonnet` pseudo-identity) or that declare no
126
+ * `model_flag`. Highest-priority link in the model resolution chain. Ignored
127
+ * by intents that don't spawn a worker (summarize). Mirrors the CLI
128
+ * `brainclaw dispatch run --model <name>` flag for CLI/MCP parity.
129
+ */
130
+ model: z.string().min(1).optional(),
112
131
  });
113
132
  export const FacadeArtifactSchema = z.object({
114
133
  type: z.string(),
@@ -160,6 +179,13 @@ export const FacadeResponseSchema = z.object({
160
179
  session_id: z.string().optional(),
161
180
  warnings: z.array(z.string()),
162
181
  execution_status: ExecutionStatusSchema.optional(),
182
+ /**
183
+ * pln#626 Phase 1 — machine-readable reason accompanying execution_status
184
+ * when it is not `delivered_and_started`: auto_execute_disabled (manual
185
+ * handoff), not_spawnable, spawn_no_worktree/capacity/no_handshake/failed,
186
+ * or intent_inbox_only (consult/ideate). Absent when everything spawned.
187
+ */
188
+ execution_reason: z.string().optional(),
163
189
  /** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
164
190
  verify_with: VerifyWithSchema.optional(),
165
191
  /**
@@ -1,18 +1,31 @@
1
1
  import { loadConfig } from './config.js';
2
2
  import { logger } from './logger.js';
3
+ import { buildCloudWriteHeaders, resolveCloudSigningIdentity, } from './federation-signing.js';
3
4
  const DEFAULT_API_URL = 'https://app.brainclaw.dev';
5
+ function envFlag(value) {
6
+ if (!value)
7
+ return false;
8
+ const v = value.trim().toLowerCase();
9
+ return v === '1' || v === 'true' || v === 'yes';
10
+ }
4
11
  function resolveCloudConfig(cwd) {
5
12
  const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
6
13
  const envApiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
14
+ const envProjectId = process.env.BRAINCLAW_PROJECT_ID;
15
+ const envRequireSigned = process.env.BRAINCLAW_CLOUD_REQUIRE_SIGNED;
7
16
  let configEnabled = false;
8
17
  let configEndpoint;
9
18
  let configApiKey;
19
+ let configProjectId;
20
+ let configRequireSigned = false;
10
21
  try {
11
22
  const config = loadConfig(cwd);
12
23
  if (config.cloud_sync) {
13
24
  configEnabled = config.cloud_sync.enabled === true;
14
25
  configEndpoint = config.cloud_sync.endpoint;
15
26
  configApiKey = config.cloud_sync.api_key;
27
+ configProjectId = config.cloud_sync.project_id;
28
+ configRequireSigned = config.cloud_sync.require_signed === true;
16
29
  }
17
30
  }
18
31
  catch {
@@ -24,7 +37,22 @@ function resolveCloudConfig(cwd) {
24
37
  // Env-supplied key implies explicit opt-in; config flag is the alternative
25
38
  const enabled = Boolean(envApiKey) || configEnabled;
26
39
  const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
27
- return { apiUrl, apiKey, enabled };
40
+ const projectId = envProjectId?.trim() || configProjectId;
41
+ const requireSigned = envRequireSigned !== undefined ? envFlag(envRequireSigned) : configRequireSigned;
42
+ return { apiUrl, apiKey, enabled, projectId, requireSigned };
43
+ }
44
+ /**
45
+ * Build the outgoing headers for a runtime write, signing when possible.
46
+ * Returns undefined when `require_signed` is set but no signing identity is
47
+ * available — the caller must NOT send the request (fail-closed).
48
+ */
49
+ function writeHeaders(body, cloud, cwd) {
50
+ const signing = resolveCloudSigningIdentity(cwd);
51
+ return buildCloudWriteHeaders(body, {
52
+ apiKey: cloud.apiKey,
53
+ signing,
54
+ requireSigned: cloud.requireSigned,
55
+ });
28
56
  }
29
57
  export async function pushSignalToCloud(message, cwd) {
30
58
  const cloud = resolveCloudConfig(cwd);
@@ -32,14 +60,17 @@ export async function pushSignalToCloud(message, cwd) {
32
60
  logger.debug('Cloud not configured — skipping push');
33
61
  return false;
34
62
  }
63
+ const body = JSON.stringify(message);
64
+ const headers = writeHeaders(body, cloud, cwd);
65
+ if (!headers) {
66
+ logger.warn('Cloud push refused: require_signed is set but no approved signing identity/key is available (fail-closed).');
67
+ return false;
68
+ }
35
69
  try {
36
70
  const response = await fetch(`${cloud.apiUrl}/api/v1/messages`, {
37
71
  method: 'POST',
38
- headers: {
39
- 'Content-Type': 'application/json',
40
- 'X-API-Key': cloud.apiKey,
41
- },
42
- body: JSON.stringify(message),
72
+ headers,
73
+ body,
43
74
  });
44
75
  if (!response.ok) {
45
76
  logger.debug(`Cloud push failed: ${response.status} ${response.statusText}`);
@@ -83,14 +114,17 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
83
114
  const cloud = resolveCloudConfig(cwd);
84
115
  if (!cloud)
85
116
  return false;
117
+ const body = JSON.stringify(boardData);
118
+ const headers = writeHeaders(body, cloud, cwd);
119
+ if (!headers) {
120
+ logger.warn('Cloud board push refused: require_signed is set but no approved signing identity/key is available (fail-closed).');
121
+ return false;
122
+ }
86
123
  try {
87
124
  const response = await fetch(`${cloud.apiUrl}/api/v1/board/${encodeURIComponent(projectName)}`, {
88
125
  method: 'POST',
89
- headers: {
90
- 'Content-Type': 'application/json',
91
- 'X-API-Key': cloud.apiKey,
92
- },
93
- body: JSON.stringify(boardData),
126
+ headers,
127
+ body,
94
128
  });
95
129
  return response.ok;
96
130
  }
@@ -98,6 +132,35 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
98
132
  return false;
99
133
  }
100
134
  }
135
+ export async function pushClaimToCloud(payload, cwd) {
136
+ const cloud = resolveCloudConfig(cwd);
137
+ if (!cloud)
138
+ return { kind: 'not_configured' };
139
+ const body = JSON.stringify(payload);
140
+ const headers = writeHeaders(body, cloud, cwd);
141
+ if (!headers)
142
+ return { kind: 'fail_closed' };
143
+ try {
144
+ const response = await fetch(`${cloud.apiUrl}/api/v1/claims/${encodeURIComponent(payload.id)}`, {
145
+ method: 'PUT',
146
+ headers,
147
+ body,
148
+ });
149
+ let code = null;
150
+ try {
151
+ const data = (await response.json());
152
+ const raw = data.code ?? data.status;
153
+ code = typeof raw === 'string' ? raw : null;
154
+ }
155
+ catch {
156
+ // Non-JSON body — leave code null; the HTTP status still classifies it.
157
+ }
158
+ return { kind: 'response', httpStatus: response.status, code };
159
+ }
160
+ catch (err) {
161
+ return { kind: 'network_error', error: err.message };
162
+ }
163
+ }
101
164
  export function isCloudConfigured(cwd) {
102
165
  return resolveCloudConfig(cwd) !== undefined;
103
166
  }
@@ -111,4 +174,72 @@ export function isCloudSyncEnabled(cwd) {
111
174
  const cloud = resolveCloudConfig(cwd);
112
175
  return cloud !== undefined && cloud.enabled;
113
176
  }
177
+ /**
178
+ * Startup diagnostics for the cloud bridge: remote health, resolved signing
179
+ * identity, approved-agent lookup, and a local↔remote key fingerprint match.
180
+ * Never throws — every probe degrades to an error field so the CLI can print a
181
+ * complete report.
182
+ */
183
+ export async function diagnoseCloudBridge(cwd) {
184
+ const cloud = resolveCloudConfig(cwd);
185
+ const apiUrl = cloud?.apiUrl ?? process.env.BRAINCLAW_CLOUD_URL ?? DEFAULT_API_URL;
186
+ const signingIdentity = resolveCloudSigningIdentity(cwd);
187
+ const diag = {
188
+ configured: Boolean(cloud),
189
+ enabled: cloud?.enabled ?? false,
190
+ apiUrl,
191
+ projectId: cloud?.projectId,
192
+ requireSigned: cloud?.requireSigned ?? false,
193
+ signing: signingIdentity
194
+ ? {
195
+ available: true,
196
+ cloudAgentId: signingIdentity.cloudAgentId,
197
+ agentName: signingIdentity.agentName,
198
+ fingerprint: signingIdentity.fingerprint,
199
+ }
200
+ : {
201
+ available: false,
202
+ reason: 'No approved agent id/name configured, or the agent has no local Ed25519 key. '
203
+ + 'Register the agent and its key with the cloud first.',
204
+ },
205
+ };
206
+ if (!cloud)
207
+ return diag;
208
+ // Remote health
209
+ try {
210
+ const res = await fetch(`${apiUrl}/api/v1/health`);
211
+ const data = (await res.json());
212
+ diag.health = { ok: res.ok, status: String(data.status ?? ''), version: String(data.version ?? '') };
213
+ }
214
+ catch (e) {
215
+ diag.health = { ok: false, error: e.message };
216
+ }
217
+ // Approved-agent lookup + fingerprint match
218
+ if (signingIdentity) {
219
+ try {
220
+ const res = await fetch(`${apiUrl}/api/v1/agents/${encodeURIComponent(signingIdentity.cloudAgentId)}`, {
221
+ headers: { 'X-API-Key': cloud.apiKey },
222
+ });
223
+ if (res.ok) {
224
+ const data = (await res.json());
225
+ const agent = data.agent;
226
+ diag.approvedAgent = {
227
+ found: Boolean(agent),
228
+ status: agent?.status,
229
+ trustLevel: agent?.trust_level,
230
+ fingerprintMatch: agent?.key_fingerprint
231
+ ? agent.key_fingerprint === signingIdentity.fingerprint
232
+ : false,
233
+ };
234
+ }
235
+ else {
236
+ diag.approvedAgent = { found: false, error: `HTTP ${res.status}` };
237
+ }
238
+ }
239
+ catch (e) {
240
+ diag.approvedAgent = { found: false, error: e.message };
241
+ }
242
+ }
243
+ return diag;
244
+ }
114
245
  //# sourceMappingURL=federation-cloud.js.map
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Federation outbox — durable queue for signed edge → cloud runtime writes
3
+ * (pln#101 Phase 2, increment 1: CLAIM). Design of record: decision dec#123.
4
+ *
5
+ * Layout under `.brainclaw/coordination/federation/`:
6
+ * outbox/<id>@r<rev>.json — pending, awaiting push
7
+ * sent/<id>@r<rev>.json — archived after a successful/superseded push (the marker)
8
+ * parked/<id>@r<rev>.json — dead-letter (conflict / persistent 4xx)
9
+ *
10
+ * Enqueue is DIFF-BASED at the claim persistence chokepoint (saveClaimUnlocked),
11
+ * which runs under the store mutation mutex, so per-claim rev reservation is
12
+ * serialized for free (no separate lock). Only lifecycle transitions enqueue;
13
+ * mid-life saves (same status) and cloud-origin materialization (suppressEnqueue)
14
+ * do not. All work here is local file I/O — never network.
15
+ *
16
+ * @module
17
+ */
18
+ import crypto from 'node:crypto';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { nowISO } from './ids.js';
22
+ import { resolveEntityDir } from './io.js';
23
+ import { isCloudSyncEnabled } from './federation-cloud.js';
24
+ import { logger } from './logger.js';
25
+ // ── Directories ─────────────────────────────────────────────────────────────
26
+ function federationSubdir(sub, cwd, mode) {
27
+ return resolveEntityDir(`federation/${sub}`, cwd ?? process.cwd(), mode);
28
+ }
29
+ function ensureDir(dir) {
30
+ if (!fs.existsSync(dir))
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ }
33
+ // ── Canonical content hash (edge is the sole computer; cloud stores+compares) ──
34
+ /** Deterministic JSON with recursively sorted object keys. */
35
+ function stableStringify(value) {
36
+ if (value === null || typeof value !== 'object')
37
+ return JSON.stringify(value);
38
+ if (Array.isArray(value))
39
+ return `[${value.map(stableStringify).join(',')}]`;
40
+ const obj = value;
41
+ const keys = Object.keys(obj).sort();
42
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
43
+ }
44
+ /**
45
+ * Canonical content hash of a claim's SEMANTIC body — excludes the rev, all
46
+ * volatile/local-only fields (user, model, adopted_at, enqueue metadata), and
47
+ * the signature timestamp. Same-rev + different hash ⇒ genuinely divergent
48
+ * bodies (a conflict the cloud must not silently absorb).
49
+ */
50
+ export function claimContentHash(claim) {
51
+ // Identity fields (agent_id/agent_name) are excluded: they are constant over a
52
+ // claim's life and the local↔cloud agent-id mapping is a transport concern,
53
+ // not claim content. What matters for divergence detection is the lifecycle body.
54
+ const body = {
55
+ id: claim.id,
56
+ status: claim.status,
57
+ scope: claim.scope,
58
+ description: claim.description,
59
+ plan_id: claim.plan_id ?? null,
60
+ host_id: claim.host_id ?? null,
61
+ session_id: claim.session_id ?? null,
62
+ expires_at: claim.expires_at ?? null,
63
+ worktree_path: claim.worktree_path ?? null,
64
+ created_at: claim.created_at,
65
+ released_at: claim.released_at ?? null,
66
+ };
67
+ return crypto.createHash('sha256').update(stableStringify(body)).digest('hex');
68
+ }
69
+ function buildClaimPayload(claim, rev, contentHash) {
70
+ return {
71
+ id: claim.id,
72
+ rev,
73
+ agent_name: claim.agent,
74
+ scope: claim.scope,
75
+ description: claim.description,
76
+ status: claim.status,
77
+ plan_id: claim.plan_id ?? null,
78
+ host_id: claim.host_id ?? null,
79
+ session_id: claim.session_id ?? null,
80
+ expires_at: claim.expires_at ?? null,
81
+ worktree_path: claim.worktree_path ?? null,
82
+ created_at: claim.created_at,
83
+ released_at: claim.released_at ?? null,
84
+ content_hash: contentHash,
85
+ };
86
+ }
87
+ // ── Rev reservation (serialized by the caller's store mutex) ───────────────────
88
+ const REV_RE = /@r(\d+)\.json$/;
89
+ function revsForId(id, dir) {
90
+ if (!fs.existsSync(dir))
91
+ return [];
92
+ const revs = [];
93
+ for (const name of fs.readdirSync(dir)) {
94
+ if (!name.startsWith(`${id}@r`))
95
+ continue;
96
+ const m = name.match(REV_RE);
97
+ if (m)
98
+ revs.push(parseInt(m[1], 10));
99
+ }
100
+ return revs;
101
+ }
102
+ /** Highest rev already reserved for this id across outbox + sent (0 if none). */
103
+ function maxRevForId(id, cwd) {
104
+ const all = [
105
+ ...revsForId(id, federationSubdir('outbox', cwd, 'read')),
106
+ ...revsForId(id, federationSubdir('sent', cwd, 'read')),
107
+ ];
108
+ return all.length ? Math.max(...all) : 0;
109
+ }
110
+ function recordFilename(id, rev) {
111
+ return `${id}@r${rev}.json`;
112
+ }
113
+ function writeRecordAtomic(dir, filename, record) {
114
+ ensureDir(dir);
115
+ const finalPath = path.join(dir, filename);
116
+ const tmpPath = path.join(dir, `.${filename}.${process.pid}.tmp`);
117
+ fs.writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
118
+ fs.renameSync(tmpPath, finalPath);
119
+ }
120
+ // ── Enablement gate (memoized per cwd — resolve config once per process) ───────
121
+ const enabledCache = new Map();
122
+ function syncEnabledCached(cwd) {
123
+ const key = cwd ?? process.cwd();
124
+ const cached = enabledCache.get(key);
125
+ if (cached !== undefined)
126
+ return cached;
127
+ let value = false;
128
+ try {
129
+ value = isCloudSyncEnabled(cwd);
130
+ }
131
+ catch {
132
+ // Missing/unreadable config → treat federation as disabled.
133
+ }
134
+ enabledCache.set(key, value);
135
+ return value;
136
+ }
137
+ /** Test/hook seam: drop the memoized enablement (config changed on disk). */
138
+ export function clearFederationEnablementCache() {
139
+ enabledCache.clear();
140
+ }
141
+ /**
142
+ * Cheap gate for the persistence seam: is a federation enqueue possibly needed?
143
+ * Lets the caller skip the prev-status load entirely when not federating.
144
+ */
145
+ export function isFederationEnqueueActive(cwd, suppress) {
146
+ return !suppress && syncEnabledCached(cwd);
147
+ }
148
+ // ── Enqueue (the diff-based seam entry point) ──────────────────────────────────
149
+ /**
150
+ * Enqueue a claim lifecycle transition for cloud sync, if it qualifies.
151
+ *
152
+ * Called from saveClaimUnlocked (under the store mutation mutex). Enqueues iff:
153
+ * cloud sync is enabled, not suppressed (cloud-origin materialization), and the
154
+ * save is a real transition — a create, or a status change. Mid-life saves that
155
+ * do not change status do NOT enqueue (dec#123). Never throws to the caller.
156
+ *
157
+ * Returns the reserved rev, or null when nothing was enqueued.
158
+ */
159
+ export function maybeEnqueueClaimTransition(claim, prevStatus, created, cwd, suppress) {
160
+ try {
161
+ if (suppress)
162
+ return null;
163
+ if (!syncEnabledCached(cwd))
164
+ return null;
165
+ const isTransition = created || prevStatus !== claim.status;
166
+ if (!isTransition)
167
+ return null;
168
+ const rev = maxRevForId(claim.id, cwd) + 1;
169
+ const contentHash = claimContentHash(claim);
170
+ const record = {
171
+ op: 'upsert_claim',
172
+ entity_type: 'claim',
173
+ entity_id: claim.id,
174
+ rev,
175
+ from_status: prevStatus ?? null,
176
+ to_status: claim.status,
177
+ content_hash: contentHash,
178
+ payload: buildClaimPayload(claim, rev, contentHash),
179
+ enqueued_at: nowISO(),
180
+ attempts: 0,
181
+ last_status: null,
182
+ last_error: null,
183
+ last_attempt_at: null,
184
+ };
185
+ writeRecordAtomic(federationSubdir('outbox', cwd, 'write'), recordFilename(claim.id, rev), record);
186
+ logger.debug(`Federation outbox: enqueued claim ${claim.id}@r${rev} (${prevStatus ?? 'new'}→${claim.status})`);
187
+ return rev;
188
+ }
189
+ catch (err) {
190
+ // Federation bookkeeping must never break a claim write.
191
+ logger.debug('Federation outbox enqueue failed (non-fatal):', err);
192
+ return null;
193
+ }
194
+ }
195
+ // ── Drain-side helpers (used by `brainclaw federation sync`) ───────────────────
196
+ function loadRecordsFrom(dir) {
197
+ if (!fs.existsSync(dir))
198
+ return [];
199
+ const out = [];
200
+ for (const name of fs.readdirSync(dir)) {
201
+ if (!name.endsWith('.json') || name.startsWith('.'))
202
+ continue;
203
+ const filepath = path.join(dir, name);
204
+ try {
205
+ out.push({ record: JSON.parse(fs.readFileSync(filepath, 'utf-8')), filepath });
206
+ }
207
+ catch (err) {
208
+ logger.debug(`Federation outbox: skipping unreadable record ${name}:`, err);
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+ export function listOutboxRecords(cwd) {
214
+ return loadRecordsFrom(federationSubdir('outbox', cwd, 'read'))
215
+ .sort((a, b) => (a.record.entity_id.localeCompare(b.record.entity_id)) || (a.record.rev - b.record.rev));
216
+ }
217
+ /** Highest rev already archived in sent/ for an id (the sync marker). 0 if none. */
218
+ export function sentMarkerRev(id, cwd) {
219
+ const revs = revsForId(id, federationSubdir('sent', cwd, 'read'));
220
+ return revs.length ? Math.max(...revs) : 0;
221
+ }
222
+ function removeFileIfExists(filepath) {
223
+ try {
224
+ if (fs.existsSync(filepath))
225
+ fs.unlinkSync(filepath);
226
+ }
227
+ catch { /* best-effort */ }
228
+ }
229
+ /** Archive a record from outbox → sent (success or superseded). */
230
+ export function archiveToSent(rec, meta, cwd) {
231
+ const sentDir = federationSubdir('sent', cwd, 'write');
232
+ const archived = {
233
+ ...rec.record,
234
+ synced_at: nowISO(),
235
+ http_status: meta.http_status,
236
+ };
237
+ writeRecordAtomic(sentDir, recordFilename(rec.record.entity_id, rec.record.rev), archived);
238
+ removeFileIfExists(rec.filepath);
239
+ }
240
+ /** Move a record from outbox → parked (dead-letter). */
241
+ export function parkRecord(rec, reason, cwd) {
242
+ const parkedDir = federationSubdir('parked', cwd, 'write');
243
+ const parked = {
244
+ ...rec.record,
245
+ parked_at: nowISO(),
246
+ parked_reason: reason,
247
+ };
248
+ writeRecordAtomic(parkedDir, recordFilename(rec.record.entity_id, rec.record.rev), parked);
249
+ removeFileIfExists(rec.filepath);
250
+ }
251
+ /** Persist an updated attempt count / last error on a still-pending record. */
252
+ export function recordAttempt(rec, meta, cwd) {
253
+ const updated = {
254
+ ...rec.record,
255
+ attempts: rec.record.attempts + 1,
256
+ last_status: meta.http_status,
257
+ last_error: meta.error,
258
+ last_attempt_at: nowISO(),
259
+ };
260
+ writeRecordAtomic(federationSubdir('outbox', cwd, 'write'), recordFilename(rec.record.entity_id, rec.record.rev), updated);
261
+ }
262
+ /**
263
+ * Startup reconcile before a drain: for each outbox record, if sent/ already
264
+ * holds a rev >= this one, this record is already superseded — drop it. If the
265
+ * SAME rev exists in sent with a different content_hash, park it (divergence).
266
+ */
267
+ export function reconcileOutbox(cwd) {
268
+ const result = { dropped: 0, parked: 0 };
269
+ const sentDir = federationSubdir('sent', cwd, 'read');
270
+ for (const rec of listOutboxRecords(cwd)) {
271
+ const sentRev = sentMarkerRev(rec.record.entity_id, cwd);
272
+ if (sentRev < rec.record.rev)
273
+ continue;
274
+ // A sent record at >= this rev exists. If the SAME rev has a different hash, park.
275
+ const sameRevPath = path.join(sentDir, recordFilename(rec.record.entity_id, rec.record.rev));
276
+ if (fs.existsSync(sameRevPath)) {
277
+ try {
278
+ const sent = JSON.parse(fs.readFileSync(sameRevPath, 'utf-8'));
279
+ if (sent.content_hash !== rec.record.content_hash) {
280
+ parkRecord(rec, 'reconcile: same rev already sent with a different content_hash', cwd);
281
+ result.parked++;
282
+ continue;
283
+ }
284
+ }
285
+ catch { /* fall through to drop */ }
286
+ }
287
+ removeFileIfExists(rec.filepath);
288
+ result.dropped++;
289
+ }
290
+ return result;
291
+ }
292
+ //# sourceMappingURL=federation-outbox.js.map