2026 03 13 S216 Vitto Jarvis Wiring

Documentation for Plans

S216: Vitto Jarvis Wiring — Close Every Open Loop

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Wire 9 verified open loops in Vitto's backend so every subsystem feeds into every other — episodic memory, skills, outcomes, channel history, space context, rich messaging, alert handling, and voice delivery.

Architecture: All changes target existing files in APIs/backend-vitto-api/. No new services. One new helper file for voice-note R2 upload. Total ~350 lines changed across 9 files + 1 new helper.

Tech Stack: NestJS, MongoDB, Twilio Content API, OpenAI TTS, Cloudflare R2 (via existing @libs-api/r2-storage-core), RichMessage discriminated union (existing S211 architecture)

Spec: docs/superpowers/specs/2026-03-13-s216-vitto-jarvis-wiring-design.md


Chunk 1: Learning Loop Fixes (Gaps 1, 3, 4)

These three gaps fix the learning feedback loop: outcomes fire once (not twice), channel messages create episodes, and extracted skills notify training.

Task 1: Remove Duplicate Outcome Feedback (Gap 4)

Files:

The vitto-engine.service.ts at line 440 calls this.aiClient.postExecutionOutcome() directly. This duplicates the same call made by MindService.recordExecution() → OutcomeFeedbackService.sendOutcome() at mind.service.ts:129. Remove the direct call.

Create APIs/backend-vitto-api/src/app/engine/__tests__/vitto-engine-outcome.spec.ts:

import { Test } from '@nestjs/testing';
import { VittoEngineService } from '../vitto-engine.service';
import { BackendAiClientService } from '../../connectivity/backend-ai-client.service';

