brainclaw 1.5.4 → 1.6.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 (60) hide show
  1. package/README.md +52 -28
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +159 -12
  4. package/dist/commands/assignment-resource.js +182 -0
  5. package/dist/commands/bootstrap-loop.js +206 -0
  6. package/dist/commands/init.js +158 -22
  7. package/dist/commands/loop.js +156 -0
  8. package/dist/commands/loops-handlers.js +110 -55
  9. package/dist/commands/mcp-read-handlers.js +45 -4
  10. package/dist/commands/mcp.js +628 -205
  11. package/dist/commands/questions.js +180 -0
  12. package/dist/commands/reply.js +190 -0
  13. package/dist/commands/session-end.js +105 -3
  14. package/dist/commands/session-start.js +32 -53
  15. package/dist/commands/setup.js +87 -48
  16. package/dist/commands/switch.js +21 -1
  17. package/dist/core/agentrun-reconciler.js +65 -0
  18. package/dist/core/agentruns.js +10 -0
  19. package/dist/core/assignments.js +29 -10
  20. package/dist/core/claims.js +29 -0
  21. package/dist/core/context.js +1 -1
  22. package/dist/core/coordination.js +1 -1
  23. package/dist/core/dispatch-status.js +219 -0
  24. package/dist/core/entity-operations.js +166 -10
  25. package/dist/core/entity-registry.js +11 -10
  26. package/dist/core/execution-adapters.js +38 -2
  27. package/dist/core/facade-schema.js +55 -0
  28. package/dist/core/federation-cloud.js +27 -12
  29. package/dist/core/federation-materialize.js +57 -0
  30. package/dist/core/instruction-templates.js +2 -0
  31. package/dist/core/loops/bootstrap-acquire.js +195 -0
  32. package/dist/core/loops/facade-schema.js +68 -1
  33. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  34. package/dist/core/loops/hooks/notify-operator.js +148 -0
  35. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  36. package/dist/core/loops/index.js +8 -2
  37. package/dist/core/loops/next-expected.js +63 -0
  38. package/dist/core/loops/presets/bootstrap.js +75 -0
  39. package/dist/core/loops/presets/index.js +16 -0
  40. package/dist/core/loops/store.js +224 -4
  41. package/dist/core/loops/types.js +346 -1
  42. package/dist/core/loops/verbs.js +739 -6
  43. package/dist/core/schema.js +31 -2
  44. package/dist/core/state.js +62 -0
  45. package/dist/core/store-resolution.js +26 -16
  46. package/dist/facts.js +7 -5
  47. package/dist/facts.json +6 -4
  48. package/docs/cli.md +115 -30
  49. package/docs/concepts/dispatch-lifecycle.md +228 -0
  50. package/docs/concepts/loop-engine.md +55 -0
  51. package/docs/concepts/multi-agent-workflows.md +167 -166
  52. package/docs/concepts/troubleshooting.md +10 -2
  53. package/docs/integrations/agents.md +14 -14
  54. package/docs/integrations/codex.md +15 -12
  55. package/docs/integrations/mcp.md +10 -4
  56. package/docs/integrations/overview.md +11 -0
  57. package/docs/playbooks/productivity/index.md +3 -3
  58. package/docs/quickstart-existing-project.md +48 -28
  59. package/docs/quickstart.md +42 -28
  60. package/package.json +1 -1
@@ -17,14 +17,16 @@ import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from
17
17
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
18
18
  import { listClaims } from './claims.js';
19
19
  import { listActionRequired } from './actions.js';
20
- import { listAssignments } from './assignments.js';
20
+ import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
21
21
  import { listAgentRuns } from './agentruns.js';
22
+ import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
22
23
  import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
23
24
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
24
25
  import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
25
26
  import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
