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.
- package/README.md +52 -28
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +159 -12
- package/dist/commands/assignment-resource.js +182 -0
- package/dist/commands/bootstrap-loop.js +206 -0
- package/dist/commands/init.js +158 -22
- package/dist/commands/loop.js +156 -0
- package/dist/commands/loops-handlers.js +110 -55
- package/dist/commands/mcp-read-handlers.js +45 -4
- package/dist/commands/mcp.js +628 -205
- 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/setup.js +87 -48
- package/dist/commands/switch.js +21 -1
- package/dist/core/agentrun-reconciler.js +65 -0
- package/dist/core/agentruns.js +10 -0
- package/dist/core/assignments.js +29 -10
- package/dist/core/claims.js +29 -0
- package/dist/core/context.js +1 -1
- package/dist/core/coordination.js +1 -1
- package/dist/core/dispatch-status.js +219 -0
- package/dist/core/entity-operations.js +166 -10
- package/dist/core/entity-registry.js +11 -10
- 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 +31 -2
- package/dist/core/state.js +62 -0
- package/dist/core/store-resolution.js +26 -16
- package/dist/facts.js +7 -5
- package/dist/facts.json +6 -4
- package/docs/cli.md +115 -30
- 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/agents.md +14 -14
- package/docs/integrations/codex.md +15 -12
- package/docs/integrations/mcp.md +10 -4
- package/docs/integrations/overview.md +11 -0
- package/docs/playbooks/productivity/index.md +3 -3
- package/docs/quickstart-existing-project.md +48 -28
- package/docs/quickstart.md +42 -28
- package/package.json +1 -1
|
@@ -17,14 +17,16 @@ import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from
|
|
|
17
17
|
import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
|
|
18
18
|
import { listClaims } from './claims.js';
|
|
19
19
|
import { listActionRequired } from './actions.js';
|
|
20
|
-
import { listAssignments } from './assignments.js';
|
|
20
|
+
import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
|
|
21
21
|
import { listAgentRuns } from './agentruns.js';
|
|
22
|
+
import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
|
|
22
23
|
import { deleteRuntimeNote, listRuntimeNotes, saveRuntimeNote, } from './runtime.js';
|
|
23
24
|
import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
|
|
24
25
|
import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
|
|
25
26
|
import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
|
|
26
27
|
import { ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
|
|
27
28
|
import { generateId } from './ids.js';
|
|
29
|
+
import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SeveritySchema, } from './schema.js';
|
|
28
30
|
/**
|
|
29
31
|
* Default provenance stamp applied on create when the caller does not
|
|
30
32
|
* supply one. `user` kind with whatever author is in the payload; the
|
|
@@ -79,6 +81,44 @@ export class InvalidTransitionError extends Error {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
// ─── FIND ─────────────────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Lazy reconciliation pass on agent_run reads (pln#503 phase 3.2).
|
|
86
|
+
*
|
|
87
|
+
* Before returning agent_run records to `bclaw_find` / `bclaw_get`, walk any
|
|
88
|
+
* record whose status is non-terminal and call `reconcileAgentRun(id)`. The
|
|
89
|
+
* reconciler:
|
|
90
|
+
* - no-ops for runs under the 60s grace window or already terminal
|
|
91
|
+
* - transitions to `completed` (inferred=true) when evidence of completion
|
|
92
|
+
* exists (post-start commit, claim released, assignment completed)
|
|
93
|
+
* - transitions to `failed` (silent_termination_no_evidence) when the run
|
|
94
|
+
* is past the stale threshold AND its pid is provably dead
|
|
95
|
+
*
|
|
96
|
+
* Without this pass, a worker that crashed before its first output keeps
|
|
97
|
+
* `status="running"` indefinitely — the empirical pattern recorded in trp#292.
|
|
98
|
+
* The full agentrun-reconciler.ts machinery already existed (pln#496); this
|
|
99
|
+
* just wires it into the canonical-grammar read path so every read of
|
|
100
|
+
* `agent_run` produces converged state.
|
|
101
|
+
*/
|
|
102
|
+
function loadAgentRunsWithReconciliation(cwd) {
|
|
103
|
+
const runs = listAgentRuns(cwd);
|
|
104
|
+
for (const run of runs) {
|
|
105
|
+
if (run.status === 'running') {
|
|
106
|
+
try {
|
|
107
|
+
reconcileDeadPidRunningAgentRunAtRead(run.id, cwd);
|
|
108
|
+
}
|
|
109
|
+
catch { /* best-effort: never block reads on reconciliation errors */ }
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!TERMINAL_STATUSES.has(run.status)) {
|
|
113
|
+
try {
|
|
114
|
+
reconcileAgentRun(run.id, cwd);
|
|
115
|
+
}
|
|
116
|
+
catch { /* best-effort: never block reads on reconciliation errors */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Re-list to capture any transitions made above.
|
|
120
|
+
return listAgentRuns(cwd);
|
|
121
|
+
}
|
|
82
122
|
export function listEntities(name, cwd, filter = {}) {
|
|
83
123
|
const all = loadAll(name, cwd);
|
|
84
124
|
const filtered = applyFilter(all, filter);
|
|
@@ -97,7 +137,7 @@ function loadAll(name, cwd) {
|
|
|
97
137
|
case 'claim': return listClaims(cwd);
|
|
98
138
|
case 'action': return listActionRequired(cwd);
|
|
99
139
|
case 'assignment': return listAssignments(cwd);
|
|
100
|
-
case 'agent_run': return
|
|
140
|
+
case 'agent_run': return loadAgentRunsWithReconciliation(cwd);
|
|
101
141
|
case 'cross_project_link': return resolveCrossProjectLinks(cwd);
|
|
102
142
|
default:
|
|
103
143
|
throw new EntityOperationUnsupportedError(name, 'find');
|
|
@@ -112,12 +152,24 @@ function applyFilter(items, filter) {
|
|
|
112
152
|
if (filter.tag) {
|
|
113
153
|
result = result.filter((item) => Array.isArray(item.tags) && item.tags.includes(filter.tag));
|
|
114
154
|
}
|
|
155
|
+
if (Array.isArray(filter.tags) && filter.tags.length > 0) {
|
|
156
|
+
result = result.filter((item) => Array.isArray(item.tags) && filter.tags.some((tag) => item.tags.includes(tag)));
|
|
157
|
+
}
|
|
115
158
|
if (filter.author) {
|
|
116
159
|
result = result.filter((item) => item.author === filter.author);
|
|
117
160
|
}
|
|
118
161
|
if (filter.plan_id) {
|
|
119
162
|
result = result.filter((item) => item.plan_id === filter.plan_id);
|
|
120
163
|
}
|
|
164
|
+
if (filter.assignment_id) {
|
|
165
|
+
result = result.filter((item) => item.assignment_id === filter.assignment_id);
|
|
166
|
+
}
|
|
167
|
+
if (filter.claim_id) {
|
|
168
|
+
result = result.filter((item) => item.claim_id === filter.claim_id);
|
|
169
|
+
}
|
|
170
|
+
if (filter.message_id) {
|
|
171
|
+
result = result.filter((item) => item.message_id === filter.message_id);
|
|
172
|
+
}
|
|
121
173
|
if (filter.source) {
|
|
122
174
|
result = result.filter((item) => item.source === filter.source);
|
|
123
175
|
}
|
|
@@ -166,8 +218,8 @@ export function createEntity(name, data, cwd) {
|
|
|
166
218
|
const res = createPlan({
|
|
167
219
|
text: requireString(data, 'text'),
|
|
168
220
|
author: requireString(data, 'author'),
|
|
169
|
-
type: data
|
|
170
|
-
priority: data
|
|
221
|
+
type: requireEnum(data, 'type', PlanTypeEnumSchema.options, { optional: true }),
|
|
222
|
+
priority: requireEnum(data, 'priority', PrioritySchema.options, { optional: true }),
|
|
171
223
|
assignee: data.assignee,
|
|
172
224
|
project: data.project,
|
|
173
225
|
tags: data.tags,
|
|
@@ -182,7 +234,7 @@ export function createEntity(name, data, cwd) {
|
|
|
182
234
|
const res = createDecision({
|
|
183
235
|
text: requireString(data, 'text'),
|
|
184
236
|
author: requireString(data, 'author'),
|
|
185
|
-
outcome: data
|
|
237
|
+
outcome: requireEnum(data, 'outcome', DecisionOutcomeSchema.options, { optional: true }),
|
|
186
238
|
tags: data.tags,
|
|
187
239
|
relatedPaths: data.related_paths,
|
|
188
240
|
planId: data.plan_id,
|
|
@@ -194,7 +246,7 @@ export function createEntity(name, data, cwd) {
|
|
|
194
246
|
const res = createConstraint({
|
|
195
247
|
text: requireString(data, 'text'),
|
|
196
248
|
author: requireString(data, 'author'),
|
|
197
|
-
category: data
|
|
249
|
+
category: requireEnum(data, 'category', ConstraintCategorySchema.options, { optional: true }),
|
|
198
250
|
tags: data.tags,
|
|
199
251
|
relatedPaths: data.related_paths,
|
|
200
252
|
}, cwd);
|
|
@@ -205,7 +257,7 @@ export function createEntity(name, data, cwd) {
|
|
|
205
257
|
const res = createTrap({
|
|
206
258
|
text: requireString(data, 'text'),
|
|
207
259
|
author: requireString(data, 'author'),
|
|
208
|
-
severity: (data
|
|
260
|
+
severity: requireEnum(data, 'severity', SeveritySchema.options, { optional: true }) ?? 'medium',
|
|
209
261
|
tags: data.tags,
|
|
210
262
|
relatedPaths: data.related_paths,
|
|
211
263
|
}, cwd);
|
|
@@ -220,8 +272,8 @@ export function createEntity(name, data, cwd) {
|
|
|
220
272
|
text: requireString(data, 'text'),
|
|
221
273
|
created_at: new Date().toISOString(),
|
|
222
274
|
tags: data.tags ?? [],
|
|
223
|
-
visibility: data
|
|
224
|
-
note_type: data
|
|
275
|
+
visibility: requireEnum(data, 'visibility', MemoryVisibilitySchema.options, { optional: true }) ?? 'shared',
|
|
276
|
+
note_type: requireEnum(data, 'note_type', RuntimeNoteTypeSchema.options, { optional: true }) ?? 'observation',
|
|
225
277
|
provenance: defaultProvenance(data),
|
|
226
278
|
...(data.agent_id ? { agent_id: data.agent_id } : {}),
|
|
227
279
|
...(data.project_id ? { project_id: data.project_id } : {}),
|
|
@@ -233,9 +285,15 @@ export function createEntity(name, data, cwd) {
|
|
|
233
285
|
}
|
|
234
286
|
case 'candidate': {
|
|
235
287
|
const id = generateId('candidate');
|
|
288
|
+
const validatedType = requireEnum(data, 'type', CandidateTypeSchema.options);
|
|
289
|
+
if (!validatedType) {
|
|
290
|
+
// requireEnum without `optional` throws on missing/invalid, but
|
|
291
|
+
// narrow the type for the assignment below.
|
|
292
|
+
throw new Error(`Missing required field: type`);
|
|
293
|
+
}
|
|
236
294
|
const candidate = {
|
|
237
295
|
id,
|
|
238
|
-
type:
|
|
296
|
+
type: validatedType,
|
|
239
297
|
text: requireString(data, 'text'),
|
|
240
298
|
created_at: new Date().toISOString(),
|
|
241
299
|
author: requireString(data, 'author'),
|
|
@@ -281,6 +339,9 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
281
339
|
// declared updatable fields (text, tags, estimated_effort, depends_on)
|
|
282
340
|
// actually land. The typed surface still covers status/assignee/priority/
|
|
283
341
|
// actualEffort for legacy CLI callers — see UpdatePlanInput.
|
|
342
|
+
// Note: `plan.type` is intentionally create-only (not in plan.updatable
|
|
343
|
+
// at entity-registry.ts) — no validation needed here.
|
|
344
|
+
validatePatchEnum(patch, 'priority', PrioritySchema.options);
|
|
284
345
|
updatePlan({
|
|
285
346
|
id,
|
|
286
347
|
patch: patch,
|
|
@@ -293,6 +354,15 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
293
354
|
// Same generic-patch escape-hatch for memory items. Registry declares
|
|
294
355
|
// severity, scope, related_paths, expires_at, etc. as updatable; the
|
|
295
356
|
// legacy explicit text/tags whitelist silently dropped them.
|
|
357
|
+
if (name === 'decision') {
|
|
358
|
+
validatePatchEnum(patch, 'outcome', DecisionOutcomeSchema.options);
|
|
359
|
+
}
|
|
360
|
+
else if (name === 'constraint') {
|
|
361
|
+
validatePatchEnum(patch, 'category', ConstraintCategorySchema.options);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
validatePatchEnum(patch, 'severity', SeveritySchema.options);
|
|
365
|
+
}
|
|
296
366
|
updateMemoryItem({
|
|
297
367
|
id,
|
|
298
368
|
type: name,
|
|
@@ -301,6 +371,9 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
301
371
|
return { entity: name, id };
|
|
302
372
|
}
|
|
303
373
|
case 'runtime_note': {
|
|
374
|
+
// Note: `note_type` is intentionally create-only (not in
|
|
375
|
+
// runtime_note.updatable at entity-registry.ts) — no validation needed.
|
|
376
|
+
validatePatchEnum(patch, 'visibility', MemoryVisibilitySchema.options);
|
|
304
377
|
const notes = listRuntimeNotes(undefined, cwd);
|
|
305
378
|
const note = notes.find((n) => n.id === id);
|
|
306
379
|
if (!note)
|
|
@@ -309,7 +382,17 @@ export function updateEntity(name, id, patch, cwd) {
|
|
|
309
382
|
saveRuntimeNote(patched, cwd);
|
|
310
383
|
return { entity: name, id };
|
|
311
384
|
}
|
|
385
|
+
case 'assignment': {
|
|
386
|
+
const assignment = loadAssignment(id, cwd);
|
|
387
|
+
if (!assignment)
|
|
388
|
+
throw new EntityNotFoundError(name, id);
|
|
389
|
+
const patched = { ...assignment, ...patch };
|
|
390
|
+
saveAssignment(patched, cwd);
|
|
391
|
+
return { entity: name, id };
|
|
392
|
+
}
|
|
312
393
|
case 'candidate': {
|
|
394
|
+
// Note: `candidate.type` is intentionally create-only (not in
|
|
395
|
+
// candidate.updatable at entity-registry.ts) — no validation needed.
|
|
313
396
|
const candidate = loadCandidate(id, cwd);
|
|
314
397
|
const patched = { ...candidate, ...patch };
|
|
315
398
|
saveCandidate(patched, cwd);
|
|
@@ -371,6 +454,28 @@ export function removeEntity(name, id, cwd, purge = false) {
|
|
|
371
454
|
const removed = removeCrossProjectLink(id, cwd);
|
|
372
455
|
return { entity: name, id: removed.name ?? removed.path, archived: false, purged: true };
|
|
373
456
|
}
|
|
457
|
+
case 'assignment': {
|
|
458
|
+
const assignment = loadAssignment(id, cwd);
|
|
459
|
+
if (!assignment)
|
|
460
|
+
throw new EntityNotFoundError(name, id);
|
|
461
|
+
if (purge) {
|
|
462
|
+
const deleted = deleteAssignment(id, cwd);
|
|
463
|
+
if (!deleted)
|
|
464
|
+
throw new EntityNotFoundError(name, id);
|
|
465
|
+
return { entity: name, id, archived: false, purged: true };
|
|
466
|
+
}
|
|
467
|
+
if (assignment.status === 'cancelled') {
|
|
468
|
+
return { entity: name, id, archived: true, purged: false };
|
|
469
|
+
}
|
|
470
|
+
if (ENTITY_REGISTRY.assignment.terminal.includes(assignment.status)) {
|
|
471
|
+
throw new Error(`assignment '${id}' is already terminal (${assignment.status}); use purge:true to hard-delete if needed`);
|
|
472
|
+
}
|
|
473
|
+
transitionAssignment(id, 'cancelled', {
|
|
474
|
+
actor: 'brainclaw',
|
|
475
|
+
status_reason: 'Archived via bclaw_remove',
|
|
476
|
+
}, cwd);
|
|
477
|
+
return { entity: name, id, archived: true, purged: false };
|
|
478
|
+
}
|
|
374
479
|
default:
|
|
375
480
|
throw new EntityOperationUnsupportedError(name, 'remove');
|
|
376
481
|
}
|
|
@@ -418,6 +523,13 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
418
523
|
}
|
|
419
524
|
throw new InvalidTransitionError(name, from, to);
|
|
420
525
|
}
|
|
526
|
+
case 'assignment': {
|
|
527
|
+
transitionAssignment(id, to, {
|
|
528
|
+
actor: 'brainclaw',
|
|
529
|
+
status_reason: _reason,
|
|
530
|
+
}, cwd);
|
|
531
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
532
|
+
}
|
|
421
533
|
default:
|
|
422
534
|
throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
|
|
423
535
|
}
|
|
@@ -447,4 +559,48 @@ function requireString(data, field) {
|
|
|
447
559
|
}
|
|
448
560
|
return value;
|
|
449
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Validates that data[field] is one of `validValues`, throwing a clear
|
|
564
|
+
* error message when the value is invalid. Fixes the silent-data-loss bug
|
|
565
|
+
* documented in candidate can_a3458961 + pln#509 step 1: previously the
|
|
566
|
+
* create path used unchecked `as` casts on enum fields, so invalid values
|
|
567
|
+
* (e.g. outcome:'accepted' instead of 'approved') were written to disk and
|
|
568
|
+
* then silently skipped at load time by the strict Zod parser. Now we
|
|
569
|
+
* validate at write time against the same valid-value lists used by the
|
|
570
|
+
* load-time schemas.
|
|
571
|
+
*
|
|
572
|
+
* Callers pass `XxxSchema.options` (a readonly tuple of valid strings)
|
|
573
|
+
* rather than the schema itself — this avoids brittle generic constraints
|
|
574
|
+
* on Zod's enum type which differs between major versions.
|
|
575
|
+
*/
|
|
576
|
+
function requireEnum(data, field, validValues, opts = {}) {
|
|
577
|
+
const value = data[field];
|
|
578
|
+
if (value === undefined || value === null) {
|
|
579
|
+
if (opts.optional)
|
|
580
|
+
return undefined;
|
|
581
|
+
throw new Error(`Missing required field: ${field}`);
|
|
582
|
+
}
|
|
583
|
+
if (typeof value !== 'string' || !validValues.includes(value)) {
|
|
584
|
+
throw new Error(`Invalid value for '${field}': got ${JSON.stringify(value)}. Expected one of: ${validValues.join(' | ')}`);
|
|
585
|
+
}
|
|
586
|
+
return value;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Validates that, if `patch[field]` is present (and not null/undefined), it
|
|
590
|
+
* matches one of `validValues`. Used by updateEntity for enum-shaped patch
|
|
591
|
+
* fields, to extend the same validation parity used at create time. Codex
|
|
592
|
+
* round 1 (pln#509 step 1 review) correctly flagged that updateEntity was
|
|
593
|
+
* still vulnerable to the same silent persistence bug when patching enum
|
|
594
|
+
* fields with invalid values. Fields not present in `patch` are ignored.
|
|
595
|
+
*/
|
|
596
|
+
function validatePatchEnum(patch, field, validValues) {
|
|
597
|
+
if (!(field in patch))
|
|
598
|
+
return;
|
|
599
|
+
const value = patch[field];
|
|
600
|
+
if (value === undefined || value === null)
|
|
601
|
+
return;
|
|
602
|
+
if (typeof value !== 'string' || !validValues.includes(value)) {
|
|
603
|
+
throw new Error(`Invalid value for '${field}' in patch: got ${JSON.stringify(value)}. Expected one of: ${validValues.join(' | ')}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
450
606
|
//# sourceMappingURL=entity-operations.js.map
|
|
@@ -234,24 +234,25 @@ const assignment = {
|
|
|
234
234
|
name: 'assignment',
|
|
235
235
|
shortLabelPrefix: 'asgn',
|
|
236
236
|
schema: AssignmentSchema,
|
|
237
|
-
updatable: ['
|
|
237
|
+
updatable: ['description', 'status_reason', 'tags'],
|
|
238
238
|
statusField: 'status',
|
|
239
239
|
transitions: {
|
|
240
|
-
created: ['offered', 'expired'],
|
|
241
|
-
offered: ['accepted', 'expired', 'rerouted'],
|
|
242
|
-
accepted: ['started', 'failed', 'timed_out'],
|
|
243
|
-
started: ['completed', 'failed', 'blocked', 'timed_out'],
|
|
244
|
-
failed: ['retrying'],
|
|
245
|
-
timed_out: ['retrying'],
|
|
246
|
-
retrying: ['offered'],
|
|
247
|
-
blocked: ['started', 'rerouted', 'failed'],
|
|
240
|
+
created: ['offered', 'expired', 'rerouted', 'cancelled'],
|
|
241
|
+
offered: ['accepted', 'expired', 'rerouted', 'failed', 'cancelled'],
|
|
242
|
+
accepted: ['started', 'failed', 'timed_out', 'rerouted', 'cancelled'],
|
|
243
|
+
started: ['completed', 'failed', 'blocked', 'timed_out', 'rerouted', 'cancelled'],
|
|
244
|
+
failed: ['retrying', 'rerouted', 'cancelled'],
|
|
245
|
+
timed_out: ['retrying', 'rerouted', 'cancelled'],
|
|
246
|
+
retrying: ['offered', 'rerouted', 'cancelled'],
|
|
247
|
+
blocked: ['started', 'rerouted', 'failed', 'cancelled'],
|
|
248
248
|
},
|
|
249
|
-
terminal: ['completed', 'expired', 'rerouted'],
|
|
249
|
+
terminal: ['completed', 'cancelled', 'expired', 'rerouted'],
|
|
250
250
|
sideEffects: {
|
|
251
251
|
'created->offered': ['timestamp:offered_at', 'event:assignment_offered', 'audit:assignment_offered'],
|
|
252
252
|
'offered->accepted': ['timestamp:accepted_at', 'event:assignment_accepted', 'sync:agent_run'],
|
|
253
253
|
'accepted->started': ['timestamp:started_at', 'event:assignment_started', 'sync:agent_run'],
|
|
254
254
|
'started->completed': ['timestamp:completed_at', 'event:assignment_completed', 'sync:agent_run'],
|
|
255
|
+
'started->cancelled': ['timestamp:cancelled_at', 'event:assignment_cancelled', 'sync:agent_run'],
|
|
255
256
|
'started->failed': ['timestamp:failed_at', 'event:assignment_failed', 'sync:agent_run'],
|
|
256
257
|
},
|
|
257
258
|
};
|
|
@@ -90,14 +90,38 @@ export class CliExecutionAdapter {
|
|
|
90
90
|
const spawnExecutable = resolvedExecutable ?? invoke.executable;
|
|
91
91
|
const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
|
|
92
92
|
const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
|
|
93
|
-
|
|
93
|
+
// pln#504: open per-assignment log files for stdout/stderr capture so silent
|
|
94
|
+
// worker deaths (trp#292) become diagnosable. Previously stdio used 'ignore'
|
|
95
|
+
// for stdout+stderr — anything the worker said vanished. Best-effort: on
|
|
96
|
+
// failure to open log files we fall back to the legacy 'ignore' behaviour
|
|
97
|
+
// rather than abort the spawn.
|
|
98
|
+
const useAckWrap = !!(options.assignmentId && (options.ackRoot ?? options.worktreePath));
|
|
99
|
+
let logFds;
|
|
100
|
+
if (useAckWrap) {
|
|
101
|
+
try {
|
|
102
|
+
const logRoot = options.ackRoot ?? options.worktreePath;
|
|
103
|
+
const logDir = path.join(logRoot, '.brainclaw', 'coordination', 'runtime', 'log');
|
|
104
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
105
|
+
logFds = {
|
|
106
|
+
stdout: fs.openSync(path.join(logDir, `${options.assignmentId}.stdout.log`), 'a'),
|
|
107
|
+
stderr: fs.openSync(path.join(logDir, `${options.assignmentId}.stderr.log`), 'a'),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Log capture is best-effort — never block the spawn on logging issues.
|
|
112
|
+
logFds = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const stdinTarget = needsStdin ? 'pipe' : 'ignore';
|
|
116
|
+
const stdoutTarget = logFds ? logFds.stdout : 'ignore';
|
|
117
|
+
const stderrTarget = logFds ? logFds.stderr : 'ignore';
|
|
118
|
+
const stdio = [stdinTarget, stdoutTarget, stderrTarget];
|
|
94
119
|
// pln#476: wrap the spawn command with a brief-ack step so the worker
|
|
95
120
|
// shell touches a sentinel file BEFORE the agent binary runs.
|
|
96
121
|
// waitForAssignmentHandshake checks that file as evidence the spawn
|
|
97
122
|
// executed — needed for codex (which lacks the brainclaw MCP context
|
|
98
123
|
// to call bclaw_assignment_update). When ackRoot/assignmentId are
|
|
99
124
|
// omitted, we keep the original direct-binary spawn.
|
|
100
|
-
const useAckWrap = !!(options.assignmentId && (options.ackRoot ?? options.worktreePath));
|
|
101
125
|
let child;
|
|
102
126
|
if (useAckWrap) {
|
|
103
127
|
const ackRoot = options.ackRoot ?? options.worktreePath;
|
|
@@ -139,6 +163,18 @@ export class CliExecutionAdapter {
|
|
|
139
163
|
child.stdin.end();
|
|
140
164
|
}
|
|
141
165
|
child.unref();
|
|
166
|
+
// Close the parent's copies of the log file descriptors. The child has its
|
|
167
|
+
// own dup'd copies and will keep writing to them after we return.
|
|
168
|
+
if (logFds) {
|
|
169
|
+
try {
|
|
170
|
+
fs.closeSync(logFds.stdout);
|
|
171
|
+
}
|
|
172
|
+
catch { /* best-effort */ }
|
|
173
|
+
try {
|
|
174
|
+
fs.closeSync(logFds.stderr);
|
|
175
|
+
}
|
|
176
|
+
catch { /* best-effort */ }
|
|
177
|
+
}
|
|
142
178
|
const pid = child.pid;
|
|
143
179
|
if (!pid) {
|
|
144
180
|
throw new Error(`Failed to spawn agent ${options.agent}: no PID returned`);
|
|
@@ -13,6 +13,7 @@ export const WorkRequestSchema = z.object({
|
|
|
13
13
|
task: z.string().optional(),
|
|
14
14
|
messageId: z.string().optional(),
|
|
15
15
|
contextTarget: z.string().optional(),
|
|
16
|
+
project: z.string().optional(),
|
|
16
17
|
compact: z.boolean().optional().default(true),
|
|
17
18
|
});
|
|
18
19
|
export const CoordinateRequestSchema = z.object({
|
|
@@ -54,6 +55,27 @@ export const CoordinateRequestSchema = z.object({
|
|
|
54
55
|
* the target agent picks up the brief async via its own bclaw_work.
|
|
55
56
|
*/
|
|
56
57
|
project: z.string().optional(),
|
|
58
|
+
/**
|
|
59
|
+
* Bypass the pre-flight uncommitted-changes check (can_30c295b4 fix).
|
|
60
|
+
* By default, bclaw_coordinate refuses dispatches when the source cwd
|
|
61
|
+
* has uncommitted modifications, because the dispatched worker spawns
|
|
62
|
+
* from HEAD and won't see them — leading to silent review on stale code.
|
|
63
|
+
* Set allow_dirty=true to override (e.g. when the caller knows the
|
|
64
|
+
* dispatched work doesn't depend on the dirty files, or when running
|
|
65
|
+
* tests). Has no effect when the source cwd is not a git repo.
|
|
66
|
+
*/
|
|
67
|
+
allow_dirty: z.boolean().optional(),
|
|
68
|
+
/**
|
|
69
|
+
* pln#511 step 2 — loop preset selector. When set on intent='ideate',
|
|
70
|
+
* the handler bypasses the kind-default ideation phases and opens the
|
|
71
|
+
* loop with the named preset's phases / stop_condition / protocol.
|
|
72
|
+
* v1 ships a single preset ('bootstrap', see src/core/loops/presets/).
|
|
73
|
+
* The handler validates the name against the preset registry and
|
|
74
|
+
* rejects unknown names with `unknown_preset`. Presets are kind-
|
|
75
|
+
* specific: passing `preset` with any intent other than 'ideate' is
|
|
76
|
+
* rejected as `preset_kind_mismatch`.
|
|
77
|
+
*/
|
|
78
|
+
preset: z.string().min(1).optional(),
|
|
57
79
|
});
|
|
58
80
|
export const FacadeArtifactSchema = z.object({
|
|
59
81
|
type: z.string(),
|
|
@@ -65,6 +87,21 @@ export const FacadeSideEffectSchema = z.object({
|
|
|
65
87
|
entity: z.string(),
|
|
66
88
|
id: z.string(),
|
|
67
89
|
});
|
|
90
|
+
/**
|
|
91
|
+
* Self-documenting verification hint attached to dispatch responses (pln#503
|
|
92
|
+
* phase 3.3). Tells the caller exactly which canonical-grammar call to make
|
|
93
|
+
* next to verify the spawn is actually doing work — `delivered_and_started`
|
|
94
|
+
* is a brief-ack signal, not proof of life. See dispatch-lifecycle.md.
|
|
95
|
+
*/
|
|
96
|
+
export const VerifyWithSchema = z.object({
|
|
97
|
+
action: z.literal('bclaw_find'),
|
|
98
|
+
entity: z.literal('agent_run'),
|
|
99
|
+
filter: z.object({ assignment_id: z.string() }),
|
|
100
|
+
/** Human-readable description of what to look for in the result. */
|
|
101
|
+
expected_when_alive: z.string(),
|
|
102
|
+
/** Doc pointer for the diagnostic flow when the check fails. */
|
|
103
|
+
see_also: z.string(),
|
|
104
|
+
});
|
|
68
105
|
export const FacadeResponseSchema = z.object({
|
|
69
106
|
status: z.enum(['ok', 'error', 'partial']),
|
|
70
107
|
intent: z.string(),
|
|
@@ -77,5 +114,23 @@ export const FacadeResponseSchema = z.object({
|
|
|
77
114
|
session_id: z.string().optional(),
|
|
78
115
|
warnings: z.array(z.string()),
|
|
79
116
|
execution_status: ExecutionStatusSchema.optional(),
|
|
117
|
+
/** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
|
|
118
|
+
verify_with: VerifyWithSchema.optional(),
|
|
119
|
+
/**
|
|
120
|
+
* pln#513 step 1 — bclaw_work hint surfaced when the project lacks a usable
|
|
121
|
+
* PROJECT.md (absent or zero bytes). True means the agent should consider
|
|
122
|
+
* opening a bootstrap loop before assuming context; the literal call to
|
|
123
|
+
* make is in `next_action`. False or absent means the project already has
|
|
124
|
+
* a PROJECT.md and the bootstrap entry-point should not be offered.
|
|
125
|
+
* Additive: existing callers that don't read it are unaffected.
|
|
126
|
+
*/
|
|
127
|
+
bootstrap_recommended: z.boolean().optional(),
|
|
128
|
+
/**
|
|
129
|
+
* pln#513 step 1 — literal MCP call to surface as the bootstrap entry-point
|
|
130
|
+
* when `bootstrap_recommended` is true. Carries the canonical-grammar text
|
|
131
|
+
* (`bclaw_coordinate(intent='ideate', preset='bootstrap')`) verbatim so the
|
|
132
|
+
* CLI doesn't have to reconstruct it.
|
|
133
|
+
*/
|
|
134
|
+
next_action: z.string().optional(),
|
|
80
135
|
});
|
|
81
136
|
//# sourceMappingURL=facade-schema.js.map
|
|
@@ -2,24 +2,29 @@ import { loadConfig } from './config.js';
|
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
const DEFAULT_API_URL = 'https://app.brainclaw.dev';
|
|
4
4
|
function resolveCloudConfig(cwd) {
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
// Check config.yaml
|
|
5
|
+
const envApiUrl = process.env.BRAINCLAW_CLOUD_URL;
|
|
6
|
+
const envApiKey = process.env.BRAINCLAW_CLOUD_API_KEY;
|
|
7
|
+
let configEnabled = false;
|
|
8
|
+
let configEndpoint;
|
|
9
|
+
let configApiKey;
|
|
12
10
|
try {
|
|
13
11
|
const config = loadConfig(cwd);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
if (config.cloud_sync) {
|
|
13
|
+
configEnabled = config.cloud_sync.enabled === true;
|
|
14
|
+
configEndpoint = config.cloud_sync.endpoint;
|
|
15
|
+
configApiKey = config.cloud_sync.api_key;
|
|
17
16
|
}
|
|
18
17
|
}
|
|
19
18
|
catch {
|
|
20
|
-
// No config —
|
|
19
|
+
// No config available — fall back to env only
|
|
21
20
|
}
|
|
22
|
-
|
|
21
|
+
const apiKey = envApiKey ?? configApiKey;
|
|
22
|
+
if (!apiKey)
|
|
23
|
+
return undefined;
|
|
24
|
+
// Env-supplied key implies explicit opt-in; config flag is the alternative
|
|
25
|
+
const enabled = Boolean(envApiKey) || configEnabled;
|
|
26
|
+
const apiUrl = envApiUrl ?? configEndpoint ?? DEFAULT_API_URL;
|
|
27
|
+
return { apiUrl, apiKey, enabled };
|
|
23
28
|
}
|
|
24
29
|
export async function pushSignalToCloud(message, cwd) {
|
|
25
30
|
const cloud = resolveCloudConfig(cwd);
|
|
@@ -96,4 +101,14 @@ export async function pushBoardToCloud(projectName, boardData, cwd) {
|
|
|
96
101
|
export function isCloudConfigured(cwd) {
|
|
97
102
|
return resolveCloudConfig(cwd) !== undefined;
|
|
98
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Returns true when cloud sync is both configured AND explicitly opted-in.
|
|
106
|
+
* Use this gate for automatic lifecycle hooks (session-start pull, session-end push).
|
|
107
|
+
* `isCloudConfigured` alone does NOT imply opt-in — a stale config api_key without
|
|
108
|
+
* `cloud_sync.enabled=true` and without the BRAINCLAW_CLOUD_API_KEY env var stays inert.
|
|
109
|
+
*/
|
|
110
|
+
export function isCloudSyncEnabled(cwd) {
|
|
111
|
+
const cloud = resolveCloudConfig(cwd);
|
|
112
|
+
return cloud !== undefined && cloud.enabled;
|
|
113
|
+
}
|
|
99
114
|
//# sourceMappingURL=federation-cloud.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CandidateSchema, HandoffSchema, RuntimeNoteSchema } from './schema.js';
|
|
2
|
+
import { saveCandidate, generateCandidateIdWithLabel } from './candidates.js';
|
|
3
|
+
import { saveRuntimeNote, generateRuntimeNoteId } from './runtime.js';
|
|
4
|
+
import { generateIdWithLabel, nowISO } from './ids.js';
|
|
5
|
+
import { mutateState } from './state.js';
|
|
6
|
+
export function materializeFederationSignal(signal, cwd) {
|
|
7
|
+
const origin = `remote:${signal.from.project_name}:${signal.from.agent_name}`;
|
|
8
|
+
if (signal.type === 'candidate') {
|
|
9
|
+
const parsed = CandidateSchema.safeParse(signal.payload);
|
|
10
|
+
if (!parsed.success)
|
|
11
|
+
return false;
|
|
12
|
+
const { id, short_label } = generateCandidateIdWithLabel(cwd);
|
|
13
|
+
saveCandidate({
|
|
14
|
+
...parsed.data,
|
|
15
|
+
id,
|
|
16
|
+
short_label,
|
|
17
|
+
created_at: nowISO(),
|
|
18
|
+
source: undefined, // remote signal — treated as 'human' (legacy default)
|
|
19
|
+
star_count: 0,
|
|
20
|
+
starred_by: [],
|
|
21
|
+
usage_count: 0,
|
|
22
|
+
usage_events: [],
|
|
23
|
+
status: 'pending',
|
|
24
|
+
}, cwd);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
if (signal.type === 'handoff') {
|
|
28
|
+
const parsed = HandoffSchema.safeParse(signal.payload);
|
|
29
|
+
if (!parsed.success)
|
|
30
|
+
return false;
|
|
31
|
+
const { id, short_label } = generateIdWithLabel('open_handoffs', cwd);
|
|
32
|
+
mutateState((state) => {
|
|
33
|
+
state.open_handoffs.push({
|
|
34
|
+
...parsed.data,
|
|
35
|
+
id,
|
|
36
|
+
short_label,
|
|
37
|
+
created_at: nowISO(),
|
|
38
|
+
tags: [...(parsed.data.tags ?? []), origin],
|
|
39
|
+
});
|
|
40
|
+
}, cwd);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (signal.type === 'runtime_note') {
|
|
44
|
+
const parsed = RuntimeNoteSchema.safeParse(signal.payload);
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
return false;
|
|
47
|
+
saveRuntimeNote({
|
|
48
|
+
...parsed.data,
|
|
49
|
+
id: generateRuntimeNoteId(),
|
|
50
|
+
created_at: nowISO(),
|
|
51
|
+
tags: [...(parsed.data.tags ?? []), origin],
|
|
52
|
+
}, cwd);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=federation-materialize.js.map
|
|
@@ -236,6 +236,8 @@ function renderSessionProtocol() {
|
|
|
236
236
|
'- Drive a turn in a loop already assigned to you → `bclaw_loop(intent=turn|complete_turn|advance|close)`',
|
|
237
237
|
'',
|
|
238
238
|
'Do NOT call `bclaw_loop(intent=open)` directly — it creates a loop structure without dispatch, so the reviewer/participant never gets the work. Use the goal entries above.',
|
|
239
|
+
'',
|
|
240
|
+
'_How to verify a dispatch actually worked:_ `execution_status="delivered_and_started"` only means the brief-ack sentinel was touched — it does NOT mean the worker is doing useful work. Always (1) `bclaw_find(entity="agent_run", filter={assignment_id})` to read the spawn record; (2) check OS pid liveness yourself (`Get-Process -Id <pid>` on Windows, `kill -0 <pid>` on POSIX); (3) if the worker is silent, read its captured streams at `.brainclaw/coordination/runtime/log/<assignment_id>.{stdout,stderr}.log`. Full FSM tables + diagnostic decision tree in `docs/concepts/dispatch-lifecycle.md`.',
|
|
239
241
|
].join('\n');
|
|
240
242
|
}
|
|
241
243
|
function renderUserWorkflow() {
|