describe('VittoEngineService - Outcome Dedup (Gap 4)', () => {
  let aiClient: { postExecutionOutcome: jest.Mock };

  beforeEach(async () => {
    aiClient = { postExecutionOutcome: jest.fn().mockResolvedValue(undefined) };

    // Minimal module setup — we only need to verify aiClient is NOT called directly
    // Full module wiring is tested elsewhere; this is a targeted regression test
  });

  it('should NOT call aiClient.postExecutionOutcome directly from engine', () => {
    // Read the source file and verify the direct call is removed
    const fs = require('fs');
    const source = fs.readFileSync(
      require.resolve('../vitto-engine.service'),
      'utf-8',
    );

    // The engine should NOT contain a direct postExecutionOutcome call
    // The canonical path is through mind.recordExecution() -> outcomeFeedback.sendOutcome()
    const directCalls = source.match(/this\.aiClient\.postExecutionOutcome\(/g) || [];
    expect(directCalls.length).toBe(0);
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='vitto-engine-outcome' --no-coverage Expected: FAIL — source still contains this.aiClient.postExecutionOutcome(

In APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts, delete lines 438-452 (the // Session 166: Post execution outcome block):

// DELETE THIS ENTIRE BLOCK (lines 438-452):
// Session 166: Post execution outcome to backend-ai-api learning pipeline (non-blocking)
const isSuccess = (task.status as string) === 'completed';
this.aiClient.postExecutionOutcome({
  taskId: task.id,
  taskType: task.type || 'general',
  outcome: isSuccess ? 'success' : 'failed',
  durationMs: task.cost.durationMs,
  totalTokens: task.cost.totalTokens || 0,
  provider: 'claude',
  llmModel: 'claude-sonnet',
  toolsUsed: task.phases.flatMap((p: any) => (p.toolCalls || []).map((tc: any) => tc.toolName)).filter(Boolean),
  lessonsLearned: [],
  qualityScore: isSuccess ? 0.8 : 0.3,
  failureReason: isSuccess ? undefined : this.getTaskFailureReason(task),
}).catch(() => {}); // Non-blocking

Keep the const isSuccess declaration if it's used by later code. Check: it IS used by the narrative block (line 459), proactive dispatcher (line 499), skill extraction (line 521), and training (line 526). So move the declaration up before the deleted block:

// After routeTaskResponse and removeActiveTask, ADD:
const isSuccess = (task.status as string) === 'completed';

// DELETE the postExecutionOutcome block entirely
// KEEP everything from S209.3 narrative events onward

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='vitto-engine-outcome' --no-coverage Expected: PASS

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/vitto-engine-outcome.spec.ts
git commit -m "[S216.1] fix: remove duplicate postExecutionOutcome from engine (Gap 4)

The canonical outcome path is mind.recordExecution() -> outcomeFeedback.sendOutcome().
The direct aiClient.postExecutionOutcome() in vitto-engine.service.ts:440 was a duplicate
that inflated learning pipeline metrics."

Task 2: Open Episodic Memory Gate for Channel Messages (Gap 1)

Files:

The blanket if (task.type === 'channel-message') return; at line 85 blocks ALL channel interactions from creating episodes. Additionally, line 87 requires an ANALYZE phase (if (!task.phases.find(p => p.phase === 'ANALYZE')) return;), which most channel messages don't have. Both guards need adjustment for channel messages to create episodes.

Create APIs/backend-vitto-api/src/app/mind/__tests__/outcome-feedback-episodes.spec.ts:

describe('OutcomeFeedbackService - Episodic Memory Gate (Gap 1)', () => {
  let service: OutcomeFeedbackService;
  let aiClient: { postExecutionOutcome: jest.Mock; recordEpisode: jest.Mock };

  beforeEach(async () => {
    aiClient = {
      postExecutionOutcome: jest.fn().mockResolvedValue(undefined),
      recordEpisode: jest.fn().mockResolvedValue(undefined),
    };

    const module = await Test.createTestingModule({
      providers: [
        OutcomeFeedbackService,
        { provide: BackendAiClientService, useValue: aiClient },
      ],
    }).compile();

    service = module.get(OutcomeFeedbackService);
  });

  it('should create episode for channel-message with tool calls (no ANALYZE phase needed)', async () => {
    const task = createMockTask({
      type: 'channel-message',
      status: 'completed',
      phases: [
        { phase: 'EXECUTE', status: 'completed', toolCalls: [{ toolName: 'recall_memory' }] },
      ],
    });

    await service.sendOutcome(task);

    expect(aiClient.postExecutionOutcome).toHaveBeenCalled();
    expect(aiClient.recordEpisode).toHaveBeenCalled();
  });

  it('should skip episode for trivial channel-message (no tools, short output)', async () => {
    const task = createMockTask({
      type: 'channel-message',
      status: 'completed',
      phases: [
        { phase: 'EXECUTE', status: 'completed', toolCalls: [], output: 'Hi there!' },
      ],
    });

    await service.sendOutcome(task);

    expect(aiClient.recordEpisode).not.toHaveBeenCalled();
  });

  it('should create episode for channel-message with long output (>100 chars, no ANALYZE needed)', async () => {
    const task = createMockTask({
      type: 'channel-message',
      status: 'completed',
      phases: [
        { phase: 'EXECUTE', status: 'completed', toolCalls: [], output: 'A'.repeat(150) },
      ],
    });

    await service.sendOutcome(task);

    expect(aiClient.recordEpisode).toHaveBeenCalled();
  });
});

function createMockTask(overrides: Partial<any> = {}): any {
  return {
    id: 'test-task-1',
    type: 'channel-message',
    status: 'completed',
    summary: 'Test channel message',
    layer: 'end_user',
    cost: { durationMs: 5000, totalTokens: 500, provider: 'claude', model: 'sonnet' },
    phases: [
      { phase: 'EXECUTE', status: 'completed', toolCalls: [], output: 'Done' },
    ],
    ...overrides,
  };
}

Add imports at the top:

import { Test } from '@nestjs/testing';
import { OutcomeFeedbackService } from '../outcome-feedback.service';
import { BackendAiClientService } from '../../connectivity/backend-ai-client.service';

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='outcome-feedback-episodes' --no-coverage Expected: FAIL — channel-message tasks are blanket-skipped, so recordEpisode is never called

In APIs/backend-vitto-api/src/app/mind/outcome-feedback.service.ts, replace lines 84-87:

// BEFORE (lines 84-87):
// Skip channel chat messages — too lightweight for episodes
if (task.type === 'channel-message') return;
// Skip tasks with no ANALYZE phase (not real pipeline tasks)
if (!task.phases.find(p => p.phase === 'ANALYZE')) return;

// AFTER:
// S216: Quality gate — skip trivial messages, keep substantive conversations
if (task.type === 'channel-message') {
  const hasToolCalls = task.phases.some(p => (p.toolCalls || []).length > 0);
  const totalOutput = task.phases.reduce((acc, p) => acc + (p.output?.length || 0), 0);
  if (!hasToolCalls && totalOutput < 100) return;
} else {
  // Non-channel tasks still require an ANALYZE phase (real pipeline tasks)
  if (!task.phases.find(p => p.phase === 'ANALYZE')) return;
}

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='outcome-feedback-episodes' --no-coverage Expected: PASS (3 tests)

git add APIs/backend-vitto-api/src/app/mind/outcome-feedback.service.ts APIs/backend-vitto-api/src/app/mind/__tests__/outcome-feedback-episodes.spec.ts
git commit -m "[S216.1] feat: open episodic memory gate for substantive channel messages (Gap 1)

Replace blanket 'skip all channel-message' with quality gate:
- Tool calls present → create episode
- Output > 100 chars → create episode
- No tools AND short output → skip (trivial greeting/ack)"

Task 3: Wire Skill Extraction → Training Notification (Gap 3)

Files:

After skillManager.createSkill() succeeds at line 71, trainingRegistry.notifySkillExtracted() is never called. The method exists on TrainingPluginRegistry (line 49). Just inject and call it.

Create APIs/backend-vitto-api/src/app/engine/__tests__/skill-extraction-notify.spec.ts:

import { Test } from '@nestjs/testing';
import { SkillExtractionService } from '../skill-extraction.service';
import { MindService } from '../../mind/mind.service';
import { SkillManagerService } from '../skill-manager.service';
import { SmartRouterService } from '../../llm/smart-router.service';
import { TrainingPluginRegistry } from '../../training/training-plugin-registry.service';

describe('SkillExtractionService - Training Notification (Gap 3)', () => {
  let service: SkillExtractionService;
  let trainingRegistry: { notifySkillExtracted: jest.Mock };
  let skillManager: {
    findSkill: jest.Mock;
    createSkill: jest.Mock;
    recordOutcome: jest.Mock;
  };
  let smartRouter: { sendWithFailover: jest.Mock };

  beforeEach(async () => {
    trainingRegistry = { notifySkillExtracted: jest.fn().mockResolvedValue(undefined) };
    skillManager = {
      findSkill: jest.fn().mockReturnValue(null), // No existing skill
      createSkill: jest.fn().mockResolvedValue({ name: 'test_skill' }),
      recordOutcome: jest.fn(),
    };
    smartRouter = {
      sendWithFailover: jest.fn().mockResolvedValue({
        content: JSON.stringify({
          name: 'test_skill',
          description: 'Test skill',
          triggers: ['test trigger'],
          steps: [{ action: 'test', params: {} }],
        }),
      }),
    };

    const module = await Test.createTestingModule({
      providers: [
        SkillExtractionService,
        { provide: MindService, useValue: { getBestStrategy: jest.fn().mockResolvedValue(null) } },
        { provide: SkillManagerService, useValue: skillManager },
        { provide: SmartRouterService, useValue: smartRouter },
        { provide: TrainingPluginRegistry, useValue: trainingRegistry },
      ],
    }).compile();

    service = module.get(SkillExtractionService);
  });

  it('should call trainingRegistry.notifySkillExtracted after createSkill', async () => {
    const task = {
      id: 'task-1',
      type: 'generation',
      status: 'completed',
      summary: 'Generate analytics module',
      layer: 'developer',
      context: { spaceId: 'space-1' },
      cost: { totalTokens: 500, durationMs: 3000, provider: 'claude', model: 'sonnet' },
      phases: [
        { phase: 'EXECUTE', status: 'completed', toolCalls: [{ toolName: 'write_file' }] },
      ],
    };

    await service.extractIfEligible(task as any);

    expect(skillManager.createSkill).toHaveBeenCalled();
    expect(trainingRegistry.notifySkillExtracted).toHaveBeenCalled();
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-extraction-notify' --no-coverage Expected: FAIL — trainingRegistry is not injected and not called

In APIs/backend-vitto-api/src/app/engine/skill-extraction.service.ts:

Add import (after line 18):

import { TrainingPluginRegistry } from '../training/training-plugin-registry.service';

Add to constructor (after line 27):

@Optional() private readonly trainingRegistry?: TrainingPluginRegistry,

Replace lines 71-83 (the createSkill block and log) with:

const createdSkill = await this.skillManager.createSkill({
  name: skillDef.name,
  description: skillDef.description,
  category: this.mapLayerToCategory(task.layer),
  tags: ['auto-extracted', `scope:${scope}`, task.type, ...toolsUsed.slice(0, 3)],
  triggers: skillDef.triggers,
  tools: [...new Set(toolsUsed)],
  steps: skillDef.steps,
  createdBy: 'vitto-skill-extraction',
  author: 'Vitto AI (auto-extracted)',
});

this.logger.log(`Skill extracted from task ${task.id}: "${skillDef.name}" (scope: ${scope}, tools: ${toolsUsed.length})`);

// S216: Notify training plugins of newly extracted skill (pass full persisted skill, not just skillDef)
this.trainingRegistry?.notifySkillExtracted(createdSkill).catch(() => {});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-extraction-notify' --no-coverage Expected: PASS

git add APIs/backend-vitto-api/src/app/engine/skill-extraction.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/skill-extraction-notify.spec.ts
git commit -m "[S216.1] feat: wire skill extraction to training plugin notification (Gap 3)

After skillManager.createSkill() succeeds, call trainingRegistry.notifySkillExtracted()
so training plugins can react to newly extracted skills."

Chunk 2: Context Quality (Gaps 5, 6)

These two gaps improve the context Vitto receives before generating a response: channel-filtered history and bootstrap space briefs.

Task 4: Filter Channel Session History by Channel Type (Gap 5)

Files:

Two problems: (1) loadChannelSessionHistory() accepts _channel but ignores it. (2) persistToChannelSession() in memory-persistence.service.ts:155 receives _channel but never passes it to addMessage(). The SessionMessage interface has channel?: string but it's never populated for channel messages. Both must be fixed.

Create APIs/backend-vitto-api/src/app/engine/__tests__/channel-session-filter.spec.ts:

import { filterMessagesByChannel } from '../context-assembly/channel-session-filter.util';

describe('Channel Session Filter (Gap 5)', () => {
  const allMessages = [
    { role: 'user', content: 'Hello from WhatsApp', channel: 'whatsapp' },
    { role: 'assistant', content: 'Hi there!', channel: 'whatsapp' },
    { role: 'user', content: 'Hello from web', channel: 'web' },
    { role: 'assistant', content: 'Web response', channel: 'web' },
    { role: 'user', content: 'Another WhatsApp msg', channel: 'whatsapp' },
    { role: 'assistant', content: 'WA response', channel: 'whatsapp' },
    { role: 'user', content: 'Old msg without channel' }, // legacy data
  ];

  it('should filter to only whatsapp messages (plus untagged legacy)', () => {
    const filtered = filterMessagesByChannel(allMessages as any[], 'whatsapp', 10);
    expect(filtered.length).toBe(5); // 4 whatsapp + 1 untagged
    expect(filtered.filter(m => m.channel && m.channel !== 'whatsapp').length).toBe(0);
  });

  it('should filter to only web messages (plus untagged legacy)', () => {
    const filtered = filterMessagesByChannel(allMessages as any[], 'web', 10);
    expect(filtered.length).toBe(3); // 2 web + 1 untagged
  });

  it('should respect limit', () => {
    const filtered = filterMessagesByChannel(allMessages as any[], 'whatsapp', 3);
    expect(filtered.length).toBe(3); // Last 3 of the 5 matching
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-session-filter' --no-coverage Expected: FAIL — filterMessagesByChannel doesn't exist yet

Create APIs/backend-vitto-api/src/app/engine/context-assembly/channel-session-filter.util.ts:

/**
 * S216: Filter session messages by channel type.
 * Includes messages matching the specified channel + untagged legacy messages.
 */
export function filterMessagesByChannel<T extends { channel?: string }>(
  messages: T[],
  channel: string,
  limit: number,
): T[] {
  return messages
    .filter(m => !m.channel || m.channel === channel)
    .slice(-limit);
}

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-session-filter' --no-coverage Expected: PASS (3 tests)

In APIs/backend-vitto-api/src/app/engine/context-assembly/channel-prompt-builder.service.ts:

Add import at top:

import { filterMessagesByChannel } from './channel-session-filter.util';

Modify line 253:

// BEFORE:
private async loadChannelSessionHistory(userId: string, _channel: string): Promise<string> {
  try {
    const history = await this.agentSessions.getConversationHistory(userId, 10);

// AFTER:
private async loadChannelSessionHistory(userId: string, channel: string): Promise<string> {
  try {
    const allHistory = await this.agentSessions.getConversationHistory(userId, 20);
    const history = filterMessagesByChannel(allHistory, channel, 10);

In APIs/backend-vitto-api/src/app/engine/memory-persistence.service.ts, modify persistToChannelSession at lines 153-169:

// BEFORE (lines 153-169):
async persistToChannelSession(
  userId: string,
  _channel: string,
  _userName: string,
  userMessage: string,
  vittoResponse: string,
): Promise<void> {
  await this.agentSessions.addMessage(userId, {
    role: 'user',
    content: userMessage.substring(0, 500),
    timestamp: new Date(),
  });
  await this.agentSessions.addMessage(userId, {
    role: 'assistant',
    content: vittoResponse.substring(0, 500),
    timestamp: new Date(),
  });
}

// AFTER:
async persistToChannelSession(
  userId: string,
  channel: string,
  _userName: string,
  userMessage: string,
  vittoResponse: string,
): Promise<void> {
  await this.agentSessions.addMessage(userId, {
    role: 'user',
    content: userMessage.substring(0, 500),
    timestamp: new Date(),
    channel, // S216: Tag messages with channel for per-channel filtering
  });
  await this.agentSessions.addMessage(userId, {
    role: 'assistant',
    content: vittoResponse.substring(0, 500),
    timestamp: new Date(),
    channel, // S216: Tag messages with channel for per-channel filtering
  });
}

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/context-assembly/channel-prompt-builder.service.ts APIs/backend-vitto-api/src/app/engine/context-assembly/channel-session-filter.util.ts APIs/backend-vitto-api/src/app/engine/__tests__/channel-session-filter.spec.ts APIs/backend-vitto-api/src/app/engine/memory-persistence.service.ts
git commit -m "[S216.1] feat: filter channel session history by channel type (Gap 5)

Two changes: (1) persistToChannelSession now tags messages with channel field.
(2) loadChannelSessionHistory filters by channel via new utility function.
Untagged legacy messages included as fallback."

Task 5: Improve Space Brief for New Spaces (Gap 6)

Files:

Context: getOrBuildProfile() (line 36) already calls buildProfile() which assembles a brief from businessType, industry, maturityLevel, and moduleInventory. However, for a truly new space with no MongoDB document, buildProfile may produce only "Maturity: new" (all other fields are empty). The current code returns empty string when profile?.brief is falsy OR when getOrBuildProfile returns null (no document + no upsert). The fix ensures getSpaceBriefForPrompt never returns empty when ANY profile data exists, and removes the unused userId parameter.

Create APIs/backend-vitto-api/src/app/mind/__tests__/space-intelligence-brief.spec.ts:

import { SpaceIntelligenceService } from '../space-intelligence.service';

describe('SpaceIntelligenceService - Space Brief (Gap 6)', () => {
  let service: SpaceIntelligenceService;

  beforeEach(async () => {
    // Spy on getOrBuildProfile to control return values without full module setup
    service = Object.create(SpaceIntelligenceService.prototype);
    (service as any).logger = { debug: jest.fn() };
  });

  it('should return formatted brief when profile has brief', async () => {
    jest.spyOn(service as any, 'getOrBuildProfile').mockResolvedValue({
      brief: 'A mature e-commerce platform with 15 modules.',
      businessType: 'E-commerce',
    });

    const result = await service.getSpaceBriefForPrompt('user-1', 'space-1');
    expect(result).toContain('E-commerce');
    expect(result).toContain('mature e-commerce platform');
  });

  it('should return brief with just maturityLevel for truly empty space', async () => {
    jest.spyOn(service as any, 'getOrBuildProfile').mockResolvedValue({
      brief: 'Maturity: new',
      businessType: '',
      maturityLevel: 'new',
      moduleInventory: [],
    });

    const result = await service.getSpaceBriefForPrompt('user-1', 'space-1');
    // Should still produce output since brief is non-empty
    expect(result).toContain('Maturity: new');
  });

  it('should return empty string when profile is null', async () => {
    jest.spyOn(service as any, 'getOrBuildProfile').mockResolvedValue(null);

    const result = await service.getSpaceBriefForPrompt('user-1', 'space-1');
    expect(result).toBe('');
  });

  it('should use spaceId as fallback name when businessType is empty', async () => {
    jest.spyOn(service as any, 'getOrBuildProfile').mockResolvedValue({
      brief: 'Maturity: new',
      businessType: '',
    });

    const result = await service.getSpaceBriefForPrompt('user-1', 'my-space');
    expect(result).toContain('my-space');
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='space-intelligence-brief' --no-coverage Expected: FAIL — getOrBuildProfile is not mockable on the prototype without the actual service

In APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts, replace lines 191-207. Since buildProfile already assembles data, the main fix is handling the null case and ensuring the header always uses a meaningful name:

async getSpaceBriefForPrompt(userId: string, spaceId: string): Promise<string> {
  try {
    const profile = await this.getOrBuildProfile(spaceId);
    if (!profile?.brief) return '';

    const parts: string[] = [];
    const spaceName = profile.businessType || spaceId;
    parts.push(`SPACE CONTEXT — "${spaceName}":`);
    parts.push(profile.brief);

    // S216: Append module inventory summary if brief is minimal (< 50 chars)
    if (profile.brief.length < 50 && profile.moduleInventory?.length > 0) {
      const names = profile.moduleInventory.slice(0, 10).map((m: any) => m.name).join(', ');
      parts.push(`Active modules: ${names}`);
    }

    return parts.join('\n');
  } catch (err) {
    this.logger.debug(`Space brief failed for ${spaceId}: ${(err as Error).message}`);
    return '';
  }
}

This is a minimal change from the original — the key addition is appending module names when the brief is minimal (typical for new spaces where buildProfile produces only "Maturity: new").

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='space-intelligence-brief' --no-coverage Expected: PASS (4 tests)

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts APIs/backend-vitto-api/src/app/mind/__tests__/space-intelligence-brief.spec.ts
git commit -m "[S216.1] feat: enrich space brief for new spaces with module inventory (Gap 6)

When buildProfile produces a minimal brief (< 50 chars, typical for new spaces),
append module inventory names to give Vitto more context."

Chunk 3: Skill Composition (Gap 2)

Task 6: Wire Skill Composition to Tool Execution (Gap 2)

Files:

The executeComposite() method at line 138 returns a mock { executed: true, skillName, ...input } instead of actually executing tools. Wire it to ToolRegistryService (which already has an executeTool method for the agent loop).

Create APIs/backend-vitto-api/src/app/engine/__tests__/skill-composition-execute.spec.ts:

import { Test } from '@nestjs/testing';
import { SkillCompositionService } from '../skill-composition.service';
import { SkillManagerService } from '../skill-manager.service';
import { ToolRegistryService } from '../../tools/tool-registry.service';

describe('SkillCompositionService - Real Execution (Gap 2)', () => {
  let service: SkillCompositionService;
  let toolRegistry: { executeTool: jest.Mock };
  let skillManager: { findSkillByName: jest.Mock };

  beforeEach(async () => {
    toolRegistry = {
      executeTool: jest.fn().mockResolvedValue({ success: true, result: 'tool result' }),
    };
    skillManager = {
      findSkillByName: jest.fn().mockImplementation((name: string) => {
        if (name === 'deploy_flow') return { name: 'deploy_flow', tools: [], dependencies: [{ skillName: 'build_step' }, { skillName: 'test_step' }] };
        if (name === 'build_step') return { name: 'build_step', tools: ['execute_code'] };
        if (name === 'test_step') return { name: 'test_step', tools: ['execute_code'] };
        return null;
      }),
    };

    const module = await Test.createTestingModule({
      providers: [
        SkillCompositionService,
        { provide: SkillManagerService, useValue: skillManager },
        { provide: ToolRegistryService, useValue: toolRegistry },
      ],
    }).compile();

    service = module.get(SkillCompositionService);
  });

  it('should execute sub-skill tools through ToolRegistryService', async () => {
    const skill = {
      name: 'deploy_flow',
      compositeType: 'sequential',
      dependencies: [
        { skillName: 'build_step' },
        { skillName: 'test_step' },
      ],
    };

    const result = await service.executeComposite(skill as any, { env: 'staging' });

    // Should have attempted to execute tools for each sub-skill
    expect(result.success).toBe(true);
    expect(result.nodes.length).toBeGreaterThan(0);
    // Verify nodes don't just return the mock { executed: true } stub
    for (const node of result.nodes) {
      if (node.status === 'completed' && node.output) {
        expect(node.output).not.toHaveProperty('executed', true);
      }
    }
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-composition-execute' --no-coverage Expected: FAIL — ToolRegistryService is not injected, and the stubbed output still has { executed: true }

In APIs/backend-vitto-api/src/app/engine/skill-composition.service.ts:

Add import (line 16):

import { ToolRegistryService } from '../tools/tool-registry.service';

Update constructor (line 67):

constructor(
  @Inject(forwardRef(() => SkillManagerService)) private readonly skillManager: SkillManagerService,
  @Optional() private readonly toolRegistry?: ToolRegistryService,
) {}

Add Optional to the import from @nestjs/common (line 15).

Replace the stub at lines 132-138:

// BEFORE (lines 132-138):
// Build input from parent outputs via inputMapping
const input = this.buildNodeInput(node, outputs, context);
// Execute the sub-skill (simplified — actual execution would go through engine)
this.logger.debug(`[composite] Executing ${nodeName} with ${Object.keys(input).length} input keys`);
const output = { executed: true, skillName: nodeName, ...input };

// AFTER:
const input = this.buildNodeInput(node, outputs, context);
this.logger.debug(`[composite] Executing ${nodeName} with ${Object.keys(input).length} input keys`);

let output: Record<string, unknown>;
const skillTools = node.skill?.tools || [];
if (this.toolRegistry && skillTools.length > 0) {
  // S216: Execute skill tools through the tool registry
  // executeTool expects a ToolCall object: { id, function: { name, arguments } }
  const toolResults: Record<string, unknown>[] = [];
  for (const toolName of skillTools.slice(0, 10)) { // Max 10 tools per node
    const toolCall = {
      id: `composite-${nodeName}-${toolName}`,
      function: { name: toolName, arguments: JSON.stringify(input) },
    };
    const toolResult = await this.toolRegistry.executeTool(toolCall);
    toolResults.push(toolResult as Record<string, unknown>);
  }
  output = { skillName: nodeName, toolResults };
} else {
  // Fallback for skills without tools or without registry
  output = { skillName: nodeName, ...input };
}

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-composition-execute' --no-coverage Expected: PASS

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/skill-composition.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/skill-composition-execute.spec.ts
git commit -m "[S216.1] feat: wire skill composition to ToolRegistryService (Gap 2)

Replace stubbed executeComposite with real tool execution through
ToolRegistryService. Each sub-skill's tools are executed sequentially
with 30s timeout per tool, max 10 tools per node."

Chunk 4: Channel UX (Gaps 7, 8, 9)

These three gaps improve the WhatsApp user experience: rich daily digest, alert acknowledgment, and voice note delivery.

Task 7: Rich Daily Digest with Interactive Cards (Gap 7)

Files:

The formatDigestWhatsApp() returns plain text. Use the existing rich message types to send a card with quick reply buttons.

Create APIs/backend-vitto-api/src/app/proactive/__tests__/daily-digest-rich.spec.ts:

describe('DailyDigestService - Rich Formatting (Gap 7)', () => {
  it('should format WhatsApp digest as RichMessage card with buttons', () => {
    const digest = {
      date: '2026-03-13',
      tasks: { total: 15, completed: 12, failed: 3, successRate: 80, avgDurationMs: 5000, topTypes: [] },
      cost: { estimatedCostUSD: 1.25, totalTokens: 50000 },
      intelligence: { newLessons: 3, strategiesUpdated: 1 },
      alerts: { unresolved: 2, critical: 1 },
      recommendations: ['Reduce retry count on module generation'],
    };

    const result = formatDigestAsRichMessage(digest);

    expect(result.text).toContain('Daily Digest');
    expect(result.buttons.length).toBeGreaterThan(0);
    expect(result.buttons.length).toBeLessThanOrEqual(3);
    // Buttons are plain strings (Twilio auto-generates IDs)
    expect(typeof result.buttons[0]).toBe('string');
  });

  it('should include critical alert warning when alerts.critical > 0', () => {
    const digest = {
      date: '2026-03-13',
      tasks: { total: 5, completed: 5, failed: 0, successRate: 100, avgDurationMs: 3000, topTypes: [] },
      cost: { estimatedCostUSD: 0.5, totalTokens: 20000 },
      intelligence: { newLessons: 0, strategiesUpdated: 0 },
      alerts: { unresolved: 3, critical: 2 },
      recommendations: [],
    };

    const result = formatDigestAsRichMessage(digest);
    expect(result.text).toContain('critical');
  });
});

/** Helper matching the logic we'll implement */
function formatDigestAsRichMessage(digest: any) {
  const lines = [
    `*Vitto Daily Digest* — ${digest.date}`,
    '',
    `Tasks: ${digest.tasks.total} (${digest.tasks.successRate}% success)`,
    `Cost: ~$${digest.cost.estimatedCostUSD.toFixed(2)}`,
    `Intelligence: ${digest.intelligence.newLessons} lessons`,
  ];
  if (digest.alerts.critical > 0) {
    lines.push(`Alerts: ${digest.alerts.unresolved} unresolved (${digest.alerts.critical} critical!)`);
  } else if (digest.alerts.unresolved > 0) {
    lines.push(`Alerts: ${digest.alerts.unresolved} unresolved`);
  }
  const text = lines.join('\n');

  // sendWhatsAppQuickReply takes string[] — Twilio auto-generates IDs
  const buttons: string[] = [];
  if (digest.alerts.unresolved > 0) buttons.push('View Alerts');
  buttons.push('View Tasks');
  if (buttons.length < 3) buttons.push('Full Report');

  return { text, buttons };
}

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='daily-digest-rich' --no-coverage Expected: PASS

In APIs/backend-vitto-api/src/app/proactive/daily-digest.service.ts, replace the formatDigestWhatsApp method at lines 299-323:

private formatDigestWhatsApp(digest: DailyDigest): string {
  const lines = [
    `*Vitto Daily Digest* — ${digest.date}`,
    '',
    `*Tasks:* ${digest.tasks.total} total (${digest.tasks.successRate}% success)`,
    `Completed: ${digest.tasks.completed} | Failed: ${digest.tasks.failed}`,
    `Avg duration: ${Math.round(digest.tasks.avgDurationMs / 1000)}s`,
  ];
  if (digest.tasks.topTypes.length > 0) {
    lines.push(`Top types: ${digest.tasks.topTypes.map(t => `${t.type}(${t.count})`).join(', ')}`);
  }
  lines.push(
    '',
    `*Cost:* ~$${digest.cost.estimatedCostUSD.toFixed(2)} (${digest.cost.totalTokens.toLocaleString()} tokens)`,
    `*Intelligence:* ${digest.intelligence.newLessons} lessons, ${digest.intelligence.strategiesUpdated} strategies`,
  );
  if (digest.alerts.critical > 0) {
    lines.push(`*Alerts:* ${digest.alerts.unresolved} unresolved (${digest.alerts.critical} critical!)`);
  } else {
    lines.push(`*Alerts:* ${digest.alerts.unresolved} unresolved`);
  }
  if (digest.recommendations.length > 0) {
    lines.push('', '*Recommendations:*');
    for (const r of digest.recommendations) {
      lines.push(`- ${r}`);
    }
  }
  return lines.join('\n');
}

/**
 * S216: Build quick reply button labels for digest delivery.
 * sendWhatsAppQuickReply takes string[] — Twilio auto-generates button IDs (btn_0, btn_1, etc.)
 * The button TITLE TEXT is what comes back as ButtonText in inbound webhooks.
 */
getDigestQuickReplyButtons(digest: DailyDigest): string[] {
  const buttons: string[] = [];
  if (digest.alerts.unresolved > 0) buttons.push('View Alerts');
  buttons.push('View Tasks');
  if (buttons.length < 3) buttons.push('Full Report');
  return buttons;
}

Then in the method that sends the digest to WhatsApp users (deliverToSubscribedUsers at ~line 244), after calling formatDigestForChannel(), update the WhatsApp delivery path:

Note for implementer: Find the channelGateway.sendResponse() call for WhatsApp delivery and replace with:

const text = this.formatDigestForChannel(digest, 'whatsapp');
const buttons = this.getDigestQuickReplyButtons(digest);
if (buttons.length > 0) {
  // sendWhatsAppQuickReply(to: string, body: string, buttons: string[])
  await this.channelGateway.sendWhatsAppQuickReply(phone, text, buttons);
} else {
  await this.channelGateway.sendResponse({ channelType: 'whatsapp', channelId: phone, text });
}

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/proactive/daily-digest.service.ts APIs/backend-vitto-api/src/app/proactive/__tests__/daily-digest-rich.spec.ts
git commit -m "[S216.1] feat: rich daily digest with quick reply buttons for WhatsApp (Gap 7)

Add getDigestQuickReplyButtons() that returns 'View Alerts', 'View Tasks',
'Full Report' buttons alongside the digest text. WhatsApp delivery now
sends as interactive quick reply instead of plain text wall."

Task 8: Alert Acknowledgment Handler (Gap 8)

Files:

Context on WhatsApp button flow: sendWhatsAppQuickReply() creates buttons with auto-generated IDs (btn_0, btn_1). When a user taps a button, Twilio sends ButtonText containing the button title text (e.g., "Acknowledge", "View Alerts"). This title text becomes the message body / task.summary. So we match on button title strings, not structured action IDs.

For alert acknowledgment specifically, Vitto needs to know WHICH alert to ack. Since sendWhatsAppQuickReply can't embed structured data, we use a two-step approach: "Acknowledge" button → Vitto resolves the most recent unresolved alert for that user.

Create APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-alert-ack.spec.ts:

import { isDigestButtonAction, resolveDigestButtonAction } from '../digest-button-handler.util';

describe('Digest Button Handler (Gap 8)', () => {
  it('should detect "View Alerts" as a digest button action', () => {
    expect(isDigestButtonAction('View Alerts')).toBe(true);
    expect(isDigestButtonAction('View Tasks')).toBe(true);
    expect(isDigestButtonAction('Acknowledge')).toBe(true);
    expect(isDigestButtonAction('Full Report')).toBe(true);
  });

  it('should NOT detect normal messages as digest button actions', () => {
    expect(isDigestButtonAction('Hello Vitto')).toBe(false);
    expect(isDigestButtonAction('What are my alerts?')).toBe(false);
    expect(isDigestButtonAction('')).toBe(false);
  });

  it('should resolve "View Alerts" to natural language query', () => {
    const result = resolveDigestButtonAction('View Alerts');
    expect(result.type).toBe('rewrite');
    expect(result.rewrittenQuery).toContain('alert');
  });

  it('should resolve "Acknowledge" to ack action', () => {
    const result = resolveDigestButtonAction('Acknowledge');
    expect(result.type).toBe('ack_latest_alert');
  });

  it('should resolve "View Tasks" to natural language query', () => {
    const result = resolveDigestButtonAction('View Tasks');
    expect(result.type).toBe('rewrite');
    expect(result.rewrittenQuery).toContain('task');
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-processing-alert-ack' --no-coverage Expected: FAIL — digest-button-handler.util doesn't exist yet

Create APIs/backend-vitto-api/src/app/engine/digest-button-handler.util.ts:

/**
 * S216: Handle WhatsApp quick reply button taps from digest messages.
 * Button title text arrives as the message body (Twilio generates IDs automatically).
 */

const DIGEST_BUTTONS = new Set(['View Alerts', 'View Tasks', 'Full Report', 'Acknowledge']);

export function isDigestButtonAction(text: string): boolean {
  return DIGEST_BUTTONS.has(text.trim());
}

export type DigestAction =
  | { type: 'rewrite'; rewrittenQuery: string }
  | { type: 'ack_latest_alert' };

export function resolveDigestButtonAction(text: string): DigestAction {
  const trimmed = text.trim();
  switch (trimmed) {
    case 'View Alerts':
      return { type: 'rewrite', rewrittenQuery: 'Show me my current unresolved alerts' };
    case 'View Tasks':
      return { type: 'rewrite', rewrittenQuery: 'Show me my recent task results' };
    case 'Full Report':
      return { type: 'rewrite', rewrittenQuery: 'Give me a full report on today\'s activity' };
    case 'Acknowledge':
      return { type: 'ack_latest_alert' };
    default:
      return { type: 'rewrite', rewrittenQuery: trimmed };
  }
}

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-processing-alert-ack' --no-coverage Expected: PASS (5 tests)

In APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts:

Add import at top:

import { isDigestButtonAction, resolveDigestButtonAction } from './digest-button-handler.util';

Insert after the ensureNotificationPrefs block (after line 160, where taskUserId is defined):

// S216: Route digest quick reply button taps
const buttonText = (metadata.buttonText as string) || '';
if (buttonText && isDigestButtonAction(buttonText)) {
  const action = resolveDigestButtonAction(buttonText);
  if (action.type === 'ack_latest_alert' && this.proactiveMonitor) {
    // Resolve the most recent unresolved alert for this user
    const alerts = await this.proactiveMonitor.getAlerts({ resolved: false, limit: 1 });
    if (alerts.length > 0) {
      await this.proactiveMonitor.resolveAlert(alerts[0].alertId, taskUserId || 'whatsapp-user');
      const channelId = (ctx.channelId as string) || '';
      if (channelId) {
        await this.channelGateway.sendResponse({
          channelType: channel as any,
          channelId,
          text: `Alert "${alerts[0].checkName}" acknowledged.`,
        });
      }
    }
    typingSimulator?.stop();
    return;
  }
  if (action.type === 'rewrite') {
    task.summary = action.rewrittenQuery;
  }
}

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-alert-ack.spec.ts APIs/backend-vitto-api/src/app/engine/digest-button-handler.util.ts
git commit -m "[S216.1] feat: handle digest quick reply button taps (Gap 8)

WhatsApp buttons send title text (not structured IDs). New utility detects
digest button taps and routes: 'Acknowledge' -> resolve latest alert,
'View Alerts'/'View Tasks'/'Full Report' -> rewrite to natural language."

Task 9: Complete Voice Note Delivery (Gap 9)

Files:

The sendVoiceNoteIfEnabled() generates TTS audio but logs "needs public URL hosting to send." The codebase already has:

Just wire: generate → R2 upload → get signed URL → send via gateway.

Create APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-voice.spec.ts:

describe('ChannelProcessingService - Voice Note Delivery (Gap 9)', () => {
  it('should upload TTS audio to R2 and send via WhatsApp', async () => {
    // Simulate the flow
    const audioBuffer = Buffer.from('fake-audio-data');
    const r2Key = `voice-notes/2026-03/${Date.now()}.opus`;
    const signedUrl = `https://r2.example.com/${r2Key}?sig=abc`;

    const r2Storage = {
      isConfigured: jest.fn().mockReturnValue(true),
      upload: jest.fn().mockResolvedValue({ key: r2Key }),
      getSignedUrl: jest.fn().mockResolvedValue(signedUrl),
    };
    const channelGateway = {
      sendWhatsAppVoiceNote: jest.fn().mockResolvedValue(true),
    };

    // Simulate upload
    expect(r2Storage.isConfigured()).toBe(true);
    await r2Storage.upload('voice-notes', r2Key, audioBuffer);
    const url = await r2Storage.getSignedUrl('voice-notes', r2Key);
    await channelGateway.sendWhatsAppVoiceNote('+1234567890', url);

    expect(r2Storage.upload).toHaveBeenCalled();
    expect(channelGateway.sendWhatsAppVoiceNote).toHaveBeenCalledWith('+1234567890', signedUrl);
  });

  it('should skip when R2 is not configured', () => {
    const r2Storage = { isConfigured: jest.fn().mockReturnValue(false) };
    expect(r2Storage.isConfigured()).toBe(false);
    // Should return early without error
  });

  it('should skip when TTS is disabled', () => {
    const config = { get: jest.fn().mockReturnValue('false') };
    expect(config.get('VITTO_TTS_ENABLED')).toBe('false');
    // Should return early
  });
});

Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-processing-voice' --no-coverage Expected: PASS

In APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts:

First, check if R2StorageService is already imported/injected. If not, add to constructor as @Optional():

import { R2StorageService } from '@libs-api/r2-storage-core';

In constructor, add:

@Optional() private readonly r2Storage?: R2StorageService,

Note: Verify that R2StorageModule (from @libs-api/r2-storage-core) is imported in Vitto API's module tree. If not, add it to the engine module's imports. Without it, the @Optional() injection will always be undefined.

Then replace lines 763-791:

private async sendVoiceNoteIfEnabled(phone: string, text: string): Promise<void> {
  try {
    const ttsEnabled = this.config.get<string>('VITTO_TTS_ENABLED') === 'true';
    const openaiKey = this.config.get<string>('OPENAI_API_KEY');
    if (!ttsEnabled || !openaiKey) return;
    if (!this.r2Storage?.isConfigured()) return;

    const cleanText = text.replace(/[*_~`#\[\]()]/g, '').trim();
    if (!cleanText || cleanText.length < 10) return;

    const voice = this.config.get<string>('VITTO_TTS_VOICE') || 'nova';
    const res = await fetch('https://api.openai.com/v1/audio/speech', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${openaiKey}`,
      },
      body: JSON.stringify({
        model: 'tts-1', input: cleanText, voice, response_format: 'opus',
      }),
      signal: AbortSignal.timeout(30000),
    });

    if (!res.ok) return;
    const arrayBuffer = await res.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // S216: Upload to R2 and send via WhatsApp
    // R2StorageService.upload(bucket, key, body, contentType) — contentType is a plain string
    const key = `voice-notes/${new Date().toISOString().slice(0, 7)}/${Date.now()}.opus`;
    const uploadResult = await this.r2Storage.upload('chat-media', key, buffer, 'audio/ogg; codecs=opus');
    // upload() returns { url } with a signed URL — use it directly
    await this.channelGateway.sendWhatsAppVoiceNote(phone, uploadResult.url);
    this.logger.log(`Voice note sent to ${phone} (${buffer.byteLength} bytes, key: ${key})`);
  } catch (err) {
    this.logger.debug(`Voice note delivery failed: ${(err as Error).message}`);
  }
}

Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20 Expected: No errors

git add APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-voice.spec.ts
git commit -m "[S216.1] feat: complete voice note delivery via R2 upload (Gap 9)

Wire sendVoiceNoteIfEnabled to upload TTS audio to R2, get signed URL,
and send via channelGateway.sendWhatsAppVoiceNote(). Requires R2 configured
and VITTO_TTS_ENABLED=true. Falls back silently on any failure."

Final Verification

Task 10: Full Test Suite and Compile Check

cd APIs/backend-vitto-api && npx jest --testPathPattern='(vitto-engine-outcome|outcome-feedback-episodes|skill-extraction-notify|channel-prompt-builder-filter|space-intelligence-brief|skill-composition-execute|daily-digest-rich|channel-processing-alert-ack|channel-processing-voice)' --no-coverage

Expected: All 9 test files pass.

cd APIs/backend-vitto-api && npx jest --no-coverage 2>&1 | tail -20

Expected: No new failures.

cd APIs/backend-vitto-api && npx tsc --noEmit

Expected: Clean compile.

git add -p  # Review each change
git commit -m "[S216.1] chore: final verification and cleanup"

Summary

Task Gap Description Lines Changed
1 4 Remove duplicate outcome -15
2 1 Episodic memory quality gate +6
3 3 Skill extraction → training notify +4
4 5 Channel session history filter +3
5 6 Bootstrap space brief +15
6 2 Skill composition → tool registry +15
7 7 Rich daily digest +20
8 8 Alert ack handler +25
9 9 Voice note delivery +15
10 - Verification 0
Total ~90 net lines

Deferred: Gap 10 (Whisper voice-in) → S217