nexus-prime 6.0.2 → 6.2.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.
@@ -13,6 +13,8 @@ import { handleGovernanceGroup } from './handlers/governance.js';
13
13
  import { handleRuntimeGroup } from './handlers/runtime.js';
14
14
  import { handleDiscoveryGroup } from './handlers/discovery.js';
15
15
  import { handleMiscGroup } from './handlers/misc.js';
16
+ import { nexusEventBus } from '../../../engines/event-bus.js';
17
+ import { recordToolInvocation } from './tool-health.js';
16
18
  /**
17
19
  * Route a tool call to the appropriate handler group.
18
20
  *
@@ -22,11 +24,24 @@ import { handleMiscGroup } from './handlers/misc.js';
22
24
  */
23
25
  export async function dispatchMcpToolCall(hctx, request, args, ctx) {
24
26
  const toolName = String(request.params?.name ?? '');
25
- // Try each group in priority order. Groups return undefined for unknown tools.
26
- return (await handleOrchestrationGroup(toolName, hctx, request, args, ctx) ??
27
- await handleMemoryGroup(toolName, hctx, request, args, ctx) ??
28
- await handleGovernanceGroup(toolName, hctx, request, args, ctx) ??
29
- await handleRuntimeGroup(toolName, hctx, request, args, ctx) ??
30
- await handleDiscoveryGroup(toolName, hctx, request, args, ctx) ??
31
- await handleMiscGroup(toolName, hctx, request, args, ctx));
27
+ const startedAt = Date.now();
28
+ try {
29
+ // Try each group in priority order. Groups return undefined for unknown tools.
30
+ const result = (await handleOrchestrationGroup(toolName, hctx, request, args, ctx) ??
31
+ await handleMemoryGroup(toolName, hctx, request, args, ctx) ??
32
+ await handleGovernanceGroup(toolName, hctx, request, args, ctx) ??
33
+ await handleRuntimeGroup(toolName, hctx, request, args, ctx) ??
34
+ await handleDiscoveryGroup(toolName, hctx, request, args, ctx) ??
35
+ await handleMiscGroup(toolName, hctx, request, args, ctx));
36
+ const durationMs = Date.now() - startedAt;
37
+ recordToolInvocation(toolName, durationMs, 'ok');
38
+ nexusEventBus.emit('tool.invocation', { toolName, durationMs, status: 'ok' });
39
+ return result;
40
+ }
41
+ catch (err) {
42
+ const durationMs = Date.now() - startedAt;
43
+ recordToolInvocation(toolName, durationMs, 'error');
44
+ nexusEventBus.emit('tool.invocation', { toolName, durationMs, status: 'error' });
45
+ throw err;
46
+ }
32
47
  }
@@ -253,28 +253,11 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
253
253
  formatBullets([
254
254
  `Workspace: ${workspace.repoName} (${workspace.workspaceSource})`,
255
255
  `Client: ${bootstrap.client?.displayName || bootstrap.client?.clientId || 'unknown'} (${bootstrap.client?.state || 'unknown'})`,
256
- `Bootstrap depth: ${bootstrap.depth || bootstrapDepth}`,
257
256
  `Memory recall: ${bootstrap.memoryRecall?.count ?? 0} result(s)`,
258
257
  `Memory stats: prefrontal ${bootstrap.memoryStats?.prefrontal ?? 0} · hippocampus ${bootstrap.memoryStats?.hippocampus ?? 0} · cortex ${bootstrap.memoryStats?.cortex ?? 0}`,
259
258
  `Recommended next step: ${bootstrap.recommendedNextStep || 'nexus_orchestrate'}`,
260
- `Execution mode: ${bootstrap.recommendedExecutionMode || 'autonomous'}`,
261
259
  `Token optimization: ${bootstrap.tokenOptimization?.autoApplied ? `auto-applied — saved ${Number(bootstrap.tokenOptimization?.planMetrics?.savings ?? 0).toLocaleString()} tokens (${bootstrap.tokenOptimization?.planMetrics?.pct ?? 0}% reduction)` : (bootstrap.tokenOptimization?.required ? 'required before broad reading' : 'not required yet')}`,
262
260
  `Catalog health: ${bootstrap.catalogHealth?.overall || 'unknown'} · selected ${bootstrap.artifactSelectionAudit?.selected?.length || 0}`,
263
- `Shortlist: ${bootstrap.shortlist?.skills?.slice(0, 3).join(', ') || 'none'} (skills), ${bootstrap.shortlist?.specialists?.slice(0, 3).join(', ') || 'none'} (specialists)`,
264
- `Knowledge fabric: ${bootstrap.sourceMixRecommendation?.dominantSource || bootstrap.knowledgeFabric?.dominantSource || 'awaiting source mix'}`,
265
- `RAG: ${bootstrap.ragCandidateStatus?.attachedCollections || 0} attached · ${bootstrap.ragCandidateStatus?.retrievedChunks || 0} retrieved`,
266
- `Task graph: ${bootstrap.taskGraphPreview?.phases?.length || 0} phases · ${bootstrap.taskGraphPreview?.independentBranches || 0} branches`,
267
- `Worker plan: ${bootstrap.workerPlanPreview?.totalWorkers || 0} workers planned`,
268
- bootstrap.needsDeepBootstrap ? 'Deep preparation: still recommended before risky or cross-file work' : 'Deep preparation: not currently required',
269
- `Payload ref: ${workspace.stateKey} · ${detailLevel}`,
270
- `Session summary: ${bootstrap.sessionSummaryBootstrap?.savedTokens ? `reused ${Number(bootstrap.sessionSummaryBootstrap.savedTokens || 0).toLocaleString()} tokens from the previous visit` : 'none available yet'}`,
271
- bootstrap.projectMemoryBootstrap?.count
272
- ? `Project memory: recovered ${bootstrap.projectMemoryBootstrap.count} prior project memories`
273
- : 'Project memory: no prior project memories recovered',
274
- bootstrap.autoGhostPass?.applied
275
- ? `Auto ghost-pass: ${bootstrap.autoGhostPass.riskAreas.length} risk area(s) · ${bootstrap.autoGhostPass.workerApproaches} approach(es)`
276
- : `Auto ghost-pass: skipped`,
277
- `Bootstrap status: ${bootstrap.clientBootstrapStatus?.clients?.length || 0} client manifests tracked`,
278
261
  (() => {
279
262
  try {
280
263
  const _lic = getSharedLicenseManager().getStatus();
@@ -287,11 +270,30 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
287
270
  return '';
288
271
  }
289
272
  })(),
273
+ ...(detailLevel === 'debug' ? [
274
+ `Bootstrap depth: ${bootstrap.depth || bootstrapDepth}`,
275
+ `Execution mode: ${bootstrap.recommendedExecutionMode || 'autonomous'}`,
276
+ `Shortlist: ${bootstrap.shortlist?.skills?.slice(0, 3).join(', ') || 'none'} (skills), ${bootstrap.shortlist?.specialists?.slice(0, 3).join(', ') || 'none'} (specialists)`,
277
+ `Knowledge fabric: ${bootstrap.sourceMixRecommendation?.dominantSource || bootstrap.knowledgeFabric?.dominantSource || 'awaiting source mix'}`,
278
+ `RAG: ${bootstrap.ragCandidateStatus?.attachedCollections || 0} attached · ${bootstrap.ragCandidateStatus?.retrievedChunks || 0} retrieved`,
279
+ `Task graph: ${bootstrap.taskGraphPreview?.phases?.length || 0} phases · ${bootstrap.taskGraphPreview?.independentBranches || 0} branches`,
280
+ `Worker plan: ${bootstrap.workerPlanPreview?.totalWorkers || 0} workers planned`,
281
+ bootstrap.needsDeepBootstrap ? 'Deep preparation: still recommended before risky or cross-file work' : 'Deep preparation: not currently required',
282
+ `Payload ref: ${workspace.stateKey} · ${detailLevel}`,
283
+ `Session summary: ${bootstrap.sessionSummaryBootstrap?.savedTokens ? `reused ${Number(bootstrap.sessionSummaryBootstrap.savedTokens || 0).toLocaleString()} tokens from the previous visit` : 'none available yet'}`,
284
+ bootstrap.projectMemoryBootstrap?.count
285
+ ? `Project memory: recovered ${bootstrap.projectMemoryBootstrap.count} prior project memories`
286
+ : 'Project memory: no prior project memories recovered',
287
+ bootstrap.autoGhostPass?.applied
288
+ ? `Auto ghost-pass: ${bootstrap.autoGhostPass.riskAreas.length} risk area(s) · ${bootstrap.autoGhostPass.workerApproaches} approach(es)`
289
+ : `Auto ghost-pass: skipped`,
290
+ `Bootstrap status: ${bootstrap.clientBootstrapStatus?.clients?.length || 0} client manifests tracked`,
291
+ ] : []),
290
292
  ]),
