brainclaw 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +52 -28
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +159 -12
  4. package/dist/commands/assignment-resource.js +182 -0
  5. package/dist/commands/bootstrap-loop.js +206 -0
  6. package/dist/commands/init.js +158 -22
  7. package/dist/commands/loop.js +156 -0
  8. package/dist/commands/loops-handlers.js +110 -55
  9. package/dist/commands/mcp-read-handlers.js +45 -4
  10. package/dist/commands/mcp.js +628 -205
  11. package/dist/commands/questions.js +180 -0
  12. package/dist/commands/reply.js +190 -0
  13. package/dist/commands/session-end.js +105 -3
  14. package/dist/commands/session-start.js +32 -53
  15. package/dist/commands/setup.js +87 -48
  16. package/dist/commands/switch.js +21 -1
  17. package/dist/core/agentrun-reconciler.js +65 -0
  18. package/dist/core/agentruns.js +10 -0
  19. package/dist/core/assignments.js +29 -10
  20. package/dist/core/claims.js +29 -0
  21. package/dist/core/context.js +1 -1
  22. package/dist/core/coordination.js +1 -1
  23. package/dist/core/dispatch-status.js +219 -0
  24. package/dist/core/entity-operations.js +166 -10
  25. package/dist/core/entity-registry.js +11 -10
  26. package/dist/core/execution-adapters.js +38 -2
  27. package/dist/core/facade-schema.js +55 -0
  28. package/dist/core/federation-cloud.js +27 -12
  29. package/dist/core/federation-materialize.js +57 -0
  30. package/dist/core/instruction-templates.js +2 -0
  31. package/dist/core/loops/bootstrap-acquire.js +195 -0
  32. package/dist/core/loops/facade-schema.js +68 -1
  33. package/dist/core/loops/hooks/bootstrap-write.js +144 -0
  34. package/dist/core/loops/hooks/notify-operator.js +148 -0
  35. package/dist/core/loops/hooks/survey-source-reader.js +256 -0
  36. package/dist/core/loops/index.js +8 -2
  37. package/dist/core/loops/next-expected.js +63 -0
  38. package/dist/core/loops/presets/bootstrap.js +75 -0
  39. package/dist/core/loops/presets/index.js +16 -0
  40. package/dist/core/loops/store.js +224 -4
  41. package/dist/core/loops/types.js +346 -1
  42. package/dist/core/loops/verbs.js +739 -6
  43. package/dist/core/schema.js +31 -2
  44. package/dist/core/state.js +62 -0
  45. package/dist/core/store-resolution.js +26 -16
  46. package/dist/facts.js +7 -5
  47. package/dist/facts.json +6 -4
  48. package/docs/cli.md +115 -30
  49. package/docs/concepts/dispatch-lifecycle.md +228 -0
  50. package/docs/concepts/loop-engine.md +55 -0
  51. package/docs/concepts/multi-agent-workflows.md +167 -166
  52. package/docs/concepts/troubleshooting.md +10 -2
  53. package/docs/integrations/agents.md +14 -14
  54. package/docs/integrations/codex.md +15 -12
  55. package/docs/integrations/mcp.md +10 -4
  56. package/docs/integrations/overview.md +11 -0
  57. package/docs/playbooks/productivity/index.md +3 -3
  58. package/docs/quickstart-existing-project.md +48 -28
  59. package/docs/quickstart.md +42 -28
  60. package/package.json +1 -1
@@ -305,6 +305,91 @@ export function printReloadReminder(detectedAgent) {
305
305
  console.log(' → Restart your AI coding agent to pick up the new MCP configuration.');
306
306
  }
307
307
  }
