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:
- Modify:
APIs/backend-vitto-api/src/app/engine/vitto-engine.service.ts:438-452 - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/vitto-engine-outcome.spec.ts(create)
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.
- Step 1: Write the failing test
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);
});
});
- Step 2: Run test to verify it fails
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='vitto-engine-outcome' --no-coverage
Expected: FAIL — source still contains this.aiClient.postExecutionOutcome(
- Step 3: Remove the duplicate outcome call
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
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='vitto-engine-outcome' --no-coverage
Expected: PASS
- Step 5: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 6: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/mind/outcome-feedback.service.ts:84-87 - Test:
APIs/backend-vitto-api/src/app/mind/__tests__/outcome-feedback-episodes.spec.ts(create)
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.
- Step 1: Write the failing test
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';
- Step 2: Run test to verify it fails
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
- Step 3: Replace blanket skip with quality gate
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;
}
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='outcome-feedback-episodes' --no-coverage
Expected: PASS (3 tests)
- Step 5: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/engine/skill-extraction.service.ts:14,26-27,71-83 - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/skill-extraction-notify.spec.ts(create)
After skillManager.createSkill() succeeds at line 71, trainingRegistry.notifySkillExtracted() is never called. The method exists on TrainingPluginRegistry (line 49). Just inject and call it.
- Step 1: Write the failing test
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();
});
});
- Step 2: Run test to verify it fails
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-extraction-notify' --no-coverage
Expected: FAIL — trainingRegistry is not injected and not called
- Step 3: Inject TrainingPluginRegistry and call notifySkillExtracted
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(() => {});
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-extraction-notify' --no-coverage
Expected: PASS
- Step 5: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/engine/context-assembly/channel-prompt-builder.service.ts:253-291 - Modify:
APIs/backend-vitto-api/src/app/engine/memory-persistence.service.ts:153-169(pass channel field) - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/channel-session-filter.spec.ts(create)
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.
- Step 1: Write the failing test
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
});
});
- Step 2: Run test to verify it fails
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-session-filter' --no-coverage
Expected: FAIL — filterMessagesByChannel doesn't exist yet
- Step 3: Create the utility function
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);
}
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-session-filter' --no-coverage
Expected: PASS (3 tests)
- Step 5: Wire the utility into loadChannelSessionHistory
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);
- Step 6: Fix memory-persistence to store channel field
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
});
}
- Step 7: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 8: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts:191-207 - Test:
APIs/backend-vitto-api/src/app/mind/__tests__/space-intelligence-brief.spec.ts(create)
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.
- Step 1: Write the failing test
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');
});
});
- Step 2: Run test to verify it fails
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
- Step 3: Simplify getSpaceBriefForPrompt
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").
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='space-intelligence-brief' --no-coverage
Expected: PASS (4 tests)
- Step 5: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 6: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/engine/skill-composition.service.ts:15-16,67,132-138 - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/skill-composition-execute.spec.ts(create)
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).
- Step 1: Write the failing test
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);
}
}
});
});
- Step 2: Run test to verify it fails
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 }
- Step 3: Wire ToolRegistryService into executeComposite
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 };
}
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='skill-composition-execute' --no-coverage
Expected: PASS
- Step 5: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 6: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/proactive/daily-digest.service.ts:299-323 - Test:
APIs/backend-vitto-api/src/app/proactive/__tests__/daily-digest-rich.spec.ts(create)
The formatDigestWhatsApp() returns plain text. Use the existing rich message types to send a card with quick reply buttons.
- Step 1: Write the failing test
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 };
}
- Step 2: Run test to verify it passes (setup test)
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='daily-digest-rich' --no-coverage
Expected: PASS
- Step 3: Update formatDigestWhatsApp to return rich message and send via gateway
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 });
}
- Step 4: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 5: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts:160(add button text routing) - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-alert-ack.spec.ts(create)
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.
- Step 1: Write the failing test
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');
});
});
- Step 2: Run test to verify it fails
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
- Step 3: Create the button handler utility
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 };
}
}
- Step 4: Run test to verify it passes
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-processing-alert-ack' --no-coverage
Expected: PASS (5 tests)
- Step 5: Wire into processChannelMessage
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;
}
}
- Step 6: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 7: Commit
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:
- Modify:
APIs/backend-vitto-api/src/app/engine/channel-processing.service.ts:763-791 - Test:
APIs/backend-vitto-api/src/app/engine/__tests__/channel-processing-voice.spec.ts(create)
The sendVoiceNoteIfEnabled() generates TTS audio but logs "needs public URL hosting to send." The codebase already has:
R2StorageServicein@libs-api/r2-storage-corewithupload()andgetSignedUrl()ChannelGatewayService.sendWhatsAppVoiceNote(to, audioUrl)at line 428
Just wire: generate → R2 upload → get signed URL → send via gateway.
- Step 1: Write the failing test
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
});
});
- Step 2: Run test to verify it passes (setup test)
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern='channel-processing-voice' --no-coverage
Expected: PASS
- Step 3: Wire R2 upload and WhatsApp send in sendVoiceNoteIfEnabled
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}`);
}
}
- Step 4: Verify TypeScript compiles
Run: cd APIs/backend-vitto-api && npx tsc --noEmit 2>&1 | head -20
Expected: No errors
- Step 5: Commit
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
- Step 1: Run all new tests
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.
- Step 2: Run existing test suite to check for regressions
cd APIs/backend-vitto-api && npx jest --no-coverage 2>&1 | tail -20
Expected: No new failures.
- Step 3: Full TypeScript compile
cd APIs/backend-vitto-api && npx tsc --noEmit
Expected: Clean compile.
- Step 4: Final commit (if any cleanup needed)
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