291
293
  detailLevel === 'debug' && bootstrap.tokenOptimization?.autoApplied && bootstrap.tokenOptimization?.plan
292
294
  ? `Auto token plan\n\`\`\`txt\n${bootstrap.tokenOptimization.plan}\n\`\`\``
293
295
  : '',
294
- formatJsonDetails('Structured details', payload),
296
+ detailLevel === 'debug' ? formatJsonDetails('Structured details', payload) : '',
295
297
  hctx.formatProtocolChecklist(),
296
298
  ].filter(Boolean).join('\n\n'),
297
299
  }],
@@ -331,6 +333,36 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
331
333
  const executionPreset = request.params.arguments?.executionPreset
332
334
  ? String(request.params.arguments.executionPreset)
333
335
  : undefined;
336
+ // Auto-apply token optimization when explicit files are provided.
337
+ // Gives agents an upfront reading plan before orchestrate runs.
338
+ // No-files path relies on knowledge fabric (handled inside orchestrate).
339
+ let upfrontTokenNote = '';
340
+ if (files && files.length > 3) {
341
+ try {
342
+ const statLimit = pLimit(8);
343
+ const fileRefs = await Promise.all(files.map((p) => statLimit(async () => {
344
+ const resolved = hctx.resolveToolPath(p, request.params.arguments ?? {});
345
+ try {
346
+ const stat = await fsPromises.stat(resolved);
347
+ return { path: resolved, sizeBytes: stat.size, lastModified: stat.mtimeMs };
348
+ }
349
+ catch {
350
+ return { path: resolved, sizeBytes: 0 };
351
+ }
352
+ })));
353
+ const upfrontPlan = await getTokenEngine().plan(prompt, fileRefs);
354
+ if (upfrontPlan.savings > 0) {
355
+ const pct = upfrontPlan.totalEstimatedTokens > 0
356
+ ? Math.round(upfrontPlan.savings / (upfrontPlan.totalEstimatedTokens + upfrontPlan.savings) * 100)
357
+ : 0;
358
+ upfrontTokenNote = `Token plan: ${upfrontPlan.files.filter(a => a.action === 'full').length} full reads, ${upfrontPlan.files.filter(a => a.action === 'outline').length} outlines, ${upfrontPlan.files.filter(a => a.action === 'skip').length} skipped. ~${upfrontPlan.savings.toLocaleString()} tokens saved (${pct}%). `;
359
+ nexusEventBus.emit('tokens.optimized', { savings: upfrontPlan.savings, pct, source: 'orchestrate-preflight' });
360
+ }
361
+ }
362
+ catch {
363
+ // Non-fatal: proceed without upfront plan
364
+ }
365
+ }
334
366
  try {
335
367
  const preset = resolveExecutionPreset(executionPreset);
336
368
  const execution = await hctx.nexusRef.orchestrate(prompt, applyExecutionPreset({
@@ -457,27 +489,30 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
457
489
  content: [{
458
490
  type: 'text',
459
491
  text: [
492
+ upfrontTokenNote ? `[Token plan] ${upfrontTokenNote}` : '',
460
493
  `Orchestrated run ${execution.state}.`,
461
494
  formatBullets([
462
495
  `Workspace: ${workspace.repoName} (${workspace.workspaceSource})`,
463
496
  `Run ID: ${execution.runId}`,
464
497
  `Summary: ${summarizeExecution(execution)}`,
465
498
  `Crew: ${execution.plannerState?.selectedCrew?.name || 'baseline path'}`,
466
- `Specialists: ${execution.plannerState?.selectedSpecialists.map((specialist) => specialist.name).slice(0, 4).join(', ') || 'none selected'}`,
467
- `Assets: ${(execution.activeSkills || []).length} skills · ${(execution.activeWorkflows || []).length} workflows · ${(runtimeUsage.artifactSelectionAudit?.selected?.length || 0)} audited selections`,
468
- `Task graph: ${runtimeUsage.taskGraph?.phases?.length || execution.taskGraph?.phases?.length || 0} phases · ${runtimeUsage.workerPlan?.totalWorkers || execution.workerPlan?.totalWorkers || 0} workers`,
469
- `Catalog health: ${runtimeUsage.catalogHealth?.overall || 'unknown'}`,
470
- preset ? `Preset: ${preset.name} (${preset.id})` : null,
471
499
  `Verification: ${verifiedWorkers}/${execution.workerResults.length} worker(s) verified`,
472
- `Worktrees: ${runtimeUsage.worktreeHealth?.overall || 'unknown'} · repaired ${runtimeUsage.worktreeHealth?.repairedEntries || 0} · broken ${runtimeUsage.worktreeHealth?.brokenEntries || 0}`,
473
- `Tokens: saved ${Number(execution.tokenTelemetry?.savedTokens || 0).toLocaleString()} · compression ${Number(execution.tokenTelemetry?.compressionPct || 0)}% · dominant ${runtimeUsage.sourceAwareTokenBudget?.dominantSource || execution.knowledgeFabric?.sourceMix?.dominantSource || 'repo'}`,
500
+ `Tokens: saved ${Number(execution.tokenTelemetry?.savedTokens || 0).toLocaleString()} · compression ${Number(execution.tokenTelemetry?.compressionPct || 0)}%`,
474
501
  autoTokenApplyNote || null,
475
- `RAG: ${(runtimeUsage.ragUsageSummary?.attachedCollections || execution.knowledgeFabric?.rag.attachedCollections.length || 0)} attached · ${(runtimeUsage.ragUsageSummary?.retrievedChunks || execution.knowledgeFabric?.rag.hits.length || 0)} retrieved`,
476
- `Memory scopes: ${Object.entries(runtimeUsage.memoryScopeUsage?.byScope || execution.memoryScopeUsage?.byScope || {}).map(([scope, count]) => `${scope}:${count}`).join(' · ') || 'awaiting shared/session reads'}`,
477
- `Payload ref: ${workspace.stateKey} · ${detailLevel}`,
502
+ ...(detailLevel === 'debug' ? [
503
+ `Specialists: ${execution.plannerState?.selectedSpecialists.map((specialist) => specialist.name).slice(0, 4).join(', ') || 'none selected'}`,
504
+ `Assets: ${(execution.activeSkills || []).length} skills · ${(execution.activeWorkflows || []).length} workflows · ${(runtimeUsage.artifactSelectionAudit?.selected?.length || 0)} audited selections`,
505
+ `Task graph: ${runtimeUsage.taskGraph?.phases?.length || execution.taskGraph?.phases?.length || 0} phases · ${runtimeUsage.workerPlan?.totalWorkers || execution.workerPlan?.totalWorkers || 0} workers`,
506
+ `Catalog health: ${runtimeUsage.catalogHealth?.overall || 'unknown'}`,
507
+ preset ? `Preset: ${preset.name} (${preset.id})` : null,
508
+ `Worktrees: ${runtimeUsage.worktreeHealth?.overall || 'unknown'} · repaired ${runtimeUsage.worktreeHealth?.repairedEntries || 0} · broken ${runtimeUsage.worktreeHealth?.brokenEntries || 0}`,
509
+ `RAG: ${(runtimeUsage.ragUsageSummary?.attachedCollections || execution.knowledgeFabric?.rag.attachedCollections.length || 0)} attached · ${(runtimeUsage.ragUsageSummary?.retrievedChunks || execution.knowledgeFabric?.rag.hits.length || 0)} retrieved`,
510
+ `Memory scopes: ${Object.entries(runtimeUsage.memoryScopeUsage?.byScope || execution.memoryScopeUsage?.byScope || {}).map(([scope, count]) => `${scope}:${count}`).join(' · ') || 'awaiting shared/session reads'}`,
511
+ `Payload ref: ${workspace.stateKey} · ${detailLevel}`,
512
+ ] : []),
478
513
  ]),
479
514
  detailLevel === 'debug' && execution.result ? `Result\n\`\`\`\n${execution.result}\n\`\`\`` : `Result preview\n\`\`\`\n${truncateText(execution.result || summarizeExecution(execution), 900)}\n\`\`\``,
480
- formatJsonDetails('Structured details', payload),
515
+ detailLevel === 'debug' ? formatJsonDetails('Structured details', payload) : '',
481
516
  hctx.formatRemainingProtocolSteps(),
482
517
  ].filter(Boolean).join('\n\n'),
483
518
  }],
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Per-tool invocation ring buffer and health aggregator.
3
+ *
4
+ * Stores the last RING_SIZE durations per tool (ok + error separately).
5
+ * Used by dispatch.ts to record each call; exposed via /api/runtime/tool-health.
6
+ */
7
+ export declare function recordToolInvocation(toolName: string, durationMs: number, status: 'ok' | 'error'): void;
8
+ export interface ToolHealthEntry {
9
+ toolName: string;
10
+ p50Ms: number;
11
+ p95Ms: number;
12
+ errorRate: number;
13
+ recentCount: number;
14
+ lastSeenMs: number;
15
+ status: 'green' | 'amber' | 'red';
16
+ }
17
+ export declare function getToolHealthSummary(): ToolHealthEntry[];
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Per-tool invocation ring buffer and health aggregator.
3
+ *
4
+ * Stores the last RING_SIZE durations per tool (ok + error separately).
5
+ * Used by dispatch.ts to record each call; exposed via /api/runtime/tool-health.
6
+ */
7
+ const RING_SIZE = 100;
8
+ const ERROR_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute sliding window for error rate
9
+ const _rings = new Map();
10
+ export function recordToolInvocation(toolName, durationMs, status) {
11
+ let ring = _rings.get(toolName);
12
+ if (!ring) {
13
+ ring = [];
14
+ _rings.set(toolName, ring);
15
+ }
16
+ ring.push({ ts: Date.now(), durationMs, status });
17
+ if (ring.length > RING_SIZE)
18
+ ring.shift();
19
+ }
20
+ function percentile(sorted, p) {
21
+ if (!sorted.length)
22
+ return 0;
23
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
24
+ return sorted[Math.max(0, idx)];
25
+ }
26
+ export function getToolHealthSummary() {
27
+ const now = Date.now();
28
+ const result = [];
29
+ for (const [toolName, ring] of _rings) {
30
+ if (!ring.length)
31
+ continue;
32
+ const windowStart = now - ERROR_RATE_WINDOW_MS;
33
+ const recent = ring.filter(r => r.ts >= windowStart);
34
+ const allDurations = ring.map(r => r.durationMs).sort((a, b) => a - b);
35
+ const errors = recent.filter(r => r.status === 'error').length;
36
+ const errorRate = recent.length > 0 ? errors / recent.length : 0;
37
+ const p50Ms = percentile(allDurations, 50);
38
+ const p95Ms = percentile(allDurations, 95);
39
+ const lastSeenMs = ring[ring.length - 1].ts;
40
+ // Red: error rate > 5% OR p95 > 5s in last 5 min
41
+ // Amber: error rate > 1% OR p95 > 2s
42
+ let status;
43
+ if (errorRate > 0.05 || p95Ms > 5_000)
44
+ status = 'red';
45
+ else if (errorRate > 0.01 || p95Ms > 2_000)
46
+ status = 'amber';
47
+ else
48
+ status = 'green';
49
+ result.push({ toolName, p50Ms, p95Ms, errorRate, recentCount: recent.length, lastSeenMs, status });
50
+ }
51
+ // Sort: red first, then amber, then green; within tier sort by p95 desc
52
+ return result.sort((a, b) => {
53
+ const tier = { red: 0, amber: 1, green: 2 };
54
+ const td = tier[a.status] - tier[b.status];
55
+ return td !== 0 ? td : b.p95Ms - a.p95Ms;
56
+ });
57
+ }
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Extracted from mcp.ts (Phase 3 split) to keep each file under 1500 LOC.
5
5
  * Uses BFS with pLimit(8) concurrency — no sync FS calls, no per-entry stat.
6
+ *
7
+ * Fast path: `git ls-files` for repos (< 300 ms even on large codebases).
8
+ * Fall back to walkDir only when not inside a git repository.
6
9
  */
7
10
  /**
8
11
  * Recursively walk `root`, returning all file paths.
@@ -10,8 +13,12 @@
10
13
  */
11
14
  export declare function walkDir(root: string): Promise<string[]>;
12
15
  /**
13
- * Scan `cwd` for TypeScript source files, caching by top-level directory mtime.
14
- * The caller provides the cache map so it lives with the adapter instance.
16
+ * Enumerate TypeScript source files via `git ls-files` (fast path) or
17
+ * recursive walkDir (fallback for non-git directories).
18
+ *
19
+ * `git ls-files` completes in < 300 ms even on large repos; walkDir on a
20
+ * repo with thousands of files can exceed the 25 s MCP handler timeout.
21
+ * Cache keyed on cwd + top-level directory mtime so repeated calls are free.
15
22
  */
16
23
  export declare function scanSourceFiles(cwd: string, scanCache: Map<string, {
17
24
  mtimeMs: number;
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * Extracted from mcp.ts (Phase 3 split) to keep each file under 1500 LOC.
5
5
  * Uses BFS with pLimit(8) concurrency — no sync FS calls, no per-entry stat.
6
+ *
7
+ * Fast path: `git ls-files` for repos (< 300 ms even on large codebases).
8
+ * Fall back to walkDir only when not inside a git repository.
6
9
  */
7
10
  import { promises as fsPromises } from 'fs';
8
11
  import * as path from 'path';
9
12
  import { pLimit } from '../../../../engines/util/p-limit.js';
13
+ import { execAsync } from '../../../../utils/exec-async.js';
10
14
  const SKIP = new Set([
11
15
  'node_modules', 'dist', '.git', '.next', '.turbo', '.cache',
12
16
  'coverage', '.nexus-prime', '.claude', '.vercel', 'build', 'out',
@@ -47,8 +51,12 @@ export async function walkDir(root) {
47
51
  return results;
48
52
  }
49
53
  /**
50
- * Scan `cwd` for TypeScript source files, caching by top-level directory mtime.
51
- * The caller provides the cache map so it lives with the adapter instance.
54
+ * Enumerate TypeScript source files via `git ls-files` (fast path) or
55
+ * recursive walkDir (fallback for non-git directories).
56
+ *
57
+ * `git ls-files` completes in < 300 ms even on large repos; walkDir on a
58
+ * repo with thousands of files can exceed the 25 s MCP handler timeout.
59
+ * Cache keyed on cwd + top-level directory mtime so repeated calls are free.
52
60
  */
53
61
  export async function scanSourceFiles(cwd, scanCache) {
54
62
  let topMtime = 0;
@@ -57,14 +65,28 @@ export async function scanSourceFiles(cwd, scanCache) {
57
65
  topMtime = stat.mtimeMs;
58
66
  }
59
67
  catch {
60
- // missing dir walk and return [] safely
68
+ // missing dir fall through and return []
61
69
  }
62
70
  const cached = scanCache.get(cwd);
63
71
  if (cached && cached.mtimeMs === topMtime) {
64
72
  return cached.files;
65
73
  }
66
- const files = await walkDir(cwd);
67
- const filtered = files.filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts'));
74
+ let filtered;
75
+ try {
76
+ // Fast path: git ls-files lists only tracked + untracked (unignored) files.
77
+ // -z uses NUL separators so paths with spaces work correctly.
78
+ const { stdout } = await execAsync('git ls-files --cached --others --exclude-standard -z -- "*.ts"', { cwd, timeout: 8_000 });
79
+ filtered = stdout
80
+ .split('\0')
81
+ .filter(Boolean)
82
+ .map((f) => path.isAbsolute(f) ? f : path.join(cwd, f))
83
+ .filter((f) => !f.endsWith('.d.ts'));
84
+ }
85
+ catch {
86
+ // Not a git repo or git unavailable — fall back to directory walk.
87
+ const files = await walkDir(cwd);
88
+ filtered = files.filter((f) => f.endsWith('.ts') && !f.endsWith('.d.ts'));
89
+ }
68
90
  scanCache.set(cwd, { mtimeMs: topMtime, files: filtered });
69
91
  return filtered;
70
92
  }
@@ -913,9 +913,14 @@ export class MCPAdapter {
913
913
  const swarm = await this.getOrchestrator().induce(goal);
914
914
  nexusEventBus.emit('nexusnet.sync', { newItemsCount: swarm.length });
915
915
  }
916
- // v6: Lazy-init Synapse/Architects on first tool call if not yet initialized
916
+ // v6: Lazy-init Synapse/Architects on first tool call if not yet initialized.
917
+ // Mandate + hire tools need Architects live so coordination bridge is set before
918
+ // executeMandatePipeline runs (otherwise worklistId is null, orphaned teams).
917
919
  if (toolName.startsWith('nexus_synapse_')) {
918
920
  this.nexusRef.ensureSynapseInit();
921
+ if (toolName === 'nexus_synapse_mandate' || toolName === 'nexus_synapse_assign_mission' || toolName === 'nexus_synapse_hire') {
922
+ this.nexusRef.ensureArchitectsInit();
923
+ }
919
924
  }
920
925
  const synapseResponse = await handleSynapseToolCall(toolName, args, this.nexusRef.getSynapse?.() ?? null);
921
926
  if (synapseResponse) {
@@ -185,41 +185,65 @@ export function initArchitects(options) {
185
185
  if (hasOperative)
186
186
  ward.start();
187
187
  const onOperativeHired = ({ operativeId, strikeTeamId }) => {
188
- knownOperatives.add(operativeId);
189
- operativeActivity.set(operativeId, { strikeTeamId, lastSortieAt: null });
190
- ensureImplicitWorklist(strikeTeamId);
188
+ try {
189
+ knownOperatives.add(operativeId);
190
+ operativeActivity.set(operativeId, { strikeTeamId, lastSortieAt: null });
191
+ ensureImplicitWorklist(strikeTeamId);
192
+ }
193
+ catch (err) {
194
+ console.error('[Architects] onOperativeHired failed', operativeId, err);
195
+ nexusEventBus.emit('architects.event.failed', { event: 'operative.hired', operativeId, error: String(err) });
196
+ }
191
197
  };
192
198
  const onMissionAssigned = ({ operativeId, missionId, title }) => {
193
- const activity = operativeActivity.get(operativeId);
194
- const strikeTeamId = activity?.strikeTeamId ?? null;
195
- if (!strikeTeamId)
196
- return;
197
- const worklistId = ensureImplicitWorklist(strikeTeamId);
198
- upsertWorkItem(db, {
199
- id: missionId,
200
- worklistId,
201
- title,
202
- status: 'todo',
203
- assignedOperativeId: operativeId,
204
- constructionLockId: null,
205
- dependsOn: [],
206
- branch: null,
207
- mergedAt: null,
208
- createdAt: new Date().toISOString(),
209
- });
199
+ try {
200
+ const activity = operativeActivity.get(operativeId);
201
+ const strikeTeamId = activity?.strikeTeamId ?? null;
202
+ if (!strikeTeamId)
203
+ return;
204
+ const worklistId = ensureImplicitWorklist(strikeTeamId);
205
+ upsertWorkItem(db, {
206
+ id: missionId,
207
+ worklistId,
208
+ title,
209
+ status: 'todo',
210
+ assignedOperativeId: operativeId,
211
+ constructionLockId: null,
212
+ dependsOn: [],
213
+ branch: null,
214
+ mergedAt: null,
215
+ createdAt: new Date().toISOString(),
216
+ });
217
+ }
218
+ catch (err) {
219
+ console.error('[Architects] onMissionAssigned failed', missionId, err);
220
+ nexusEventBus.emit('architects.event.failed', { event: 'mission.assigned', operativeId, missionId, error: String(err) });
221
+ }
210
222
  };
211
223
  const onSortieCompleted = ({ operativeId, missionId, status }) => {
212
- const activity = operativeActivity.get(operativeId);
213
- if (activity) {
214
- activity.lastSortieAt = new Date().toISOString();
215
- operativeActivity.set(operativeId, activity);
224
+ try {
225
+ const activity = operativeActivity.get(operativeId);
226
+ if (activity) {
227
+ activity.lastSortieAt = new Date().toISOString();
228
+ operativeActivity.set(operativeId, activity);
229
+ }
230
+ if (!missionId)
231
+ return;
232
+ updateWorkItemStatus(db, missionId, status === 'completed' ? 'done' : status === 'failed' ? 'failed' : 'blocked');
233
+ }
234
+ catch (err) {
235
+ console.error('[Architects] onSortieCompleted failed', missionId, err);
236
+ nexusEventBus.emit('architects.event.failed', { event: 'sortie.completed', operativeId, missionId: missionId ?? undefined, error: String(err) });
216
237
  }
217
- if (!missionId)
218
- return;
219
- updateWorkItemStatus(db, missionId, status === 'completed' ? 'done' : status === 'failed' ? 'failed' : 'blocked');
220
238
  };
221
239
  const onStriketeamDeployed = ({ strikeTeamId }) => {
222
- ensureImplicitWorklist(strikeTeamId);
240
+ try {
241
+ ensureImplicitWorklist(strikeTeamId);
242
+ }
243
+ catch (err) {
244
+ console.error('[Architects] onStriketeamDeployed failed', strikeTeamId, err);
245
+ nexusEventBus.emit('architects.event.failed', { event: 'striketeam.deployed', strikeTeamId, error: String(err) });
246
+ }
223
247
  };
224
248
  const onStanddown = () => setConvergencePaused(true);
225
249
  const onResumed = () => setConvergencePaused(false);
package/dist/cli/hook.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
19
  import os from 'os';
20
+ import { spawn } from 'child_process';
20
21
  function findDaemonLock(cwd) {
21
22
  const nexusPrimeDir = path.join(os.homedir(), '.nexus-prime');
22
23
  if (!fs.existsSync(nexusPrimeDir))
@@ -103,6 +104,31 @@ async function readStdinJson() {
103
104
  // --------------------------------------------------------------------------
104
105
  // Hook subcommands
105
106
  // --------------------------------------------------------------------------
107
+ function spawnDaemon(cwd) {
108
+ const entrypoint = process.argv[1];
109
+ if (!entrypoint)
110
+ return;
111
+ try {
112
+ const child = spawn(process.execPath, [entrypoint, 'daemon', '__serve'], {
113
+ cwd,
114
+ env: { ...process.env, NEXUS_WORKSPACE_ROOT: cwd },
115
+ detached: true,
116
+ stdio: 'ignore',
117
+ });
118
+ child.unref();
119
+ }
120
+ catch { /* never block the hook */ }
121
+ }
122
+ async function waitForDaemonLock(cwd, timeoutMs) {
123
+ const deadline = Date.now() + timeoutMs;
124
+ while (Date.now() < deadline) {
125
+ const lock = findDaemonLock(cwd);
126
+ if (lock)
127
+ return lock;
128
+ await new Promise(r => setTimeout(r, 100).unref());
129
+ }
130
+ return null;
131
+ }
106
132
  /** UserPromptSubmit — call nexus_session_bootstrap so the model never has to. */
107
133
  export async function runHookBootstrap() {
108
134
  const data = await readStdinJson();
@@ -110,9 +136,15 @@ export async function runHookBootstrap() {
110
136
  const goal = String(data.user_message ?? data.prompt ?? '').trim();
111
137
  if (!goal)
112
138
  return;
113
- const lock = findDaemonLock(cwd);
139
+ let lock = findDaemonLock(cwd);
140
+ if (!lock) {
141
+ // Daemon not running — spawn it and wait up to 1.5 s for the lock to appear.
142
+ // If it doesn't come up in time, fall back silently (never block the model).
143
+ spawnDaemon(cwd);
144
+ lock = await waitForDaemonLock(cwd, 1_500);
145
+ }
114
146
  if (!lock)
115
- return; // no daemon running — model must bootstrap manually
147
+ return;
116
148
  await callDaemonTool(lock, 'nexus_session_bootstrap', {
117
149
  goal: goal.slice(0, 500),
118
150
  }, 10_000);
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createHash } from 'crypto';
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
8
+ import { homedir } from 'os';
8
9
  import { fileURLToPath } from 'url';
9
10
  import { dirname, join, resolve } from 'path';
10
11
  import { detectInstalledIDEs, getMcpConfigForIDE } from '../agents/adapters/ide-compat.js';
@@ -93,9 +94,12 @@ export async function configureIDE(ide, opts = {}) {
93
94
  silent: opts.verbose === false,
94
95
  workspaceRoot,
95
96
  });
96
- // Write Claude Code hooks alongside the MCP entry (idempotent merge)
97
+ // Write Claude Code hooks alongside the MCP entry (idempotent merge).
98
+ // Write to both workspace and global user settings so desktop-mode
99
+ // Claude Code (which reads ~/.claude/settings.json) also auto-bootstraps.
97
100
  if (ide === 'claude-code') {
98
101
  _writeClaudeCodeHooks(workspaceRoot);
102
+ _writeClaudeCodeHooks(homedir());
99
103
  }
100
104
  // Write workspace-local configs for other IDEs that may be present
101
105
  _writeWorkspaceLocalConfigs(ide, workspaceRoot);
@@ -23,6 +23,11 @@
23
23
  <script type="module" src="./main.js"></script>
24
24
  </head>
25
25
  <body>
26
+ <script>
27
+ if(location.protocol==='file:'){
28
+ document.write('<div style="position:fixed;top:0;left:0;right:0;z-index:9999;background:#d97706;color:#fff;text-align:center;padding:6px 12px;font:13px/1.4 system-ui,sans-serif">Preview mode \u2014 API unavailable. Open <a href="http://localhost:3377/" style="color:#fff;text-decoration:underline">http://localhost:3377/</a> for the live dashboard.</div><div style="height:34px"></div>');
29
+ }
30
+ </script>
26
31
  <div class="noise" aria-hidden="true"></div>
27
32
  <div id="toast-stack" aria-live="polite" aria-atomic="true"></div>
28
33
 
@@ -108,6 +113,12 @@
108
113
  <div><div class="kb-col-hd"><span class="kb-col-title">Done</span><span class="kb-count" id="kc-done">0</span></div><div class="kb-body" id="kb-done"></div></div>
109
114
  </div>
110
115
 
116
+ <!-- Tool health strip -->
117
+ <div id="tool-health-card" class="card" style="margin-bottom:16px;display:none">
118
+ <div class="shd" style="margin-bottom:8px">Tool Health <span id="tool-health-stamp" style="font-size:11px;opacity:.5;font-weight:400"></span></div>
119
+ <div id="tool-health-list" style="display:flex;flex-wrap:wrap;gap:8px"></div>
120
+ </div>
121
+
111
122
  <!-- Events feed + system pulse -->
112
123
  <div class="cockpit-bottom">
113
124
  <div class="card">
@@ -47,6 +47,10 @@ setOnEvent(evt => {
47
47
  Board.render();
48
48
  _renderTicker();
49
49
  }
50
+ // Refresh tool health tile on any tool invocation event (board always refreshes)
51
+ if (String(evt.type||'') === 'tool.invocation' && tab === 'board') {
52
+ Board.loadToolHealth();
53
+ }
50
54
  if (tab === 'workforce' && ['synapse','operative','mission'].includes(evt.category)) {
51
55
  Workforce.render();
52
56
  }
@@ -18,6 +18,10 @@
18
18
  .org-box.root-box { border-color: rgba(0,255,136,0.4); color: var(--accent); }
19
19
  .org-box.team-box { border-color: rgba(0,212,255,0.3); color: var(--secondary); }
20
20
  .org-vline { width: 1px; height: 14px; background: var(--border); margin: 0 auto; }
21
+ .org-row { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
22
+ .org-branch { display: flex; flex-direction: column; align-items: center; }
23
+ /* Team tint: subtree root gets a faint background based on team color slot */
24
+ .org-branch[data-team] > div > .org-box { border-color: rgba(0,212,255,0.25); }
21
25
  .op-status-dot {
22
26
  display: inline-block; width: 5px; height: 5px; border-radius: 50%;
23
27
  margin-right: 5px; background: var(--text-dim); vertical-align: middle;
@@ -72,6 +72,8 @@ export async function load() {
72
72
  api('/api/dashboard/surface/operate', 5000),
73
73
  api('/api/synapse/health', 5000),
74
74
  ]);
75
+ // Non-blocking: tool health data from ring buffer
76
+ loadToolHealth();
75
77
  S.tokensSummary = tok;
76
78
  S.tokensLifetime = life;
77
79
  S.operateSurface = op;
@@ -94,6 +96,7 @@ export function render() {
94
96
  renderKanban();
95
97
  renderEvents();
96
98
  renderPulse();
99
+ renderToolHealth();
97
100
  }
98
101
 
99
102
  /* ── Memory pyramid (above kanban) ── */
@@ -295,6 +298,40 @@ function renderPulse() {
295
298
  el.innerHTML=rows.map(([k,v])=>`<div class="row"><span class="row-k">${esc(k)}</span><span class="row-v">${esc(v)}</span></div>`).join('');
296
299
  }
297
300
 
301
+ /* ── Tool health strip ── */
302
+ let _toolHealth = [];
303
+
304
+ export async function loadToolHealth() {
305
+ const data = await api('/api/runtime/tool-health', 10_000);
306
+ if (Array.isArray(data)) {
307
+ _toolHealth = data;
308
+ renderToolHealth();
309
+ }
310
+ }
311
+
312
+ function renderToolHealth() {
313
+ const card = $('tool-health-card');
314
+ const list = $('tool-health-list');
315
+ const stamp = $('tool-health-stamp');
316
+ if (!card || !list) return;
317
+ if (!_toolHealth.length) { card.style.display = 'none'; return; }
318
+ card.style.display = '';
319
+ if (stamp) stamp.textContent = 'updated ' + new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
320
+
321
+ const colorMap = { green: 'var(--accent)', amber: '#ffd14d', red: '#ff5f57' };
322
+ list.innerHTML = _toolHealth.map(t => {
323
+ const col = colorMap[t.status] || 'var(--muted)';
324
+ const errPct = Math.round((t.errorRate || 0) * 100);
325
+ const p95 = t.p95Ms >= 1000 ? (t.p95Ms / 1000).toFixed(1) + 's' : t.p95Ms + 'ms';
326
+ const pill = errPct > 0 ? `<span style="background:#ff5f5722;color:#ff5f57;border-radius:4px;padding:1px 5px;font-size:10px">${errPct}% err</span>` : '';
327
+ return `<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;background:var(--surface2);font-size:12px">
328
+ <span style="width:8px;height:8px;border-radius:50%;background:${col};flex-shrink:0"></span>
329
+ <span style="font-family:var(--font-mono);color:var(--fg1)">${esc(t.toolName.replace('nexus_',''))}</span>
330
+ <span style="color:var(--muted)">${p95}</span>${pill}
331
+ </div>`;
332
+ }).join('');
333
+ }
334
+
298
335
  /* ── Run drawer helper ── */
299
336
  function _openRunDrawer(runId) {
300
337
  const run = S.runs.find(r=>r.id===runId||r.runId===runId);
@@ -168,6 +168,28 @@ export function render() {
168
168
  }
169
169
 
170
170
  /* ── Org chart ── */
171
+ function _renderOpNode(op) {
172
+ const sc=op.status==='ACTIVE'||op.status==='active'?'active':op.status==='ZOMBIE'||op.status==='dead'?'dead':'';
173
+ const interval=op.sortieIntervalMs||60000, lastAt=op.lastSortieAt||op.heartbeatAt||Date.now();
174
+ const pillId=`op-pill-${esc(op.id||op.operativeId||'')}`;
175
+ return `<div class="org-box" id="${pillId}" data-opid="${esc(op.id||op.operativeId)}" data-opname="${esc(op.role||op.name||'agent')}" data-interval="${interval}" data-lastat="${lastAt}">
176
+ <span class="op-status-dot ${esc(sc)}"></span>${esc(op.role||op.name||'agent')}
177
+ </div>`;
178
+ }
179
+
180
+ function _renderOrgBranch(op, childrenOf, depth) {
181
+ const kids = childrenOf.get(op.id||op.operativeId) || [];
182
+ const kidsHtml = (depth < 3 && kids.length)
183
+ ? `<div class="org-vline"></div><div class="org-row">${kids.map(k => _renderOrgBranch(k, childrenOf, depth+1)).join('')}</div>`
184
+ : '';
185
+ const teamTint = op.team ? `data-team="${esc(op.team)}"` : '';
186
+ return `<div class="org-branch" ${teamTint}>
187
+ <div style="display:flex;flex-direction:column;align-items:center">
188
+ ${_renderOpNode(op)}${kidsHtml}
189
+ </div>
190
+ </div>`;
191
+ }
192
+
171
193
  function renderOrgChart() {
172
194
  const el=$('org-container'); if (!el) return;
173
195
  const ops=S.synapseHealth;
@@ -175,30 +197,29 @@ function renderOrgChart() {
175
197
  el.innerHTML=`<div class="empty"><div class="empty-title">${V.empty.workforce}</div><div class="empty-sub">Hire a specialist from the panel below.</div></div>`;
176
198
  return;
177
199
  }
178
- const teams={};
179
- for (const op of ops) { const t=op.team||'solo'; (teams[t]=teams[t]||[]).push(op); }
180
- const tHtml=Object.entries(teams).map(([name,members])=>{
181
- const mHtml=members.map(op=>{
182
- const sc=op.status==='ACTIVE'||op.status==='active'?'active':op.status==='ZOMBIE'||op.status==='dead'?'dead':'';
183
- const interval=op.sortieIntervalMs||60000, lastAt=op.lastSortieAt||op.heartbeatAt||Date.now();
184
- const pillId=`op-pill-${esc(op.id||op.operativeId||'')}`;
185
- return `<div style="display:flex;flex-direction:column;align-items:center">
186
- <div class="org-vline" style="height:10px"></div>
187
- <div class="org-box" id="${pillId}" data-opid="${esc(op.id||op.operativeId)}" data-opname="${esc(op.role||op.name||'agent')}" data-interval="${interval}" data-lastat="${lastAt}">
188
- <span class="op-status-dot ${esc(sc)}"></span>${esc(op.role||op.name||'agent')}
189
- </div>
190
- </div>`;
191
- }).join('');
192
- return `<div style="display:flex;flex-direction:column;align-items:center">
193
- <div class="org-box team-box">${esc(name)}</div>
194
- <div class="org-vline"></div>
195
- <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">${mHtml}</div>
196
- </div>`;
197
- }).join('');
200
+
201
+ // Build parent→children map keyed by operative id (null = root).
202
+ const childrenOf = new Map();
203
+ for (const op of ops) {
204
+ const parent = op.reportsToOperativeId || null;
205
+ if (!childrenOf.has(parent)) childrenOf.set(parent, []);
206
+ childrenOf.get(parent).push(op);
207
+ }
208
+ // Operatives with no parent whose parent id doesn't match any known op are also roots.
209
+ const knownIds = new Set(ops.map(o => o.id||o.operativeId));
210
+ for (const op of ops) {
211
+ if (op.reportsToOperativeId && !knownIds.has(op.reportsToOperativeId)) {
212
+ if (!childrenOf.has(null)) childrenOf.set(null, []);
213
+ childrenOf.get(null).push(op);
214
+ }
215
+ }
216
+ const roots = childrenOf.get(null) || ops; // fallback: all ops as roots if no hierarchy
217
+
218
+ const tHtml = roots.map(op => _renderOrgBranch(op, childrenOf, 0)).join('');
198
219
  el.innerHTML=`<div style="display:flex;flex-direction:column;align-items:center">
199
220
  <div class="org-box root-box">Orchestrator</div>
200
221
  <div class="org-vline"></div>
201
- <div style="display:flex;gap:16px;flex-wrap:wrap;justify-content:center">${tHtml}</div>
222
+ <div class="org-row">${tHtml}</div>
202
223
  </div>`;
203
224
 
204
225
  // Attach click + heartbeat
@@ -266,11 +287,30 @@ function renderSpecialistGrid() {
266
287
  }
267
288
 
268
289
  /* ── Hire sheet ── */
290
+ function _buildHireSelectors() {
291
+ const ops = (S.synapseHealth || []);
292
+ const teams = [...new Set(ops.map(o => o.team).filter(Boolean))];
293
+ const opOptions = [`<option value="">— none (team lead) —</option>`,
294
+ ...ops.map(o => `<option value="${esc(o.id||o.operativeId)}">${esc(o.role||o.name||o.id)}</option>`)
295
+ ].join('');
296
+ const teamOptions = [`<option value="">— solo —</option>`,
297
+ ...teams.map(t => `<option value="${esc(t)}">${esc(t)}</option>`)
298
+ ].join('');
299
+ const sel = (id, label, opts) =>
300
+ `<label style="display:block;margin-bottom:4px;font-size:var(--text-sm);color:var(--text-muted)">${label}</label>
301
+ <select id="${id}" style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4)">${opts}</select>`;
302
+ return { opOptions: sel('hire-reports-to', 'Reports to (optional)', opOptions),
303
+ teamOptions: sel('hire-team', 'Strike team (optional)', teamOptions) };
304
+ }
305
+
269
306
  function _showHireSheet(specialistId, name) {
307
+ const { opOptions, teamOptions } = _buildHireSelectors();
270
308
  openDrawer({ title: `Hire ${name}`,
271
309
  body: `<div class="dsec">
272
310
  <div class="dsec-title">Specialist</div>
273
311
  <div style="font-size:var(--text-sm);color:var(--text-muted);margin-bottom:var(--space-4)">${esc(name)}<br><span style="opacity:.6">${esc(specialistId)}</span></div>
312
+ ${opOptions}
313
+ ${teamOptions}
274
314
  <label style="display:block;margin-bottom:var(--space-2);font-size:var(--text-sm);color:var(--text-muted)">Budget cap (USD)</label>
275
315
  <input type="number" id="hire-budget" value="2.00" step="0.50" min="0.50" max="50"
276
316
  style="width:100%;background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:var(--text-sm);color:var(--text-main);margin-bottom:var(--space-4)">
@@ -281,12 +321,19 @@ function _showHireSheet(specialistId, name) {
281
321
  const specId = btn.target.dataset.specid;
282
322
  const specName = btn.target.dataset.specname;
283
323
  const budget = parseFloat(document.getElementById('hire-budget')?.value||'2');
324
+ const reportsToVal = (document.getElementById('hire-reports-to')?.value||'').trim()||null;
325
+ const teamVal = (document.getElementById('hire-team')?.value||'').trim()||null;
284
326
  btn.target.disabled = true;
285
327
  btn.target.textContent = 'Hiring…';
286
328
 
287
329
  // Optimistic hire: open the success drawer immediately (<5ms), then reconcile.
288
330
  const optimisticRecord = { operative: { specialistId: specId, _pending: true }, pricing: null };
289
- const result = await post('/api/synapse/hire', { specialistId: specId, budgetCapUsd: budget }, { optimistic: optimisticRecord });
331
+ const result = await post('/api/synapse/hire', {
332
+ specialistId: specId,
333
+ budgetCapUsd: budget,
334
+ reportsToOperativeId: reportsToVal,
335
+ strikeTeamId: teamVal,
336
+ }, { optimistic: optimisticRecord });
290
337
 
291
338
  // Immediately open drawer with pending state.
292
339
  openDrawer({ title: `${specName} hired`,
@@ -88,8 +88,8 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
88
88
  const operativeId = result?.id ?? result?.operativeId ?? null;
89
89
  const baseUrl = ctx.getAddress() ?? 'http://localhost:3377';
90
90
  // Optionally fire a warm-up first sortie (body.fireFirstSortie === true).
91
- // Non-blocking — we don't await; the dispatch runs in the background.
92
- let firstDispatchRunId = null;
91
+ // Non-blocking — dispatch runs in the background; runId is emitted via SSE
92
+ // (synapse.first-sortie.dispatched) when ready so the client doesn't race.
93
93
  if (body.fireFirstSortie === true && operativeId && result?.specialistId) {
94
94
  const repoRoot = ctx.repoRoot ?? process.cwd();
95
95
  scheduleFirstSortie({
@@ -97,14 +97,20 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
97
97
  repoRoot,
98
98
  repoName: path.basename(repoRoot),
99
99
  budgetCapUsd: result?.budgetCapUsd ?? 0.50,
100
- }).then(r => { if (r)
101
- firstDispatchRunId = r.runId; }).catch(() => { });
100
+ }).then(r => {
101
+ if (r?.runId) {
102
+ nexusEventBus.emit('dashboard.action', {
103
+ action: 'synapse.first-sortie.dispatched',
104
+ status: 'ok',
105
+ target: r.runId,
106
+ });
107
+ }
108
+ }).catch(() => { });
102
109
  }
103
110
  ctx.respondJson(res, {
104
111
  operative: result,
105
112
  pricing,
106
113
  firstSortieEstimate: pricing,
107
- firstDispatchRunId,
108
114
  dashboardUrl: operativeId ? `${baseUrl}/#workforce/${operativeId}` : `${baseUrl}/#workforce`,
109
115
  }, 201);
110
116
  }
@@ -114,6 +120,10 @@ export const handleGovernanceRoutes = async (ctx, req, res, url) => {
114
120
  return true;
115
121
  }
116
122
  if (req.method === 'POST' && url.pathname === '/api/synapse/deploy-crew') {
123
+ // Ensure Architects is live before mandate so coordination bridge is wired.
124
+ // getSynapse() triggers ensureSynapseInit(); getArchitects() triggers ensureArchitectsInit()
125
+ // + reconnectSynapseCoordination() so worklistId is non-null in the pipeline.
126
+ ctx.getArchitects();
117
127
  const synapse = ctx.getSynapse();
118
128
  if (!synapse) {
119
129
  const initErr = ctx.getSynapseInitError();
@@ -1,3 +1,4 @@
1
+ import { getToolHealthSummary } from '../../agents/adapters/mcp/tool-health.js';
1
2
  export const handleHealthRoutes = async (ctx, req, res, url) => {
2
3
  if (req.method === 'GET' && url.pathname === '/api/license') {
3
4
  const { getSharedLicenseManager } = await import('../../licensing/index.js');
@@ -22,5 +23,9 @@ export const handleHealthRoutes = async (ctx, req, res, url) => {
22
23
  await ctx.respondCachedJson(res, 'health', 15_000, () => ctx.collectHealth());
23
24
  return true;
24
25
  }
26
+ if (req.method === 'GET' && url.pathname === '/api/runtime/tool-health') {
27
+ ctx.respondJson(res, getToolHealthSummary());
28
+ return true;
29
+ }
25
30
  return false;
26
31
  };
@@ -281,6 +281,16 @@ export class DashboardServer {
281
281
  this.serveAppAsset(res, url.pathname);
282
282
  return;
283
283
  }
284
+ // Root-level aliases so relative paths in index.html work when served at /
285
+ // (e.g. ./styles/tokens.css → /styles/tokens.css → here).
286
+ // /app/* kept for backward compat (bookmarks, devtools, any hardcoded refs).
287
+ const ROOT_APP_PREFIXES = ['/styles/', '/views/', '/widgets/'];
288
+ const ROOT_APP_FILES = new Set(['/main.js', '/api.js', '/router.js', '/sse.js', '/state.js', '/vocabulary.js']);
289
+ if (req.method === 'GET' && (ROOT_APP_PREFIXES.some(p => url.pathname.startsWith(p)) ||
290
+ ROOT_APP_FILES.has(url.pathname))) {
291
+ this.serveAppAsset(res, '/app' + url.pathname);
292
+ return;
293
+ }
284
294
  if (req.method === 'GET' && url.pathname === '/welcome') {
285
295
  this.serveStaticHtml(res, path.join(__dirname, 'welcome.html'));
286
296
  return;
@@ -415,6 +425,7 @@ export class DashboardServer {
415
425
  serveAppIndex(res) {
416
426
  const strictHeaders = {
417
427
  'Content-Type': 'text/html',
428
+ 'Cache-Control': 'no-cache, must-revalidate',
418
429
  'Content-Security-Policy': [
419
430
  "default-src 'self'",
420
431
  "style-src 'self'",
@@ -174,6 +174,8 @@ export class SseBroker {
174
174
  }
175
175
  }
176
176
  function mapEventCategory(type) {
177
+ if (type === 'tool.invocation')
178
+ return 'mcp';
177
179
  if (type.startsWith('mcp.'))
178
180
  return 'mcp';
179
181
  if (type.startsWith('planner.'))
@@ -1,4 +1,4 @@
1
- export type NexusEventType = 'system.boot' | 'planner.stage' | 'memory.store' | 'memory.dedup' | 'memory.recall' | 'memory.flushed' | 'memory.health.tick' | 'memory.sqlite.retry' | 'pod.signal' | 'tokens.optimized' | 'tokens.searchSaved' | 'session.summaryBootstrap' | 'phantom.worker.start' | 'phantom.worker.complete' | 'phantom.merge.complete' | 'phantom.merge' | 'guardrail.check' | 'ghost.pass' | 'graph.query' | 'graph.sync.failed' | 'graph.coverage.low' | 'graph.cr.build.start' | 'graph.cr.build.complete' | 'graph.cr.build.failed' | 'darwin.cycle' | 'darwin.cycle.complete' | 'session.dna' | 'skill.register' | 'skill.deploy' | 'skill.revoke' | 'hook.deploy' | 'hook.revoke' | 'hook.fire' | 'hook.error' | 'workflow.deploy' | 'workflow.run' | 'automation.deploy' | 'automation.revoke' | 'automation.run' | 'shield.decision' | 'memory.audit' | 'federation.heartbeat' | 'client.heartbeat' | 'client.inferred' | 'client.status' | 'dashboard.action' | 'nexus.shutdown' | 'orchestrator.disposed' | 'orchestrator.funnel.stage' | 'nexusnet.publish' | 'nexusnet.sync' | 'mcp.call.start' | 'mcp.call.stream' | 'mcp.call.complete' | 'mcp.handler.complete' | 'mcp.handler.failed' | 'mcp.handler.retry' | 'mcp.handler.best-effort-failed' | 'memory.tier.promoted' | 'memory.tier.promoted.failed' | 'memory.pre-compaction' | 'memory.flush-requested' | 'entanglement.create' | 'entanglement.collapse' | 'entanglement.correlate' | 'cas.encode' | 'cas.decode' | 'cas.pattern_learned' | 'kv.merge' | 'kv.adapt' | 'kv.consensus' | 'byzantine.vote.started' | 'byzantine.vote.result' | 'synapse.ready' | 'synapse.operative.hired' | 'synapse.operative.retired' | 'synapse.operative.health.changed' | 'synapse.striketeam.deployed' | 'synapse.striketeam.completed' | 'synapse.mission.assigned' | 'synapse.mission.completed' | 'synapse.sortie.started' | 'synapse.sortie.completed' | 'synapse.sortie.failed' | 'synapse.fieldreport.submitted' | 'synapse.echo.fired' | 'synapse.budget.warning' | 'synapse.budget.exceeded' | 'synapse.approval.requested' | 'synapse.approval.resolved' | 'synapse.compaction.standdown' | 'synapse.compaction.resumed' | 'synapse.watchdog.stall' | 'synapse.watchdog.zombie' | 'architects.ready' | 'architects.blueprint.instantiated' | 'architects.worklist.created' | 'architects.workitem.claimed' | 'architects.workitem.completed' | 'architects.workitem.blocked' | 'architects.constructionlock.acquired' | 'architects.constructionlock.renewed' | 'architects.constructionlock.released' | 'architects.constructionlock.contested' | 'architects.relay.sent' | 'architects.relay.read' | 'architects.sentinel.patrol' | 'architects.sentinel.stall' | 'architects.sentinel.zombie' | 'architects.ward.patrol' | 'architects.ward.escalation' | 'architects.convergence.started' | 'architects.convergence.merged' | 'architects.convergence.failed' | 'architects.dispatch.go' | 'architects.dispatch.queued' | 'architects.relay.failed' | 'ledger.duplicate-prevented' | 'nexus.circuit-open' | 'nexus.circuit-tripped' | 'license.activated' | 'license.deactivated' | 'license.expired' | 'license.cap.warning' | 'license.cap.reached' | 'dispatch.started' | 'dispatch.event' | 'dispatch.token-usage' | 'dispatch.complete' | 'dispatch.failed' | 'dispatch.cancelled' | 'dispatch.budget-exceeded' | 'mcp.auto-memory.failed' | 'daemon.self-heal.db-corrupt' | 'feature.time_to_first_bootstrap' | 'feature.time_to_first_memory' | 'feature.time_to_first_interaction' | 'feature.time_to_first_mission_complete';
1
+ export type NexusEventType = 'system.boot' | 'planner.stage' | 'memory.store' | 'memory.dedup' | 'memory.recall' | 'memory.flushed' | 'memory.health.tick' | 'memory.sqlite.retry' | 'pod.signal' | 'tokens.optimized' | 'tokens.searchSaved' | 'session.summaryBootstrap' | 'phantom.worker.start' | 'phantom.worker.complete' | 'phantom.merge.complete' | 'phantom.merge' | 'guardrail.check' | 'ghost.pass' | 'graph.query' | 'graph.sync.failed' | 'graph.coverage.low' | 'graph.cr.build.start' | 'graph.cr.build.complete' | 'graph.cr.build.failed' | 'darwin.cycle' | 'darwin.cycle.complete' | 'session.dna' | 'skill.register' | 'skill.deploy' | 'skill.revoke' | 'hook.deploy' | 'hook.revoke' | 'hook.fire' | 'hook.error' | 'workflow.deploy' | 'workflow.run' | 'automation.deploy' | 'automation.revoke' | 'automation.run' | 'shield.decision' | 'memory.audit' | 'federation.heartbeat' | 'client.heartbeat' | 'client.inferred' | 'client.status' | 'dashboard.action' | 'nexus.shutdown' | 'orchestrator.disposed' | 'orchestrator.funnel.stage' | 'nexusnet.publish' | 'nexusnet.sync' | 'mcp.call.start' | 'mcp.call.stream' | 'mcp.call.complete' | 'mcp.handler.complete' | 'mcp.handler.failed' | 'mcp.handler.retry' | 'mcp.handler.best-effort-failed' | 'tool.invocation' | 'memory.tier.promoted' | 'memory.tier.promoted.failed' | 'memory.pre-compaction' | 'memory.flush-requested' | 'entanglement.create' | 'entanglement.collapse' | 'entanglement.correlate' | 'cas.encode' | 'cas.decode' | 'cas.pattern_learned' | 'kv.merge' | 'kv.adapt' | 'kv.consensus' | 'byzantine.vote.started' | 'byzantine.vote.result' | 'synapse.ready' | 'synapse.operative.hired' | 'synapse.operative.retired' | 'synapse.operative.health.changed' | 'synapse.striketeam.deployed' | 'synapse.striketeam.completed' | 'synapse.mission.assigned' | 'synapse.mission.completed' | 'synapse.sortie.started' | 'synapse.sortie.completed' | 'synapse.sortie.failed' | 'synapse.fieldreport.submitted' | 'synapse.echo.fired' | 'synapse.budget.warning' | 'synapse.budget.exceeded' | 'synapse.approval.requested' | 'synapse.approval.resolved' | 'synapse.compaction.standdown' | 'synapse.compaction.resumed' | 'synapse.watchdog.stall' | 'synapse.watchdog.zombie' | 'architects.ready' | 'architects.blueprint.instantiated' | 'architects.worklist.created' | 'architects.workitem.claimed' | 'architects.workitem.completed' | 'architects.workitem.blocked' | 'architects.constructionlock.acquired' | 'architects.constructionlock.renewed' | 'architects.constructionlock.released' | 'architects.constructionlock.contested' | 'architects.relay.sent' | 'architects.relay.read' | 'architects.sentinel.patrol' | 'architects.sentinel.stall' | 'architects.sentinel.zombie' | 'architects.ward.patrol' | 'architects.ward.escalation' | 'architects.convergence.started' | 'architects.convergence.merged' | 'architects.convergence.failed' | 'architects.dispatch.go' | 'architects.dispatch.queued' | 'architects.relay.failed' | 'architects.event.failed' | 'ledger.duplicate-prevented' | 'nexus.circuit-open' | 'nexus.circuit-tripped' | 'license.activated' | 'license.deactivated' | 'license.expired' | 'license.cap.warning' | 'license.cap.reached' | 'dispatch.started' | 'dispatch.event' | 'dispatch.token-usage' | 'dispatch.complete' | 'dispatch.failed' | 'dispatch.cancelled' | 'dispatch.budget-exceeded' | 'mcp.auto-memory.failed' | 'daemon.self-heal.db-corrupt' | 'feature.time_to_first_bootstrap' | 'feature.time_to_first_memory' | 'feature.time_to_first_interaction' | 'feature.time_to_first_mission_complete';
2
2
  export interface NexusEventPayloads {
3
3
  'system.boot': {
4
4
  version: string;
@@ -302,6 +302,11 @@ export interface NexusEventPayloads {
302
302
  stage: string;
303
303
  error: string;
304
304
  };
305
+ 'tool.invocation': {
306
+ toolName: string;
307
+ durationMs: number;
308
+ status: 'ok' | 'error';
309
+ };
305
310
  'memory.tier.promoted': {
306
311
  memoryId: string;
307
312
  from: string;
@@ -620,6 +625,13 @@ export interface NexusEventPayloads {
620
625
  subject: string;
621
626
  error: string;
622
627
  };
628
+ 'architects.event.failed': {
629
+ event: string;
630
+ operativeId?: string;
631
+ missionId?: string;
632
+ strikeTeamId?: string;
633
+ error: string;
634
+ };
623
635
  'ledger.duplicate-prevented': {
624
636
  fingerprint: string;
625
637
  existingId: string;
@@ -144,7 +144,7 @@ export function initSynapse(options) {
144
144
  if (payload.operativeId) {
145
145
  db.prepare('UPDATE synapse_operatives SET health_state=\'BLOCKED\', state=\'SUSPENDED\', suspend_reason=\'manual\' WHERE id=?').run(payload.operativeId);
146
146
  nexusEventBus.emit('synapse.operative.health.changed', { operativeId: payload.operativeId, healthState: 'BLOCKED' });
147
- providers.memory.store(`[Architects:Blocked] ${payload.operativeId} ${payload.reason ?? ''}`.trim(), 0.82, ['#synapse', '#blocked']);
147
+ providers.memory.store(`[Architects:Blocked] ${payload.operativeId} ${payload.reason ?? ''}`.trim(), 0.82, ['#synapse', '#blocked'], undefined, 0, { provenance: { agentId: payload.operativeId, source: 'system' } });
148
148
  }
149
149
  };
150
150
  const stallListener = ({ operativeId }) => {
@@ -69,6 +69,7 @@ export async function executeMandatePipeline(db, mandateText, providers, opts =
69
69
  // Ensure Architects creates a blueprint + worklist for this team (auto-wiring)
70
70
  const worklistId = providers.coordination?.getWorklistId(teamId) ?? null;
71
71
  const blueprintId = worklistId ? `implicit-blueprint:${teamId}` : null;
72
+ let leadOperativeId = null;
72
73
  const operativeIds = Array.from({ length: maxOps }, (_, index) => {
73
74
  const skill = matched.skills[index] ?? matched.skills[0];
74
75
  const specialist = matched.specialists[index] ?? matched.specialists[0] ?? null;
@@ -82,7 +83,11 @@ export async function executeMandatePipeline(db, mandateText, providers, opts =
82
83
  strikeTeamId: teamId,
83
84
  sortieIntervalMs: SynapseConfig.sortieIntervalMs,
84
85
  origin: 'mandate',
86
+ // First operative is the team lead; subsequent members report to it.
87
+ reportsToOperativeId: index === 0 ? null : leadOperativeId,
85
88
  });
89
+ if (index === 0)
90
+ leadOperativeId = operative.id;
86
91
  nexusEventBus.emit('synapse.operative.hired', {
87
92
  operativeId: operative.id,
88
93
  name: operative.name,
@@ -152,7 +157,12 @@ export async function executeMandatePipeline(db, mandateText, providers, opts =
152
157
  correlationId: teamId,
153
158
  status: 'deployed',
154
159
  });
155
- providers.memory.store(`[Synapse:Mandate] ${mandateText}`, 0.85, ['#synapse', '#mandate', `#team:${teamId}`]);
160
+ providers.memory.store(`[Synapse:Mandate] ${mandateText}`, 0.85, ['#synapse', '#mandate', `#team:${teamId}`], undefined, 0, {
161
+ provenance: {
162
+ source: 'runtime',
163
+ containerTags: [`#team:${teamId}`, ...(worklistId ? [`#worklist:${worklistId}`] : [])],
164
+ },
165
+ });
156
166
  return getStrikeTeam(db, teamId);
157
167
  })();
158
168
  return team;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-prime",
3
- "version": "6.0.2",
3
+ "version": "6.2.0",
4
4
  "description": "Local-first MCP control plane for coding agents with bootstrap-orchestrate execution, memory fabric, token budgeting, and worktree-backed swarms",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,6 +25,7 @@
25
25
  "nexus-prime": "dist/cli.js"
26
26
  },
27
27
  "scripts": {
28
+ "prepare": "git config core.hooksPath .hooks || true",
28
29
  "nexus:dev": "node dev-orchestrator.mjs",
29
30
  "generate:competitive-landscape": "tsx scripts/generate-competitive-landscape.ts",
30
31
  "generate:readme-catalog": "tsx scripts/generate-readme-runtime-catalog.ts",