308
+ function logDetectedAgentSurfaces(detectedName, detectedSurfaces) {
309
+ console.log('');
310
+ if (detectedName) {
311
+ console.log(`Detected AI agent: ${detectedName}`);
312
+ }
313
+ else {
314
+ console.log('No AI agent detected automatically.');
315
+ }
316
+ const visibleSurfaces = detectedSurfaces.filter((surface) => surface.status !== 'not_detected');
317
+ if (visibleSurfaces.length > 0) {
318
+ console.log('Other AI work surfaces on this machine:');
319
+ for (const surface of visibleSurfaces) {
320
+ console.log(` - ${surface.display_name} [${surface.surface_kind}, ${surface.status}]`);
321
+ }
322
+ const usageHints = renderAiSurfaceUsageHints(visibleSurfaces);
323
+ if (usageHints.length > 0) {
324
+ console.log('');
325
+ console.log('Suggested uses:');
326
+ for (const line of usageHints) {
327
+ console.log(` ${line}`);
328
+ }
329
+ }
330
+ console.log('');
331
+ console.log('These surfaces are tracked separately from coding agents and will use tailored onboarding flows.');
332
+ }
333
+ }
334
+ async function resolveSelectedAgentsForSetup(options, detectedName) {
335
+ console.log('Supported agents:');
336
+ ALL_KNOWN_AGENTS.forEach((a, i) => {
337
+ const tag = a === detectedName ? ' ← detected' : '';
338
+ console.log(` ${i + 1}) ${a}${tag}`);
339
+ });
340
+ let agentChoice;
341
+ if (options.agents) {
342
+ agentChoice = options.agents;
343
+ }
344
+ else if (options.yes || !process.stdin.isTTY) {
345
+ agentChoice = detectedName ? 'detected' : 'all';
346
+ }
347
+ else {
348
+ const defaultChoice = detectedName ? 'detected' : 'all';
349
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
350
+ try {
351
+ agentChoice = (await rl.question(`Configure agents: (d)etected, (a)ll, or numbers e.g. 1,3 [${defaultChoice}]: `)).trim() || defaultChoice;
352
+ }
353
+ finally {
354
+ rl.close();
355
+ }
356
+ }
357
+ const selectedAgents = parseAgentSelection(agentChoice, detectedName);
358
+ console.log(`Selected agents: ${selectedAgents.length === 0 ? '(none)' : selectedAgents.join(', ')}`);
359
+ return selectedAgents;
360
+ }
361
+ export async function runSetupMachine(options = {}) {
362
+ const env = process.env;
363
+ const detectedAi = detectAiAgent(env);
364
+ const detectedName = detectedAi?.name;
365
+ const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
366
+ const detectedSurfaces = testMode ? [] : buildAiSurfaceInventory();
367
+ console.log(BRAINCLAW_ASCII);
368
+ console.log('Machine bootstrap only — no repositories will be scanned or initialized.');
369
+ logDetectedAgentSurfaces(detectedName, detectedSurfaces);
370
+ const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName);
371
+ console.log('\n→ Installing machine-level brainclaw prerequisites...');
372
+ const written = runGlobalInstall(selectedAgents, env);
373
+ if (written.length > 0) {
374
+ for (const filePath of written) {
375
+ console.log(` ✔ ${filePath}`);
376
+ }
377
+ }
378
+ else {
379
+ console.log(' (all machine-level prerequisites already up to date)');
380
+ }
381
+ installVscodeExtension();
382
+ writeSetupState({
383
+ completed_at: new Date().toISOString(),
384
+ roots: [],
385
+ initialised_repos: [],
386
+ global_configs_written: selectedAgents,
387
+ }, env);
388
+ printReloadReminder(detectedName);
389
+ console.log('');
390
+ console.log('Next step: run `brainclaw init` inside the project you want to create or refresh.');
391
+ console.log('If the project already has .brainclaw/ and you want to register another agent explicitly, run `brainclaw enable-agent <agent-name>` there.');
392
+ }
308
393
  // ─── Main CLI wizard ──────────────────────────────────────────────────────────
