brainclaw 1.5.5 → 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.
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +124 -7
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +37 -0
- package/dist/commands/mcp.js +621 -202
- package/dist/commands/questions.js +180 -0
- package/dist/commands/reply.js +190 -0
- package/dist/commands/session-end.js +105 -3
- package/dist/commands/session-start.js +32 -53
- package/dist/commands/switch.js +17 -1
- package/dist/core/agentrun-reconciler.js +65 -0
- package/dist/core/claims.js +29 -0
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +128 -9
- package/dist/core/execution-adapters.js +38 -2
- package/dist/core/facade-schema.js +55 -0
- package/dist/core/federation-cloud.js +27 -12
- package/dist/core/federation-materialize.js +57 -0
- package/dist/core/instruction-templates.js +2 -0
- package/dist/core/loops/bootstrap-acquire.js +195 -0
- package/dist/core/loops/facade-schema.js +68 -1
- package/dist/core/loops/hooks/bootstrap-write.js +144 -0
- package/dist/core/loops/hooks/notify-operator.js +148 -0
- package/dist/core/loops/hooks/survey-source-reader.js +256 -0
- package/dist/core/loops/index.js +8 -2
- package/dist/core/loops/next-expected.js +63 -0
- package/dist/core/loops/presets/bootstrap.js +75 -0
- package/dist/core/loops/presets/index.js +16 -0
- package/dist/core/loops/store.js +224 -4
- package/dist/core/loops/types.js +346 -1
- package/dist/core/loops/verbs.js +739 -6
- package/dist/core/schema.js +28 -2
- package/dist/core/state.js +62 -0
- package/dist/facts.js +7 -5
- package/dist/facts.json +6 -4
- package/docs/concepts/dispatch-lifecycle.md +228 -0
- package/docs/concepts/loop-engine.md +55 -0
- package/docs/concepts/multi-agent-workflows.md +167 -166
- package/docs/concepts/troubleshooting.md +10 -2
- package/docs/integrations/overview.md +14 -12
- 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
|
|
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
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
|
|
254
|
-
|
|
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)
|
package/dist/commands/switch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { loadActiveProject, saveActiveProject, clearActiveProject } from '../cor
|
|
|
3
3
|
import { loadCurrentSession, saveCurrentSession } from '../core/identity.js';
|
|
4
4
|
import { memoryExists } from '../core/io.js';
|
|
5
5
|
import { resolveProjectRef } from '../core/store-resolution.js';
|
|
6
|
+
import { resolveProjectCwd } from '../core/cross-project.js';
|
|
6
7
|
import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
|
|
7
8
|
import { loadConfig } from '../core/config.js';
|
|
8
9
|
/**
|
|
@@ -16,7 +17,22 @@ export function switchProject(projectRef, options = {}) {
|
|
|
16
17
|
if (!wsRoot) {
|
|
17
18
|
throw new Error('No brainclaw workspace found. Run `brainclaw init` first.');
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
// pln#515 step 4 — resolution priority:
|
|
21
|
+
// 1. resolveProjectRef: workspace store-chain children (existing path)
|
|
22
|
+
// 2. resolveProjectCwd: cross_project_links (added so bclaw_switch can
|
|
23
|
+
// target externally-linked projects, not just store-chain children).
|
|
24
|
+
// resolveProjectCwd returns the original cwd on no-match, so we check
|
|
25
|
+
// for a real change before treating it as a resolution.
|
|
26
|
+
let resolved = resolveProjectRef(projectRef, cwd);
|
|
27
|
+
if (!resolved) {
|
|
28
|
+
try {
|
|
29
|
+
const linkResolved = resolveProjectCwd(projectRef, cwd);
|
|
30
|
+
if (linkResolved !== cwd) {
|
|
31
|
+
resolved = linkResolved;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* link resolution failure surfaces as the same error below */ }
|
|
35
|
+
}
|
|
20
36
|
if (!resolved) {
|
|
21
37
|
throw new Error(`Cannot resolve project "${projectRef}". Use bclaw_switch with list=true to see available projects.`);
|
|
22
38
|
}
|