Plan: Create LLMProvider Interface + ClaudeCLIProvider#
Summary#
Define the LLMProvider abstraction interface with a normalized PhaseResult type and wrap the existing ClaudeInvocationService + SignalParser as ClaudeCLIProvider. This decouples the <promise> signal protocol from the core pipeline, making it a Claude-specific detail — Phase 1 foundation for multi-tenant.
Files#
| File | Action | Description |
|---|---|---|
src/llm/llm-provider.interface.ts | create | LLMProvider interface, PhaseResult type, LLM_PROVIDER injection token |
src/llm/claude/claude-cli.provider.ts | create | ClaudeCLIProvider implementing LLMProvider — delegates to ClaudeInvocationService, maps SignalResult → PhaseResult |
src/llm/llm.module.ts | create | LLMModule — provides LLM_PROVIDER token bound to ClaudeCLIProvider, exports it |
src/llm/claude/claude-cli.provider.spec.ts | create | Unit tests for all 5 signal mapping scenarios + error handling + no-signal edge case |
Steps#
- Define
PhaseResulttype andLLMProviderinterface insrc/llm/llm-provider.interface.ts.PhaseResultmust capture:status('complete' | 'blocked' | 'skip'),blockedType('transient' | 'persistent' | 'conflict' | null),reason,completeType,retryAfter,prNumber,prBranch,conflictMetadata(reuseConflictMetadatafromsrc/conflict/conflict.types.ts), andmetadata. ExportLLM_PROVIDERas aSymbol('LLM_PROVIDER')injection token.LLMProvider.invoke()signature:invoke(skill: string, env: Record<string, string>, taskId: number): Promise<PhaseResult>. - Implement
ClaudeCLIProviderinsrc/llm/claude/claude-cli.provider.ts. InjectClaudeInvocationServicevia constructor.invoke()callsthis.claude.invoke(), then mapsSignalResult→PhaseResult: maptype('complete'→'complete', 'skip'→'skip', 'blocked'/'partial'/'none'→'blocked'), mapsubtypetoblockedType('' defaults to 'transient' for blocked/partial/none), extractprNumber/prBranchfromresult.metadata, pass throughreason,completeType,retryAfter,conflictMetadata. Wrap in try/catch — on error returnPhaseResult { status: 'blocked', blockedType: 'transient', reason: error.message }. - Create
LLMModuleinsrc/llm/llm.module.ts. ImportInvokeModule. ProvideLLM_PROVIDERtoken usinguseClass: ClaudeCLIProvider. ExportLLM_PROVIDER. - Write unit tests in
src/llm/claude/claude-cli.provider.spec.ts. MockClaudeInvocationService. Cover: (a) complete signal →PhaseResult { status: 'complete' }, (b) blocked:transient →{ status: 'blocked', blockedType: 'transient' }, (c) blocked:persistent →{ status: 'blocked', blockedType: 'persistent' }, (d) blocked:conflict withconflictMetadata→{ status: 'blocked', blockedType: 'conflict' }, (e) skip →{ status: 'skip' }, (f) no signal (type: 'none') →{ status: 'blocked', blockedType: 'transient' }, (g) invoke throws →{ status: 'blocked', blockedType: 'transient', reason: error.message }.
Verification#
npx tsc --noEmitpasses with zero errorsnpm run lintpasses with zero warningsnpm run test -- --testPathPattern llmpasses all 7 test cases
Risks#
SignalResult.metadatakey names (prNumber,prBranch) are string-keyed — if upstream changes these keys, the mapping breaks silently. Mitigation: test cases assert metadata extraction explicitly.partialsignal type has no directPhaseResultequivalent — mapped toblocked:transient(matches currenttoSignalKind()behavior inPhaseRouterServiceline 743).