309
394
  export async function runSetup(options = {}) {
310
395
  const env = process.env;
@@ -399,54 +484,8 @@ export async function runSetup(options = {}) {
399
484
  const detectedName = detectedAi?.name;
400
485
  const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
401
486
  const detectedSurfaces = testMode ? [] : buildAiSurfaceInventory();
402
- console.log('');
403
- if (detectedName) {
404
- console.log(`Detected AI agent: ${detectedName}`);
405
- }
406
- else {
407
- console.log('No AI agent detected automatically.');
408
- }
409
- const visibleSurfaces = detectedSurfaces.filter((surface) => surface.status !== 'not_detected');
410
- if (visibleSurfaces.length > 0) {
411
- console.log('Other AI work surfaces on this machine:');
412
- for (const surface of visibleSurfaces) {
413
- console.log(` - ${surface.display_name} [${surface.surface_kind}, ${surface.status}]`);
414
- }
415
- const usageHints = renderAiSurfaceUsageHints(visibleSurfaces);
416
- if (usageHints.length > 0) {
417
- console.log('');
418
- console.log('Suggested uses:');
419
- for (const line of usageHints) {
420
- console.log(` ${line}`);
421
- }
422
- }
423
- console.log('');
424
- console.log('These surfaces are tracked separately from coding agents and will use tailored onboarding flows.');
425
- }
426
- console.log('Supported agents:');
427
- ALL_KNOWN_AGENTS.forEach((a, i) => {
428
- const tag = a === detectedName ? ' ← detected' : '';
429
- console.log(` ${i + 1}) ${a}${tag}`);
430
- });
431
- let agentChoice;
432
- if (options.agents) {
433
- agentChoice = options.agents;
434
- }
435
- else if (options.yes || !process.stdin.isTTY) {
436
- agentChoice = detectedName ? 'detected' : 'all';
437
- }
438
- else {
439
- const defaultChoice = detectedName ? 'detected' : 'all';
440
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
441
- try {
442
- agentChoice = (await rl.question(`Configure agents: (d)etected, (a)ll, or numbers e.g. 1,3 [${defaultChoice}]: `)).trim() || defaultChoice;
443
- }
444
- finally {
445
- rl.close();
446
- }
447
- }
448
- const selectedAgents = parseAgentSelection(agentChoice, detectedName);
449
- console.log(`Selected agents: ${selectedAgents.length === 0 ? '(none)' : selectedAgents.join(', ')}`);
487
+ logDetectedAgentSurfaces(detectedName, detectedSurfaces);
488
+ const selectedAgents = await resolveSelectedAgentsForSetup(options, detectedName);
450
489
  // Step 5: Global install
451
490
  console.log('\n→ Installing global brainclaw prerequisites...');
452
491
  const written = runGlobalInstall(selectedAgents, env);
@@ -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
- const resolved = resolveProjectRef(projectRef, cwd);
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
  }
@@ -244,6 +260,10 @@ function listProjects(wsRoot, json) {
244
260
  * this returns the farthest one — the true multi-project workspace root.
245
261
  */
246
262
  function findOutermostWorkspaceRoot(startDir) {
263
+ const envWorkspace = process.env.BRAINCLAW_CWD?.trim();
264
+ if (envWorkspace && memoryExists(path.resolve(envWorkspace))) {
265
+ return path.resolve(envWorkspace);
266
+ }
247
267
  let dir = path.resolve(startDir);
248
268
  const root = path.parse(dir).root;
249
269
  const home = process.env.HOME || process.env.USERPROFILE || root;
@@ -50,6 +50,8 @@ export const DEFAULT_HEALTH_CHECK_GRACE_MS = 60_000;
50
50
  * declared `failed` with `silent_termination_no_evidence`. Default 30 min.
51
51
  */
52
52
  export const DEFAULT_STALE_AFTER_MS = 30 * 60_000;
53
+ export const DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS = 5 * 60_000;
54
+ export const DEFAULT_DEAD_PID_READ_SWEEP_LIMIT = 50;
53
55
  const TERMINAL_STATUSES = new Set([
54
56
  'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
55
57
  ]);
@@ -309,6 +311,69 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
309
311
  evidence, previous_status, current_status: run.status,
310
312
  };
311
313
  }
314
+ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {}) {
315
+ const run = loadAgentRun(runId, cwd);
316
+ if (!run) {
317
+ const evidence = {
318
+ age_ms: 0, has_post_start_commit: false, claim_released: false,
319
+ assignment_completed: false, process_alive: undefined,
320
+ };
321
+ return {
322
+ run_id: runId, action: 'no_op', reason: 'run not found', evidence,
323
+ previous_status: 'created', current_status: 'created',
324
+ };
325
+ }
326
+ const evidence = collectEvidence(run, cwd, { nowMs: options.nowMs });
327
+ if (run.status !== 'running') {
328
+ return {
329
+ run_id: run.id, action: 'no_op', reason: `run status is ${run.status}, not running`,
330
+ evidence, previous_status: run.status, current_status: run.status,
331
+ };
332
+ }
333
+ if (evidence.process_alive !== false) {
334
+ return {
335
+ run_id: run.id, action: 'no_op',
336
+ reason: evidence.process_alive === true ? 'process alive' : 'pid liveness unknown',
337
+ evidence, previous_status: run.status, current_status: run.status,
338
+ };
339
+ }
340
+ try {
341
+ transitionAgentRun(run.id, 'cancelled', {
342
+ actor: options.actor ?? 'reconciler',
343
+ status_reason: 'pid_dead_at_read',
344
+ }, cwd);
345
+ return {
346
+ run_id: run.id, action: 'cancelled_dead_pid', reason: 'pid_dead_at_read',
347
+ evidence, previous_status: run.status, current_status: 'cancelled',
348
+ };
349
+ }
350
+ catch (err) {
351
+ return {
352
+ run_id: run.id, action: 'no_op',
353
+ reason: `cancel transition rejected: ${err instanceof Error ? err.message : String(err)}`,
354
+ evidence, previous_status: run.status, current_status: run.status,
355
+ };
356
+ }
357
+ }
358
+ export function sweepDeadPidRunningAgentRunsAtRead(cwd, options = {}) {
359
+ const now = options.nowMs ?? Date.now();
360
+ const minAgeMs = options.staleAfterMs ?? DEFAULT_DEAD_PID_READ_SWEEP_AGE_MS;
361
+ const cutoff = now - minAgeMs;
362
+ const limit = options.limit ?? DEFAULT_DEAD_PID_READ_SWEEP_LIMIT;
363
+ const candidates = listAgentRuns(cwd, { status: 'running' })
364
+ .filter((run) => {
365
+ const lastEventAt = run.last_event_at ?? run.started_at ?? run.created_at;
366
+ const ts = new Date(lastEventAt).getTime();
367
+ return Number.isFinite(ts) && ts <= cutoff;
368
+ })
369
+ .sort((left, right) => {
370
+ const leftTs = new Date(left.last_event_at ?? left.started_at ?? left.created_at).getTime();
371
+ const rightTs = new Date(right.last_event_at ?? right.started_at ?? right.created_at).getTime();
372
+ return rightTs - leftTs;
373
+ })
374
+ .slice(0, limit);
375
+ return candidates.map((run) => reconcileDeadPidRunningAgentRunAtRead(run.id, cwd, options));
376
+ }
312
377
  /**
313
378
  * Reconcile every non-terminal agent_run matching `filter`. Useful for
314
379
  * batch sweeps from `bclaw_assignment_events` or `brainclaw doctor --dispatch`.
@@ -320,6 +320,16 @@ export function syncAgentRunFromAssignmentTransition(assignment, newStatus, opti
320
320
  artifacts: options.artifacts,
321
321
  }, cwd);
322
322
  return;
323
+ case 'cancelled':
324
+ if (!canTransitionRun(run, 'cancelled'))
325
+ return;
326
+ transitionAgentRun(run.id, 'cancelled', {
327
+ actor: options.actor,
328
+ actor_id: options.actor_id,
329
+ session_id: options.session_id,
330
+ status_reason: options.status_reason ?? 'Cancelled via assignment lifecycle',
331
+ }, cwd);
332
+ return;
323
333
  case 'failed':
324
334
  if (!canTransitionRun(run, 'failed'))
325
335
  return;
@@ -64,6 +64,22 @@ export function listAssignments(cwd, filter) {
64
64
  items = items.filter((a) => a.sequence_id === filter.sequence_id);
65
65
  return items;
66
66
  }
67
+ export function deleteAssignment(id, cwd) {
68
+ const store = assignmentStore(cwd);
69
+ if (!store.exists(id)) {
70
+ return false;
71
+ }
72
+ mutate({ cwd }, () => {
73
+ const writableStore = new JsonStore({
74
+ dirPath: assignmentsDir(cwd, 'write'),
75
+ documentType: 'assignment',
76
+ getId: (a) => a.id,
77
+ sort: (a, b) => a.created_at.localeCompare(b.created_at),
78
+ });
79
+ writableStore.delete(id);
80
+ });
81
+ return true;
82
+ }
67
83
  // ── ID Generation ────────────────────────────────────────────
68
84
  export function generateAssignmentId(cwd) {
69
85
  return generateIdWithLabel('assignments', cwd);
@@ -78,15 +94,15 @@ export function generateAssignmentId(cwd) {
78
94
  * rerouted a still-unstarted lane.
79
95
  */