26
27
  import { ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
27
28
  import { generateId } from './ids.js';
29
+ import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SeveritySchema, } from './schema.js';
28
30
  /**
29
31
  * Default provenance stamp applied on create when the caller does not
30
32
  * supply one. `user` kind with whatever author is in the payload; the
@@ -79,6 +81,44 @@ export class InvalidTransitionError extends Error {
79
81
  }
80
82
  }
81
83
  // ─── FIND ─────────────────────────────────────────────────────────────
84
+ /**
85
+ * Lazy reconciliation pass on agent_run reads (pln#503 phase 3.2).
86
+ *
87
+ * Before returning agent_run records to `bclaw_find` / `bclaw_get`, walk any
88
+ * record whose status is non-terminal and call `reconcileAgentRun(id)`. The
89
+ * reconciler:
90
+ * - no-ops for runs under the 60s grace window or already terminal
91
+ * - transitions to `completed` (inferred=true) when evidence of completion
92
+ * exists (post-start commit, claim released, assignment completed)
93
+ * - transitions to `failed` (silent_termination_no_evidence) when the run
94
+ * is past the stale threshold AND its pid is provably dead
95
+ *
96
+ * Without this pass, a worker that crashed before its first output keeps
97
+ * `status="running"` indefinitely — the empirical pattern recorded in trp#292.
98
+ * The full agentrun-reconciler.ts machinery already existed (pln#496); this
99
+ * just wires it into the canonical-grammar read path so every read of
100
+ * `agent_run` produces converged state.
101
+ */
102
+ function loadAgentRunsWithReconciliation(cwd) {
103
+ const runs = listAgentRuns(cwd);
104
+ for (const run of runs) {
105
+ if (run.status === 'running') {
106
+ try {
107
+ reconcileDeadPidRunningAgentRunAtRead(run.id, cwd);
108
+ }
109
+ catch { /* best-effort: never block reads on reconciliation errors */ }
110
+ continue;
111
+ }
112
+ if (!TERMINAL_STATUSES.has(run.status)) {
113
+ try {
114
+ reconcileAgentRun(run.id, cwd);
115
+ }
116
+ catch { /* best-effort: never block reads on reconciliation errors */ }
117
+ }
118
+ }
119
+ // Re-list to capture any transitions made above.
120
+ return listAgentRuns(cwd);
121
+ }
82
122
  export function listEntities(name, cwd, filter = {}) {
83
123
  const all = loadAll(name, cwd);
84
124
  const filtered = applyFilter(all, filter);
@@ -97,7 +137,7 @@ function loadAll(name, cwd) {
97
137
  case 'claim': return listClaims(cwd);
98
138
  case 'action': return listActionRequired(cwd);
99
139
  case 'assignment': return listAssignments(cwd);
100
- case 'agent_run': return listAgentRuns(cwd);
140
+ case 'agent_run': return loadAgentRunsWithReconciliation(cwd);
101
141
  case 'cross_project_link': return resolveCrossProjectLinks(cwd);
102
142
  default:
103
143
  throw new EntityOperationUnsupportedError(name, 'find');
@@ -112,12 +152,24 @@ function applyFilter(items, filter) {
112
152
  if (filter.tag) {
113
153
  result = result.filter((item) => Array.isArray(item.tags) && item.tags.includes(filter.tag));
114
154
  }
155
+ if (Array.isArray(filter.tags) && filter.tags.length > 0) {
156
+ result = result.filter((item) => Array.isArray(item.tags) && filter.tags.some((tag) => item.tags.includes(tag)));
157
+ }
115
158
  if (filter.author) {
116
159
  result = result.filter((item) => item.author === filter.author);
117
160
  }
118
161
  if (filter.plan_id) {
119
162
  result = result.filter((item) => item.plan_id === filter.plan_id);
120
163
  }
164
+ if (filter.assignment_id) {
165
+ result = result.filter((item) => item.assignment_id === filter.assignment_id);
166
+ }
167
+ if (filter.claim_id) {
168
+ result = result.filter((item) => item.claim_id === filter.claim_id);
169
+ }
170
+ if (filter.message_id) {
171
+ result = result.filter((item) => item.message_id === filter.message_id);
172
+ }
121
173
  if (filter.source) {
122
174
  result = result.filter((item) => item.source === filter.source);
123
175
  }
@@ -166,8 +218,8 @@ export function createEntity(name, data, cwd) {
166
218
  const res = createPlan({
167
219
  text: requireString(data, 'text'),
168
220
  author: requireString(data, 'author'),
169
- type: data.type,
170
- priority: data.priority,
221
+ type: requireEnum(data, 'type', PlanTypeEnumSchema.options, { optional: true }),
222
+ priority: requireEnum(data, 'priority', PrioritySchema.options, { optional: true }),
171
223
  assignee: data.assignee,
172
224
  project: data.project,
173
225
  tags: data.tags,
@@ -182,7 +234,7 @@ export function createEntity(name, data, cwd) {
182
234
  const res = createDecision({
183
235
  text: requireString(data, 'text'),
184
236
  author: requireString(data, 'author'),
185
- outcome: data.outcome,
237
+ outcome: requireEnum(data, 'outcome', DecisionOutcomeSchema.options, { optional: true }),
186
238
  tags: data.tags,
187
239
  relatedPaths: data.related_paths,
188
240
  planId: data.plan_id,
@@ -194,7 +246,7 @@ export function createEntity(name, data, cwd) {
194
246
  const res = createConstraint({
195
247
  text: requireString(data, 'text'),
196
248
  author: requireString(data, 'author'),
197
- category: data.category,
249
+ category: requireEnum(data, 'category', ConstraintCategorySchema.options, { optional: true }),
198
250
  tags: data.tags,
199
251
  relatedPaths: data.related_paths,
200
252
  }, cwd);
@@ -205,7 +257,7 @@ export function createEntity(name, data, cwd) {
205
257
  const res = createTrap({
206
258
  text: requireString(data, 'text'),
207
259
  author: requireString(data, 'author'),
208
- severity: (data.severity ?? 'medium'),
260
+ severity: requireEnum(data, 'severity', SeveritySchema.options, { optional: true }) ?? 'medium',
209
261
  tags: data.tags,
210
262
  relatedPaths: data.related_paths,
211
263
  }, cwd);
@@ -220,8 +272,8 @@ export function createEntity(name, data, cwd) {
220
272
  text: requireString(data, 'text'),
221
273
  created_at: new Date().toISOString(),
222
274
  tags: data.tags ?? [],
223
- visibility: data.visibility ?? 'shared',
224
- note_type: data.note_type ?? 'observation',
275
+ visibility: requireEnum(data, 'visibility', MemoryVisibilitySchema.options, { optional: true }) ?? 'shared',
276
+ note_type: requireEnum(data, 'note_type', RuntimeNoteTypeSchema.options, { optional: true }) ?? 'observation',
225
277
  provenance: defaultProvenance(data),
226
278
  ...(data.agent_id ? { agent_id: data.agent_id } : {}),
227
279
  ...(data.project_id ? { project_id: data.project_id } : {}),
@@ -233,9 +285,15 @@ export function createEntity(name, data, cwd) {
233
285
  }
234
286
  case 'candidate': {
235
287
  const id = generateId('candidate');
288
+ const validatedType = requireEnum(data, 'type', CandidateTypeSchema.options);
289
+ if (!validatedType) {
290
+ // requireEnum without `optional` throws on missing/invalid, but
291
+ // narrow the type for the assignment below.
292
+ throw new Error(`Missing required field: type`);
293
+ }
236
294
  const candidate = {
237
295
  id,
238
- type: requireString(data, 'type'),
296
+ type: validatedType,
239
297
  text: requireString(data, 'text'),
240
298
  created_at: new Date().toISOString(),
241
299
  author: requireString(data, 'author'),
@@ -281,6 +339,9 @@ export function updateEntity(name, id, patch, cwd) {
281
339
  // declared updatable fields (text, tags, estimated_effort, depends_on)
282
340
  // actually land. The typed surface still covers status/assignee/priority/
283
341
  // actualEffort for legacy CLI callers — see UpdatePlanInput.
342
+ // Note: `plan.type` is intentionally create-only (not in plan.updatable
343
+ // at entity-registry.ts) — no validation needed here.
344
+ validatePatchEnum(patch, 'priority', PrioritySchema.options);
284
345
  updatePlan({
285
346
  id,
286
347
  patch: patch,
@@ -293,6 +354,15 @@ export function updateEntity(name, id, patch, cwd) {
293
354
  // Same generic-patch escape-hatch for memory items. Registry declares
294
355
  // severity, scope, related_paths, expires_at, etc. as updatable; the
295
356
  // legacy explicit text/tags whitelist silently dropped them.
357
+ if (name === 'decision') {
358
+ validatePatchEnum(patch, 'outcome', DecisionOutcomeSchema.options);
359
+ }
360
+ else if (name === 'constraint') {
361
+ validatePatchEnum(patch, 'category', ConstraintCategorySchema.options);
362
+ }
363
+ else {
364
+ validatePatchEnum(patch, 'severity', SeveritySchema.options);
365
+ }
296
366
  updateMemoryItem({
297
367
  id,
298
368
  type: name,
@@ -301,6 +371,9 @@ export function updateEntity(name, id, patch, cwd) {
301
371
  return { entity: name, id };
302
372
  }
303
373
  case 'runtime_note': {
374
+ // Note: `note_type` is intentionally create-only (not in
375
+ // runtime_note.updatable at entity-registry.ts) — no validation needed.
376
+ validatePatchEnum(patch, 'visibility', MemoryVisibilitySchema.options);
304
377
  const notes = listRuntimeNotes(undefined, cwd);
305
378
  const note = notes.find((n) => n.id === id);
306
379
  if (!note)
@@ -309,7 +382,17 @@ export function updateEntity(name, id, patch, cwd) {
309
382
  saveRuntimeNote(patched, cwd);
310
383
  return { entity: name, id };
311
384
  }
385
+ case 'assignment': {
386
+ const assignment = loadAssignment(id, cwd);
387
+ if (!assignment)
388
+ throw new EntityNotFoundError(name, id);
389
+ const patched = { ...assignment, ...patch };
390
+ saveAssignment(patched, cwd);
391
+ return { entity: name, id };
392
+ }
312
393
  case 'candidate': {
394
+ // Note: `candidate.type` is intentionally create-only (not in
395
+ // candidate.updatable at entity-registry.ts) — no validation needed.
313
396
  const candidate = loadCandidate(id, cwd);
314
397
  const patched = { ...candidate, ...patch };
315
398
  saveCandidate(patched, cwd);
@@ -371,6 +454,28 @@ export function removeEntity(name, id, cwd, purge = false) {
371
454
  const removed = removeCrossProjectLink(id, cwd);
372
455
  return { entity: name, id: removed.name ?? removed.path, archived: false, purged: true };
373
456
  }
457
+ case 'assignment': {
458
+ const assignment = loadAssignment(id, cwd);
459
+ if (!assignment)
460
+ throw new EntityNotFoundError(name, id);
461
+ if (purge) {
462
+ const deleted = deleteAssignment(id, cwd);
463
+ if (!deleted)
464
+ throw new EntityNotFoundError(name, id);
465
+ return { entity: name, id, archived: false, purged: true };
466
+ }
467
+ if (assignment.status === 'cancelled') {
468
+ return { entity: name, id, archived: true, purged: false };
469
+ }
470
+ if (ENTITY_REGISTRY.assignment.terminal.includes(assignment.status)) {
471
+ throw new Error(`assignment '${id}' is already terminal (${assignment.status}); use purge:true to hard-delete if needed`);
472
+ }
473
+ transitionAssignment(id, 'cancelled', {
474
+ actor: 'brainclaw',
475
+ status_reason: 'Archived via bclaw_remove',
476
+ }, cwd);
477
+ return { entity: name, id, archived: true, purged: false };
478
+ }
374
479
  default:
375
480
  throw new EntityOperationUnsupportedError(name, 'remove');
376
481
  }
@@ -418,6 +523,13 @@ export function transitionEntity(name, id, to, cwd, _reason) {
418
523
  }
419
524
  throw new InvalidTransitionError(name, from, to);
420
525
  }
526
+ case 'assignment': {
527
+ transitionAssignment(id, to, {
528
+ actor: 'brainclaw',
529
+ status_reason: _reason,
530
+ }, cwd);
531
+ return { entity: name, id, from, to, side_effects: sideEffects };
532
+ }
421
533
  default:
422
534
  throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
423
535
  }
@@ -447,4 +559,48 @@ function requireString(data, field) {
447
559
  }
448
560
  return value;
449
561
  }
562
+ /**
563
+ * Validates that data[field] is one of `validValues`, throwing a clear
564
+ * error message when the value is invalid. Fixes the silent-data-loss bug
565
+ * documented in candidate can_a3458961 + pln#509 step 1: previously the
566
+ * create path used unchecked `as` casts on enum fields, so invalid values
567
+ * (e.g. outcome:'accepted' instead of 'approved') were written to disk and
568
+ * then silently skipped at load time by the strict Zod parser. Now we
569
+ * validate at write time against the same valid-value lists used by the
570
+ * load-time schemas.
571
+ *
572
+ * Callers pass `XxxSchema.options` (a readonly tuple of valid strings)
573
+ * rather than the schema itself — this avoids brittle generic constraints
574
+ * on Zod's enum type which differs between major versions.
575
+ */
576
+ function requireEnum(data, field, validValues, opts = {}) {
577
+ const value = data[field];
578
+ if (value === undefined || value === null) {
579
+ if (opts.optional)
580
+ return undefined;
581
+ throw new Error(`Missing required field: ${field}`);
582
+ }
583
+ if (typeof value !== 'string' || !validValues.includes(value)) {
584
+ throw new Error(`Invalid value for '${field}': got ${JSON.stringify(value)}. Expected one of: ${validValues.join(' | ')}`);
585
+ }
586
+ return value;
587
+ }
588
+ /**
589
+ * Validates that, if `patch[field]` is present (and not null/undefined), it
590
+ * matches one of `validValues`. Used by updateEntity for enum-shaped patch
591
+ * fields, to extend the same validation parity used at create time. Codex
592
+ * round 1 (pln#509 step 1 review) correctly flagged that updateEntity was
593
+ * still vulnerable to the same silent persistence bug when patching enum
594
+ * fields with invalid values. Fields not present in `patch` are ignored.
595
+ */
596
+ function validatePatchEnum(patch, field, validValues) {
597
+ if (!(field in patch))
598
+ return;
599
+ const value = patch[field];
600
+ if (value === undefined || value === null)
601
+ return;
602
+ if (typeof value !== 'string' || !validValues.includes(value)) {
603
+ throw new Error(`Invalid value for '${field}' in patch: got ${JSON.stringify(value)}. Expected one of: ${validValues.join(' | ')}`);
604
+ }
605
+ }
450
606
  //# sourceMappingURL=entity-operations.js.map
@@ -234,24 +234,25 @@ const assignment = {
234
234
  name: 'assignment',
235
235
  shortLabelPrefix: 'asgn',
236
236
  schema: AssignmentSchema,
237
- updatable: ['brief', 'tags'],
237
+ updatable: ['description', 'status_reason', 'tags'],
238
238
  statusField: 'status',
239
239
  transitions: {
240
- created: ['offered', 'expired'],
241
- offered: ['accepted', 'expired', 'rerouted'],
242
- accepted: ['started', 'failed', 'timed_out'],
243
- started: ['completed', 'failed', 'blocked', 'timed_out'],
244
- failed: ['retrying'],
245
- timed_out: ['retrying'],
246
- retrying: ['offered'],
247
- blocked: ['started', 'rerouted', 'failed'],
240
+ created: ['offered', 'expired', 'rerouted', 'cancelled'],
241
+ offered: ['accepted', 'expired', 'rerouted', 'failed', 'cancelled'],
242
+ accepted: ['started', 'failed', 'timed_out', 'rerouted', 'cancelled'],
243
+ started: ['completed', 'failed', 'blocked', 'timed_out', 'rerouted', 'cancelled'],
244
+ failed: ['retrying', 'rerouted', 'cancelled'],
245
+ timed_out: ['retrying', 'rerouted', 'cancelled'],
246
+ retrying: ['offered', 'rerouted', 'cancelled'],
247
+ blocked: ['started', 'rerouted', 'failed', 'cancelled'],
248
248
  },
249
- terminal: ['completed', 'expired', 'rerouted'],
249
+ terminal: ['completed', 'cancelled', 'expired', 'rerouted'],
250
250
  sideEffects: {
251
251
  'created->offered': ['timestamp:offered_at', 'event:assignment_offered', 'audit:assignment_offered'],
252
252
  'offered->accepted': ['timestamp:accepted_at', 'event:assignment_accepted', 'sync:agent_run'],
253
253
  'accepted->started': ['timestamp:started_at', 'event:assignment_started', 'sync:agent_run'],
254
254
  'started->completed': ['timestamp:completed_at', 'event:assignment_completed', 'sync:agent_run'],
255
+ 'started->cancelled': ['timestamp:cancelled_at', 'event:assignment_cancelled', 'sync:agent_run'],
255
256
  'started->failed': ['timestamp:failed_at', 'event:assignment_failed', 'sync:agent_run'],
256
257
  },
257
258
  };
@@ -90,14 +90,38 @@ export class CliExecutionAdapter {
90
90
  const spawnExecutable = resolvedExecutable ?? invoke.executable;
91
91
  const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
92
92
  const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
93
- const stdio = needsStdin ? ['pipe', 'ignore', 'ignore'] : 'ignore';
93
+ // pln#504: open per-assignment log files for stdout/stderr capture so silent
94
+ // worker deaths (trp#292) become diagnosable. Previously stdio used 'ignore'
95
+ // for stdout+stderr — anything the worker said vanished. Best-effort: on
96
+ // failure to open log files we fall back to the legacy 'ignore' behaviour
97
+ // rather than abort the spawn.
98
+ const useAckWrap = !!(options.assignmentId && (options.ackRoot ?? options.worktreePath));
99
+ let logFds;
100
+ if (useAckWrap) {
101
+ try {
102
+ const logRoot = options.ackRoot ?? options.worktreePath;
103
+ const logDir = path.join(logRoot, '.brainclaw', 'coordination', 'runtime', 'log');
104
+ fs.mkdirSync(logDir, { recursive: true });
105
+ logFds = {
106
+ stdout: fs.openSync(path.join(logDir, `${options.assignmentId}.stdout.log`), 'a'),
107
+ stderr: fs.openSync(path.join(logDir, `${options.assignmentId}.stderr.log`), 'a'),
108
+ };
109
+ }
110
+ catch {
111
+ // Log capture is best-effort — never block the spawn on logging issues.
112
+ logFds = undefined;
113
+ }
114
+ }
115
+ const stdinTarget = needsStdin ? 'pipe' : 'ignore';
116
+ const stdoutTarget = logFds ? logFds.stdout : 'ignore';
117
+ const stderrTarget = logFds ? logFds.stderr : 'ignore';
118
+ const stdio = [stdinTarget, stdoutTarget, stderrTarget];
94
119
  // pln#476: wrap the spawn command with a brief-ack step so the worker
95
120
  // shell touches a sentinel file BEFORE the agent binary runs.
96
121
  // waitForAssignmentHandshake checks that file as evidence the spawn
97
122
  // executed — needed for codex (which lacks the brainclaw MCP context
98
123
  // to call bclaw_assignment_update). When ackRoot/assignmentId are
99
124
  // omitted, we keep the original direct-binary spawn.
100
- const useAckWrap = !!(options.assignmentId && (options.ackRoot ?? options.worktreePath));
101
125
  let child;
102
126
  if (useAckWrap) {
103
127
  const ackRoot = options.ackRoot ?? options.worktreePath;
@@ -139,6 +163,18 @@ export class CliExecutionAdapter {
139
163
  child.stdin.end();
140
164
  }
141
165
  child.unref();
166
+ // Close the parent's copies of the log file descriptors. The child has its
167
+ // own dup'd copies and will keep writing to them after we return.
168
+ if (logFds) {
169
+ try {
170
+ fs.closeSync(logFds.stdout);
171
+ }
172
+ catch { /* best-effort */ }
173
+ try {
174
+ fs.closeSync(logFds.stderr);
175
+ }
176
+ catch { /* best-effort */ }
177
+ }
142
178
  const pid = child.pid;
143
179
  if (!pid) {
144
180
  throw new Error(`Failed to spawn agent ${options.agent}: no PID returned`);
@@ -13,6 +13,7 @@ export const WorkRequestSchema = z.object({
13
13
  task: z.string().optional(),
14
14
  messageId: z.string().optional(),
15
15
  contextTarget: z.string().optional(),
16
+ project: z.string().optional(),
16
17
  compact: z.boolean().optional().default(true),
17
18
  });
18
19
  export const CoordinateRequestSchema = z.object({
@@ -54,6 +55,27 @@ export const CoordinateRequestSchema = z.object({
54
55
  * the target agent picks up the brief async via its own bclaw_work.
55
56
  */
56
57
  project: z.string().optional(),
58
+ /**
59
+ * Bypass the pre-flight uncommitted-changes check (can_30c295b4 fix).
60
+ * By default, bclaw_coordinate refuses dispatches when the source cwd
61
+ * has uncommitted modifications, because the dispatched worker spawns
62
+ * from HEAD and won't see them — leading to silent review on stale code.
63
+ * Set allow_dirty=true to override (e.g. when the caller knows the
64
+ * dispatched work doesn't depend on the dirty files, or when running
65
+ * tests). Has no effect when the source cwd is not a git repo.
66
+ */
67
+ allow_dirty: z.boolean().optional(),
68
+ /**
69
+ * pln#511 step 2 — loop preset selector. When set on intent='ideate',
70
+ * the handler bypasses the kind-default ideation phases and opens the
71
+ * loop with the named preset's phases / stop_condition / protocol.
72
+ * v1 ships a single preset ('bootstrap', see src/core/loops/presets/).
73
+ * The handler validates the name against the preset registry and
74
+ * rejects unknown names with `unknown_preset`. Presets are kind-
75
+ * specific: passing `preset` with any intent other than 'ideate' is
76
+ * rejected as `preset_kind_mismatch`.
77
+ */
78
+ preset: z.string().min(1).optional(),
57
79
  });
58
80
  export const FacadeArtifactSchema = z.object({
59
81
  type: z.string(),
@@ -65,6 +87,21 @@ export const FacadeSideEffectSchema = z.object({
65
87
  entity: z.string(),
66
88
  id: z.string(),
67
89
  });
90
+ /**
91
+ * Self-documenting verification hint attached to dispatch responses (pln#503
92
+ * phase 3.3). Tells the caller exactly which canonical-grammar call to make
93
+ * next to verify the spawn is actually doing work — `delivered_and_started`
94
+ * is a brief-ack signal, not proof of life. See dispatch-lifecycle.md.
95
+ */
96
+ export const VerifyWithSchema = z.object({
97
+ action: z.literal('bclaw_find'),
98
+ entity: z.literal('agent_run'),
99
+ filter: z.object({ assignment_id: z.string() }),
100
+ /** Human-readable description of what to look for in the result. */
101
+ expected_when_alive: z.string(),
102
+ /** Doc pointer for the diagnostic flow when the check fails. */
103
+ see_also: z.string(),
104
+ });
68
105
  export const FacadeResponseSchema = z.object({
69
106
  status: z.enum(['ok', 'error', 'partial']),
70
107
  intent: z.string(),
@@ -77,5 +114,23 @@ export const FacadeResponseSchema = z.object({
77
114
  session_id: z.string().optional(),
78
115
  warnings: z.array(z.string()),
79
116
  execution_status: ExecutionStatusSchema.optional(),
117
+ /** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
118
+ verify_with: VerifyWithSchema.optional(),
119
+ /**
120
+ * pln#513 step 1 — bclaw_work hint surfaced when the project lacks a usable
121
+ * PROJECT.md (absent or zero bytes). True means the agent should consider
122
+ * opening a bootstrap loop before assuming context; the literal call to
123
+ * make is in `next_action`. False or absent means the project already has
124
+ * a PROJECT.md and the bootstrap entry-point should not be offered.
125
+ * Additive: existing callers that don't read it are unaffected.
126
+ */
127
+ bootstrap_recommended: z.boolean().optional(),
128
+ /**
129
+ * pln#513 step 1 — literal MCP call to surface as the bootstrap entry-point
130
+ * when `bootstrap_recommended` is true. Carries the canonical-grammar text
131
+ * (`bclaw_coordinate(intent='ideate', preset='bootstrap')`) verbatim so the
132
+ * CLI doesn't have to reconstruct it.
133
+ */
134
+ next_action: z.string().optional(),
80
135
  });
81
136
  //# sourceMappingURL=facade-schema.js.map
@@ -2,24 +2,29 @@ import { loadConfig } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  const DEFAULT_API_URL = 'https://app.brainclaw.dev';
4
4
  function resolveCloudConfig(cwd) {
5
- // Check env vars first
6
- const apiUrl = process.env.BRAINCLAW_CLOUD_URL ?? DEFAULT_API_URL;
7
- const apiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
8
- if (apiKey) {
9
- return { apiUrl, apiKey };
10
- }
11
- // Check config.yaml
5
+ const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
6
+ const envApiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
7
+ let configEnabled = false;
8
+ let configEndpoint;
9
+ let configApiKey;
12
10
  try {
13
11
  const config = loadConfig(cwd);
14
- const cloud = config.cloud;
15
- if (cloud?.api_key) {
16
- return { apiUrl: cloud.api_url ?? apiUrl, apiKey: cloud.api_key };
12
+ if (config.cloud_sync) {
13
+ configEnabled = config.cloud_sync.enabled === true;
14
+ configEndpoint = config.cloud_sync.endpoint;
15
+ configApiKey = config.cloud_sync.api_key;
17
16
  }
18
17
  }
19
18
  catch {
20
- // No config — cloud not configured
19
+ // No config available fall back to env only
21
20
  }
22
- return undefined;
21
+ const apiKey = envApiKey ?? configApiKey;
22
+ if (!apiKey)
23
+ return undefined;
24
+ // Env-supplied key implies explicit opt-in; config flag is the alternative
25
+ const enabled = Boolean(envApiKey) || configEnabled;
26
+ const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
27
+ return { apiUrl, apiKey, enabled };
23
28
  }
24
29
  export async function pushSignalToCloud(message, cwd) {
25
30
  const cloud = resolveCloudConfig(cwd);
@@ -96,4 +101,14 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
96
101
  export function isCloudConfigured(cwd) {
97
102
  return resolveCloudConfig(cwd) !== undefined;
98
103
  }
104
+ /**
105
+ * Returns true when cloud sync is both configured AND explicitly opted-in.
106
+ * Use this gate for automatic lifecycle hooks (session-start pull, session-end push).
107
+ * `isCloudConfigured` alone does NOT imply opt-in — a stale config api_key without
108
+ * `cloud_sync.enabled=true` and without the BRAINCLAW_CLOUD_API_KEY env var stays inert.
109
+ */
110
+ export function isCloudSyncEnabled(cwd) {
111
+ const cloud = resolveCloudConfig(cwd);
112
+ return cloud !== undefined && cloud.enabled;
113
+ }
99
114
  //# sourceMappingURL=federation-cloud.js.map
@@ -0,0 +1,57 @@
1
+ import { CandidateSchema, HandoffSchema, RuntimeNoteSchema } from './schema.js';
2
+ import { saveCandidate, generateCandidateIdWithLabel } from './candidates.js';
3
+ import { saveRuntimeNote, generateRuntimeNoteId } from './runtime.js';
4
+ import { generateIdWithLabel, nowISO } from './ids.js';
5
+ import { mutateState } from './state.js';
6
+ export function materializeFederationSignal(signal, cwd) {
7
+ const origin = `remote:${signal.from.project_name}:${signal.from.agent_name}`;
8
+ if (signal.type === 'candidate') {
9
+ const parsed = CandidateSchema.safeParse(signal.payload);
10
+ if (!parsed.success)
11
+ return false;
12
+ const { id, short_label } = generateCandidateIdWithLabel(cwd);
13
+ saveCandidate({
14
+ ...parsed.data,
15
+ id,
16
+ short_label,
17
+ created_at: nowISO(),
18
+ source: undefined, // remote signal — treated as 'human' (legacy default)
19
+ star_count: 0,
20
+ starred_by: [],
21
+ usage_count: 0,
22
+ usage_events: [],
23
+ status: 'pending',
24
+ }, cwd);
25
+ return true;
26
+ }
27
+ if (signal.type === 'handoff') {
28
+ const parsed = HandoffSchema.safeParse(signal.payload);
29
+ if (!parsed.success)
30
+ return false;
31
+ const { id, short_label } = generateIdWithLabel('open_handoffs', cwd);
32
+ mutateState((state) => {
33
+ state.open_handoffs.push({
34
+ ...parsed.data,
35
+ id,
36
+ short_label,
37
+ created_at: nowISO(),
38
+ tags: [...(parsed.data.tags ?? []), origin],
39
+ });
40
+ }, cwd);
41
+ return true;
42
+ }
43
+ if (signal.type === 'runtime_note') {
44
+ const parsed = RuntimeNoteSchema.safeParse(signal.payload);
45
+ if (!parsed.success)
46
+ return false;
47
+ saveRuntimeNote({
48
+ ...parsed.data,
49
+ id: generateRuntimeNoteId(),
50
+ created_at: nowISO(),
51
+ tags: [...(parsed.data.tags ?? []), origin],
52
+ }, cwd);
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+ //# sourceMappingURL=federation-materialize.js.map
@@ -236,6 +236,8 @@ function renderSessionProtocol() {
236
236
  '- Drive a turn in a loop already assigned to you → `bclaw_loop(intent=turn|complete_turn|advance|close)`',
237
237
  '',
238
238
  'Do NOT call `bclaw_loop(intent=open)` directly — it creates a loop structure without dispatch, so the reviewer/participant never gets the work. Use the goal entries above.',
239
+ '',
240
+ '_How to verify a dispatch actually worked:_ `execution_status="delivered_and_started"` only means the brief-ack sentinel was touched — it does NOT mean the worker is doing useful work. Always (1) `bclaw_find(entity="agent_run", filter={assignment_id})` to read the spawn record; (2) check OS pid liveness yourself (`Get-Process -Id <pid>` on Windows, `kill -0 <pid>` on POSIX); (3) if the worker is silent, read its captured streams at `.brainclaw/coordination/runtime/log/<assignment_id>.{stdout,stderr}.log`. Full FSM tables + diagnostic decision tree in `docs/concepts/dispatch-lifecycle.md`.',
239
241
  ].join('\n');
240
242
  }
241
243
  function renderUserWorkflow() {