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
package/dist/core/loops/store.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { memoryDir, writeFileAtomic } from '../io.js';
|
|
5
5
|
import { nowISO } from '../ids.js';
|
|
6
|
-
import {
|
|
6
|
+
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
7
|
+
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
8
|
+
import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
|
|
7
9
|
function loopsDir(cwd) {
|
|
8
10
|
return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
|
|
9
11
|
}
|
|
@@ -66,10 +68,35 @@ function buildSlot(partial) {
|
|
|
66
68
|
status: partial.status ?? 'open',
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
|
-
export function appendEvent(loopId, event, cwd
|
|
71
|
+
export function appendEvent(loopId, event, cwd,
|
|
72
|
+
/**
|
|
73
|
+
* pln#513 Phase 4 codex review fix — callers can pass the in-memory next
|
|
74
|
+
* thread snapshot when they're about to write it. Without this, the
|
|
75
|
+
* notification hook's `getLoop(loopId)` read sees the PREVIOUS thread
|
|
76
|
+
* because `appendEvent` runs before `writeThreadFile` at the verb call
|
|
77
|
+
* sites — meaning the just-added operator_question artifact isn't
|
|
78
|
+
* reachable, and the OS notification can't include the question text.
|
|
79
|
+
* Optional + additive: existing callers (and future ones that don't
|
|
80
|
+
* benefit) keep the disk-read fallback below.
|
|
81
|
+
*/
|
|
82
|
+
threadSnapshot) {
|
|
70
83
|
const parsed = LoopEventSchema.parse(event);
|
|
71
84
|
ensureLoopsDir(cwd);
|
|
72
85
|
fs.appendFileSync(eventsPath(loopId, cwd), `${JSON.stringify(parsed)}\n`);
|
|
86
|
+
// pln#513 step 4 — best-effort OS notification on input_requested events.
|
|
87
|
+
// Prefer the in-memory snapshot from the caller (carries the freshly-
|
|
88
|
+
// added operator_question). Fall back to a disk read so any direct
|
|
89
|
+
// appendEvent caller without a snapshot still gets best-effort scoping.
|
|
90
|
+
if (parsed.kind === 'input_requested') {
|
|
91
|
+
try {
|
|
92
|
+
const loop = threadSnapshot ?? getLoop(loopId, cwd);
|
|
93
|
+
if (loop)
|
|
94
|
+
notifyOperatorOnInputRequested(parsed, loop, cwd);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// hook is best-effort; never propagate.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
73
100
|
}
|
|
74
101
|
export function writeThreadFile(thread, cwd) {
|
|
75
102
|
const parsed = LoopThreadSchema.parse(thread);
|
|
@@ -90,7 +117,11 @@ export function openLoop(input, cwd) {
|
|
|
90
117
|
const id = generateLoopId();
|
|
91
118
|
const mutation_id = generateMutationId();
|
|
92
119
|
const slots = (input.slots ?? []).map(buildSlot);
|
|
93
|
-
|
|
120
|
+
// pln#511 step 2 — an explicit `protocol` override (carried by loop
|
|
121
|
+
// presets) wins over the kind/mode-derived default. When no override
|
|
122
|
+
// is supplied, fall back to the legacy resolveProtocol() path so
|
|
123
|
+
// existing callers (review, default ideation) are unaffected.
|
|
124
|
+
const protocol = input.protocol ?? resolveProtocol(input.kind, input.mode);
|
|
94
125
|
const thread = {
|
|
95
126
|
schema_version: 1,
|
|
96
127
|
id,
|
|
@@ -106,6 +137,9 @@ export function openLoop(input, cwd) {
|
|
|
106
137
|
iteration_count: 0,
|
|
107
138
|
slots,
|
|
108
139
|
artifacts: [],
|
|
140
|
+
// pln#508 step 1 — bootstrap loop primitives. Default to no open
|
|
141
|
+
// questions; the request_input handler (step 2) appends/removes ids.
|
|
142
|
+
open_questions: [],
|
|
109
143
|
linked: input.linked,
|
|
110
144
|
stop_condition: input.stop_condition ?? protocolDefaults.stop_condition,
|
|
111
145
|
created_at: now,
|
|
@@ -163,6 +197,73 @@ export function listLoopEvents(id, cwd) {
|
|
|
163
197
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
|
|
164
198
|
return lines.map((line) => LoopEventSchema.parse(JSON.parse(line)));
|
|
165
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* pln#512 step 2 — sentinel thrown by closeLoop when the bootstrap close
|
|
202
|
+
* hook intercepts an attempt to complete an overwrite of an existing
|
|
203
|
+
* non-empty PROJECT.md. The thread is left in `status='paused'` with
|
|
204
|
+
* `pause_reason='awaiting_file_apply'` and a fresh operator_question on
|
|
205
|
+
* `open_questions`; the caller is expected to surface that question to the
|
|
206
|
+
* operator, then re-attempt the close once an answer is provided (or rely on
|
|
207
|
+
* the provideInput post-hook to auto-complete the close — see verbs.ts).
|
|
208
|
+
*
|
|
209
|
+
* Thrown with a stable `code` field so callers can `if (e.code ===
|
|
210
|
+
* 'awaiting_file_apply_approval') ...` instead of string-matching the
|
|
211
|
+
* message.
|
|
212
|
+
*/
|
|
213
|
+
export class AwaitingFileApplyApprovalError extends Error {
|
|
214
|
+
loop_id;
|
|
215
|
+
question_id;
|
|
216
|
+
target_path;
|
|
217
|
+
diff_artifact_id;
|
|
218
|
+
code = 'awaiting_file_apply_approval';
|
|
219
|
+
constructor(loop_id, question_id, target_path, diff_artifact_id) {
|
|
220
|
+
super(`closeLoop: awaiting_file_apply_approval — loop ${loop_id} paused on question ${question_id} ` +
|
|
221
|
+
`for overwrite of ${target_path}; provide_input(replies_to=${question_id}, chosen_option_id=approve|reject) to resolve`);
|
|
222
|
+
this.loop_id = loop_id;
|
|
223
|
+
this.question_id = question_id;
|
|
224
|
+
this.target_path = target_path;
|
|
225
|
+
this.diff_artifact_id = diff_artifact_id;
|
|
226
|
+
this.name = 'AwaitingFileApplyApprovalError';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* pln#512 step 2 — builds the operator_question artifact that asks the
|
|
231
|
+
* operator whether to overwrite an existing PROJECT.md with the loop's
|
|
232
|
+
* `project_md_final`. The question shape is fixed (Phase 0 spec §3-5): two
|
|
233
|
+
* options `approve` / `reject`, default `reject`, pause_scope='loop',
|
|
234
|
+
* on_timeout='use_default'.
|
|
235
|
+
*
|
|
236
|
+
* Returns the (Schema-validated) artifact + its question_id so the caller
|
|
237
|
+
* can splice it onto the thread and push the id into `open_questions`.
|
|
238
|
+
*/
|
|
239
|
+
function buildFileOverwriteApprovalQuestion(args) {
|
|
240
|
+
const question_id = `qst_${crypto.randomBytes(6).toString('hex')}`;
|
|
241
|
+
const body = {
|
|
242
|
+
question_id,
|
|
243
|
+
question_text: 'Apply the proposed PROJECT.md diff?',
|
|
244
|
+
evidence: [
|
|
245
|
+
`existing PROJECT.md at ${args.target_path}`,
|
|
246
|
+
`project_md_final artifact ${args.project_md_final_id}`,
|
|
247
|
+
],
|
|
248
|
+
suggested_default: 'reject',
|
|
249
|
+
options: [
|
|
250
|
+
{ id: 'approve', label: 'Apply diff', tradeoff: 'overwrites current PROJECT.md' },
|
|
251
|
+
{ id: 'reject', label: 'Keep current', tradeoff: 'discards proposed final' },
|
|
252
|
+
],
|
|
253
|
+
pause_scope: 'loop',
|
|
254
|
+
on_timeout: 'use_default',
|
|
255
|
+
by_slot_id: args.slot_id,
|
|
256
|
+
};
|
|
257
|
+
const artifact = LoopArtifactSchema.parse({
|
|
258
|
+
artifact_id: `art_${crypto.randomBytes(6).toString('hex')}`,
|
|
259
|
+
phase: args.phase,
|
|
260
|
+
type: 'operator_question',
|
|
261
|
+
body: JSON.stringify(body),
|
|
262
|
+
produced_by: args.produced_by,
|
|
263
|
+
produced_at: args.now,
|
|
264
|
+
});
|
|
265
|
+
return { artifact, question_id };
|
|
266
|
+
}
|
|
166
267
|
export function closeLoop(input, cwd) {
|
|
167
268
|
const current = getLoop(input.id, cwd);
|
|
168
269
|
if (!current) {
|
|
@@ -171,11 +272,124 @@ export function closeLoop(input, cwd) {
|
|
|
171
272
|
if (current.status !== 'open' && current.status !== 'paused') {
|
|
172
273
|
throw new Error(`closeLoop: loop ${input.id} is already ${current.status}`);
|
|
173
274
|
}
|
|
275
|
+
// pln#512 step 2 — bootstrap preset close pre-hook. When completing a
|
|
276
|
+
// bootstrap loop, materialize PROJECT.md from the final artifact:
|
|
277
|
+
// - absent / empty target → atomic write, proceed with close.
|
|
278
|
+
// - present + non-empty target → pause the close, request operator
|
|
279
|
+
// approval for the overwrite; provideInput post-hook resumes + closes.
|
|
280
|
+
// - no project_md_final artifact → proceed (nothing to write).
|
|
281
|
+
//
|
|
282
|
+
// Only runs when final_status='completed' — cancel/blocked paths skip
|
|
283
|
+
// the hook because the operator didn't actually converge on a PROJECT.md.
|
|
284
|
+
const runBootstrapHook = input.final_status === 'completed' && current.protocol?.preset === 'bootstrap';
|
|
285
|
+
let fileWritten = false;
|
|
286
|
+
let project_md_final_id;
|
|
287
|
+
if (runBootstrapHook) {
|
|
288
|
+
const writeResult = writeProjectMdSafe(current, cwd);
|
|
289
|
+
// Locate the project_md_final artifact id used by the hook (for the
|
|
290
|
+
// file_apply_requested / file_apply_resolved event correlation field).
|
|
291
|
+
for (let i = current.artifacts.length - 1; i >= 0; i--) {
|
|
292
|
+
if (current.artifacts[i].type === 'project_md_final') {
|
|
293
|
+
project_md_final_id = current.artifacts[i].artifact_id;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (writeResult.needs_approval) {
|
|
298
|
+
if (!writeResult.diff_artifact) {
|
|
299
|
+
throw new Error(`closeLoop: writeProjectMdSafe returned needs_approval without a diff_artifact — invariant violation`);
|
|
300
|
+
}
|
|
301
|
+
if (!project_md_final_id) {
|
|
302
|
+
throw new Error(`closeLoop: writeProjectMdSafe returned needs_approval but no project_md_final artifact found on loop ${current.id}`);
|
|
303
|
+
}
|
|
304
|
+
const slot = current.slots[0];
|
|
305
|
+
if (!slot) {
|
|
306
|
+
throw new Error(`closeLoop: bootstrap loop ${current.id} has no slots — cannot synthesize operator_question for file_overwrite_approval`);
|
|
307
|
+
}
|
|
308
|
+
const pauseNow = nowISO();
|
|
309
|
+
const pauseMutationId = generateMutationId();
|
|
310
|
+
const pauseVersion = current.version + 1;
|
|
311
|
+
const eventsSoFar = listLoopEvents(input.id, cwd);
|
|
312
|
+
let pauseSeq = (eventsSoFar[eventsSoFar.length - 1]?.seq ?? 0) + 1;
|
|
313
|
+
const { artifact: questionArtifact, question_id } = buildFileOverwriteApprovalQuestion({
|
|
314
|
+
phase: current.current_phase,
|
|
315
|
+
slot_id: slot.slot_id,
|
|
316
|
+
produced_by: slot.agent_id ?? slot.agent ?? input.actor,
|
|
317
|
+
target_path: writeResult.target_path,
|
|
318
|
+
project_md_final_id,
|
|
319
|
+
now: pauseNow,
|
|
320
|
+
});
|
|
321
|
+
const pausedThread = {
|
|
322
|
+
...current,
|
|
323
|
+
version: pauseVersion,
|
|
324
|
+
mutation_id: pauseMutationId,
|
|
325
|
+
status: 'paused',
|
|
326
|
+
pause_reason: 'awaiting_file_apply',
|
|
327
|
+
pending_file_apply: {
|
|
328
|
+
artifact_id: project_md_final_id,
|
|
329
|
+
target_path: writeResult.target_path,
|
|
330
|
+
diff_artifact_id: writeResult.diff_artifact.artifact_id,
|
|
331
|
+
},
|
|
332
|
+
artifacts: [...current.artifacts, writeResult.diff_artifact, questionArtifact],
|
|
333
|
+
open_questions: [...current.open_questions, question_id],
|
|
334
|
+
updated_at: pauseNow,
|
|
335
|
+
};
|
|
336
|
+
appendEvent(current.id, {
|
|
337
|
+
event_id: crypto.randomUUID(),
|
|
338
|
+
loop_id: current.id,
|
|
339
|
+
seq: pauseSeq,
|
|
340
|
+
at: pauseNow,
|
|
341
|
+
by: input.actor,
|
|
342
|
+
mutation_id: pauseMutationId,
|
|
343
|
+
kind: 'file_apply_requested',
|
|
344
|
+
artifact_id: project_md_final_id,
|
|
345
|
+
target_path: writeResult.target_path,
|
|
346
|
+
}, cwd);
|
|
347
|
+
pauseSeq += 1;
|
|
348
|
+
appendEvent(current.id, {
|
|
349
|
+
event_id: crypto.randomUUID(),
|
|
350
|
+
loop_id: current.id,
|
|
351
|
+
seq: pauseSeq,
|
|
352
|
+
at: pauseNow,
|
|
353
|
+
by: input.actor,
|
|
354
|
+
mutation_id: pauseMutationId,
|
|
355
|
+
kind: 'input_requested',
|
|
356
|
+
question_id,
|
|
357
|
+
pause_scope: 'loop',
|
|
358
|
+
by_slot_id: slot.slot_id,
|
|
359
|
+
}, cwd,
|
|
360
|
+
// pln#513 phase 4 codex review fix — pass the paused-thread snapshot
|
|
361
|
+
// so the notification hook reads the freshly-added file_overwrite
|
|
362
|
+
// operator_question rather than the previous on-disk thread.
|
|
363
|
+
pausedThread);
|
|
364
|
+
writeThreadFile(pausedThread, cwd);
|
|
365
|
+
throw new AwaitingFileApplyApprovalError(current.id, question_id, writeResult.target_path, writeResult.diff_artifact.artifact_id);
|
|
366
|
+
}
|
|
367
|
+
fileWritten = writeResult.written === true;
|
|
368
|
+
}
|
|
174
369
|
const now = nowISO();
|
|
175
370
|
const mutation_id = generateMutationId();
|
|
176
371
|
const version = current.version + 1;
|
|
177
372
|
const events = listLoopEvents(input.id, cwd);
|
|
178
|
-
|
|
373
|
+
let seq = (events[events.length - 1]?.seq ?? 0) + 1;
|
|
374
|
+
// Emit file_apply_resolved(approved=true) BEFORE the closed event when
|
|
375
|
+
// the bootstrap hook wrote the file directly (absent/empty target). The
|
|
376
|
+
// synthetic event documents that the close went through without operator
|
|
377
|
+
// intervention so the journal reads symmetrically with the paused-then-
|
|
378
|
+
// approved branch.
|
|
379
|
+
if (runBootstrapHook && fileWritten && project_md_final_id) {
|
|
380
|
+
appendEvent(current.id, {
|
|
381
|
+
event_id: crypto.randomUUID(),
|
|
382
|
+
loop_id: current.id,
|
|
383
|
+
seq,
|
|
384
|
+
at: now,
|
|
385
|
+
by: input.actor,
|
|
386
|
+
mutation_id,
|
|
387
|
+
kind: 'file_apply_resolved',
|
|
388
|
+
artifact_id: project_md_final_id,
|
|
389
|
+
approved: true,
|
|
390
|
+
}, cwd);
|
|
391
|
+
seq += 1;
|
|
392
|
+
}
|
|
179
393
|
const next = {
|
|
180
394
|
...current,
|
|
181
395
|
version,
|
|
@@ -183,6 +397,12 @@ export function closeLoop(input, cwd) {
|
|
|
183
397
|
status: input.final_status,
|
|
184
398
|
updated_at: now,
|
|
185
399
|
closed_at: now,
|
|
400
|
+
// pln#508 step 3 — schema invariant requires pause_reason /
|
|
401
|
+
// pending_file_apply to be absent outside status='paused'. closeLoop on
|
|
402
|
+
// a paused thread must clear both, otherwise LoopThreadSchema.parse
|
|
403
|
+
// rejects the write.
|
|
404
|
+
pause_reason: undefined,
|
|
405
|
+
pending_file_apply: undefined,
|
|
186
406
|
};
|
|
187
407
|
appendEvent(input.id, {
|
|
188
408
|
event_id: crypto.randomUUID(),
|