80
96
  const VALID_TRANSITIONS = new Map([
81
- ['created', new Set(['offered', 'rerouted'])],
82
- ['offered', new Set(['accepted', 'failed', 'expired', 'rerouted'])],
83
- ['accepted', new Set(['started', 'timed_out', 'rerouted'])],
84
- ['started', new Set(['completed', 'failed', 'blocked', 'timed_out', 'rerouted'])],
85
- ['failed', new Set(['retrying', 'rerouted'])],
86
- ['timed_out', new Set(['retrying', 'rerouted'])],
87
- ['retrying', new Set(['offered', 'rerouted'])],
88
- ['blocked', new Set(['rerouted', 'started', 'failed'])],
89
- // Terminal: completed, expired, rerouted (no outgoing transitions)
97
+ ['created', new Set(['offered', 'rerouted', 'cancelled'])],
98
+ ['offered', new Set(['accepted', 'failed', 'expired', 'rerouted', 'cancelled'])],
99
+ ['accepted', new Set(['started', 'timed_out', 'rerouted', 'cancelled'])],
100
+ ['started', new Set(['completed', 'failed', 'blocked', 'timed_out', 'rerouted', 'cancelled'])],
101
+ ['failed', new Set(['retrying', 'rerouted', 'cancelled'])],
102
+ ['timed_out', new Set(['retrying', 'rerouted', 'cancelled'])],
103
+ ['retrying', new Set(['offered', 'rerouted', 'cancelled'])],
104
+ ['blocked', new Set(['rerouted', 'started', 'failed', 'cancelled'])],
105
+ // Terminal: completed, cancelled, expired, rerouted (no outgoing transitions)
90
106
  ]);
