brainclaw 1.5.5 → 1.7.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 (58) hide show
  1. package/README.md +5 -4
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +124 -7
  4. package/dist/commands/bootstrap-loop.js +206 -0
  5. package/dist/commands/loop.js +156 -0
  6. package/dist/commands/loops-handlers.js +110 -55
  7. package/dist/commands/mcp-read-handlers.js +37 -0
  8. package/dist/commands/mcp-schemas.generated.js +33 -0
  9. package/dist/commands/mcp.js +661 -207
  10. package/dist/commands/questions.js +180 -0
  11. package/dist/commands/reply.js +190 -0
  12. package/dist/commands/session-end.js +105 -3
  13. package/dist/commands/session-start.js +32 -53
  14. package/dist/commands/setup.js +64 -13
  15. package/dist/commands/switch.js +17 -1
  16. package/dist/core/agent-capability.js +19 -0
  17. package/dist/core/agent-files.js +86 -0
  18. package/dist/core/agent-integrations.js +34 -0
  19. package/dist/core/agent-inventory.js +27 -0
  20. package/dist/core/agentrun-reconciler.js +130 -0
  21. package/dist/core/ai-agent-detection.js +17 -1
  22. package/dist/core/claims.js +54 -3
  23. package/dist/core/dirty-scope.js +242 -0
  24. package/dist/core/dispatch-status.js +219 -0
  25. package/dist/core/entity-operations.js +128 -9
  26. package/dist/core/execution-adapters.js +38 -2
  27. package/dist/core/facade-schema.js +71 -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 +30 -2
  44. package/dist/core/state.js +62 -0
  45. package/dist/core/worktree.js +58 -0
  46. package/dist/facts.js +360 -5
  47. package/dist/facts.json +359 -4
  48. package/docs/cli.md +10 -7
  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 +36 -2
  53. package/docs/index.md +4 -3
  54. package/docs/integrations/hermes.md +78 -0
  55. package/docs/integrations/overview.md +22 -18
  56. package/docs/mcp-schema-changelog.md +8 -1
  57. package/docs/quickstart-existing-project.md +1 -1
  58. package/package.json +5 -4
@@ -19,12 +19,14 @@ import { listClaims } from './claims.js';
19
19
  import { listActionRequired } from './actions.js';
20
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)
@@ -318,6 +391,8 @@ export function updateEntity(name, id, patch, cwd) {
318
391
  return { entity: name, id };
319
392
  }
320
393
  case 'candidate': {
394
+ // Note: `candidate.type` is intentionally create-only (not in
395
+ // candidate.updatable at entity-registry.ts) — no validation needed.
321
396
  const candidate = loadCandidate(id, cwd);
322
397
  const patched = { ...candidate, ...patch };
323
398
  saveCandidate(patched, cwd);
@@ -484,4 +559,48 @@ function requireString(data, field) {
484
559
  }
485
560
  return value;
486
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
+ }
487
606
  //# sourceMappingURL=entity-operations.js.map
@@ -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,43 @@ 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.preprocess(
68
+ // MCP clients that don't know allow_dirty is a boolean (it was previously
69
+ // absent from the published inputSchema) send the string "true"/"false".
70
+ // Coerce those so the documented escape hatch actually works; leave real
71
+ // booleans and undefined untouched.
72
+ (value) => (typeof value === 'string' ? value.trim().toLowerCase() === 'true' : value), z.boolean().optional()),
73
+ /**
74
+ * pln#520 Tier 2 (P2c) — explicit git ref (commit / branch / tag) the
75
+ * dispatched worker should build its worktree from, instead of the default
76
+ * HEAD. When set on a worktree-creating intent (assign / review / reroute),
77
+ * the worktree is checked out at this ref, so uncommitted working-tree
78
+ * changes in the source are intentionally out of scope and the scope-aware
79
+ * dirty guard allows the dispatch. Passed through to
80
+ * createCoordinatorClaim's worktreeBaseRef. Ignored by intents that don't
81
+ * create a worktree (consult / ideate / summarize).
82
+ */
83
+ ref: z.string().min(1).optional(),
84
+ /**
85
+ * pln#511 step 2 — loop preset selector. When set on intent='ideate',
86
+ * the handler bypasses the kind-default ideation phases and opens the
87
+ * loop with the named preset's phases / stop_condition / protocol.
88
+ * v1 ships a single preset ('bootstrap', see src/core/loops/presets/).
89
+ * The handler validates the name against the preset registry and
90
+ * rejects unknown names with `unknown_preset`. Presets are kind-
91
+ * specific: passing `preset` with any intent other than 'ideate' is
92
+ * rejected as `preset_kind_mismatch`.
93
+ */
94
+ preset: z.string().min(1).optional(),
57
95
  });
58
96
  export const FacadeArtifactSchema = z.object({
59
97
  type: z.string(),
@@ -65,6 +103,21 @@ export const FacadeSideEffectSchema = z.object({
65
103
  entity: z.string(),
66
104
  id: z.string(),
67
105
  });
106
+ /**
107
+ * Self-documenting verification hint attached to dispatch responses (pln#503
108
+ * phase 3.3). Tells the caller exactly which canonical-grammar call to make
109
+ * next to verify the spawn is actually doing work — `delivered_and_started`
110
+ * is a brief-ack signal, not proof of life. See dispatch-lifecycle.md.
111
+ */
112
+ export const VerifyWithSchema = z.object({
113
+ action: z.literal('bclaw_find'),
114
+ entity: z.literal('agent_run'),
115
+ filter: z.object({ assignment_id: z.string() }),
116
+ /** Human-readable description of what to look for in the result. */
117
+ expected_when_alive: z.string(),
118
+ /** Doc pointer for the diagnostic flow when the check fails. */
119
+ see_also: z.string(),
120
+ });
68
121
  export const FacadeResponseSchema = z.object({
69
122
  status: z.enum(['ok', 'error', 'partial']),
70
123
  intent: z.string(),
@@ -77,5 +130,23 @@ export const FacadeResponseSchema = z.object({
77
130
  session_id: z.string().optional(),
78
131
  warnings: z.array(z.string()),
79
132
  execution_status: ExecutionStatusSchema.optional(),
133
+ /** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
134
+ verify_with: VerifyWithSchema.optional(),
135
+ /**
136
+ * pln#513 step 1 — bclaw_work hint surfaced when the project lacks a usable
137
+ * PROJECT.md (absent or zero bytes). True means the agent should consider
138
+ * opening a bootstrap loop before assuming context; the literal call to
139
+ * make is in `next_action`. False or absent means the project already has
140
+ * a PROJECT.md and the bootstrap entry-point should not be offered.
141
+ * Additive: existing callers that don't read it are unaffected.
142
+ */
143
+ bootstrap_recommended: z.boolean().optional(),
144
+ /**
145
+ * pln#513 step 1 — literal MCP call to surface as the bootstrap entry-point
146
+ * when `bootstrap_recommended` is true. Carries the canonical-grammar text
147
+ * (`bclaw_coordinate(intent='ideate', preset='bootstrap')`) verbatim so the
148
+ * CLI doesn't have to reconstruct it.
149
+ */
150
+ next_action: z.string().optional(),
80
151
  });
81
152
  //# 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() {