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
@@ -0,0 +1,180 @@
1
+ import { memoryExists } from '../core/io.js';
2
+ import { listLoops, listLoopEvents } from '../core/loops/index.js';
3
+ import { resolveCurrentAgentName } from '../core/agent-registry.js';
4
+ function parseQuestionBody(artifact) {
5
+ if (artifact.type !== 'operator_question' || !artifact.body)
6
+ return undefined;
7
+ try {
8
+ return JSON.parse(artifact.body);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ function parseAnswerBody(artifact) {
15
+ if (artifact.type !== 'operator_answer' || !artifact.body)
16
+ return undefined;
17
+ try {
18
+ return JSON.parse(artifact.body);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function relativeAge(seconds) {
25
+ if (seconds < 60)
26
+ return `${Math.max(0, Math.floor(seconds))}s ago`;
27
+ if (seconds < 3600)
28
+ return `${Math.floor(seconds / 60)}m ago`;
29
+ if (seconds < 86400)
30
+ return `${Math.floor(seconds / 3600)}h ago`;
31
+ return `${Math.floor(seconds / 86400)}d ago`;
32
+ }
33
+ function truncate(s, max) {
34
+ if (s.length <= max)
35
+ return s;
36
+ return `${s.slice(0, max - 1)}…`;
37
+ }
38
+ function findAnswerFor(loop, questionId) {
39
+ for (const artifact of loop.artifacts) {
40
+ const body = parseAnswerBody(artifact);
41
+ if (body && body.replies_to === questionId) {
42
+ return { artifact, body };
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ function loopWasTimedOut(loop, questionId, cwd) {
48
+ if (loop.status === 'cancelled') {
49
+ // Check events for pause_timeout that targeted this question with cancel_loop
50
+ try {
51
+ const events = listLoopEvents(loop.id, cwd);
52
+ for (const ev of events) {
53
+ if (ev.kind === 'pause_timeout' && ev.question_id === questionId && ev.action_taken === 'cancel_loop') {
54
+ return true;
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ // ignore
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+ function determineStatus(loop, questionBody, cwd) {
65
+ if (loop.open_questions.includes(questionBody.question_id)) {
66
+ return { status: 'awaiting' };
67
+ }
68
+ const answer = findAnswerFor(loop, questionBody.question_id);
69
+ if (answer) {
70
+ const isTimedOut = answer.body.by === 'system' || answer.body.resolved_via === 'timeout_default';
71
+ const answerInfo = {
72
+ resolved_via: answer.body.resolved_via,
73
+ answer_text: answer.body.answer_text,
74
+ chosen_option_id: answer.body.chosen_option_id,
75
+ by: answer.body.by,
76
+ synthetic: answer.body.synthetic === true,
77
+ produced_at: answer.artifact.produced_at,
78
+ };
79
+ return { status: isTimedOut ? 'timed_out' : 'answered', answer: answerInfo };
80
+ }
81
+ if (loopWasTimedOut(loop, questionBody.question_id, cwd)) {
82
+ return { status: 'timed_out' };
83
+ }
84
+ // No answer artifact and not in open_questions — treat as answered for safety.
85
+ return { status: 'answered' };
86
+ }
87
+ function collectQuestions(loops, cwd, now) {
88
+ const rows = [];
89
+ for (const loop of loops) {
90
+ for (const artifact of loop.artifacts) {
91
+ const body = parseQuestionBody(artifact);
92
+ if (!body)
93
+ continue;
94
+ const { status, answer } = determineStatus(loop, body, cwd);
95
+ const producedAtMs = Date.parse(artifact.produced_at);
96
+ const ageSeconds = Number.isFinite(producedAtMs)
97
+ ? Math.max(0, (now - producedAtMs) / 1000)
98
+ : 0;
99
+ rows.push({
100
+ question_id: body.question_id,
101
+ loop_id: loop.id,
102
+ loop_title: loop.title,
103
+ phase: artifact.phase,
104
+ status,
105
+ question_text: body.question_text,
106
+ evidence: body.evidence,
107
+ suggested_default: body.suggested_default,
108
+ options: body.options,
109
+ pause_scope: body.pause_scope,
110
+ on_timeout: body.on_timeout,
111
+ timeout_at: body.timeout_at,
112
+ produced_at: artifact.produced_at,
113
+ age_seconds: ageSeconds,
114
+ answer,
115
+ });
116
+ }
117
+ }
118
+ return rows;
119
+ }
120
+ function formatTable(rows) {
121
+ if (rows.length === 0)
122
+ return 'No questions match the current filters.';
123
+ const header = ['QUESTION_ID', 'LOOP_ID', 'STATUS', 'QUESTION', 'AGE'];
124
+ const data = rows.map((r) => [
125
+ r.question_id,
126
+ r.loop_id,
127
+ r.status,
128
+ truncate(r.question_text, 50),
129
+ relativeAge(r.age_seconds),
130
+ ]);
131
+ const widths = header.map((h, i) => Math.max(h.length, ...data.map((row) => row[i].length)));
132
+ const pad = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(' ').trimEnd();
133
+ const lines = [pad(header), pad(widths.map((w) => '-'.repeat(w)))];
134
+ for (const row of data)
135
+ lines.push(pad(row));
136
+ return lines.join('\n');
137
+ }
138
+ /**
139
+ * `brainclaw questions` — list operator_question artifacts across loops in
140
+ * the current project. Filters by --loop, --status, --mine; emits a human
141
+ * table or --json.
142
+ *
143
+ * v1 caveat: question targeting (which agent should answer) isn't tracked
144
+ * on the artifact body yet. The --mine filter therefore returns ALL awaiting
145
+ * questions for human callers; agent callers see none (since no question is
146
+ * provably theirs). Documented in the brief.
147
+ */
148
+ export function runQuestionsCommand(options = {}, cwd) {
149
+ if (!memoryExists(cwd)) {
150
+ console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
151
+ process.exit(1);
152
+ }
153
+ const status = options.status ?? 'awaiting';
154
+ const allLoops = listLoops({}, cwd);
155
+ const loops = options.loop
156
+ ? allLoops.filter((l) => l.id === options.loop)
157
+ : allLoops;
158
+ const now = Date.now();
159
+ let rows = collectQuestions(loops, cwd, now);
160
+ rows = rows.filter((r) => r.status === status);
161
+ if (options.mine) {
162
+ const caller = resolveCurrentAgentName(cwd);
163
+ const isHuman = !/^(claude|codex|copilot|gemini|opencode|cline|continue|antigravity|sonnet|opus|haiku)/i.test(caller);
164
+ if (!isHuman) {
165
+ // Agent callers can't prove ownership of any question — return empty
166
+ // until question targeting is tracked (v1 heuristic).
167
+ rows = [];
168
+ }
169
+ // For humans, --mine is a no-op in v1 because question targeting isn't tracked.
170
+ }
171
+ rows.sort((a, b) => b.produced_at.localeCompare(a.produced_at));
172
+ const result = { questions: rows };
173
+ if (options.json) {
174
+ console.log(JSON.stringify(result, null, 2));
175
+ return result;
176
+ }
177
+ console.log(formatTable(rows));
178
+ return result;
179
+ }
180
+ //# sourceMappingURL=questions.js.map
@@ -0,0 +1,190 @@
1
+ import { memoryExists } from '../core/io.js';
2
+ import { computeNextExpected, listLoops, provideInput } from '../core/loops/index.js';
3
+ import { resolveCurrentAgentName } from '../core/agent-registry.js';
4
+ // NextExpectedHint imported from `../core/loops/index.js` — single
5
+ // source of truth shared with the MCP facade (hoisted per can_e57c7782).
6
+ function fail(message, exitCode, opts) {
7
+ if (opts.json) {
8
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
9
+ }
10
+ else {
11
+ console.error(`Error: ${message}`);
12
+ }
13
+ process.exit(exitCode);
14
+ }
15
+ function parseQuestionBody(artifact) {
16
+ if (artifact.type !== 'operator_question' || !artifact.body)
17
+ return undefined;
18
+ try {
19
+ return JSON.parse(artifact.body);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ function parseAnswerBody(artifact) {
26
+ if (artifact.type !== 'operator_answer' || !artifact.body)
27
+ return undefined;
28
+ try {
29
+ return JSON.parse(artifact.body);
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
35
+ function findLoopForQuestion(loops, questionId) {
36
+ for (const loop of loops) {
37
+ for (const artifact of loop.artifacts) {
38
+ const body = parseQuestionBody(artifact);
39
+ if (body && body.question_id === questionId) {
40
+ const isOpen = loop.open_questions.includes(questionId);
41
+ let existingAnswer;
42
+ if (!isOpen) {
43
+ for (const a of loop.artifacts) {
44
+ const ab = parseAnswerBody(a);
45
+ if (ab && ab.replies_to === questionId) {
46
+ existingAnswer = ab;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ return { loop, question: body, isOpen, existingAnswer };
52
+ }
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ function formatNextExpected(hint) {
58
+ if (!hint)
59
+ return ' (loop has no further expected action)';
60
+ const bits = [` next: ${hint.action} (${hint.intent})`];
61
+ if (hint.phase)
62
+ bits.push(` phase: ${hint.phase}`);
63
+ if (hint.slot_id)
64
+ bits.push(` slot: ${hint.slot_id}${hint.role ? ` [${hint.role}]` : ''}`);
65
+ if (hint.from_phase && hint.to_phase)
66
+ bits.push(` ${hint.from_phase} → ${hint.to_phase}`);
67
+ if (hint.blocking_on.length)
68
+ bits.push(` blocking_on: ${hint.blocking_on.join(', ')}`);
69
+ if (hint.reason)
70
+ bits.push(` reason: ${hint.reason}`);
71
+ return bits.join('\n');
72
+ }
73
+ /**
74
+ * `brainclaw reply <qst_id>` — wraps the provideInput verb. Locates the loop
75
+ * containing the question, validates the requested resolution against the
76
+ * question's options/suggested_default, and prints the resulting next_expected
77
+ * hint on success.
78
+ *
79
+ * Exit codes:
80
+ * 0 — success.
81
+ * 1 — validation error (unknown qst, already resolved, mutually-exclusive
82
+ * flags, missing flags, invalid option id, --skip without default).
83
+ * 2 — verb threw a domain error (e.g. terminal loop status).
84
+ */
85
+ export function runReplyCommand(questionId, options = {}, cwd) {
86
+ if (!memoryExists(cwd)) {
87
+ console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
88
+ process.exit(1);
89
+ }
90
+ // Validate mutually-exclusive resolution flags.
91
+ const modes = [];
92
+ if (options.answer !== undefined)
93
+ modes.push('answer');
94
+ if (options.choose !== undefined)
95
+ modes.push('choose');
96
+ if (options.skip)
97
+ modes.push('skip');
98
+ if (modes.length === 0) {
99
+ fail('reply requires exactly one of --answer <text>, --choose <option_id>, or --skip', 1, options);
100
+ }
101
+ if (modes.length > 1) {
102
+ fail(`--answer, --choose, and --skip are mutually exclusive (got: ${modes.join(', ')})`, 1, options);
103
+ }
104
+ const mode = modes[0];
105
+ // Validate qst id format up-front so we can give a nicer error than
106
+ // "unknown question" for typos like `brainclaw reply foo`.
107
+ if (!/^qst_[0-9a-z]+$/.test(questionId)) {
108
+ fail(`invalid question_id "${questionId}" — expected format qst_<hex>`, 1, options);
109
+ }
110
+ const loops = listLoops({}, cwd);
111
+ const located = findLoopForQuestion(loops, questionId);
112
+ if (!located) {
113
+ fail(`question not found: ${questionId} (run \`brainclaw questions\` to list pending questions)`, 1, options);
114
+ }
115
+ const { loop, question, isOpen, existingAnswer } = located;
116
+ if (!isOpen) {
117
+ if (mode === 'answer' || mode === 'choose') {
118
+ const detail = existingAnswer
119
+ ? ` (resolved via ${existingAnswer.resolved_via}${existingAnswer.by === 'system' ? ', synthetic' : ''})`
120
+ : '';
121
+ fail(`question already resolved: ${questionId}${detail}`, 1, options);
122
+ }
123
+ // --skip on an already-resolved question is treated as idempotent replay:
124
+ // provideInput's idempotent path returns the existing answer artifact.
125
+ }
126
+ if (mode === 'choose') {
127
+ const optionId = options.choose;
128
+ const opts = question.options ?? [];
129
+ const found = opts.find((o) => o.id === optionId);
130
+ if (!found) {
131
+ const known = opts.map((o) => o.id).join(', ') || '<none>';
132
+ fail(`--choose ${optionId}: question ${questionId} has no such option (known: ${known})`, 1, options);
133
+ }
134
+ }
135
+ // pln#512 phase 3 codex review fix #2: when the source question carries
136
+ // structured options, `--answer` cannot resolve it correctly. The verb
137
+ // post-hooks (e.g. file_overwrite_approval) branch on chosen_option_id,
138
+ // so a free-text answer of "approve" creates an answer artifact whose
139
+ // chosen_option_id is empty — the hook treats that as REJECT. Refuse the
140
+ // mode mismatch up front and point the operator at --choose.
141
+ if (mode === 'answer' && question.options && question.options.length > 0) {
142
+ const opts = question.options;
143
+ const known = opts.map((o) => o.id).join(', ');
144
+ const text = (options.answer ?? '').trim();
145
+ const matchedOption = opts.find((o) => o.id === text);
146
+ const hint = matchedOption
147
+ ? ` — to pick this option, use \`--choose ${matchedOption.id}\``
148
+ : ` — pick one with \`--choose <id>\` (known: ${known})`;
149
+ fail(`question ${questionId} has structured options; --answer is not accepted${hint}`, 1, options);
150
+ }
151
+ if (mode === 'skip' && question.suggested_default === undefined) {
152
+ fail(`--skip on ${questionId}: source question has no suggested_default to materialize`, 1, options);
153
+ }
154
+ const actor = resolveCurrentAgentName(cwd);
155
+ const resolvedVia = mode === 'answer' ? 'answer' : mode === 'choose' ? 'choose' : 'skip';
156
+ let result;
157
+ try {
158
+ result = provideInput({
159
+ loop_id: loop.id,
160
+ replies_to: questionId,
161
+ resolved_via: resolvedVia,
162
+ answer_text: mode === 'answer' ? options.answer : undefined,
163
+ chosen_option_id: mode === 'choose' ? options.choose : undefined,
164
+ actor,
165
+ }, cwd);
166
+ }
167
+ catch (err) {
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ fail(`provide_input verb rejected the call: ${message}`, 2, options);
170
+ }
171
+ const hint = computeNextExpected(result.thread);
172
+ const out = {
173
+ ok: true,
174
+ loop_id: loop.id,
175
+ question_id: questionId,
176
+ resolved_via: resolvedVia,
177
+ artifact_id: result.artifact_id,
178
+ next_expected: hint,
179
+ duplicate: result.duplicate || undefined,
180
+ };
181
+ if (options.json) {
182
+ console.log(JSON.stringify(out, null, 2));
183
+ return out;
184
+ }
185
+ const prefix = result.duplicate ? '↺ Replay-acked' : '✔ Answered';
186
+ console.log(`${prefix} ${questionId} via ${resolvedVia} on loop ${loop.id}`);
187
+ console.log(formatNextExpected(hint));
188
+ return out;
189
+ }
190
+ //# sourceMappingURL=reply.js.map
@@ -9,6 +9,7 @@ import { loadState, persistState } from '../core/state.js';
9
9
  import { listArchivedCandidates, listCandidates } from '../core/candidates.js';
10
10
  import { createFederationMessage } from '../core/federation-message.js';
11
11
  import { pushSignal } from '../core/federation-transport.js';
12
+ import { pushSignalToCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
12
13
  import { loadConfig } from '../core/config.js';
13
14
  import { resolveCrossProjectLinks } from '../core/cross-project.js';
14
15
  import { createCandidateFromInput } from './reflect.js';
@@ -25,9 +26,9 @@ export const REFLECTION_QUESTIONS = [
25
26
  'What should have been done differently (design, process, or approach)?',
26
27
  'What should brainclaw itself improve based on this session?',
27
28
  ];
28
- export function runSessionEnd(options = {}) {
29
+ export async function runSessionEnd(options = {}) {
29
30
  try {
30
- const result = endSession(options);
31
+ const result = await endSession(options);
31
32
  if (options.json) {
32
33
  console.log(JSON.stringify(result, null, 2));
33
34
  return;
@@ -97,7 +98,7 @@ export function runSessionEnd(options = {}) {
97
98
  process.exit(1);
98
99
  }
99
100
  }
100
- export function endSession(options = {}) {
101
+ export async function endSession(options = {}) {
101
102
  if (!memoryExists(options.cwd)) {
102
103
  throw new Error('.brainclaw/ not found. Run `brainclaw init` first.');
103
104
  }
@@ -303,6 +304,24 @@ export function endSession(options = {}) {
303
304
  if (pushedSignals > 0 && !options.json) {
304
305
  console.log(`✔ Pushed ${pushedSignals} signal(s) to linked projects`);
305
306
  }
307
+ // Cloud federation push (Phase 1 — opt-in via cloud_sync.enabled)
308
+ let pushedCloudSignals = 0;
309
+ if (isCloudSyncEnabled(options.cwd)) {
310
+ try {
311
+ pushedCloudSignals = await pushSessionCloudSignals({
312
+ sessionId,
313
+ actor,
314
+ sessionNotes,
315
+ cwd: options.cwd,
316
+ });
317
+ }
318
+ catch {
319
+ // Non-fatal — cloud push failure should not block session end
320
+ }
321
+ }
322
+ if (pushedCloudSignals > 0 && !options.json) {
323
+ console.log(`✔ Pushed ${pushedCloudSignals} signal(s) to cloud`);
324
+ }
306
325
  appendAuditEntry({
307
326
  action: 'session_end',
308
327
  actor: actor.agent,
@@ -435,6 +454,89 @@ function pushSessionFederationSignals(input) {
435
454
  }
436
455
  return pushed;
437
456
  }
457
+ /**
458
+ * Push session-scoped handoffs / candidates / runtime_notes to the cloud federation.
459
+ * Skips entities with visibility = 'machine' or 'private' — only 'shared' (default) goes out.
460
+ * Failures per-entity are swallowed so a single bad fetch does not abort the rest.
461
+ */
462
+ async function pushSessionCloudSignals(input) {
463
+ const cwd = input.cwd ?? process.cwd();
464
+ const config = loadConfig(cwd);
465
+ const fromProjectName = config.project_name ?? path.basename(cwd);
466
+ const currentState = loadState(cwd);
467
+ const sessionHandoffs = currentState.open_handoffs.filter((handoff) => handoff.session_id === input.sessionId);
468
+ const sessionCandidates = [
469
+ ...listCandidates(undefined, cwd),
470
+ ...listArchivedCandidates('accepted', cwd),
471
+ ...listArchivedCandidates('rejected', cwd),
472
+ ].filter((candidate) => candidate.session_id === input.sessionId);
473
+ const sessionRuntimeNotes = input.sessionNotes.filter((note) => note.session_id === input.sessionId);
474
+ // Conservative cloud-push gate (review finding 2026-05-15, finalized via
475
+ // pln#365 finalization 2026-05-15):
476
+ //
477
+ // All four signal-bearing schemas now carry a `visibility` field:
478
+ // - RuntimeNoteSchema (schema.ts:899) — defaults to 'shared'
479
+ // - TrapSchema (schema.ts:184) — defaults to 'shared'
480
+ // - HandoffSchema (schema.ts:~248) — optional, no default (opt-in)
481
+ // - CandidateSchema (schema.ts:~619) — optional, no default (opt-in)
482
+ //
483
+ // Handoffs and candidates are opt-in because their text / snapshot.diff
484
+ // can carry per-host secrets. An agent must explicitly set
485
+ // `visibility: 'shared'` to push such an entity to cloud. RuntimeNotes
486
+ // default to shared since they're already the lightest-weight signal.
487
+ //
488
+ // The gate below is intentionally literal — `entity.visibility === 'shared'`.
489
+ // Undefined or absent visibility means "stay local" regardless of cloud_sync.
490
+ const isExplicitlyShared = (entity) => {
491
+ return entity.visibility === 'shared';
492
+ };
493
+ let pushed = 0;
494
+ const pushOne = async (entityType, entity) => {
495
+ const message = createFederationMessage({
496
+ version: 1,
497
+ from: {
498
+ project_id: input.actor.project_id ?? config.project_id,
499
+ project_name: fromProjectName,
500
+ project_path: cwd,
501
+ agent_name: input.actor.agent,
502
+ agent_id: input.actor.agent_id,
503
+ host_id: input.actor.host_id,
504
+ },
505
+ to: {
506
+ // Cloud is a broadcast bus — no specific target project at this layer.
507
+ project_name: 'broadcast',
508
+ project_path: '',
509
+ },
510
+ type: entityType,
511
+ payload: entity,
512
+ causal_parent: input.sessionId,
513
+ });
514
+ try {
515
+ const ok = await pushSignalToCloud(message, cwd);
516
+ if (ok)
517
+ pushed++;
518
+ }
519
+ catch {
520
+ // Per-entity failure should not abort the loop
521
+ }
522
+ };
523
+ for (const handoff of sessionHandoffs) {
524
+ if (!isExplicitlyShared(handoff))
525
+ continue;
526
+ await pushOne('handoff', handoff);
527
+ }
528
+ for (const candidate of sessionCandidates) {
529
+ if (!isExplicitlyShared(candidate))
530
+ continue;
531
+ await pushOne('candidate', candidate);
532
+ }
533
+ for (const note of sessionRuntimeNotes) {
534
+ if (!isExplicitlyShared(note))
535
+ continue;
536
+ await pushOne('runtime_note', note);
537
+ }
538
+ return pushed;
539
+ }
438
540
  function resolvePublisherLink(target, publisherLinks, entityType, cwd) {
439
541
  const normalized = target.trim().toLowerCase();
440
542
  if (!normalized)
@@ -9,25 +9,25 @@ import { requireMinimumTrustLevel, resolveCurrentModel, resolveOrAutoRegisterAge
9
9
  import { buildContext, renderContextPromptTemplate } from '../core/context.js';
10
10
  import { writeContextMarker } from '../core/freshness.js';
11
11
  import { saveRuntimeNote, generateRuntimeNoteId } from '../core/runtime.js';
12
- import { nowISO, generateId, generateIdWithLabel } from '../core/ids.js';
12
+ import { nowISO, generateId } from '../core/ids.js';
13
13
  import { appendAuditEntry } from '../core/audit.js';
14
14
  import { releaseStaleClaimsFromOtherAgents } from '../core/claims.js';
15
- import { SessionSnapshotSchema, CandidateSchema, HandoffSchema, RuntimeNoteSchema } from '../core/schema.js';
15
+ import { SessionSnapshotSchema } from '../core/schema.js';
16
16
  import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
17
17
  import { buildAgentInventory, loadAgentInventory, saveAgentInventory, diffInventory } from '../core/agent-inventory.js';
18
18
  import { checkMemoryPressure } from '../core/gc-semantic.js';
19
19
  import { pullSignalsFromLinkedProjects, markSignalProcessed } from '../core/federation-transport.js';
20
- import { saveCandidate, generateCandidateIdWithLabel } from '../core/candidates.js';
21
- import { mutateState } from '../core/state.js';
20
+ import { pullSignalsFromCloud, isCloudSyncEnabled } from '../core/federation-cloud.js';
21
+ import { materializeFederationSignal } from '../core/federation-materialize.js';
22
22
  function sessionsDir(cwd) {
23
23
  return resolveEntityDir('sessions', cwd ?? process.cwd(), 'read');
24
24
  }
25
25
  function sessionSnapshotPath(sessionId, cwd) {
26
26
  return path.join(sessionsDir(cwd), `${sessionId}.json`);
27
27
  }
28
- export function runSessionStart(options = {}) {
28
+ export async function runSessionStart(options = {}) {
29
29
  try {
30
- const snapshot = startSession({
30
+ const snapshot = await startSession({
31
31
  ...options,
32
32
  maintenanceMode: options.maintenanceMode ?? 'full',
33
33
  });
@@ -96,7 +96,7 @@ export function runSessionStart(options = {}) {
96
96
  process.exit(1);
97
97
  }
98
98
  }
99
- export function startSession(options = {}) {
99
+ export async function startSession(options = {}) {
100
100
  if (!memoryExists(options.cwd)) {
101
101
  throw new Error('.brainclaw/ not found. Run `brainclaw init` first.');
102
102
  }
@@ -243,60 +243,17 @@ export function startSession(options = {}) {
243
243
  }
244
244
  catch { /* non-fatal */ }
245
245
  }
246
- // Materialize incoming federation signals from linked projects
246
+ // Materialize incoming federation signals from linked projects (Phase 0 — local)
247
247
  if (maintenanceMode === 'full') {
248
248
  try {
249
249
  const federationSignals = pullSignalsFromLinkedProjects(options.cwd);
250
250
  let materialized = 0;
251
251
  for (const signal of federationSignals) {
252
252
  try {
253
- const origin = `remote:${signal.from.project_name}:${signal.from.agent_name}`;
254
- if (signal.type === 'candidate') {
255
- const parsed = CandidateSchema.safeParse(signal.payload);
256
- if (parsed.success) {
257
- const { id, short_label } = generateCandidateIdWithLabel(options.cwd);
258
- saveCandidate({
259
- ...parsed.data,
260
- id,
261
- short_label,
262
- created_at: nowISO(),
263
- source: undefined, // remote federation signal — treated as 'human' (legacy default)
264
- star_count: 0,
265
- starred_by: [],
266
- usage_count: 0,
267
- usage_events: [],
268
- status: 'pending',
269
- }, options.cwd);
270
- }
271
- }
272
- else if (signal.type === 'handoff') {
273
- const parsed = HandoffSchema.safeParse(signal.payload);
274
- if (parsed.success) {
275
- const { id, short_label } = generateIdWithLabel('open_handoffs', options.cwd);
276
- mutateState((state) => {
277
- state.open_handoffs.push({
278
- ...parsed.data,
279
- id,
280
- short_label,
281
- created_at: nowISO(),
282
- tags: [...(parsed.data.tags ?? []), origin],
283
- });
284
- }, options.cwd);
285
- }
286
- }
287
- else if (signal.type === 'runtime_note') {
288
- const parsed = RuntimeNoteSchema.safeParse(signal.payload);
289
- if (parsed.success) {
290
- saveRuntimeNote({
291
- ...parsed.data,
292
- id: generateRuntimeNoteId(),
293
- created_at: nowISO(),
294
- tags: [...(parsed.data.tags ?? []), origin],
295
- }, options.cwd);
296
- }
253
+ if (materializeFederationSignal(signal, options.cwd)) {
254
+ materialized++;
297
255
  }
298
256
  markSignalProcessed(signal.from.project_path, signal.id);
299
- materialized++;
300
257
  }
301
258
  catch { /* skip this signal — do not block session start */ }
302
259
  }
@@ -306,6 +263,28 @@ export function startSession(options = {}) {
306
263
  }
307
264
  catch { /* Non-fatal — federation pull failure should not block session start */ }
308
265
  }
266
+ // Materialize incoming federation signals from cloud (Phase 1 — opt-in via cloud_sync.enabled)
267
+ if (maintenanceMode === 'full' && isCloudSyncEnabled(options.cwd)) {
268
+ try {
269
+ const cloudSignals = await pullSignalsFromCloud(actor.agent, { limit: 100 }, options.cwd);
270
+ let cloudMaterialized = 0;
271
+ for (const signal of cloudSignals) {
272
+ try {
273
+ if (materializeFederationSignal(signal, options.cwd)) {
274
+ cloudMaterialized++;
275
+ }
276
+ // No markSignalProcessed for cloud signals — cloud-side tracks delivery via the
277
+ // inbox endpoint's own state (per-agent read cursor). If the cloud returns the
278
+ // same signal twice, the idempotency_key field allows future dedup at materialize time.
279
+ }
280
+ catch { /* skip this signal — do not block session start */ }
281
+ }
282
+ if (cloudMaterialized > 0) {
283
+ console.log(`✔ Materialized ${cloudMaterialized} federation signal(s) from cloud`);
284
+ }
285
+ }
286
+ catch { /* Non-fatal — cloud pull failure should not block session start */ }
287
+ }
309
288
  return {
310
289
  ...snapshot,
311
290
  ...(agentGitHygiene.isGitRepo && (agentGitHygiene.missingGitignorePaths.length > 0 || agentGitHygiene.trackedPaths.length > 0)