91
107
  export function validateTransition(from, to) {
92
108
  const allowed = VALID_TRANSITIONS.get(from);
@@ -148,6 +164,9 @@ export function transitionAssignment(id, newStatus, options, cwd) {
148
164
  case 'completed':
149
165
  assignment.completed_at = now;
150
166
  break;
167
+ case 'cancelled':
168
+ assignment.cancelled_at = now;
169
+ break;
151
170
  case 'failed':
152
171
  assignment.failed_at = now;
153
172
  break;
@@ -280,7 +299,7 @@ export function createAssignment(options, cwd) {
280
299
  }
281
300
  // ── Active Assignment Lookup ─────────────────────────────────
282
301
  /** Statuses that indicate a finished assignment (no longer active). */
283
- const TERMINAL_STATUSES = new Set(['completed', 'expired', 'rerouted']);
302
+ const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'expired', 'rerouted']);
284
303
  /**
285
304
  * Return the most recently created non-terminal assignment for the given agent.
286
305
  * When `claimId` is provided, it is used as a fast-path lookup before falling
@@ -59,6 +59,35 @@ export function saveClaim(claim, cwd) {
59
59
  catch { /* best-effort */ }
60
60
  });
61
61
  }
62
+ /**
63
+ * Atomically check for an active claim on `scope` and save a new one if absent.
64
+ *
65
+ * Atomicity is provided by running both operations inside a single mutate() call;
66
+ * the mutation-pipeline mutex serializes filesystem writes on the claims store.
67
+ */
68
+ export function acquireClaimScope(input, cwd) {
69
+ return mutate({ cwd }, () => {
70
+ const conflictingClaim = listClaims(cwd).find((claim) => claim.status === 'active' && claim.scope === input.scope);
71
+ if (conflictingClaim) {
72
+ return { acquired: false, conflicting_claim: conflictingClaim };
73
+ }
74
+ const claim = {
75
+ id: generateClaimId(),
76
+ agent: input.agent,
77
+ agent_id: input.agent_id,
78
+ user: input.user,
79
+ session_id: input.session_id,
80
+ scope: input.scope,
81
+ description: input.description,
82
+ created_at: nowISO(),
83
+ status: 'active',
84
+ plan_id: input.plan_id,
85
+ model: input.model,
86
+ };
87
+ saveClaim(claim, cwd);
88
+ return { acquired: true, claim };
89
+ });
90
+ }
62
91
  export function loadClaim(id, cwd) {
63
92
  return claimStore(cwd).load(id);
64
93
  }
