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.
Files changed (43) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/cli.js +124 -7
  3. package/dist/commands/bootstrap-loop.js +206 -0
  4. package/dist/commands/loop.js +156 -0
  5. package/dist/commands/loops-handlers.js +110 -55
  6. package/dist/commands/mcp-read-handlers.js +37 -0
  7. package/dist/commands/mcp.js +621 -202
  8. package/dist/commands/questions.js +180 -0
  9. package/dist/commands/reply.js +190 -0
  10. package/dist/commands/session-end.js +105 -3
  11. package/dist/commands/session-start.js +32 -53
  12. package/dist/commands/switch.js +17 -1
  13. package/dist/core/agentrun-reconciler.js +65 -0
  14. package/dist/core/claims.js +29 -0
  15. package/dist/core/dispatch-status.js +219 -0
  16. package/dist/core/entity-operations.js +128 -9
  17. package/dist/core/execution-adapters.js +38 -2
  18. package/dist/core/facade-schema.js +55 -0
  19. package/dist/core/federation-cloud.js +27 -12
  20. package/dist/core/federation-materialize.js +57 -0
  21. package/dist/core/instruction-templates.js +2 -0
  22. package/dist/core/loops/bootstrap-acquire.js +195 -0
  23. package/dist/core/loops/facade-schema.js +68 -1
  24. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  25. package/dist/core/loops/hooks/notify-operator.js +148 -0
  26. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  27. package/dist/core/loops/index.js +8 -2
  28. package/dist/core/loops/next-expected.js +63 -0
  29. package/dist/core/loops/presets/bootstrap.js +75 -0
  30. package/dist/core/loops/presets/index.js +16 -0
  31. package/dist/core/loops/store.js +224 -4
  32. package/dist/core/loops/types.js +346 -1
  33. package/dist/core/loops/verbs.js +739 -6
  34. package/dist/core/schema.js +28 -2
  35. package/dist/core/state.js +62 -0
  36. package/dist/facts.js +7 -5
  37. package/dist/facts.json +6 -4
  38. package/docs/concepts/dispatch-lifecycle.md +228 -0
  39. package/docs/concepts/loop-engine.md +55 -0
  40. package/docs/concepts/multi-agent-workflows.md +167 -166
  41. package/docs/concepts/troubleshooting.md +10 -2
  42. package/docs/integrations/overview.md +14 -12
  43. package/package.json +1 -1
@@ -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 { DEFAULT_PROTOCOLS, LoopEventSchema, LoopThreadSchema, } from './types.js';
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
- const protocol = resolveProtocol(input.kind, input.mode);
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
- const seq = (events[events.length - 1]?.seq ?? 0) + 1;
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(),