@@ -445,7 +445,7 @@ export function buildContext(options = {}) {
445
445
  const currentSession = loadCurrentSession(contextCwd);
446
446
  if (currentAgentIdentity || agent) {
447
447
  const claimPlanIds = new Set(myClaims.map((c) => c.plan_id).filter(Boolean));
448
- const activeAssignments = listAssignments(contextCwd, { agent: agentName }).filter((assignment) => !['completed', 'failed', 'expired', 'rerouted'].includes(assignment.status));
448
+ const activeAssignments = listAssignments(contextCwd, { agent: agentName }).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted'].includes(assignment.status));
449
449
  const inProgressPlans = state.plan_items.filter((p) => p.status === 'in_progress' &&
450
450
  (p.assignee === agentName || claimPlanIds.has(p.id)));
451
451
  if (myClaims.length > 0 || activeAssignments.length > 0 || inProgressPlans.length > 0) {
@@ -88,7 +88,7 @@ export function buildCoordinationSnapshot(options = {}) {
88
88
  : filteredClaims,
89
89
  active_assignments: (agent
90
90
  ? listAssignments(options.cwd, { agent })
91
- : listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'expired', 'rerouted'].includes(assignment.status) &&
91
+ : listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted'].includes(assignment.status) &&
92
92
  (!project || !assignment.plan_id || filteredPlans.some((plan) => plan.id === assignment.plan_id))),
93
93
  active_runs: (agent
94
94
  ? listAgentRuns(options.cwd, { agent })
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Consolidated dispatch status (pln#503 phase 3.1).
3
+ *
4
+ * Resolves a single dispatch reference (`asgn_…`, `clm_…`, `lop_…`, `run_…`)
5
+ * into the full set of linked entities — assignment, claim, loop, agent_run —
6
+ * plus on-disk artefacts (brief-ack sentinel, stdout/stderr logs) and a
7
+ * pid-liveness check, then computes a health verdict + a recommended next
8
+ * action for the caller.
9
+ *
10
+ * The motivating use case: an agent who just called `bclaw_coordinate` and
11
+ * got `execution_status: "delivered_and_started"` should be able to verify
12
+ * the dispatch is alive without running five separate `bclaw_find` calls.
13
+ * The `verify_with` hint added in phase 3.3 already points callers at this
14
+ * tool by name.
15
+ *
16
+ * See docs/concepts/dispatch-lifecycle.md for the full entity-relationship
17
+ * and FSM model.
18
+ *
19
+ * @module
20
+ */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { loadAssignment, listAssignments } from './assignments.js';
24
+ import { loadAgentRun, listAgentRuns } from './agentruns.js';
25
+ import { loadClaim } from './claims.js';
26
+ import { getLoop, listLoops } from './loops/store.js';
27
+ import { isProcessAlive } from './agentrun-reconciler.js';
28
+ const DEFAULT_TAIL = 20;
29
+ const DEFAULT_STALL_MS = 5 * 60_000;
30
+ // ── Internal helpers ──────────────────────────────────────────────────────
31
+ function readLogTail(filePath, lines) {
32
+ try {
33
+ const stat = fs.statSync(filePath);
34
+ if (lines <= 0) {
35
+ return { path: filePath, exists: true, size_bytes: stat.size };
36
+ }
37
+ const content = fs.readFileSync(filePath, 'utf-8');
38
+ const all = content.split(/\r?\n/);
39
+ // Strip trailing empty line from final \n
40
+ if (all.length > 0 && all[all.length - 1] === '')
41
+ all.pop();
42
+ return {
43
+ path: filePath,
44
+ exists: true,
45
+ size_bytes: stat.size,
46
+ tail: all.slice(-lines),
47
+ };
48
+ }
49
+ catch {
50
+ return { path: filePath, exists: false, size_bytes: 0 };
51
+ }
52
+ }
53
+ function findLoopByAssignmentId(assignmentId, cwd) {
54
+ for (const loop of listLoops({}, cwd)) {
55
+ if (loop.slots.some((s) => s.assignment_id === assignmentId))
56
+ return loop;
57
+ }
58
+ return undefined;
59
+ }
60
+ function resolveTarget(targetId, cwd) {
61
+ if (targetId.startsWith('asgn_')) {
62
+ return { resolved_from: 'assignment_id', assignment_id: targetId };
63
+ }
64
+ if (targetId.startsWith('run_')) {
65
+ const run = loadAgentRun(targetId, cwd);
66
+ if (run)
67
+ return { resolved_from: 'run_id', assignment_id: run.assignment_id, agent_run: run };
68
+ return { resolved_from: 'unresolved' };
69
+ }
70
+ if (targetId.startsWith('clm_')) {
71
+ const assignments = listAssignments(cwd, { claim_id: targetId });
72
+ if (assignments.length > 0) {
73
+ // Pick the most recent assignment for this claim (latest created_at).
74
+ const recent = [...assignments].sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
75
+ return { resolved_from: 'claim_id', assignment_id: recent.id };
76
+ }
77
+ return { resolved_from: 'claim_id' };
78
+ }
79
+ if (targetId.startsWith('lop_')) {
80
+ const loop = getLoop(targetId, cwd);
81
+ if (loop) {
82
+ // Prefer the slot in the current_phase with an assignment_id; fall back
83
+ // to any slot's assignment_id.
84
+ const phaseSlot = loop.slots.find((s) => s.phase === loop.current_phase && s.assignment_id);
85
+ const anySlot = loop.slots.find((s) => s.assignment_id);
86
+ const slot = phaseSlot ?? anySlot;
87
+ if (slot?.assignment_id) {
88
+ return { resolved_from: 'loop_id', assignment_id: slot.assignment_id };
89
+ }
90
+ return { resolved_from: 'loop_id' };
91
+ }
92
+ return { resolved_from: 'unresolved' };
93
+ }
94
+ return { resolved_from: 'unresolved' };
95
+ }
96
+ const TERMINAL_RUN_STATUSES = new Set([
97
+ 'completed', 'failed', 'cancelled', 'timed_out', 'interrupted',
98
+ ]);
99
+ function computeDiagnosis(assignment, agentRun, runtime, options) {
100
+ if (!assignment && !agentRun) {
101
+ return {
102
+ health: 'unknown',
103
+ summary: 'target_id did not resolve to any assignment or agent_run',
104
+ recommended_next_action: 'Verify the target_id is correct (asgn_/clm_/lop_/run_). Use bclaw_find(entity="assignment") to list available assignments.',
105
+ };
106
+ }
107
+ if (!agentRun) {
108
+ return {
109
+ health: 'not_dispatched',
110
+ summary: `assignment exists (status=${assignment?.status}) but no agent_run record — the spawn never produced a process, or the assignment is still waiting to be picked up`,
111
+ recommended_next_action: assignment?.status === 'offered'
112
+ ? 'Wait for the target agent to accept, or reroute via bclaw_coordinate(intent="reroute", scope=…).'
113
+ : 'Re-dispatch with bclaw_coordinate or check for an earlier spawn error.',
114
+ };
115
+ }
116
+ if (TERMINAL_RUN_STATUSES.has(agentRun.status)) {
117
+ const isSuccess = agentRun.status === 'completed';
118
+ return {
119
+ health: 'terminal',
120
+ summary: `agent_run already terminal (status=${agentRun.status})${agentRun.status_reason ? `: ${agentRun.status_reason}` : ''}`,
121
+ recommended_next_action: isSuccess
122
+ ? 'Harvest artifacts and move on; if the assignment is still open, transition it to completed.'
123
+ : 'Read stderr log (path in runtime.log_files.stderr) for the failure detail; reroute or retry if appropriate.',
124
+ };
125
+ }
126
+ // status is running / launching / waiting_input / blocked → check liveness
127
+ const lastEventMs = new Date(agentRun.last_event_at ?? agentRun.started_at ?? agentRun.created_at).getTime();
128
+ const stallAge = options.nowMs - lastEventMs;
129
+ if (runtime.pid_alive === false) {
130
+ return {
131
+ health: 'silent_death',
132
+ summary: `agent_run.status="${agentRun.status}" but pid ${runtime.pid} is dead — worker exited without self-reporting; lazy reconciler will mark it failed after the stale window (default 30min)`,
133
+ recommended_next_action: 'Read .stderr.log for the exit reason; then trigger reconciliation by calling bclaw_find(entity="agent_run") again, or cancel + reroute.',
134
+ };
135
+ }
136
+ if (runtime.pid_alive === true && stallAge > options.stallMs) {
137
+ return {
138
+ health: 'stalled',
139
+ summary: `agent_run alive (pid=${runtime.pid}) but no activity for ${Math.round(stallAge / 1000)}s; last_event_at=${agentRun.last_event_at ?? '(never)'}`,
140
+ recommended_next_action: 'Tail the stdout/stderr log to see whether the worker is doing useful work; if truly hung, kill the pid and reroute.',
141
+ };
142
+ }
143
+ if (runtime.pid_alive === true) {
144
+ return {
145
+ health: 'healthy',
146
+ summary: `agent_run.status="${agentRun.status}", pid ${runtime.pid} alive, last activity ${Math.round(stallAge / 1000)}s ago`,
147
+ recommended_next_action: 'No action — the dispatch is alive and recent. Re-check periodically until terminal.',
148
+ };
149
+ }
150
+ // pid_alive undefined → cannot determine (no pid tracked, or signal failed)
151
+ return {
152
+ health: 'unknown',
153
+ summary: `agent_run.status="${agentRun.status}" but pid liveness could not be determined (pid=${agentRun.pid ?? '(none)'})`,
154
+ recommended_next_action: 'Read the stdout/stderr log for life signs; or wait for the lazy reconciler to converge based on commit / claim / assignment evidence.',
155
+ };
156
+ }
157
+ // ── Public API ─────────────────────────────────────────────────────────────
158
+ export function getDispatchStatus(options) {
159
+ const cwd = options.cwd;
160
+ const tailLines = options.tail_log_lines ?? DEFAULT_TAIL;
161
+ const stallMs = options.stall_threshold_ms ?? DEFAULT_STALL_MS;
162
+ const nowMs = options.nowMs ?? Date.now();
163
+ const resolved = resolveTarget(options.target_id, cwd);
164
+ const assignmentId = resolved.assignment_id;
165
+ const assignment = assignmentId ? loadAssignment(assignmentId, cwd) : undefined;
166
+ const claim = assignment?.claim_id ? loadClaim(assignment.claim_id, cwd) : undefined;
167
+ // Prefer the pre-resolved agent_run (when target_id was a run_…); otherwise
168
+ // look up by assignment_id and pick the most recent attempt.
169
+ let agentRun = resolved.agent_run;
170
+ if (!agentRun && assignmentId) {
171
+ const runs = listAgentRuns(cwd, { assignment_id: assignmentId });
172
+ agentRun = [...runs].sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
173
+ }
174
+ let loop;
175
+ if (resolved.resolved_from === 'loop_id') {
176
+ loop = getLoop(options.target_id, cwd);
177
+ }
178
+ else if (assignmentId) {
179
+ loop = findLoopByAssignmentId(assignmentId, cwd);
180
+ }
181
+ // Runtime artefacts (ack file + log files) — all under the project's
182
+ // coordination root. Use the cwd or the runtime cwd as the anchor; the
183
+ // dispatcher writes them under cwd/.brainclaw/coordination/runtime/...
184
+ const projectRoot = cwd ?? process.cwd();
185
+ const runtimeRoot = path.join(projectRoot, '.brainclaw', 'coordination', 'runtime');
186
+ const ackPath = assignmentId ? path.join(runtimeRoot, 'ack', `${assignmentId}.ack`) : undefined;
187
+ const stdoutPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stdout.log`) : undefined;
188
+ const stderrPath = assignmentId ? path.join(runtimeRoot, 'log', `${assignmentId}.stderr.log`) : undefined;
189
+ const runtime = {
190
+ pid: agentRun?.pid,
191
+ pid_alive: isProcessAlive(agentRun?.pid),
192
+ ack_file: {
193
+ exists: ackPath ? fs.existsSync(ackPath) : false,
194
+ path: ackPath,
195
+ },
196
+ log_files: {
197
+ stdout: stdoutPath ? readLogTail(stdoutPath, tailLines) : undefined,
198
+ stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
199
+ },
200
+ };
201
+ const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
202
+ return {
203
+ target_id: options.target_id,
204
+ resolved_from: resolved.resolved_from,
205
+ entities: {
206
+ assignment_id: assignmentId,
207
+ claim_id: assignment?.claim_id,
208
+ loop_id: loop?.id,
209
+ run_id: agentRun?.id,
210
+ },
211
+ assignment,
212
+ claim,
213
+ loop,
214
+ agent_run: agentRun,
215
+ runtime,
216
+ diagnosis,
217
+ };
218
+ }
219
+ //# sourceMappingURL=dispatch-status.js.map