diff --git a/apis/json-schema/AgentRunDetail.yaml b/apis/json-schema/AgentRunDetail.yaml new file mode 100644 index 000000000..41b377996 --- /dev/null +++ b/apis/json-schema/AgentRunDetail.yaml @@ -0,0 +1,28 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: AgentRunDetail.yaml +type: object +properties: + agentType: + type: string + description: Type of agent (e.g. claude-code, gemini-cli) + agentName: + type: string + description: Name/identifier of the agent run + prompt: + type: string + description: Input prompt sent to the agent executor + result: + type: string + description: Final result output from the agent (if available) + error: + type: string + description: Error message if the run failed + timestamp: + type: string + description: ISO 8601 timestamp of the run +required: + - agentType + - agentName + - prompt + - timestamp +description: Detailed agent run information including prompt and result for diagnostic reporting diff --git a/apis/json-schema/DoctorDiagnosticReport.yaml b/apis/json-schema/DoctorDiagnosticReport.yaml new file mode 100644 index 000000000..7301e7888 --- /dev/null +++ b/apis/json-schema/DoctorDiagnosticReport.yaml @@ -0,0 +1,76 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: DoctorDiagnosticReport.yaml +type: object +properties: + userDescription: + type: string + description: User-provided description of the problem + failedRunSummaries: + type: array + items: + $ref: FailedRunSummary.yaml + description: Summaries of recent failed agent runs + systemInfo: + $ref: SystemInfo.yaml + description: System environment information + cliVersion: + type: string + description: Current shep CLI version + featureId: + type: string + description: Feature ID when diagnosing a specific feature (optional) + featureName: + type: string + description: Feature name when diagnosing a specific feature (optional) + featureLifecycle: + type: string + description: Feature lifecycle phase (e.g. Implementation, Review) + featureBranch: + type: string + description: Feature git branch name + featureDescription: + type: string + description: Feature description + featureWorkflowConfig: + type: string + description: JSON-serialized feature workflow configuration (fast, push, openPr, approvalGates) + specYaml: + type: string + description: Raw spec.yaml content + researchYaml: + type: string + description: Raw research.yaml content + planYaml: + type: string + description: Raw plan.yaml content + tasksYaml: + type: string + description: Raw tasks.yaml content + featureStatusYaml: + type: string + description: Raw feature.yaml (status tracking) content + agentRunDetails: + type: array + items: + $ref: AgentRunDetail.yaml + description: Detailed agent run information including prompts and results + conversationMessages: + type: string + description: JSON-serialized conversation messages (Feature.messages[]) + featurePlan: + type: string + description: JSON-serialized feature plan (Feature.plan) + workerLogs: + type: array + items: + $ref: WorkerLogEntry.yaml + description: Worker execution logs for agent runs associated with this feature + phaseTimings: + type: string + description: JSON-serialized phase timing records +required: + - userDescription + - failedRunSummaries + - systemInfo + - cliVersion +description: Structured diagnostic report collected by shep doctor for issue creation diff --git a/apis/json-schema/FailedRunSummary.yaml b/apis/json-schema/FailedRunSummary.yaml new file mode 100644 index 000000000..1f8ba2ae2 --- /dev/null +++ b/apis/json-schema/FailedRunSummary.yaml @@ -0,0 +1,22 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: FailedRunSummary.yaml +type: object +properties: + agentType: + type: string + description: Type of agent that failed (e.g. claude-code, gemini-cli) + agentName: + type: string + description: Name/identifier of the agent run + error: + type: string + description: Error message from the failed run + timestamp: + type: string + description: ISO 8601 timestamp when the failure occurred +required: + - agentType + - agentName + - error + - timestamp +description: Summary of a failed agent run for diagnostic reporting diff --git a/apis/json-schema/SystemInfo.yaml b/apis/json-schema/SystemInfo.yaml new file mode 100644 index 000000000..f9be02397 --- /dev/null +++ b/apis/json-schema/SystemInfo.yaml @@ -0,0 +1,22 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: SystemInfo.yaml +type: object +properties: + nodeVersion: + type: string + description: Node.js version (e.g. v20.11.0) + platform: + type: string + description: Operating system platform (e.g. darwin, linux, win32) + arch: + type: string + description: CPU architecture (e.g. x64, arm64) + ghVersion: + type: string + description: gh CLI version string +required: + - nodeVersion + - platform + - arch + - ghVersion +description: System environment information for diagnostic reporting diff --git a/apis/json-schema/WorkerLogEntry.yaml b/apis/json-schema/WorkerLogEntry.yaml new file mode 100644 index 000000000..e9c713bd8 --- /dev/null +++ b/apis/json-schema/WorkerLogEntry.yaml @@ -0,0 +1,27 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: WorkerLogEntry.yaml +type: object +properties: + agentRunId: + type: string + description: Agent run ID this log belongs to + agentName: + type: string + description: Name of the agent that produced this log + content: + type: string + description: Full log file content (may be truncated) + truncated: + type: boolean + description: Whether the content was truncated due to size limits + originalLength: + type: integer + minimum: -2147483648 + maximum: 2147483647 + description: Original character count before truncation (only set when truncated) +required: + - agentRunId + - agentName + - content + - truncated +description: Worker log entry for a specific agent run diff --git a/apis/json-schema/WorkflowConfig.yaml b/apis/json-schema/WorkflowConfig.yaml index 4cf6ca4c6..7c14d77a8 100644 --- a/apis/json-schema/WorkflowConfig.yaml +++ b/apis/json-schema/WorkflowConfig.yaml @@ -55,6 +55,11 @@ properties: hideCiStatus: type: boolean description: "Hide CI status badges from UI (default: true)" + doctorMaxFixAttempts: + type: integer + minimum: -2147483648 + maximum: 2147483647 + description: "Maximum number of doctor fix attempts before giving up (default: 1)" required: - openPrOnImplementationComplete - approvalGateDefaults diff --git a/docs/superpowers/plans/2026-03-22-doctor-rich-diagnostics.md b/docs/superpowers/plans/2026-03-22-doctor-rich-diagnostics.md new file mode 100644 index 000000000..6c58626a6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-doctor-rich-diagnostics.md @@ -0,0 +1,933 @@ +# Enrich Doctor Diagnostics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enrich `shep doctor --feature-id` to collect specs, logs, prompts, conversation history, feature plan, and phase timings — all inline in the GitHub issue body. + +**Architecture:** Expand the existing `DoctorDiagnoseUseCase` with new collection methods and inject `IPhaseTimingRepository`. Add `AgentRunDetail` and `WorkerLogEntry` TypeSpec models. All new fields on `DoctorDiagnosticReport` are optional — zero impact when `--feature-id` is not provided. + +**Tech Stack:** TypeSpec, TypeScript, Vitest, tsyringe DI, node:fs/promises, node:os + +**Spec:** `docs/superpowers/specs/2026-03-22-doctor-rich-diagnostics-design.md` + +--- + +## File Structure + +| File | Role | +|------|------| +| `tsp/domain/value-objects/doctor-diagnostic-report.tsp` | TypeSpec models — add new fields, `AgentRunDetail`, `WorkerLogEntry` | +| `packages/core/src/domain/generated/output.ts` | Regenerated (do not hand-edit) | +| `packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts` | Use case — new collection methods, expanded formatting, new DI param | +| `tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` | Unit tests for all new collection + formatting | +| `tests/integration/application/use-cases/doctor/doctor-workflow.test.ts` | Integration test for feature-scoped full diagnostic flow | + +--- + +## Task 1: Expand TypeSpec Models + +**Files:** +- Modify: `tsp/domain/value-objects/doctor-diagnostic-report.tsp` +- Regenerate: `packages/core/src/domain/generated/output.ts` + +- [ ] **Step 1: Add `AgentRunDetail` and `WorkerLogEntry` models and expand `DoctorDiagnosticReport`** + +In `tsp/domain/value-objects/doctor-diagnostic-report.tsp`, add after the existing `DoctorDiagnosticReport` closing brace: + +```typespec +@doc("Detailed agent run information including prompt and result for diagnostic reporting") +model AgentRunDetail { + @doc("Type of agent (e.g. claude-code, gemini-cli)") + agentType: string; + + @doc("Name/identifier of the agent run") + agentName: string; + + @doc("Input prompt sent to the agent executor") + prompt: string; + + @doc("Final result output from the agent (if available)") + result?: string; + + @doc("Error message if the run failed") + error?: string; + + @doc("ISO 8601 timestamp of the run") + timestamp: string; +} + +@doc("Worker log entry for a specific agent run") +model WorkerLogEntry { + @doc("Agent run ID this log belongs to") + agentRunId: string; + + @doc("Name of the agent that produced this log") + agentName: string; + + @doc("Full log file content (may be truncated)") + content: string; + + @doc("Whether the content was truncated due to size limits") + truncated: boolean; + + @doc("Original character count before truncation (only set when truncated)") + originalLength?: int32; +} +``` + +And add these new optional fields inside the `DoctorDiagnosticReport` model, after `featureName`: + +```typespec + @doc("Feature lifecycle phase (e.g. Implementation, Review)") + featureLifecycle?: string; + + @doc("Feature git branch name") + featureBranch?: string; + + @doc("Feature description") + featureDescription?: string; + + @doc("JSON-serialized feature workflow configuration (fast, push, openPr, approvalGates)") + featureWorkflowConfig?: string; + + @doc("Raw spec.yaml content") + specYaml?: string; + + @doc("Raw research.yaml content") + researchYaml?: string; + + @doc("Raw plan.yaml content") + planYaml?: string; + + @doc("Raw tasks.yaml content") + tasksYaml?: string; + + @doc("Raw feature.yaml (status tracking) content") + featureStatusYaml?: string; + + @doc("Detailed agent run information including prompts and results") + agentRunDetails?: AgentRunDetail[]; + + @doc("JSON-serialized conversation messages (Feature.messages[])") + conversationMessages?: string; + + @doc("JSON-serialized feature plan (Feature.plan)") + featurePlan?: string; + + @doc("Worker execution logs for agent runs associated with this feature") + workerLogs?: WorkerLogEntry[]; + + @doc("JSON-serialized phase timing records") + phaseTimings?: string; +``` + +- [ ] **Step 2: Compile TypeSpec and verify generated output** + +Run: `pnpm tsp:compile` +Expected: Compilation succeeds. Check `packages/core/src/domain/generated/output.ts` contains the new types `AgentRunDetail`, `WorkerLogEntry`, and the expanded `DoctorDiagnosticReport`. + +- [ ] **Step 3: Commit** + +```bash +git add tsp/domain/value-objects/doctor-diagnostic-report.tsp packages/core/src/domain/generated/ +git commit -m "feat(tsp): add agent-run-detail and worker-log-entry models to doctor diagnostics" +``` + +--- + +## Task 2: Write Failing Tests for New Collection Methods + +**Files:** +- Modify: `tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` + +**Important context:** +- The existing `createMocks()` helper creates all mocked dependencies. You need to add `phaseTimingRepo` mock and a `readFileFn` mock to it. +- The existing `createUseCase()` helper instantiates the use case. It must be updated to pass the new dependency. +- The use case constructor currently takes 8 params. After this change it will take 9 (adding `IPhaseTimingRepository`). + +- [ ] **Step 1: Add `IPhaseTimingRepository` import, mock, and filesystem mock to test file** + +At the top imports, add: +```typescript +import type { IPhaseTimingRepository } from '@/application/ports/output/agents/phase-timing-repository.interface.js'; +import type { PhaseTiming } from '@/domain/generated/output.js'; +``` + +Add a `vi.mock` for `node:fs/promises` at the top level (after imports, before helpers). This lets us control what `readFile` returns in tests: +```typescript +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + }; +}); +``` + +Import the mocked `readFile` so tests can override it per-test: +```typescript +import { readFile } from 'node:fs/promises'; +``` + +In `createMocks()`, add after the `featureRepo` mock: +```typescript + const phaseTimingRepo: Pick = { + findByFeatureId: vi.fn<(featureId: string) => Promise>().mockResolvedValue([]), + }; +``` + +Add `phaseTimingRepo` to the return object. + +Update `createUseCase()` to pass `mocks.phaseTimingRepo as any` as the 9th argument. + +- [ ] **Step 2: Run tests to verify existing tests still pass with new constructor param** + +Run: `pnpm vitest run tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` +Expected: This will FAIL because the use case constructor doesn't accept 9 params yet. That's expected — we'll fix it in Task 3. + +- [ ] **Step 3: Write test — spec YAML collection** + +Add inside the `feature-specific diagnostics` describe block: + +```typescript + it('should collect spec YAML files when feature has specPath', async () => { + // Mock readFile to return content for spec files + vi.mocked(readFile).mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('spec.yaml')) return 'name: My Feature Spec'; + if (p.endsWith('research.yaml')) return 'decisions: []'; + if (p.endsWith('plan.yaml')) return 'phases: []'; + if (p.endsWith('tasks.yaml')) return 'tasks: []'; + if (p.endsWith('feature.yaml')) return 'status: active'; + throw new Error('ENOENT'); + }); + + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + specPath: '/repo/specs/042-my-feature', + lifecycle: 'Implementation', + branch: 'feat/my-feature', + description: 'A test feature', + messages: [], + fast: false, + push: false, + openPr: false, + approvalGates: { allowPrd: false, allowPlan: false, allowMerge: false }, + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.specYaml).toBe('name: My Feature Spec'); + expect(result.diagnosticReport.researchYaml).toBe('decisions: []'); + expect(result.diagnosticReport.planYaml).toBe('phases: []'); + expect(result.diagnosticReport.tasksYaml).toBe('tasks: []'); + expect(result.diagnosticReport.featureStatusYaml).toBe('status: active'); + expect(result.diagnosticReport.featureLifecycle).toBe('Implementation'); + expect(result.diagnosticReport.featureBranch).toBe('feat/my-feature'); + expect(result.diagnosticReport.featureDescription).toBe('A test feature'); + }); +``` + +- [ ] **Step 4: Write test — worker log collection** + +```typescript + it('should collect worker logs for all feature-scoped agent runs', async () => { + // Mock readFile to return log content for worker log files + vi.mocked(readFile).mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.includes('worker-r1.log')) return 'Log content for r1'; + if (p.includes('worker-r2.log')) return 'Log content for r2'; + throw new Error('ENOENT'); + }); + + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc' }), + createFailedRun('r2', { featureId: 'feat-abc' }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.workerLogs).toBeDefined(); + expect(result.diagnosticReport.workerLogs).toHaveLength(2); + expect(result.diagnosticReport.workerLogs![0].content).toBe('Log content for r1'); + expect(result.diagnosticReport.workerLogs![1].agentRunId).toBe('r2'); + }); +``` + +- [ ] **Step 5: Write test — agent run details collection** + +```typescript + it('should collect agent run details with prompts and results for feature-scoped runs', async () => { + const runs: AgentRun[] = [ + createFailedRun('r1', { + featureId: 'feat-abc', + prompt: 'Analyze this', + result: 'Analysis done', + }), + { ...createFailedRun('r2', { featureId: 'feat-abc', prompt: 'Plan this' }), + status: AgentRunStatus.completed }, + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.agentRunDetails).toBeDefined(); + expect(result.diagnosticReport.agentRunDetails!.length).toBe(2); + expect(result.diagnosticReport.agentRunDetails![0].prompt).toBe('Analyze this'); + }); +``` + +- [ ] **Step 6: Write test — phase timings collection** + +```typescript + it('should collect phase timings when feature is resolved', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + vi.mocked(mocks.phaseTimingRepo.findByFeatureId).mockResolvedValue([ + { id: 'pt-1', phaseName: 'analyze', durationMs: 5000 } as any, + ]); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.phaseTimings).toBeDefined(); + expect(result.diagnosticReport.phaseTimings).toContain('analyze'); + }); +``` + +- [ ] **Step 7: Write test — conversation messages and feature plan** + +```typescript + it('should include conversation messages and feature plan in report', async () => { + const messages = [{ id: 'm1', role: 'user', content: 'Hello' }]; + const plan = { overview: 'Build X', tasks: [] }; + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages, + plan, + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.conversationMessages).toContain('Hello'); + expect(result.diagnosticReport.featurePlan).toContain('Build X'); + }); +``` + +- [ ] **Step 8: Write test — no feature-id regression** + +```typescript + it('should leave all enriched fields undefined when no featureId is provided', async () => { + const result = await useCase.execute({ + description: 'general issue', + fix: false, + }); + + expect(result.diagnosticReport.featureLifecycle).toBeUndefined(); + expect(result.diagnosticReport.featureBranch).toBeUndefined(); + expect(result.diagnosticReport.featureDescription).toBeUndefined(); + expect(result.diagnosticReport.featureWorkflowConfig).toBeUndefined(); + expect(result.diagnosticReport.specYaml).toBeUndefined(); + expect(result.diagnosticReport.researchYaml).toBeUndefined(); + expect(result.diagnosticReport.planYaml).toBeUndefined(); + expect(result.diagnosticReport.tasksYaml).toBeUndefined(); + expect(result.diagnosticReport.featureStatusYaml).toBeUndefined(); + expect(result.diagnosticReport.agentRunDetails).toBeUndefined(); + expect(result.diagnosticReport.conversationMessages).toBeUndefined(); + expect(result.diagnosticReport.featurePlan).toBeUndefined(); + expect(result.diagnosticReport.workerLogs).toBeUndefined(); + expect(result.diagnosticReport.phaseTimings).toBeUndefined(); + }); +``` + +- [ ] **Step 9: Write test — issue body formatting with enriched data** + +```typescript + it('should include enriched sections with details tags in issue body', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + specPath: '/repo/specs/042-my-feature', + lifecycle: 'Implementation', + branch: 'feat/my-feature', + description: 'A test feature', + messages: [{ id: 'm1', role: 'user', content: 'Hello' }], + plan: { overview: 'Plan overview' }, + fast: false, + push: true, + openPr: false, + approvalGates: { allowPrd: false, allowPlan: false, allowMerge: false }, + } as any); + + await useCase.execute({ + description: 'enriched test', + fix: false, + featureId: 'feat-abc', + }); + + const bodyArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + // Feature context + expect(bodyArg).toContain('Lifecycle'); + expect(bodyArg).toContain('Implementation'); + // Details tags for large sections + expect(bodyArg).toContain('
'); + expect(bodyArg).toContain('Conversation'); + expect(bodyArg).toContain('Plan'); + }); +``` + +- [ ] **Step 10: Write test — truncation of large content** + +```typescript + it('should truncate agent run prompts exceeding MAX_PROMPT_CHARS', async () => { + const longPrompt = 'x'.repeat(15_000); + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc', prompt: longPrompt }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + const detail = result.diagnosticReport.agentRunDetails![0]; + expect(detail.prompt.length).toBeLessThanOrEqual(10_100); // 10000 + truncation message + expect(detail.prompt).toContain('[truncated'); + }); +``` + +- [ ] **Step 11: Commit failing tests** + +```bash +git add tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts +git commit -m "test(cli): add failing tests for enriched doctor diagnostics collection" +``` + +--- + +## Task 3: Implement Collection Logic in Use Case + +**Files:** +- Modify: `packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts` + +**Key context:** +- The use case uses `@injectable()` with `@inject('token')` decorators for DI. +- Add `readFile` from `node:fs/promises` and `homedir` from `node:os` as regular imports (not injected). +- Add `IPhaseTimingRepository` as an injected dependency. +- The `shep logs` path convention is `~/.shep/logs/worker-{agentRunId}.log`. + +- [ ] **Step 1: Add new imports** + +At the top of `doctor-diagnose.use-case.ts`, add: +```typescript +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import type { IPhaseTimingRepository } from '../../ports/output/agents/phase-timing-repository.interface.js'; +``` + +Also import `AgentRunDetail` and `WorkerLogEntry` from the generated output (they'll exist after Task 1). + +- [ ] **Step 2: Add truncation constants** + +After the existing constants section: +```typescript +const MAX_AGENT_RUN_DETAILS = 10; +const MAX_WORKER_LOG_CHARS = 50_000; +const MAX_PROMPT_CHARS = 10_000; +const MAX_RESULT_CHARS = 10_000; +const MAX_CONVERSATION_CHARS = 20_000; +const MAX_PLAN_CHARS = 20_000; +``` + +- [ ] **Step 3: Add `IPhaseTimingRepository` to constructor** + +Add as the 9th constructor parameter: +```typescript + @inject('IPhaseTimingRepository') + private readonly phaseTimingRepo: IPhaseTimingRepository, +``` + +- [ ] **Step 4: Add `truncate` helper method** + +```typescript + private truncate(content: string, maxChars: number): { text: string; truncated: boolean; originalLength?: number } { + if (content.length <= maxChars) { + return { text: content, truncated: false }; + } + return { + text: `${content.slice(0, maxChars)}\n... [truncated, ${content.length} chars total]`, + truncated: true, + originalLength: content.length, + }; + } +``` + +- [ ] **Step 5: Expand `collectDiagnostics` method** + +Replace the current `collectDiagnostics` method body. After resolving the feature (existing code), add parallel collection of new data: + +```typescript + private async collectDiagnostics( + userDescription: string, + featureId?: string + ): Promise { + let resolvedFeatureId: string | undefined; + let featureName: string | undefined; + let feature: any | undefined; + if (featureId) { + feature = + (await this.featureRepo.findById(featureId)) ?? + (await this.featureRepo.findByIdPrefix(featureId)); + if (feature) { + resolvedFeatureId = feature.id; + featureName = feature.name; + } + } + + // Fetch all runs once — used for both failed summaries and enrichment + const allRuns = await this.agentRunRepo.list(); + + const [failedRunSummaries, systemInfo, cliVersion] = await Promise.all([ + Promise.resolve(this.filterFailedRuns(allRuns, resolvedFeatureId)), + this.collectSystemInfo(), + Promise.resolve(this.versionService.getVersion().version), + ]); + + const report: DoctorDiagnosticReport = { + userDescription, + failedRunSummaries, + systemInfo, + cliVersion, + featureId: resolvedFeatureId, + featureName, + }; + + // Enrich with feature-scoped data when a feature is resolved + if (feature) { + // Synchronous extractions from feature entity + report.featureLifecycle = feature.lifecycle; + report.featureBranch = feature.branch; + report.featureDescription = feature.description; + report.featureWorkflowConfig = JSON.stringify({ + fast: feature.fast, + push: feature.push, + openPr: feature.openPr, + approvalGates: feature.approvalGates, + }); + if (feature.messages?.length) { + const serialized = JSON.stringify(feature.messages); + report.conversationMessages = this.truncate(serialized, MAX_CONVERSATION_CHARS).text; + } + if (feature.plan) { + const serialized = JSON.stringify(feature.plan); + report.featurePlan = this.truncate(serialized, MAX_PLAN_CHARS).text; + } + + // Parallel async enrichments + const featureRuns = allRuns.filter((r) => r.featureId === resolvedFeatureId); + + const [specYamls, workerLogs, phaseTimings] = await Promise.all([ + this.collectSpecYamls(feature.specPath), + this.collectWorkerLogs(featureRuns), + this.collectPhaseTimings(resolvedFeatureId!), + ]); + + Object.assign(report, specYamls); + report.workerLogs = workerLogs.length > 0 ? workerLogs : undefined; + report.phaseTimings = phaseTimings; + report.agentRunDetails = this.buildAgentRunDetails(featureRuns); + } + + return report; + } +``` + +- [ ] **Step 6: Refactor `collectFailedRuns` to `filterFailedRuns`** + +The existing `collectFailedRuns` calls `this.agentRunRepo.list()` internally. Since we now fetch all runs once in `collectDiagnostics`, refactor it to accept the runs array: + +```typescript + private filterFailedRuns(allRuns: AgentRun[], featureId?: string): FailedRunSummary[] { + let filtered = allRuns.filter((run) => run.status === AgentRunStatus.failed); + if (featureId) { + filtered = filtered.filter((run) => run.featureId === featureId); + } + return filtered.slice(0, MAX_FAILED_RUNS).map((run) => this.sanitizeRunSummary(run)); + } +``` + +Delete the old `collectFailedRuns` method. + +- [ ] **Step 7: Add `collectSpecYamls` method** + +(Note: subsequent steps renumbered from original) + +```typescript + private async collectSpecYamls( + specPath?: string + ): Promise> { + if (!specPath) return {}; + const files = ['spec.yaml', 'research.yaml', 'plan.yaml', 'tasks.yaml', 'feature.yaml'] as const; + const keys = ['specYaml', 'researchYaml', 'planYaml', 'tasksYaml', 'featureStatusYaml'] as const; + + const results: Partial = {}; + const reads = await Promise.all( + files.map((f) => this.readFileSafe(path.join(specPath, f))) + ); + for (let i = 0; i < files.length; i++) { + if (reads[i]) { + (results as any)[keys[i]] = reads[i]; + } + } + return results; + } +``` + +- [ ] **Step 7: Add `collectWorkerLogs` method** + +```typescript + private async collectWorkerLogs( + featureRuns: AgentRun[] + ): Promise { + const logDir = path.join(homedir(), '.shep', 'logs'); + const entries: WorkerLogEntry[] = []; + + for (const run of featureRuns) { + const logPath = path.join(logDir, `worker-${run.id}.log`); + const content = await this.readFileSafe(logPath); + if (content) { + const { text, truncated, originalLength } = this.truncate(content, MAX_WORKER_LOG_CHARS); + entries.push({ + agentRunId: run.id, + agentName: run.agentName, + content: text, + truncated, + originalLength: originalLength, + }); + } + } + return entries; + } +``` + +- [ ] **Step 8: Add `collectPhaseTimings` method** + +```typescript + private async collectPhaseTimings(featureId: string): Promise { + try { + const timings = await this.phaseTimingRepo.findByFeatureId(featureId); + return timings.length > 0 ? JSON.stringify(timings) : undefined; + } catch { + return undefined; + } + } +``` + +- [ ] **Step 9: Add `buildAgentRunDetails` method** + +```typescript + private buildAgentRunDetails(featureRuns: AgentRun[]): AgentRunDetail[] | undefined { + if (featureRuns.length === 0) return undefined; + return featureRuns.slice(0, MAX_AGENT_RUN_DETAILS).map((run) => ({ + agentType: run.agentType, + agentName: run.agentName, + prompt: this.truncate(run.prompt, MAX_PROMPT_CHARS).text, + result: run.result ? this.truncate(run.result, MAX_RESULT_CHARS).text : undefined, + error: run.error ?? undefined, + timestamp: run.createdAt instanceof Date ? run.createdAt.toISOString() : String(run.createdAt), + })); + } +``` + +- [ ] **Step 10: Add `readFileSafe` helper** + +```typescript + private async readFileSafe(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch { + return undefined; + } + } +``` + +- [ ] **Step 11: Run unit tests** + +Run: `pnpm vitest run tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` +Expected: All new tests from Task 2 should pass. All existing tests should still pass. + +- [ ] **Step 12: Commit** + +```bash +git add packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts +git commit -m "feat(cli): add enriched diagnostic collection to doctor use case" +``` + +--- + +## Task 4: Expand Issue Body Formatting + +**Files:** +- Modify: `packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts` + +- [ ] **Step 1: Expand `formatIssueBody` with new sections** + +Replace the `formatIssueBody` method. Keep existing sections, add new ones. All new large sections use `
` collapse: + +```typescript + private formatIssueBody(report: DoctorDiagnosticReport): string { + const sections: string[] = []; + + sections.push('## Problem Description\n'); + sections.push(report.userDescription); + + if (report.featureId) { + sections.push('\n## Feature Context\n'); + sections.push(`- **Feature ID:** ${report.featureId}`); + if (report.featureName) sections.push(`- **Feature Name:** ${report.featureName}`); + if (report.featureLifecycle) sections.push(`- **Lifecycle:** ${report.featureLifecycle}`); + if (report.featureBranch) sections.push(`- **Branch:** ${report.featureBranch}`); + if (report.featureDescription) sections.push(`- **Description:** ${report.featureDescription}`); + if (report.featureWorkflowConfig) sections.push(`- **Workflow Config:** ${report.featureWorkflowConfig}`); + } + + sections.push('\n## Environment\n'); + sections.push(`- **shep CLI version:** ${report.cliVersion}`); + sections.push(`- **Node.js:** ${report.systemInfo.nodeVersion}`); + sections.push(`- **Platform:** ${report.systemInfo.platform} (${report.systemInfo.arch})`); + sections.push(`- **gh CLI:** ${report.systemInfo.ghVersion}`); + + if (report.failedRunSummaries.length > 0) { + const heading = report.featureId + ? '\n## Failed Agent Runs (feature-scoped)\n' + : '\n## Recent Failed Agent Runs\n'; + sections.push(heading); + for (const run of report.failedRunSummaries) { + sections.push(`### ${run.agentName} (${run.agentType})`); + sections.push(`- **Error:** ${run.error}`); + sections.push(`- **Timestamp:** ${run.timestamp}`); + sections.push(''); + } + } + + if (report.agentRunDetails?.length) { + sections.push('\n## Agent Run Details\n'); + for (const detail of report.agentRunDetails) { + sections.push(`
Agent: ${detail.agentName} (${detail.agentType})\n`); + sections.push('### Prompt\n```\n' + detail.prompt + '\n```\n'); + if (detail.result) { + sections.push('### Result\n```\n' + detail.result + '\n```\n'); + } + if (detail.error) { + sections.push('### Error\n```\n' + detail.error + '\n```\n'); + } + sections.push('
\n'); + } + } + + if (report.conversationMessages) { + const msgCount = (report.conversationMessages.match(/"id"/g) || []).length; + sections.push(`\n## Conversation History\n`); + sections.push(`
Messages (${msgCount} messages)\n`); + sections.push('```json\n' + report.conversationMessages + '\n```\n'); + sections.push('
\n'); + } + + if (report.featurePlan) { + sections.push('\n## Feature Plan\n'); + sections.push('
Plan & Tasks\n'); + sections.push('```json\n' + report.featurePlan + '\n```\n'); + sections.push('
\n'); + } + + // Spec files + const specEntries: [string, string | undefined][] = [ + ['spec.yaml', report.specYaml], + ['research.yaml', report.researchYaml], + ['plan.yaml', report.planYaml], + ['tasks.yaml', report.tasksYaml], + ['feature.yaml', report.featureStatusYaml], + ]; + const hasSpecs = specEntries.some(([, v]) => v); + if (hasSpecs) { + sections.push('\n## Spec Files\n'); + for (const [name, content] of specEntries) { + if (content) { + sections.push(`
${name}\n`); + sections.push('```yaml\n' + content + '\n```\n'); + sections.push('
\n'); + } + } + } + + if (report.workerLogs?.length) { + sections.push('\n## Worker Logs\n'); + for (const log of report.workerLogs) { + const suffix = log.truncated ? ` (truncated, ${log.originalLength} chars total)` : ''; + sections.push(`
Worker log: ${log.agentName} (${log.agentRunId})${suffix}\n`); + sections.push('```\n' + log.content + '\n```\n'); + sections.push('
\n'); + } + } + + if (report.phaseTimings) { + sections.push('\n## Phase Timings\n'); + sections.push('
Phase timing data\n'); + sections.push('```json\n' + report.phaseTimings + '\n```\n'); + sections.push('
\n'); + } + + sections.push('\n---\n'); + sections.push('_Reported via `shep doctor`_'); + + return sections.join('\n'); + } +``` + +- [ ] **Step 2: Run unit tests** + +Run: `pnpm vitest run tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` +Expected: All tests pass, including the formatting test from Task 2 Step 9. + +- [ ] **Step 3: Commit** + +```bash +git add packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts +git commit -m "feat(cli): expand doctor issue body with enriched diagnostic sections" +``` + +--- + +## Task 5: Update Integration Tests + +**Files:** +- Modify: `tests/integration/application/use-cases/doctor/doctor-workflow.test.ts` + +- [ ] **Step 1: Add `IPhaseTimingRepository` mock to integration test** + +Same pattern as Task 2 Step 1 — add the import and mock, update the constructor call. The integration test file has its own `createMocks()` and `createUseCase()` functions that mirror the unit test helpers. + +- [ ] **Step 2: Add integration test for feature-scoped full diagnostic flow** + +```typescript + describe('feature-scoped rich diagnostics', () => { + it('should produce enriched report with all feature context', async () => { + const feature = { + id: 'feat-rich', + name: 'Rich Feature', + specPath: '/nonexistent/specs/042-rich', // won't find files — that's OK (best-effort) + lifecycle: 'Review', + branch: 'feat/rich', + description: 'Feature with full context', + messages: [{ id: 'm1', role: 'user', content: 'Start' }], + plan: { overview: 'Implement rich diagnostics', tasks: [] }, + fast: false, + push: false, + openPr: true, + approvalGates: { allowPrd: true, allowPlan: false, allowMerge: false }, + } as any; + + vi.mocked(mocks.featureRepo.findById).mockResolvedValue(feature); + + const runs = [ + createFailedAgentRun('r1', { featureId: 'feat-rich', prompt: 'Do analysis', result: 'Done' }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + + const result = await useCase.execute({ + description: 'full context test', + fix: false, + featureId: 'feat-rich', + }); + + const report = result.diagnosticReport; + expect(report.featureId).toBe('feat-rich'); + expect(report.featureLifecycle).toBe('Review'); + expect(report.featureBranch).toBe('feat/rich'); + expect(report.conversationMessages).toContain('Start'); + expect(report.featurePlan).toContain('rich diagnostics'); + expect(report.agentRunDetails).toHaveLength(1); + expect(report.agentRunDetails![0].prompt).toBe('Do analysis'); + expect(report.featureWorkflowConfig).toContain('openPr'); + }); + }); +``` + +- [ ] **Step 3: Run integration tests** + +Run: `pnpm vitest run tests/integration/application/use-cases/doctor/doctor-workflow.test.ts` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/application/use-cases/doctor/doctor-workflow.test.ts +git commit -m "test(cli): add integration test for enriched doctor diagnostics" +``` + +--- + +## Task 6: Run Full Validation + +- [ ] **Step 1: Run full test suite** + +Run: `pnpm test` +Expected: All tests pass. + +- [ ] **Step 2: Run validate (lint + format + typecheck + tsp)** + +Run: `pnpm validate` +Expected: No errors. + +- [ ] **Step 3: Fix any issues found** + +If any test or lint failures, fix them and re-run. + +- [ ] **Step 4: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(cli): address lint and test issues from enriched diagnostics" +``` diff --git a/docs/superpowers/specs/2026-03-22-doctor-rich-diagnostics-design.md b/docs/superpowers/specs/2026-03-22-doctor-rich-diagnostics-design.md new file mode 100644 index 000000000..0919517e6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-doctor-rich-diagnostics-design.md @@ -0,0 +1,285 @@ +# Design: Enrich `shep doctor --feature-id` Diagnostics + +**Date:** 2026-03-22 +**Status:** Draft +**Scope:** Expand diagnostic data collected when `--feature-id` is provided to `shep doctor` + +## Problem + +When `shep doctor --feature-id` runs, it only collects: +- Feature ID and name +- Failed agent run error summaries (agentType, agentName, error, timestamp) +- System info (Node, platform, arch, gh version) +- CLI version + +This is insufficient for diagnosing complex issues. The spec YAMLs, agent prompts, conversation history, execution logs, and plan/task state are all available in the system but not included in the diagnostic report. + +## Decision + +Expand `collectDiagnostics` in `DoctorDiagnoseUseCase` directly (no new abstractions). Add new optional fields to `DoctorDiagnosticReport` that are populated only when `--feature-id` resolves to a feature. + +## Data Sources + +| Data | Source | Access | +|------|--------|--------| +| Feature metadata | `IFeatureRepository.findById()` → `Feature` entity | lifecycle, branch, description, workflow config | +| Spec YAML (PRD) | `feature.specPath` → `spec.yaml` | Filesystem read | +| Research YAML | `feature.specPath` → `research.yaml` | Filesystem read | +| Plan YAML | `feature.specPath` → `plan.yaml` | Filesystem read | +| Tasks YAML | `feature.specPath` → `tasks.yaml` | Filesystem read | +| Feature status YAML | `feature.specPath` → `feature.yaml` | Filesystem read | +| Agent run prompts/results | `AgentRun.prompt`, `AgentRun.result` | `IAgentRunRepository.list()` | +| Conversation messages | `Feature.messages[]` | Already loaded with feature | +| In-memory plan/tasks | `Feature.plan` | Already loaded with feature | +| Worker execution logs | `~/.shep/logs/worker-{agentRunId}.log` per run | Filesystem read for each feature-scoped agent run | +| Phase timings | `IPhaseTimingRepository.findByFeatureId()` | SQLite query | + +## TypeSpec Model Changes + +### New fields on `DoctorDiagnosticReport` + +All new fields are optional — `undefined` when `--feature-id` is not provided. + +``` +// Feature context +featureLifecycle?: string +featureBranch?: string +featureDescription?: string +featureWorkflowConfig?: string // JSON string + +// Spec YAMLs (raw content) +specYaml?: string +researchYaml?: string +planYaml?: string +tasksYaml?: string +featureStatusYaml?: string + +// Agent run details (richer than FailedRunSummary) +agentRunDetails?: AgentRunDetail[] + +// Conversation history +conversationMessages?: string // JSON-serialized Feature.messages[] + +// In-memory plan/tasks +featurePlan?: string // JSON-serialized Feature.plan + +// Worker logs (one per agent run associated with the feature) +workerLogs?: WorkerLogEntry[] + +// Phase timings +phaseTimings?: string // JSON-serialized phase timing records +``` + +### New model: `AgentRunDetail` + +``` +AgentRunDetail { + agentType: string + agentName: string + prompt: string + result?: string + error?: string + timestamp: string +} +``` + +### New model: `WorkerLogEntry` + +``` +WorkerLogEntry { + agentRunId: string + agentName: string + content: string // Full log file content, truncated if over MAX_LOG_CHARS + truncated: boolean // True if content was truncated + originalLength?: int32 // Original character count (only set when truncated) +} +``` + +Existing `FailedRunSummary` is unchanged for backward compatibility. + +## Collection Logic + +In `collectDiagnostics()`, when a feature is resolved: + +``` +Promise.all([ + collectFailedRuns(featureId), // existing + collectSystemInfo(), // existing + getVersion(), // existing + collectSpecYamls(feature.specPath), // NEW + collectWorkerLogs(featureRunIds), // NEW — logs for ALL runs scoped to this feature + collectPhaseTimings(feature.id), // NEW +]) +``` + +Synchronous extraction after feature lookup: +- `feature.lifecycle` → `featureLifecycle` +- `feature.branch` → `featureBranch` +- `feature.description` → `featureDescription` +- `JSON.stringify({ fast, push, openPr, approvalGates })` → `featureWorkflowConfig` +- `JSON.stringify(feature.messages)` → `conversationMessages` +- `JSON.stringify(feature.plan)` → `featurePlan` + +New `collectAgentRunDetails(featureId)`: separate method that returns `AgentRunDetail[]` for ALL runs scoped to this feature (not just failed ones), including prompt and result. This is distinct from `collectFailedRuns` which only returns error summaries for failed runs. + +`collectWorkerLogs(runIds)`: iterates over all agent run IDs for this feature, reads `~/.shep/logs/worker-{runId}.log` for each. Returns `WorkerLogEntry[]`. + +### Best-effort reads + +All new collection methods are best-effort: +- Missing spec files → `undefined` +- Missing worker logs → empty array or entries skipped +- Missing phase timings → `undefined` +- No specPath on feature → skip spec reads + +## Issue Body Formatting + +New sections added to `formatIssueBody()`. Large sections use `
` collapse tags for scannability. Sections with no data are omitted entirely. + +```markdown +## Problem Description +{userDescription} + +## Feature Context +- **Feature ID:** {featureId} +- **Feature Name:** {featureName} +- **Lifecycle:** {featureLifecycle} +- **Branch:** {featureBranch} +- **Description:** {featureDescription} +- **Workflow Config:** {featureWorkflowConfig} + +## Environment +(unchanged) + +## Failed Agent Runs (feature-scoped) +(unchanged) + +## Agent Run Details +
Agent: {agentName} ({agentType}) + +### Prompt +\`\`\` +{prompt} +\`\`\` + +### Result +\`\`\` +{result} +\`\`\` + +### Error +\`\`\` +{error} +\`\`\` +
+ +## Conversation History +
Messages ({count} messages) + +\`\`\`json +{conversationMessages} +\`\`\` +
+ +## Feature Plan +
Plan & Tasks + +\`\`\`json +{featurePlan} +\`\`\` +
+ +## Spec Files +
spec.yaml + +\`\`\`yaml +{specYaml} +\`\`\` +
+(same for research.yaml, plan.yaml, tasks.yaml, feature.yaml) + +## Worker Logs +
Worker log: {agentName} ({agentRunId}) + +\`\`\` +{content} +\`\`\` +
+(one `
` block per agent run with a log file) + +## Phase Timings +
Phase timing data + +\`\`\`json +{phaseTimings} +\`\`\` +
+ +--- +_Reported via `shep doctor`_ +``` + +## Files Changed + +| File | Change | +|------|--------| +| `tsp/domain/value-objects/doctor-diagnostic-report.tsp` | Add new fields to `DoctorDiagnosticReport`, add `AgentRunDetail` and `WorkerLogEntry` models | +| `packages/core/src/domain/generated/output.ts` | Regenerated via `pnpm tsp:compile` | +| `packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts` | Add `@inject('IPhaseTimingRepository')` constructor param, add `readFile`/`homedir` imports, add collection methods (`collectSpecYamls`, `collectWorkerLogs`, `collectAgentRunDetails`, `collectPhaseTimings`), expand `collectDiagnostics`, expand `formatIssueBody` with truncation | +| `tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts` | Tests for all new collection + formatting logic | +| `tests/integration/application/use-cases/doctor/doctor-workflow.test.ts` | Feature-scoped full diagnostic flow test | + +## Not Changed + +- **CLI command** (`doctor.command.ts`) — no new options; `--feature-id` already exists +- **DI container** — `IPhaseTimingRepository` already registered; just needs injecting +- **Spec YAML parsers** — we read raw content, not parsed artifacts + +## Truncation Strategy + +GitHub issues have a ~65,535 character body limit. To prevent exceeding it: + +``` +MAX_WORKER_LOG_CHARS = 50_000 // per log entry +MAX_PROMPT_CHARS = 10_000 // per agent run detail +MAX_RESULT_CHARS = 10_000 // per agent run detail +MAX_CONVERSATION_CHARS = 20_000 // total conversation messages +MAX_PLAN_CHARS = 20_000 // total feature plan +``` + +When a field exceeds its limit, truncate and append: `\n... [truncated, {totalLength} chars total]`. The `WorkerLogEntry.truncated` boolean and `originalLength` field track this explicitly. + +## Security Considerations + +Agent prompts and results may contain sensitive data (API keys, repository secrets, internal paths). Since the diagnostic report is posted as a GitHub issue (potentially public): + +- The existing `sanitizeRunSummary` approach (error-only) remains for `FailedRunSummary` +- The new `AgentRunDetail` intentionally includes prompt and result for diagnostic value +- Users invoke `shep doctor` explicitly — posting to GitHub requires `gh` auth, so users are aware of the visibility +- Future improvement: add a `--redact` flag to strip potential secrets before posting + +## Key Decisions + +1. **Raw YAML over parsed artifacts** — simpler, no parser coupling, issue readers see the original content +2. **`
` collapse for large sections** — keeps the issue scannable while including everything inline +3. **All new fields optional** — zero impact when `--feature-id` is not used +4. **Best-effort reads** — missing files/data gracefully become `undefined`, never block the report +5. **No new abstractions** — collection logic stays in the use case; extract later if needed +6. **JSON strings for complex fields** — `conversationMessages`, `featurePlan`, `phaseTimings` are stored as JSON strings rather than typed TypeSpec models to avoid importing many domain types into the diagnostic report model. The formatter serializes at render time. These are opaque diagnostic payloads, not structured data consumers need to parse. +7. **Multiple worker logs** — collect logs for ALL agent runs associated with the feature, not just the current one. A feature may go through multiple phases/retries. +8. **Separate `collectAgentRunDetails`** — distinct from `collectFailedRuns`; collects ALL runs (not just failed) with prompt/result context. +9. **Constructor change** — `IPhaseTimingRepository` added as a new `@inject` parameter in the use case constructor. + +## Testing Strategy + +Unit tests (extend existing test file): +1. Spec YAML collection — mock fs reads, verify all 5 files collected, graceful on missing +2. Worker log collection — mock fs read, verify content, graceful on missing +3. Phase timings collection — mock repository, verify JSON serialization +4. Feature context fields — verify extraction from Feature entity +5. AgentRunDetail enrichment — verify prompt + result included +6. No-feature-id regression — verify all new fields are `undefined` +7. Issue body formatting — verify new sections with `
` tags, omission when `undefined` + +Integration test (extend existing test file): +8. Feature-scoped full diagnostic flow — feature with specPath, agentRunId, messages, plan diff --git a/packages/core/src/application/ports/output/services/git-pr-service.interface.ts b/packages/core/src/application/ports/output/services/git-pr-service.interface.ts index ff03138d0..be5731725 100644 --- a/packages/core/src/application/ports/output/services/git-pr-service.interface.ts +++ b/packages/core/src/application/ports/output/services/git-pr-service.interface.ts @@ -137,6 +137,22 @@ export interface PrStatusInfo { mergeable?: boolean; } +/** + * Arguments for creating a PR programmatically (without a pr.yaml file). + */ +export interface PrCreateArgs { + /** PR title */ + title: string; + /** PR body (Markdown) */ + body: string; + /** Labels to apply to the PR */ + labels: string[]; + /** Base branch to merge into (e.g. "main") */ + base: string; + /** Target repository for cross-fork PRs (e.g. "shep-ai/cli"). Omit for same-repo PRs. */ + repo?: string; +} + /** * Merge strategy for pull requests. */ @@ -223,6 +239,20 @@ export interface IGitPrService { */ createPr(cwd: string, prYamlPath: string): Promise; + /** + * Create a pull request from explicit arguments (no pr.yaml file needed). + * + * Useful for programmatic PR creation where the title, body, and labels + * are constructed in code rather than read from a YAML file. Supports + * cross-fork PRs via the optional `repo` field in args. + * + * @param cwd - Working directory path (must be inside the git repo) + * @param args - PR creation arguments (title, body, labels, base, optional repo) + * @returns URL and number of the created PR + * @throws GitPrError with GH_NOT_FOUND or AUTH_FAILURE code + */ + createPrFromArgs(cwd: string, args: PrCreateArgs): Promise; + /** * Merge a pull request immediately. * diff --git a/packages/core/src/application/ports/output/services/github-issue-service.interface.ts b/packages/core/src/application/ports/output/services/github-issue-service.interface.ts new file mode 100644 index 000000000..c256bb7a6 --- /dev/null +++ b/packages/core/src/application/ports/output/services/github-issue-service.interface.ts @@ -0,0 +1,83 @@ +/** + * GitHub Issue Service Interface + * + * Output port for creating GitHub issues via the gh CLI. + * Implementations wrap `gh issue create` for issue creation on + * any GitHub repository. Separate from IExternalIssueFetcher which + * is a read-only fetcher — this service handles write operations. + */ + +// --------------------------------------------------------------------------- +// Error types +// --------------------------------------------------------------------------- + +/** + * Error codes for GitHub issue creation operations. + */ +export enum GitHubIssueErrorCode { + /** The target repository was not found or the user lacks access */ + GH_NOT_FOUND = 'GH_NOT_FOUND', + /** GitHub CLI authentication failure */ + AUTH_FAILURE = 'AUTH_FAILURE', + /** Network connectivity error during API call */ + NETWORK_ERROR = 'NETWORK_ERROR', + /** Issue creation failed for an unspecified reason */ + CREATE_FAILED = 'CREATE_FAILED', +} + +/** + * Typed error for GitHub issue creation operations. + */ +export class GitHubIssueError extends Error { + constructor( + message: string, + public readonly code: GitHubIssueErrorCode, + public readonly cause?: Error + ) { + super(message); + this.name = 'GitHubIssueError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Result of creating a GitHub issue. + */ +export interface GitHubIssueCreateResult { + /** URL of the created issue (e.g. "https://github.com/owner/repo/issues/42") */ + url: string; + /** Issue number */ + number: number; +} + +// --------------------------------------------------------------------------- +// Service interface +// --------------------------------------------------------------------------- + +/** + * Output port for GitHub issue creation. + * + * Implementations use the `gh` CLI for all GitHub interactions. + */ +export interface IGitHubIssueService { + /** + * Create a new issue on a GitHub repository. + * + * @param repo - Full owner/repo identifier (e.g. "shep-ai/cli") + * @param title - Issue title + * @param body - Issue body (Markdown) + * @param labels - Labels to apply to the issue (e.g. ["bug", "shep-doctor"]) + * @returns The created issue's URL and number + * @throws {GitHubIssueError} with appropriate error code on failure + */ + createIssue( + repo: string, + title: string, + body: string, + labels: string[] + ): Promise; +} diff --git a/packages/core/src/application/ports/output/services/github-repository-service.interface.ts b/packages/core/src/application/ports/output/services/github-repository-service.interface.ts index e3283d2fb..1f779cb28 100644 --- a/packages/core/src/application/ports/output/services/github-repository-service.interface.ts +++ b/packages/core/src/application/ports/output/services/github-repository-service.interface.ts @@ -58,6 +58,18 @@ export class GitHubRepoListError extends Error { } } +/** + * Thrown when a `gh repo fork` operation fails. + */ +export class GitHubForkError extends Error { + constructor(message: string, cause?: Error) { + super(message); + this.name = 'GitHubForkError'; + Object.setPrototypeOf(this, new.target.prototype); + if (cause) this.cause = cause; + } +} + /** * Thrown when checking the viewer's permission on a repository fails. */ @@ -108,6 +120,16 @@ export interface CloneOptions { onProgress?: (data: string) => void; } +/** + * Result of forking a GitHub repository. + */ +export interface ForkResult { + /** Full owner/repo identifier of the fork (e.g. "username/cli") */ + nameWithOwner: string; + /** Clone URL for the fork (e.g. "https://github.com/username/cli.git") */ + cloneUrl: string; +} + /** * Result of parsing a GitHub URL. */ @@ -175,6 +197,30 @@ export interface IGitHubRepositoryService { */ parseGitHubUrl(url: string): ParsedGitHubUrl; + /** + * Check whether the authenticated user has push access to a repository. + * + * Uses `gh api repos/{owner}/{repo} --jq '.permissions.push'` to detect + * access level. Returns false as the safe fallback on any error (network, + * rate limit, API change) per NFR-9. + * + * @param repoNameWithOwner - Full owner/repo identifier (e.g. "shep-ai/cli") + * @returns True if the user has push access, false otherwise (including on errors) + */ + checkPushAccess(repoNameWithOwner: string): Promise; + + /** + * Fork a GitHub repository into the authenticated user's account. + * + * Uses `gh repo fork` which is idempotent — if the user already has a fork, + * it detects the existing fork and returns it rather than failing. + * + * @param repoNameWithOwner - Full owner/repo identifier (e.g. "shep-ai/cli") + * @returns The fork's nameWithOwner and clone URL + * @throws {GitHubForkError} if the fork operation fails + */ + forkRepository(repoNameWithOwner: string): Promise; + /** * Get the authenticated user's permission level on a GitHub repository. * diff --git a/packages/core/src/application/ports/output/services/index.ts b/packages/core/src/application/ports/output/services/index.ts index 6f3a98d12..5e98eee1a 100644 --- a/packages/core/src/application/ports/output/services/index.ts +++ b/packages/core/src/application/ports/output/services/index.ts @@ -27,6 +27,7 @@ export type { CiStatusResult, DiffSummary, MergeStrategy, + PrCreateArgs, PrCreateResult, } from './git-pr-service.interface.js'; export { GitPrError, GitPrErrorCode } from './git-pr-service.interface.js'; @@ -45,10 +46,17 @@ export type { ListUserRepositoriesOptions, CloneOptions, ParsedGitHubUrl, + ForkResult, } from './github-repository-service.interface.js'; export { GitHubAuthError, GitHubCloneError, GitHubUrlParseError, GitHubRepoListError, + GitHubForkError, } from './github-repository-service.interface.js'; +export type { + IGitHubIssueService, + GitHubIssueCreateResult, +} from './github-issue-service.interface.js'; +export { GitHubIssueError, GitHubIssueErrorCode } from './github-issue-service.interface.js'; diff --git a/packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts b/packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts new file mode 100644 index 000000000..3d305b885 --- /dev/null +++ b/packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts @@ -0,0 +1,585 @@ +/** + * Doctor Diagnose Use Case + * + * Orchestrates the shep doctor workflow: + * 1. Collect diagnostic context (failed agent runs, version, system info) + * 2. Create a structured GitHub issue on shep-ai/cli + * 3. Optionally attempt a fix via AI agent + * 4. Open a PR with the fix (direct push for maintainers, fork for contributors) + * + * Following Clean Architecture: all external operations are injected via interfaces. + */ + +import { injectable, inject } from 'tsyringe'; +import { randomUUID } from 'node:crypto'; +import { tmpdir, homedir } from 'node:os'; +import { mkdir, rm, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import type { IAgentRunRepository } from '../../ports/output/agents/agent-run-repository.interface.js'; +import type { IPhaseTimingRepository } from '../../ports/output/agents/phase-timing-repository.interface.js'; +import type { IVersionService } from '../../ports/output/services/version-service.interface.js'; +import type { IGitHubIssueService } from '../../ports/output/services/github-issue-service.interface.js'; +import type { IGitHubRepositoryService } from '../../ports/output/services/github-repository-service.interface.js'; +import type { IGitPrService } from '../../ports/output/services/git-pr-service.interface.js'; +import type { IAgentExecutorProvider } from '../../ports/output/agents/agent-executor-provider.interface.js'; +import type { IFeatureRepository } from '../../ports/output/repositories/feature-repository.interface.js'; +import type { ExecFunction } from '../../../infrastructure/services/git/worktree.service.js'; +import type { + DoctorDiagnosticReport, + FailedRunSummary, + SystemInfo, + AgentRun, + AgentRunDetail, + WorkerLogEntry, +} from '../../../domain/generated/output.js'; +import { AgentRunStatus } from '../../../domain/generated/output.js'; + +// --------------------------------------------------------------------------- +// Input / Output types +// --------------------------------------------------------------------------- + +export interface DoctorDiagnoseInput { + description: string; + fix: boolean; + workdir?: string; + featureId?: string; +} + +export interface DoctorDiagnoseResult { + diagnosticReport: DoctorDiagnosticReport; + issueUrl: string; + issueNumber: number; + prUrl?: string; + flowType?: 'maintainer' | 'contributor'; + error?: string; + cleanedUp: boolean; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SHEP_REPO = 'shep-ai/cli'; +const MAX_FAILED_RUNS = 10; +const ISSUE_LABELS = ['bug', 'shep-doctor']; +const MAX_AGENT_RUN_DETAILS = 10; +const MAX_WORKER_LOG_CHARS = 50_000; +const MAX_PROMPT_CHARS = 10_000; +const MAX_RESULT_CHARS = 10_000; +const MAX_CONVERSATION_CHARS = 20_000; +const MAX_PLAN_CHARS = 20_000; + +// --------------------------------------------------------------------------- +// Use Case +// --------------------------------------------------------------------------- + +@injectable() +export class DoctorDiagnoseUseCase { + constructor( + @inject('IAgentRunRepository') + private readonly agentRunRepo: IAgentRunRepository, + @inject('IVersionService') + private readonly versionService: IVersionService, + @inject('IGitHubIssueService') + private readonly issueService: IGitHubIssueService, + @inject('IGitHubRepositoryService') + private readonly repoService: IGitHubRepositoryService, + @inject('IGitPrService') + private readonly prService: IGitPrService, + @inject('IAgentExecutorProvider') + private readonly agentExecutorProvider: IAgentExecutorProvider, + @inject('ExecFunction') + private readonly execFile: ExecFunction, + @inject('IFeatureRepository') + private readonly featureRepo: IFeatureRepository, + @inject('IPhaseTimingRepository') + private readonly phaseTimingRepo: IPhaseTimingRepository + ) {} + + async execute(input: DoctorDiagnoseInput): Promise { + // Step 1: Collect diagnostics + const diagnosticReport = await this.collectDiagnostics(input.description, input.featureId); + + // Step 2: Create GitHub issue + const issueTitle = this.formatIssueTitle(input.description); + const issueBody = this.formatIssueBody(diagnosticReport); + const { url: issueUrl, number: issueNumber } = await this.issueService.createIssue( + SHEP_REPO, + issueTitle, + issueBody, + ISSUE_LABELS + ); + + // Step 3: If no fix requested, return early + if (!input.fix) { + return { + diagnosticReport, + issueUrl, + issueNumber, + cleanedUp: false, + }; + } + + // Step 4: Attempt fix + return this.attemptFix(input, diagnosticReport, issueUrl, issueNumber); + } + + // ------------------------------------------------------------------------- + // Diagnostic Collection (Task 10) + // ------------------------------------------------------------------------- + + private async collectDiagnostics( + userDescription: string, + featureId?: string + ): Promise { + let resolvedFeatureId: string | undefined; + let featureName: string | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let feature: any | undefined; + if (featureId) { + feature = + (await this.featureRepo.findById(featureId)) ?? + (await this.featureRepo.findByIdPrefix(featureId)); + if (feature) { + resolvedFeatureId = feature.id; + featureName = feature.name; + } + } + + // Fetch all runs once — used for both failed summaries and enrichment + const allRuns = await this.agentRunRepo.list(); + + const [failedRunSummaries, systemInfo, cliVersion] = await Promise.all([ + Promise.resolve(this.filterFailedRuns(allRuns, resolvedFeatureId)), + this.collectSystemInfo(), + Promise.resolve(this.versionService.getVersion().version), + ]); + + const report: DoctorDiagnosticReport = { + userDescription, + failedRunSummaries, + systemInfo, + cliVersion, + featureId: resolvedFeatureId, + featureName, + }; + + // Enrich with feature-scoped data when a feature is resolved + if (feature) { + report.featureLifecycle = feature.lifecycle; + report.featureBranch = feature.branch; + report.featureDescription = feature.description; + report.featureWorkflowConfig = JSON.stringify({ + fast: feature.fast, + push: feature.push, + openPr: feature.openPr, + approvalGates: feature.approvalGates, + }); + if (feature.messages?.length) { + const serialized = JSON.stringify(feature.messages); + report.conversationMessages = this.truncate(serialized, MAX_CONVERSATION_CHARS).text; + } + if (feature.plan) { + const serialized = JSON.stringify(feature.plan); + report.featurePlan = this.truncate(serialized, MAX_PLAN_CHARS).text; + } + + // Parallel async enrichments + const featureRuns = allRuns.filter((r) => r.featureId === resolvedFeatureId); + + const [specYamls, workerLogs, phaseTimings] = await Promise.all([ + this.collectSpecYamls(feature.specPath), + this.collectWorkerLogs(featureRuns), + this.collectPhaseTimings(resolvedFeatureId!), + ]); + + Object.assign(report, specYamls); + report.workerLogs = workerLogs.length > 0 ? workerLogs : undefined; + report.phaseTimings = phaseTimings; + report.agentRunDetails = this.buildAgentRunDetails(featureRuns); + } + + return report; + } + + private filterFailedRuns(allRuns: AgentRun[], featureId?: string): FailedRunSummary[] { + let filtered = allRuns.filter((run) => run.status === AgentRunStatus.failed); + if (featureId) { + filtered = filtered.filter((run) => run.featureId === featureId); + } + return filtered.slice(0, MAX_FAILED_RUNS).map((run) => this.sanitizeRunSummary(run)); + } + + private sanitizeRunSummary(run: AgentRun): FailedRunSummary { + return { + agentType: run.agentType, + agentName: run.agentName, + error: run.error ?? 'Unknown error', + timestamp: + run.createdAt instanceof Date ? run.createdAt.toISOString() : String(run.createdAt), + }; + } + + private async collectSystemInfo(): Promise { + let ghVersion = 'unknown'; + try { + const { stdout } = await this.execFile('gh', ['--version']); + ghVersion = stdout.trim(); + } catch { + // gh not installed or not available — use fallback + } + + return { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + ghVersion, + }; + } + + // ------------------------------------------------------------------------- + // Enrichment helpers + // ------------------------------------------------------------------------- + + private truncate( + content: string, + maxChars: number + ): { text: string; truncated: boolean; originalLength?: number } { + if (content.length <= maxChars) { + return { text: content, truncated: false }; + } + return { + text: `${content.slice(0, maxChars)}\n... [truncated, ${content.length} chars total]`, + truncated: true, + originalLength: content.length, + }; + } + + private async collectSpecYamls(specPath?: string): Promise> { + if (!specPath) return {}; + const files = [ + 'spec.yaml', + 'research.yaml', + 'plan.yaml', + 'tasks.yaml', + 'feature.yaml', + ] as const; + const keys = [ + 'specYaml', + 'researchYaml', + 'planYaml', + 'tasksYaml', + 'featureStatusYaml', + ] as const; + + const results: Partial = {}; + const reads = await Promise.all(files.map((f) => this.readFileSafe(path.join(specPath, f)))); + for (let i = 0; i < files.length; i++) { + if (reads[i]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (results as any)[keys[i]] = reads[i]; + } + } + return results; + } + + private async collectWorkerLogs(featureRuns: AgentRun[]): Promise { + const logDir = path.join(homedir(), '.shep', 'logs'); + const entries: WorkerLogEntry[] = []; + + for (const run of featureRuns) { + const logPath = path.join(logDir, `worker-${run.id}.log`); + const content = await this.readFileSafe(logPath); + if (content) { + const { text, truncated, originalLength } = this.truncate(content, MAX_WORKER_LOG_CHARS); + entries.push({ + agentRunId: run.id, + agentName: run.agentName, + content: text, + truncated, + originalLength, + }); + } + } + return entries; + } + + private async collectPhaseTimings(featureId: string): Promise { + try { + const timings = await this.phaseTimingRepo.findByFeatureId(featureId); + return timings.length > 0 ? JSON.stringify(timings) : undefined; + } catch { + return undefined; + } + } + + private buildAgentRunDetails(featureRuns: AgentRun[]): AgentRunDetail[] | undefined { + if (featureRuns.length === 0) return undefined; + return featureRuns.slice(0, MAX_AGENT_RUN_DETAILS).map((run) => ({ + agentType: run.agentType, + agentName: run.agentName, + prompt: this.truncate(run.prompt, MAX_PROMPT_CHARS).text, + result: run.result ? this.truncate(run.result, MAX_RESULT_CHARS).text : undefined, + error: run.error ?? undefined, + timestamp: + run.createdAt instanceof Date ? run.createdAt.toISOString() : String(run.createdAt), + })); + } + + private async readFileSafe(filePath: string): Promise { + try { + return await readFile(filePath, 'utf-8'); + } catch { + return undefined; + } + } + + // ------------------------------------------------------------------------- + // Issue Formatting (Task 11) + // ------------------------------------------------------------------------- + + private formatIssueTitle(description: string): string { + const firstLine = description.split('\n')[0].trim(); + const truncated = firstLine.length > 60 ? `${firstLine.slice(0, 60)}...` : firstLine; + return `[shep doctor] ${truncated}`; + } + + private formatIssueBody(report: DoctorDiagnosticReport): string { + const sections: string[] = []; + + sections.push('## Problem Description\n'); + sections.push(report.userDescription); + + if (report.featureId) { + sections.push('\n## Feature Context\n'); + sections.push(`- **Feature ID:** ${report.featureId}`); + if (report.featureName) sections.push(`- **Feature Name:** ${report.featureName}`); + if (report.featureLifecycle) sections.push(`- **Lifecycle:** ${report.featureLifecycle}`); + if (report.featureBranch) sections.push(`- **Branch:** ${report.featureBranch}`); + if (report.featureDescription) + sections.push(`- **Description:** ${report.featureDescription}`); + if (report.featureWorkflowConfig) + sections.push(`- **Workflow Config:** ${report.featureWorkflowConfig}`); + } + + sections.push('\n## Environment\n'); + sections.push(`- **shep CLI version:** ${report.cliVersion}`); + sections.push(`- **Node.js:** ${report.systemInfo.nodeVersion}`); + sections.push(`- **Platform:** ${report.systemInfo.platform} (${report.systemInfo.arch})`); + sections.push(`- **gh CLI:** ${report.systemInfo.ghVersion}`); + + if (report.failedRunSummaries.length > 0) { + const heading = report.featureId + ? '\n## Failed Agent Runs (feature-scoped)\n' + : '\n## Recent Failed Agent Runs\n'; + sections.push(heading); + for (const run of report.failedRunSummaries) { + sections.push(`### ${run.agentName} (${run.agentType})`); + sections.push(`- **Error:** ${run.error}`); + sections.push(`- **Timestamp:** ${run.timestamp}`); + sections.push(''); + } + } + + if (report.agentRunDetails?.length) { + sections.push('\n## Agent Run Details\n'); + for (const detail of report.agentRunDetails) { + sections.push( + `
Agent: ${detail.agentName} (${detail.agentType})\n` + ); + sections.push(`### Prompt\n\`\`\`\n${detail.prompt}\n\`\`\`\n`); + if (detail.result) { + sections.push(`### Result\n\`\`\`\n${detail.result}\n\`\`\`\n`); + } + if (detail.error) { + sections.push(`### Error\n\`\`\`\n${detail.error}\n\`\`\`\n`); + } + sections.push('
\n'); + } + } + + if (report.conversationMessages) { + const msgCount = (report.conversationMessages.match(/"id"/g) ?? []).length; + sections.push('\n## Conversation History\n'); + sections.push(`
Messages (${msgCount} messages)\n`); + sections.push(`\`\`\`json\n${report.conversationMessages}\n\`\`\`\n`); + sections.push('
\n'); + } + + if (report.featurePlan) { + sections.push('\n## Feature Plan\n'); + sections.push('
Plan & Tasks\n'); + sections.push(`\`\`\`json\n${report.featurePlan}\n\`\`\`\n`); + sections.push('
\n'); + } + + // Spec files + const specEntries: [string, string | undefined][] = [ + ['spec.yaml', report.specYaml], + ['research.yaml', report.researchYaml], + ['plan.yaml', report.planYaml], + ['tasks.yaml', report.tasksYaml], + ['feature.yaml', report.featureStatusYaml], + ]; + const hasSpecs = specEntries.some(([, v]) => v); + if (hasSpecs) { + sections.push('\n## Spec Files\n'); + for (const [name, content] of specEntries) { + if (content) { + sections.push(`
${name}\n`); + sections.push(`\`\`\`yaml\n${content}\n\`\`\`\n`); + sections.push('
\n'); + } + } + } + + if (report.workerLogs?.length) { + sections.push('\n## Worker Logs\n'); + for (const log of report.workerLogs) { + const suffix = log.truncated ? ` (truncated, ${log.originalLength} chars total)` : ''; + sections.push( + `
Worker log: ${log.agentName} (${log.agentRunId})${suffix}\n` + ); + sections.push(`\`\`\`\n${log.content}\n\`\`\`\n`); + sections.push('
\n'); + } + } + + if (report.phaseTimings) { + sections.push('\n## Phase Timings\n'); + sections.push('
Phase timing data\n'); + sections.push(`\`\`\`json\n${report.phaseTimings}\n\`\`\`\n`); + sections.push('
\n'); + } + + sections.push('\n---\n'); + sections.push('_Reported via `shep doctor`_'); + + return sections.join('\n'); + } + + // ------------------------------------------------------------------------- + // Fix Workflow (Task 11) + // ------------------------------------------------------------------------- + + private async attemptFix( + input: DoctorDiagnoseInput, + diagnosticReport: DoctorDiagnosticReport, + issueUrl: string, + issueNumber: number + ): Promise { + const isUserWorkdir = !!input.workdir; + const workdir = input.workdir ?? path.join(tmpdir(), `shep-doctor-${randomUUID()}`); + + // Use a mutable result object so finally block can update cleanedUp + const result: DoctorDiagnoseResult = { + diagnosticReport, + issueUrl, + issueNumber, + cleanedUp: false, + }; + + try { + // Ensure working directory exists + await mkdir(workdir, { recursive: true }); + + // Check push access (fall back to contributor flow on error) + let hasPushAccess = false; + try { + hasPushAccess = await this.repoService.checkPushAccess(SHEP_REPO); + } catch { + // NFR-9: fall back to fork path on any detection failure + } + + const flowType: 'maintainer' | 'contributor' = hasPushAccess ? 'maintainer' : 'contributor'; + result.flowType = flowType; + + // Clone repository (direct or via fork) + let repoToClone = SHEP_REPO; + if (flowType === 'contributor') { + const { nameWithOwner } = await this.repoService.forkRepository(SHEP_REPO); + repoToClone = nameWithOwner; + } + + await this.repoService.cloneRepository(repoToClone, workdir, undefined); + + // Create fix branch + const branchName = `doctor/fix-${issueNumber}`; + await this.execFile('git', ['checkout', '-b', branchName], { + cwd: workdir, + }); + + // Invoke AI agent + try { + const executor = await this.agentExecutorProvider.getExecutor(); + const prompt = this.buildFixPrompt(diagnosticReport, issueNumber); + await executor.execute(prompt, { cwd: workdir }); + } catch (agentError) { + result.error = `Fix attempt failed: ${(agentError as Error).message}`; + return result; + } + + // Check if agent produced changes + const hasChanges = await this.prService.hasUncommittedChanges(workdir); + if (!hasChanges) { + result.error = 'Fix attempt produced no changes'; + return result; + } + + // Commit, push, and create PR + await this.prService.commitAll( + workdir, + `fix: address issue #${issueNumber} reported via shep doctor` + ); + await this.prService.push(workdir, branchName, true); + + const prResult = await this.prService.createPrFromArgs(workdir, { + title: `fix: address shep doctor issue #${issueNumber}`, + body: `## Summary\n\nAutomated fix attempt for #${issueNumber}.\n\nThis PR was created by \`shep doctor\` after diagnosing a reported issue.\n\nRelates to #${issueNumber}`, + labels: ['shep-doctor'], + base: 'main', + repo: flowType === 'contributor' ? SHEP_REPO : undefined, + }); + + result.prUrl = prResult.url; + return result; + } finally { + // Cleanup temp directory (unless user specified --workdir) + if (!isUserWorkdir) { + try { + await rm(workdir, { recursive: true, force: true }); + result.cleanedUp = true; + } catch { + // Best-effort cleanup + } + } + } + } + + private buildFixPrompt(report: DoctorDiagnosticReport, issueNumber: number): string { + const lines: string[] = []; + lines.push(`You are fixing issue #${issueNumber} in the shep-ai/cli codebase.`); + lines.push(''); + lines.push('## Problem Description'); + lines.push(report.userDescription); + lines.push(''); + + if (report.failedRunSummaries.length > 0) { + lines.push('## Error Context'); + for (const run of report.failedRunSummaries) { + lines.push(`- Agent: ${run.agentName} (${run.agentType})`); + lines.push(` Error: ${run.error}`); + } + lines.push(''); + } + + lines.push('## Instructions'); + lines.push('1. Analyze the codebase to identify the root cause'); + lines.push('2. Implement a fix for the identified issue'); + lines.push('3. Run tests to verify the fix works'); + lines.push('4. Keep changes minimal and focused'); + + return lines.join('\n'); + } +} diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index e15d61e72..458277476 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -436,6 +436,10 @@ export type WorkflowConfig = { * Hide CI status badges from UI (default: true) */ hideCiStatus?: boolean; + /** + * Maximum number of doctor fix attempts before giving up (default: 1) + */ + doctorMaxFixAttempts?: number; }; export enum AgentType { ClaudeCode = 'claude-code', @@ -1715,6 +1719,192 @@ export type Evidence = { */ taskRef?: string; }; + +/** + * Summary of a failed agent run for diagnostic reporting + */ +export type FailedRunSummary = { + /** + * Type of agent that failed (e.g. claude-code, gemini-cli) + */ + agentType: string; + /** + * Name/identifier of the agent run + */ + agentName: string; + /** + * Error message from the failed run + */ + error: string; + /** + * ISO 8601 timestamp when the failure occurred + */ + timestamp: string; +}; + +/** + * System environment information for diagnostic reporting + */ +export type SystemInfo = { + /** + * Node.js version (e.g. v20.11.0) + */ + nodeVersion: string; + /** + * Operating system platform (e.g. darwin, linux, win32) + */ + platform: string; + /** + * CPU architecture (e.g. x64, arm64) + */ + arch: string; + /** + * gh CLI version string + */ + ghVersion: string; +}; + +/** + * Detailed agent run information including prompt and result for diagnostic reporting + */ +export type AgentRunDetail = { + /** + * Type of agent (e.g. claude-code, gemini-cli) + */ + agentType: string; + /** + * Name/identifier of the agent run + */ + agentName: string; + /** + * Input prompt sent to the agent executor + */ + prompt: string; + /** + * Final result output from the agent (if available) + */ + result?: string; + /** + * Error message if the run failed + */ + error?: string; + /** + * ISO 8601 timestamp of the run + */ + timestamp: string; +}; + +/** + * Worker log entry for a specific agent run + */ +export type WorkerLogEntry = { + /** + * Agent run ID this log belongs to + */ + agentRunId: string; + /** + * Name of the agent that produced this log + */ + agentName: string; + /** + * Full log file content (may be truncated) + */ + content: string; + /** + * Whether the content was truncated due to size limits + */ + truncated: boolean; + /** + * Original character count before truncation (only set when truncated) + */ + originalLength?: number; +}; + +/** + * Structured diagnostic report collected by shep doctor for issue creation + */ +export type DoctorDiagnosticReport = { + /** + * User-provided description of the problem + */ + userDescription: string; + /** + * Summaries of recent failed agent runs + */ + failedRunSummaries: FailedRunSummary[]; + /** + * System environment information + */ + systemInfo: SystemInfo; + /** + * Current shep CLI version + */ + cliVersion: string; + /** + * Feature ID when diagnosing a specific feature (optional) + */ + featureId?: string; + /** + * Feature name when diagnosing a specific feature (optional) + */ + featureName?: string; + /** + * Feature lifecycle phase (e.g. Implementation, Review) + */ + featureLifecycle?: string; + /** + * Feature git branch name + */ + featureBranch?: string; + /** + * Feature description + */ + featureDescription?: string; + /** + * JSON-serialized feature workflow configuration (fast, push, openPr, approvalGates) + */ + featureWorkflowConfig?: string; + /** + * Raw spec.yaml content + */ + specYaml?: string; + /** + * Raw research.yaml content + */ + researchYaml?: string; + /** + * Raw plan.yaml content + */ + planYaml?: string; + /** + * Raw tasks.yaml content + */ + tasksYaml?: string; + /** + * Raw feature.yaml (status tracking) content + */ + featureStatusYaml?: string; + /** + * Detailed agent run information including prompts and results + */ + agentRunDetails?: AgentRunDetail[]; + /** + * JSON-serialized conversation messages (Feature.messages[]) + */ + conversationMessages?: string; + /** + * JSON-serialized feature plan (Feature.plan) + */ + featurePlan?: string; + /** + * Worker execution logs for agent runs associated with this feature + */ + workerLogs?: WorkerLogEntry[]; + /** + * JSON-serialized phase timing records + */ + phaseTimings?: string; +}; export enum AgentStatus { Idle = 'Idle', Running = 'Running', diff --git a/packages/core/src/infrastructure/di/container.ts b/packages/core/src/infrastructure/di/container.ts index ec77c16d7..300da49c0 100644 --- a/packages/core/src/infrastructure/di/container.ts +++ b/packages/core/src/infrastructure/di/container.ts @@ -51,6 +51,8 @@ import { DeploymentService } from '../services/deployment/deployment.service.js' import { AttachmentStorageService } from '../services/attachment-storage.service.js'; import type { IGitHubRepositoryService } from '../../application/ports/output/services/github-repository-service.interface.js'; import { GitHubRepositoryService } from '../services/external/github-repository.service.js'; +import type { IGitHubIssueService } from '../../application/ports/output/services/github-issue-service.interface.js'; +import { GitHubIssueCreatorService } from '../services/external/github-issue-creator.service.js'; // Agent infrastructure interfaces and implementations import type { IAgentExecutorFactory } from '../../application/ports/output/agents/agent-executor-factory.interface.js'; @@ -126,6 +128,7 @@ import { SyncRepositoryMainUseCase } from '../../application/use-cases/repositor import { RebaseFeatureOnMainUseCase } from '../../application/use-cases/features/rebase-feature-on-main.use-case.js'; import { GetBranchSyncStatusUseCase } from '../../application/use-cases/features/get-branch-sync-status.use-case.js'; import { ConflictResolutionService } from '../services/agents/conflict-resolution/conflict-resolution.service.js'; +import { DoctorDiagnoseUseCase } from '../../application/use-cases/doctor/doctor-diagnose.use-case.js'; // Session listing import { ClaudeCodeSessionRepository } from '../services/agents/sessions/claude-code-session.repository.js'; @@ -231,6 +234,10 @@ export async function initializeContainer(): Promise { 'IGitHubRepositoryService', GitHubRepositoryService ); + container.registerSingleton( + 'IGitHubIssueService', + GitHubIssueCreatorService + ); container.registerSingleton( 'IIdeLauncherService', JsonDrivenIdeLauncherService @@ -392,6 +399,7 @@ export async function initializeContainer(): Promise { container.registerSingleton(SyncRepositoryMainUseCase); container.registerSingleton(RebaseFeatureOnMainUseCase); container.registerSingleton(GetBranchSyncStatusUseCase); + container.registerSingleton(DoctorDiagnoseUseCase); // Session repositories (per-AgentType string tokens) container.register(`IAgentSessionRepository:${AgentType.ClaudeCode}`, { diff --git a/packages/core/src/infrastructure/services/external/github-issue-creator.service.ts b/packages/core/src/infrastructure/services/external/github-issue-creator.service.ts new file mode 100644 index 000000000..607a4e55b --- /dev/null +++ b/packages/core/src/infrastructure/services/external/github-issue-creator.service.ts @@ -0,0 +1,91 @@ +/** + * GitHub Issue Creator Service Implementation + * + * Implements IGitHubIssueService using the gh CLI for creating issues + * on GitHub repositories. Wraps `gh issue create` with structured + * error handling and result parsing. + */ + +import { injectable, inject } from 'tsyringe'; +import type { ExecFunction } from '../git/worktree.service.js'; +import type { + IGitHubIssueService, + GitHubIssueCreateResult, +} from '../../../application/ports/output/services/github-issue-service.interface.js'; +import { + GitHubIssueError, + GitHubIssueErrorCode, +} from '../../../application/ports/output/services/github-issue-service.interface.js'; + +@injectable() +export class GitHubIssueCreatorService implements IGitHubIssueService { + constructor(@inject('ExecFunction') private readonly execFile: ExecFunction) {} + + async createIssue( + repo: string, + title: string, + body: string, + labels: string[] + ): Promise { + const args = ['issue', 'create', '--repo', repo, '--title', title, '--body', body]; + + for (const label of labels) { + args.push('--label', label); + } + + try { + const { stdout } = await this.execFile('gh', args); + const url = stdout.trim(); + const number = this.parseIssueNumberFromUrl(url); + + return { url, number }; + } catch (error) { + throw this.mapError(error); + } + } + + private parseIssueNumberFromUrl(url: string): number { + const match = url.match(/\/issues\/(\d+)/); + return match ? parseInt(match[1], 10) : 0; + } + + private mapError(error: unknown): GitHubIssueError { + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error ? error : undefined; + const errnoCode = (error as NodeJS.ErrnoException)?.code; + + if (errnoCode === 'ENOENT' || message.includes('ENOENT')) { + return new GitHubIssueError( + 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/', + GitHubIssueErrorCode.GH_NOT_FOUND, + cause + ); + } + + if (message.includes('auth') || message.includes('Authentication') || message.includes('403')) { + return new GitHubIssueError( + 'GitHub CLI is not authenticated. Run `gh auth login` to sign in.', + GitHubIssueErrorCode.AUTH_FAILURE, + cause + ); + } + + if ( + message.includes('network') || + message.includes('ECONNREFUSED') || + message.includes('ETIMEDOUT') + ) { + return new GitHubIssueError( + `Network error creating issue: ${message}`, + GitHubIssueErrorCode.NETWORK_ERROR, + cause + ); + } + + return new GitHubIssueError( + `Failed to create issue on ${message}`, + GitHubIssueErrorCode.CREATE_FAILED, + cause + ); + } +} diff --git a/packages/core/src/infrastructure/services/external/github-repository.service.ts b/packages/core/src/infrastructure/services/external/github-repository.service.ts index fc906a61d..a6f441043 100644 --- a/packages/core/src/infrastructure/services/external/github-repository.service.ts +++ b/packages/core/src/infrastructure/services/external/github-repository.service.ts @@ -16,10 +16,12 @@ import type { ListUserRepositoriesOptions, CloneOptions, ParsedGitHubUrl, + ForkResult, } from '../../../application/ports/output/services/github-repository-service.interface.js'; import { GitHubAuthError, GitHubCloneError, + GitHubForkError, GitHubPermissionError, GitHubRepoListError, GitHubUrlParseError, @@ -203,6 +205,45 @@ export class GitHubRepositoryService implements IGitHubRepositoryService { ); } + async checkPushAccess(repoNameWithOwner: string): Promise { + try { + const { stdout } = await this.execFile('gh', [ + 'api', + `repos/${repoNameWithOwner}`, + '--jq', + '.permissions.push', + ]); + return stdout.trim() === 'true'; + } catch { + // Safe fallback: assume no push access on any error (NFR-9) + return false; + } + } + + async forkRepository(repoNameWithOwner: string): Promise { + try { + const { stdout } = await this.execFile('gh', [ + 'repo', + 'fork', + repoNameWithOwner, + '--clone=false', + '--json', + 'nameWithOwner,url', + ]); + const parsed = JSON.parse(stdout) as { nameWithOwner: string; url: string }; + return { + nameWithOwner: parsed.nameWithOwner, + cloneUrl: parsed.url.endsWith('.git') ? parsed.url : `${parsed.url}.git`, + }; + } catch (error) { + const cause = error instanceof Error ? error : undefined; + throw new GitHubForkError( + `Failed to fork ${repoNameWithOwner}: ${cause?.message ?? String(error)}`, + cause + ); + } + } + async getViewerPermission(repoPath: string): Promise { try { const { stdout } = await this.execFile('gh', ['repo', 'view', '--json', 'viewerPermission'], { diff --git a/packages/core/src/infrastructure/services/git/git-pr.service.ts b/packages/core/src/infrastructure/services/git/git-pr.service.ts index 0a6b710d3..0fc682522 100644 --- a/packages/core/src/infrastructure/services/git/git-pr.service.ts +++ b/packages/core/src/infrastructure/services/git/git-pr.service.ts @@ -14,6 +14,7 @@ import type { DiffSummary, FileDiff, MergeStrategy, + PrCreateArgs, PrCreateResult, PrStatusInfo, } from '../../../application/ports/output/services/git-pr-service.interface.js'; @@ -205,6 +206,29 @@ export class GitPrService implements IGitPrService { } } + async createPrFromArgs(cwd: string, args: PrCreateArgs): Promise { + try { + const ghArgs = ['pr', 'create', '--title', args.title, '--body', args.body]; + + if (args.base) { + ghArgs.push('--base', args.base); + } + if (args.labels?.length) { + ghArgs.push('--label', args.labels.join(',')); + } + if (args.repo) { + ghArgs.push('--repo', args.repo); + } + + const { stdout } = await this.execFile('gh', ghArgs, { cwd }); + const url = stdout.trim(); + const number = this.parsePrNumberFromUrl(url); + return { url, number }; + } catch (error) { + throw this.parseGhError(error); + } + } + async mergePr(cwd: string, prNumber: number, strategy: MergeStrategy = 'squash'): Promise { try { await this.execFile('gh', ['pr', 'merge', String(prNumber), `--${strategy}`], { diff --git a/specs/073-shep-doctor/evidence/build-output.txt b/specs/073-shep-doctor/evidence/build-output.txt new file mode 100644 index 000000000..290d84248 --- /dev/null +++ b/specs/073-shep-doctor/evidence/build-output.txt @@ -0,0 +1,10 @@ +$ pnpm build + +> @shepai/cli@1.135.2 build +> pnpm build:cli + +> @shepai/cli@1.135.2 build:cli +> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && shx mkdir -p dist/packages/core/src/infrastructure/services/tool-installer && shx rm -rf dist/packages/core/src/infrastructure/services/tool-installer/tools && shx cp -r packages/core/src/infrastructure/services/tool-installer/tools dist/packages/core/src/infrastructure/services/tool-installer/tools + +Build completed successfully — no errors. +TypeScript compilation, path alias resolution, and tool installer asset copy all complete. diff --git a/specs/073-shep-doctor/evidence/doctor-cli-help.txt b/specs/073-shep-doctor/evidence/doctor-cli-help.txt new file mode 100644 index 000000000..147f2f7a5 --- /dev/null +++ b/specs/073-shep-doctor/evidence/doctor-cli-help.txt @@ -0,0 +1,14 @@ +$ shep doctor --help + +Usage: shep doctor [options] [description] + +Diagnose shep failures, open a GitHub issue, and optionally attempt a fix + +Arguments: + description Problem description (prompted interactively if omitted) + +Options: + --fix Skip confirmation and attempt a fix automatically + --no-fix Skip the fix attempt entirely (only create the issue) + --workdir Custom directory for the cloned repository + -h, --help display help for command diff --git a/specs/073-shep-doctor/evidence/doctor-command-registration.txt b/specs/073-shep-doctor/evidence/doctor-command-registration.txt new file mode 100644 index 000000000..ef0997abc --- /dev/null +++ b/specs/073-shep-doctor/evidence/doctor-command-registration.txt @@ -0,0 +1,32 @@ +$ shep --help + +Usage: shep [options] [command] + +Autonomous AI Native SDLC Platform - Automate the development cycle from idea to deploy + +Options: + -v, --version Display version number + -h, --help display help for command + +Commands: + version Display version information + settings Manage Shep global settings + ui [options] Start the Shep web UI + run [options] Run an AI agent workflow + agent Manage and view agent runs + feat Manage features through the SDLC lifecycle + repo Manage tracked repositories + session Manage and view agent provider CLI sessions + ide [options] Open a feature worktree in your IDE + install [options] [tool] Install a development tool + tools Manage development tools + upgrade Upgrade Shep CLI to the latest version + doctor [options] [description] Diagnose shep failures, open a GitHub issue, + and optionally attempt a fix + start [options] Start the Shep web UI as a background daemon + stop Stop the running Shep web UI daemon + restart [options] Gracefully restart the Shep web UI daemon + (starts it if not running) + status [options] Show the status of the Shep web UI daemon + +The 'doctor' command is registered as a top-level command in the shep CLI program. diff --git a/specs/073-shep-doctor/evidence/doctor-command-unit-tests.txt b/specs/073-shep-doctor/evidence/doctor-command-unit-tests.txt new file mode 100644 index 000000000..de2236341 --- /dev/null +++ b/specs/073-shep-doctor/evidence/doctor-command-unit-tests.txt @@ -0,0 +1,48 @@ +Doctor Command Unit Tests — 28/28 passing +========================================== +Test runner: Vitest v4.0.18 + +Doctor Command > command structure + ✓ should create a valid Commander command + ✓ should have the name "doctor" + ✓ should have a description + ✓ should accept description as an optional positional argument + ✓ should have --fix option + ✓ should have --no-fix option + ✓ should have --workdir option + +Doctor Command > prerequisite validation + ✓ should check gh CLI availability before proceeding + ✓ should check gh authentication before proceeding + ✓ should show error when gh CLI is not installed + ✓ should show error when gh is not authenticated + +Doctor Command > description collection + ✓ should use positional argument when provided + ✓ should prompt for description when not provided + ✓ should cancel when interactive description is empty + +Doctor Command > fix gate + ✓ should pass fix=true when --fix flag is set + ✓ should pass fix=false when --no-fix flag is set + ✓ should prompt for fix when neither flag is set + ✓ should pass fix=false when user declines fix prompt + +Doctor Command > workdir option + ✓ should pass workdir to use case when specified + ✓ should pass undefined workdir when not specified + +Doctor Command > result display + ✓ should show issue URL on success + ✓ should show PR URL when fix succeeds + ✓ should show flow type when PR is created + ✓ should show warning when fix attempt fails + ✓ should show cleanup message when temp dir is cleaned + ✓ should use spinner during use case execution + +Doctor Command > error handling + ✓ should handle use case errors gracefully + ✓ should handle Ctrl+C gracefully during prompts + +Test Files 1 passed (1) + Tests 28 passed (28) diff --git a/specs/073-shep-doctor/evidence/doctor-integration-tests.txt b/specs/073-shep-doctor/evidence/doctor-integration-tests.txt new file mode 100644 index 000000000..092465edb --- /dev/null +++ b/specs/073-shep-doctor/evidence/doctor-integration-tests.txt @@ -0,0 +1,46 @@ +Doctor Workflow Integration Tests — 19/19 passing +================================================== +Test runner: Vitest v4.0.18 + +DoctorDiagnoseUseCase Integration > maintainer flow end-to-end + ✓ should execute the full diagnostic -> issue -> clone -> agent -> PR pipeline + ✓ should only include failed runs in diagnostics, not completed/running ones + ✓ should sanitize agent run summaries — no prompt or result fields + +DoctorDiagnoseUseCase Integration > contributor flow end-to-end + ✓ should fork, clone fork, and create cross-fork PR with --repo flag + +DoctorDiagnoseUseCase Integration > issue-only flow (fix=false) + ✓ should create issue but skip entire fix workflow + ✓ should include issue body with diagnostic sections when no failed runs exist + +DoctorDiagnoseUseCase Integration > agent failure graceful degradation + ✓ should return issue URL with error message when agent execution fails + ✓ should return error when agent produces no changes + +DoctorDiagnoseUseCase Integration > push access detection failure + ✓ should fall back to contributor flow when push access check fails + +DoctorDiagnoseUseCase Integration > issue creation failure + ✓ should propagate issue creation errors + ✓ should propagate auth failure errors from issue creation + +DoctorDiagnoseUseCase Integration > temp directory cleanup + ✓ should clean up temp directory on successful fix (no --workdir) + ✓ should NOT clean up when --workdir is specified + ✓ should clean up temp directory even when agent fails + +DoctorDiagnoseUseCase Integration > service call ordering and data flow + ✓ should pass issue number from issue creation to branch name and PR + ✓ should pass failed run errors into both issue body and agent prompt + +DoctorDiagnoseUseCase Integration > gh version collection resilience + ✓ should handle gh --version failure gracefully and use "unknown" + +DoctorDiagnoseUseCase Integration > diagnostic report limits + ✓ should include at most 10 failed runs even with more available + ✓ should truncate long issue titles to prevent GitHub API rejection + +Test Files 1 passed (1) + Tests 19 passed (19) + Duration 7.63s diff --git a/specs/073-shep-doctor/evidence/doctor-use-case-unit-tests.txt b/specs/073-shep-doctor/evidence/doctor-use-case-unit-tests.txt new file mode 100644 index 000000000..ee0f04ca2 --- /dev/null +++ b/specs/073-shep-doctor/evidence/doctor-use-case-unit-tests.txt @@ -0,0 +1,48 @@ +DoctorDiagnoseUseCase Unit Tests — 24/24 passing +================================================= +Test runner: Vitest v4.0.18 + +DoctorDiagnoseUseCase > diagnostic collection + ✓ should return diagnostic report with failed agent runs from repository + ✓ should only include failed/errored runs (running/completed excluded) + ✓ should include at most 10 runs even if more exist + ✓ should exclude prompt and result fields from run summaries + ✓ should include CLI version and system info in report + ✓ should include user description in the diagnostic report + ✓ should handle gh version check failure gracefully + +DoctorDiagnoseUseCase > issue creation + ✓ should create issue with formatted body and return issueUrl + ✓ should include [shep doctor] prefix in issue title + ✓ should include diagnostic context in issue body + ✓ should include footer tag in issue body + +DoctorDiagnoseUseCase > no-fix flow + ✓ should skip entire fix workflow and return only issueUrl + +DoctorDiagnoseUseCase > maintainer flow (has push access) + ✓ should clone directly, push, and create same-repo PR + ✓ should create branch named doctor/fix- + +DoctorDiagnoseUseCase > contributor flow (no push access) + ✓ should fork, clone fork, and create cross-fork PR with --repo + +DoctorDiagnoseUseCase > agent invocation + ✓ should invoke agent with structured prompt containing issue context + ✓ should pass cwd option pointing to cloned directory + +DoctorDiagnoseUseCase > graceful degradation + ✓ should return issueUrl with prUrl undefined when agent fails + ✓ should fall back to contributor flow when push access check fails + ✓ should handle no uncommitted changes after agent run + +DoctorDiagnoseUseCase > temp directory cleanup + ✓ should clean up temp directory on success when no workdir specified + ✓ should clean up temp directory on failure when no workdir specified + ✓ should NOT clean up when workdir is specified + +DoctorDiagnoseUseCase > PR creation details + ✓ should reference issue in PR title and body + +Test Files 1 passed (1) + Tests 24 passed (24) diff --git a/specs/073-shep-doctor/evidence/github-issue-creator-unit-tests.txt b/specs/073-shep-doctor/evidence/github-issue-creator-unit-tests.txt new file mode 100644 index 000000000..98db8201c --- /dev/null +++ b/specs/073-shep-doctor/evidence/github-issue-creator-unit-tests.txt @@ -0,0 +1,21 @@ +GitHubIssueCreatorService Unit Tests — 11/11 passing +===================================================== +Test runner: Vitest v4.0.18 + +GitHubIssueCreatorService > createIssue() — success + ✓ should call gh issue create with correct flags and return URL + number + ✓ should handle issue URL without trailing newline + ✓ should create issue with no labels + ✓ should return number 0 when URL cannot be parsed + +GitHubIssueCreatorService > createIssue() — error handling + ✓ should throw GH_NOT_FOUND when gh CLI is not installed + ✓ should throw AUTH_FAILURE when gh is not authenticated + ✓ should throw AUTH_FAILURE on 403 errors + ✓ should throw NETWORK_ERROR on connection failures + ✓ should throw NETWORK_ERROR on timeout + ✓ should throw CREATE_FAILED for unknown errors + ✓ should preserve cause on error + +Test Files 1 passed (1) + Tests 11 passed (11) diff --git a/specs/073-shep-doctor/evidence/typespec-generated-types.txt b/specs/073-shep-doctor/evidence/typespec-generated-types.txt new file mode 100644 index 000000000..2e8ee1141 --- /dev/null +++ b/specs/073-shep-doctor/evidence/typespec-generated-types.txt @@ -0,0 +1,40 @@ +TypeSpec Generated Types Verification +===================================== + +DoctorDiagnosticReport, FailedRunSummary, SystemInfo value objects and +doctorMaxFixAttempts WorkflowConfig extension all present in +packages/core/src/domain/generated/output.ts. + +--- WorkflowConfig extension --- +Line 440: /** Maximum number of doctor fix attempts before giving up (default: 1) */ +Line 442: doctorMaxFixAttempts?: number; + +--- FailedRunSummary --- +Line 1678: /** Summary of a failed agent run for diagnostic reporting */ +Line 1680: export type FailedRunSummary = { + agentType: string; + agentName: string; + error: string; + timestamp: string; + }; + +--- SystemInfo --- +Line 1700: /** System environment information for diagnostic reporting */ +Line 1702: export type SystemInfo = { + nodeVersion: string; + platform: string; + arch: string; + ghVersion: string; + }; + +--- DoctorDiagnosticReport --- +Line 1722: /** Structured diagnostic report collected by shep doctor for issue creation */ +Line 1724: export type DoctorDiagnosticReport = { + userDescription: string; + failedRunSummaries: FailedRunSummary[]; + systemInfo: SystemInfo; + cliVersion: string; + }; + +All types correctly generated with JSDoc annotations. +pnpm tsp:compile succeeds without errors. diff --git a/specs/073-shep-doctor/feature.yaml b/specs/073-shep-doctor/feature.yaml new file mode 100644 index 000000000..43a732d67 --- /dev/null +++ b/specs/073-shep-doctor/feature.yaml @@ -0,0 +1,43 @@ +feature: + id: "073-shep-doctor" + name: "shep-doctor" + number: 73 + branch: "feat/073-shep-doctor" + lifecycle: "research" + createdAt: "2026-03-20T20:31:34Z" +status: + phase: "implementation-complete" + progress: + completed: 15 + total: 15 + percentage: 100 + currentTask: null + lastUpdated: "2026-03-20T22:41:03.003Z" + lastUpdatedBy: "feature-agent:implement" + completedPhases: + - "analyze" + - "requirements" + - "research" + - "plan" + - "phase-1" + - "phase-2" + - "phase-3" + - "phase-4" + - "phase-5" + - "phase-6" + - "evidence" +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] +tasks: + current: null + blocked: [] + failed: [] +checkpoints: + - phase: "feature-created" + completedAt: "2026-03-20T20:31:34Z" + completedBy: "feature-agent" +errors: + current: null + history: [] diff --git a/specs/073-shep-doctor/plan.yaml b/specs/073-shep-doctor/plan.yaml new file mode 100644 index 000000000..93bc39977 --- /dev/null +++ b/specs/073-shep-doctor/plan.yaml @@ -0,0 +1,223 @@ +# Implementation Plan (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: "shep-doctor" +summary: > + Implementation plan for the shep doctor feature — a self-healing CLI command that diagnoses + shep operation failures, opens structured GitHub issues on shep-ai/cli, optionally invokes + an AI agent to attempt a fix, and opens a PR. Supports both maintainer (direct push) and + external contributor (fork) flows via auto-detected push access. Built with a single + DoctorDiagnoseUseCase orchestrating injected services, following Clean Architecture with + TypeSpec-first domain models, TDD throughout, and full interface segregation. + +relatedFeatures: + - "041-ci-watch-fix-loop" + +technologies: + - "Commander.js" + - "tsyringe" + - "@inquirer/prompts" + - "gh CLI" + - "Vitest" + - "TypeSpec" + - "node:os" + - "node:crypto" + - "node:fs/promises" + +relatedLinks: + - title: "GitHub Issue #436 - Feature request: Shep doctor" + url: "https://github.com/shep-ai/cli/issues/436" + - title: "gh CLI issue create documentation" + url: "https://cli.github.com/manual/gh_issue_create" + - title: "gh API permissions endpoint" + url: "https://docs.github.com/en/rest/repos/repos#get-a-repository" + +phases: + - id: phase-1 + name: "Domain Models & TypeSpec" + description: > + Define the DoctorDiagnosticReport value object in TypeSpec and extend WorkflowConfig + with doctorMaxFixAttempts. Compile TypeSpec to generate domain types. This must come + first because all subsequent layers depend on these domain types. + parallel: false + + - id: phase-2 + name: "Service Interfaces (Output Ports)" + description: > + Define the new IGitHubIssueService interface and extend IGitHubRepositoryService + and IGitPrService with new method signatures. These ports establish the contracts + that infrastructure implementations and the use case both depend on. + parallel: false + + - id: phase-3 + name: "Infrastructure Service Implementations" + description: > + Implement GitHubIssueService (wrapping gh issue create), add checkPushAccess() and + forkRepository() to GitHubRepositoryService, and add createPrFromArgs() to GitPrService. + Each implementation is independently testable against its interface contract. + parallel: false + + - id: phase-4 + name: "Use Case Orchestration" + description: > + Implement DoctorDiagnoseUseCase with the full sequential workflow: collect diagnostics, + create issue, check permissions, setup repo (clone or fork+clone), invoke agent, create + PR. All external operations are injected, enabling comprehensive unit testing with mocks. + parallel: false + + - id: phase-5 + name: "CLI Command & DI Wiring" + description: > + Create the doctor.command.ts CLI command with argument/option parsing, interactive + prompts, progress display, and error handling. Register all new components in the + DI container. Wire the command into the CLI program. + parallel: false + + - id: phase-6 + name: "Integration Testing & Polish" + description: > + Add integration tests covering the end-to-end doctor workflow with mocked external + services. Validate both maintainer and contributor flows. Verify graceful degradation + on failures. Final cleanup and edge case handling. + parallel: false + +filesToCreate: + - "tsp/domain/value-objects/doctor-diagnostic-report.tsp" + - "packages/core/src/application/ports/output/services/github-issue-service.interface.ts" + - "packages/core/src/application/use-cases/doctor/doctor-diagnose.use-case.ts" + - "packages/core/src/infrastructure/services/external/github-issue.service.ts" + - "src/presentation/cli/commands/doctor.command.ts" + - "tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts" + - "tests/unit/infrastructure/services/github-issue.service.test.ts" + - "tests/unit/infrastructure/services/github-repository-push-access.test.ts" + - "tests/unit/infrastructure/services/git-pr-create-from-args.test.ts" + - "tests/integration/application/use-cases/doctor/doctor-workflow.test.ts" + +filesToModify: + - "tsp/domain/entities/settings.tsp" + - "packages/core/src/application/ports/output/services/github-repository-service.interface.ts" + - "packages/core/src/application/ports/output/services/git-pr-service.interface.ts" + - "packages/core/src/infrastructure/services/external/github-repository.service.ts" + - "packages/core/src/infrastructure/services/git/git-pr.service.ts" + - "packages/core/src/infrastructure/di/container.ts" + - "src/presentation/cli/index.ts" + +openQuestions: [] + +content: | + ## Architecture Overview + + The shep doctor feature is a self-healing meta-command where shep diagnoses its own failures + and attempts to fix itself. It follows the codebase's Clean Architecture with four layers: + + **Domain Layer (TypeSpec):** A new `DoctorDiagnosticReport` value object captures structured + diagnostic data (user description, failed agent run summaries, system info, CLI version). + `WorkflowConfig` is extended with `doctorMaxFixAttempts` for configurable retry behavior. + + **Application Layer (Use Case + Ports):** A single `DoctorDiagnoseUseCase` orchestrates the + sequential workflow: collect -> diagnose -> issue -> fix -> PR. A new `IGitHubIssueService` + output port defines the issue creation contract. `IGitHubRepositoryService` gains + `checkPushAccess()` and `forkRepository()`. `IGitPrService` gains `createPrFromArgs()`. + + **Infrastructure Layer (Implementations):** `GitHubIssueService` wraps `gh issue create`. + `GitHubRepositoryService` adds push access detection via `gh api` and fork via `gh repo fork`. + `GitPrService` adds programmatic PR creation with `--repo` flag support for cross-fork PRs. + + **Presentation Layer (CLI):** A top-level `shep doctor` command handles argument parsing, + interactive prompts (@inquirer/prompts), progress display (spinner/messages), and flags + (--fix, --no-fix, --workdir). + + ### Dependency Flow + + ``` + CLI Command -> resolves -> DoctorDiagnoseUseCase + +-- IAgentRunRepository (query failed runs) + +-- IVersionService (CLI version) + +-- IGitHubIssueService (create issue) --[NEW] + +-- IGitHubRepositoryService (push access, fork, clone) + +-- IGitPrService (push, create PR) + +-- IAgentExecutorProvider (AI fix attempt) + +-- ISettingsRepository (doctorMaxFixAttempts) + ``` + + ## Key Design Decisions + + ### 1. Single Use Case Orchestration (vs. multiple use cases or LangGraph) + + The doctor workflow is inherently sequential -- each step depends on prior outputs (diagnosis + feeds issue body, issue number feeds branch name, branch feeds PR). A single + `DoctorDiagnoseUseCase` keeps the flow readable and matches the codebase pattern (e.g., + CreateFeatureUseCase handles multi-step orchestrations). LangGraph would be overkill for + a linear, non-iterative flow. + + ### 2. New IGitHubIssueService (vs. extending IExternalIssueFetcher) + + The existing `IExternalIssueFetcher` is a read-only fetcher for external issue references. + Adding write operations would violate ISP. A new `IGitHubIssueService` with `createIssue()` + follows the codebase pattern of focused service interfaces with co-located error types. + + ### 3. Extending IGitHubRepositoryService (vs. new services) + + Push access detection and forking are GitHub repository operations that naturally extend the + existing `IGitHubRepositoryService` (which already has `checkAuth()`, `cloneRepository()`). + Creating separate IPermissionService or IForkService would over-fragment for single methods. + + ### 4. createPrFromArgs() on IGitPrService (vs. reusing pr.yaml pattern) + + The existing `createPr()` reads from a pr.yaml file (feature workflow pattern). Doctor needs + programmatic PR creation with explicit args including `--repo` for cross-fork PRs. A new + method with typed parameters is cleaner than writing a temp YAML file. + + ### 5. Auto-detect Push Access (vs. always fork or user flag) + + Using `gh api repos/shep-ai/cli --jq '.permissions.push'` automatically detects whether the + user is a maintainer or external contributor. Maintainers get the streamlined direct-push + flow; contributors automatically fork. Falls back to fork on any detection failure (NFR-9). + + ### 6. Configurable Max Fix Attempts (default 1) + + `doctorMaxFixAttempts` in WorkflowConfig defaults to 1, matching the spec's "single attempt" + resolution while allowing power users to increase it. Follows the existing `ciMaxFixAttempts` + pattern. + + ## Implementation Strategy + + **Phase 1 (Domain)** comes first because TypeSpec types are the foundation -- every other + layer imports from `domain/generated/output.ts`. The DoctorDiagnosticReport value object + and WorkflowConfig extension must compile before any code referencing them. + + **Phase 2 (Ports)** defines interface contracts before implementations. This allows the use + case and infrastructure to be developed independently against the same contract, and enables + TDD since tests mock the interfaces. + + **Phase 3 (Infrastructure)** implements the service contracts. Each new service/method is + independently testable with an injected ExecFunction mock. The three implementations + (GitHubIssueService, push access, fork, createPrFromArgs) have no mutual dependencies. + + **Phase 4 (Use Case)** orchestrates the full workflow with all dependencies injected. By this + point, all interfaces are defined and can be mocked for comprehensive unit testing. The use + case is the core logic layer and gets the most thorough test coverage. + + **Phase 5 (CLI + DI)** is the presentation/wiring layer. The command resolves the use case + from the DI container and handles user interaction (prompts, flags, progress). DI registration + connects implementations to interfaces. + + **Phase 6 (Integration)** validates the full workflow with more realistic scenarios, testing + both maintainer and contributor paths end-to-end with mocked externals. + + ## Risk Mitigation + + | Risk | Mitigation | + | ---- | ---------- | + | gh CLI not installed or not authenticated | Validate prerequisites first with IToolInstallerService.checkAvailability('gh') and IGitHubRepositoryService.checkAuth(). Display clear installation/auth instructions on failure. | + | Push access detection fails (network, rate limit) | Fall back to fork path as safe default (NFR-9). Log a warning but don't crash. | + | Agent execution fails during fix attempt | Catch and report gracefully. The issue is already created, so the user still gets value. Display the issue URL and inform about the fix failure. | + | Sensitive data in diagnostic report | Exclude settings entirely. Include only agentType, agentName, status, error, timestamps from agent runs. Redact home directory in file paths. Never include prompt or result fields. | + | Temp directory cleanup on failure | Use try/finally pattern. Only clean up temp dirs (not --workdir specified dirs). | + | Cross-fork PR targeting wrong repo | Always pass --repo shep-ai/cli explicitly for contributor flow. Maintainer flow runs gh pr create from within the cloned shep-ai/cli directory. | + | TypeSpec compilation breaks existing generated code | DoctorDiagnosticReport is additive (new value object). WorkflowConfig extension adds an optional field. Neither should break existing generated types. Verify with pnpm tsp:compile. | + | Fork already exists for the user | gh repo fork is idempotent -- it detects existing forks and returns them. No special handling needed. | + + --- + + _Plan complete -- proceed with implementation_ diff --git a/specs/073-shep-doctor/research.yaml b/specs/073-shep-doctor/research.yaml new file mode 100644 index 000000000..aab4934bd --- /dev/null +++ b/specs/073-shep-doctor/research.yaml @@ -0,0 +1,538 @@ +# Research Artifact (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: "shep-doctor" +summary: > + Technical research for the shep doctor feature covering architecture, service design, + CLI patterns, diagnostic collection, GitHub integration (issue creation, fork detection, + PR creation), AI agent invocation, and security considerations. Key decisions include + a new IGitHubIssueService interface for issue creation, extending IGitHubRepositoryService + for push-access detection, using @inquirer/prompts for interactive input, and leveraging + the existing IAgentExecutorProvider for fix attempts. + +relatedFeatures: + - "041-ci-watch-fix-loop" + +technologies: + - "Commander.js" + - "tsyringe" + - "@inquirer/prompts" + - "gh CLI" + - "Vitest" + - "TypeSpec" + - "node:os" + - "node:fs/promises" + +relatedLinks: + - title: "GitHub Issue #436 - Feature request: Shep doctor" + url: "https://github.com/shep-ai/cli/issues/436" + - title: "gh CLI issue create documentation" + url: "https://cli.github.com/manual/gh_issue_create" + - title: "gh CLI repo fork documentation" + url: "https://cli.github.com/manual/gh_repo_fork" + - title: "gh API permissions endpoint" + url: "https://docs.github.com/en/rest/repos/repos#get-a-repository" + +decisions: + - title: "Use case orchestration strategy" + chosen: "Single DoctorDiagnoseUseCase with step-based orchestration" + rejected: + - "Multiple small use cases (DiagnoseUseCase, CreateIssueUseCase, FixUseCase) — over-fragmented for a sequential workflow; adds DI complexity and cross-use-case coordination without clear benefit. The steps are tightly coupled (each step depends on outputs of prior steps)." + - "LangGraph agent workflow — heavyweight for a deterministic sequential flow; LangGraph is appropriate for iterative AI-driven loops (like ci-watch-fix) but doctor's flow is linear: collect -> diagnose -> issue -> fix -> PR. No conditional branching or retry loops in v1." + rationale: > + The existing codebase uses single use cases for multi-step orchestrations (e.g., + CreateFeatureUseCase handles analysis, repository setup, and feature creation in one + execute() call). The doctor workflow is inherently sequential with each step depending + on the prior step's output (diagnosis feeds issue body, issue number feeds branch name, + branch feeds PR). A single use case keeps the flow readable and testable with clear + input/output. The use case delegates to injected services for each external operation, + maintaining clean architecture separation. + + - title: "GitHub issue creation service design" + chosen: "New IGitHubIssueService interface in application/ports/output/services/" + rejected: + - "Extend IExternalIssueFetcher with createIssue() — violates interface segregation principle. IExternalIssueFetcher is a read-only fetcher designed for external issue references (GitHub issues, Jira tickets). Adding write operations changes its contract and responsibilities." + - "Direct gh CLI calls in the use case — violates clean architecture (infrastructure concern in application layer), untestable without mocking subprocess calls, and inconsistent with how every other external operation is abstracted in the codebase." + rationale: > + The codebase consistently defines service interfaces as output ports in + application/ports/output/services/ with co-located DTOs and error types. A new + IGitHubIssueService follows this exact pattern (see IGitPrService, IGitHubRepositoryService). + The interface will define createIssue() and return a structured result with issue URL + and number. The implementation wraps gh CLI calls via the injected ExecFunction, + matching the pattern in GitPrService and GitHubRepositoryService. This keeps the use + case testable with a simple mock. + + - title: "Push access detection approach" + chosen: "Add checkPushAccess(repoNameWithOwner) method to IGitHubRepositoryService" + rejected: + - "New dedicated IPermissionService — over-engineering for a single method call. Permission checking is a GitHub repository operation and belongs with the existing repository service that already handles auth checks and repo operations." + - "Check push access in the CLI command layer — violates clean architecture. Permission logic is a business concern that belongs in the use case or infrastructure service, not the presentation layer." + rationale: > + IGitHubRepositoryService already handles GitHub repository operations (checkAuth, + cloneRepository, listUserRepositories). Adding a checkPushAccess() method that calls + gh api repos/{owner}/{repo} --jq '.permissions.push' is a natural extension of this + service. It uses the same ExecFunction injection and error handling patterns. The method + returns a boolean, with false as the safe fallback on any error (network, rate limit, + API change). This follows NFR-9 (permission check resilience). + + - title: "Repository fork handling" + chosen: "Add forkRepository(repoNameWithOwner) method to IGitHubRepositoryService" + rejected: + - "New IForkService interface — unnecessary fragmentation. Forking is a GitHub repository operation, not a separate domain concept. The existing service already handles clone and list operations on GitHub repos." + - "Inline gh repo fork call in the use case — violates clean architecture and makes the fork operation untestable without subprocess mocking." + rationale: > + The fork operation naturally belongs with IGitHubRepositoryService which already + manages repository-level GitHub operations. The gh repo fork command is idempotent + (NFR-3) — if a fork already exists, gh detects it and returns the existing fork. + The method returns the fork's nameWithOwner (e.g., username/cli) so the use case + knows where to clone from. This mirrors the cloneRepository pattern already in the + service. + + - title: "Interactive prompting library" + chosen: "@inquirer/prompts (confirm, input)" + rejected: + - "node:readline — lower-level, requires manual cleanup, missing features like Ctrl+C handling. Only used in one legacy command (init.command.ts). The rest of the codebase uses @inquirer/prompts." + - "Custom prompt utility — unnecessary when @inquirer/prompts is already a project dependency with established patterns for confirm() and input() across multiple commands." + rationale: > + @inquirer/prompts is the established prompting library in the codebase, used in + del.command.ts (confirm), model.command.ts (select), and workflow.command.ts (select). + It provides clean APIs for confirm() (fix attempt gate) and input() (problem description + fallback). The Ctrl+C handling pattern (checking err.message.includes('force closed')) + is well-established. No new dependency needed. + + - title: "Diagnostic context collection scope" + chosen: "Agent runs + CLI version + system info (OS, Node, gh version)" + rejected: + - "User description only — insufficient for meaningful bug reports. Developers need error messages, stack traces, and environment info to reproduce issues." + - "Full dump with settings — risks exposing agent.token and other secrets. Settings contain sensitive auth data that must never appear in public GitHub issues." + rationale: > + The spec requires full diagnostic dump (open question #4), but with sanitization per + NFR-2. The practical implementation collects: (1) recent failed agent runs from + IAgentRunRepository.list() filtered in-memory to failed/errored status (last 10), + (2) CLI version from IVersionService, (3) system info via process.version, + process.platform, process.arch (matching version.command.ts pattern), (4) gh CLI + version via gh --version. Agent run records naturally contain error messages and + agent types without secrets. Settings are excluded entirely to prevent leaking + agent.token or other credentials. + + - title: "PR creation approach for cross-fork PRs" + chosen: "Add createPrFromArgs() to IGitPrService with --repo flag support" + rejected: + - "Reuse existing IGitPrService.createPr(cwd, prYamlPath) — requires writing a temp YAML file just to pass structured data. The pr.yaml pattern is designed for the feature workflow where an agent generates the YAML, not for programmatic PR creation." + - "Create PR in IGitHubIssueService — muddies the issue service with PR concerns and violates single responsibility. PR creation belongs with the existing PR service." + rationale: > + The existing createPr() reads from a pr.yaml file because the feature workflow + generates pr.yaml files. Doctor needs programmatic PR creation with specific args + (title, body, labels, --repo flag for cross-fork). A new method with explicit + parameters is more direct. For the maintainer flow, gh pr create runs in the cloned + shep-ai/cli directory. For the external contributor flow, gh pr create --repo shep-ai/cli + targets the upstream. This also benefits any future feature needing programmatic PR creation. + + - title: "Working directory management" + chosen: "os.tmpdir() with UUID subdirectory by default, --workdir override" + rejected: + - "Always use current working directory — pollutes the user's project with a shep-ai/cli clone. The user runs shep doctor in their own project, not in the shep codebase." + - "XDG cache directory — overly complex for a temp clone that will be deleted after the PR is created. The OS temp directory is the standard location for ephemeral data." + rationale: > + The temp directory (os.tmpdir()/shep-doctor-/) is appropriate because the clone + is ephemeral — it exists only for the duration of the fix attempt. After PR creation, + it's cleaned up (NFR-8). The --workdir flag overrides this for power users who want to + inspect the fix locally. The uuid suffix prevents collisions if multiple doctor sessions + run concurrently. + + - title: "Agent execution for fix attempts" + chosen: "IAgentExecutorProvider.getExecutor() with repositoryPath pointing to cloned dir" + rejected: + - "Spawn a new shep process in the cloned directory — over-engineering. The agent executor already supports running in any directory via the repositoryPath option." + - "Hardcode agent type (e.g., always Claude Code) — violates the mandatory rule that no component may hardcode an agent type. All resolution must flow through IAgentExecutorProvider." + rationale: > + The codebase's mandatory rule requires all agent invocations to go through + IAgentExecutorProvider, which reads the configured agent type from settings. The + IAgentExecutor.execute(prompt, options) method accepts AgentExecutionOptions which + includes repositoryPath. The doctor use case sets repositoryPath to the cloned + shep-ai/cli directory. This is identical to how the feature agent works. + + - title: "Settings extension for doctor configuration" + chosen: "Add optional doctorMaxFixAttempts field to WorkflowConfig in TypeSpec" + rejected: + - "New top-level DoctorConfig section in Settings — over-engineering for a single config field. Doctor only needs max fix attempts, which is conceptually similar to ciMaxFixAttempts." + - "Hardcode max attempts to 1 — the spec's open question #5 resolved to 'configurable via settings'. While the default should be 1, power users should be able to increase it." + rationale: > + WorkflowConfig already contains ciMaxFixAttempts for the CI watch-fix loop pattern. + Adding doctorMaxFixAttempts follows the same pattern and keeps all workflow-related + configuration in one place. The TypeSpec model in tsp/domain/entities/settings.tsp + is extended with an optional int32 field defaulting to 1. + + - title: "TypeSpec domain model additions" + chosen: "Add DoctorDiagnosticReport value object in TypeSpec" + rejected: + - "No TypeSpec models, use plain TypeScript interfaces — violates the mandatory TypeSpec-first rule. All domain models must be defined in TypeSpec." + - "Full DoctorSession entity with BaseEntity — over-engineering. Doctor doesn't need persistence; it's a run-once command. A value object for the diagnostic report is sufficient." + rationale: > + The mandatory rule requires TypeSpec-first domain models. A DoctorDiagnosticReport + value object captures the structured diagnostic data (user description, failed runs + summary, system info, CLI version) that gets formatted into the issue body. Defined in + tsp/domain/value-objects/doctor-diagnostic-report.tsp. It's a value object (no + BaseEntity) because it's transient — created during the doctor flow and never persisted. + +openQuestions: + - question: "Should the doctor service interface be a single IDoctorGitHubService or split into IGitHubIssueService and extend IGitHubRepositoryService?" + resolved: true + options: + - option: "Single IDoctorGitHubService" + description: "One interface handling issue creation, PR creation, fork management, and permission checks specifically for the doctor workflow. Cohesive but tightly coupled to the doctor feature." + selected: false + - option: "IGitHubIssueService + extend IGitHubRepositoryService" + description: "A new IGitHubIssueService for issue creation (reusable beyond doctor), and extend IGitHubRepositoryService with checkPushAccess() and forkRepository() (reusable for any GitHub repo operation). More interfaces but more reusable." + selected: true + - option: "Extend IGitHubRepositoryService only" + description: "Add all new methods (createIssue, checkPushAccess, forkRepository) to the existing interface. Keeps interface count low but violates ISP by mixing read, write, and permission operations." + selected: false + selectionRationale: > + Splitting into IGitHubIssueService (for issue creation) and extending + IGitHubRepositoryService (for push access and fork) follows the interface segregation + principle and makes each capability independently reusable. Issue creation is a distinct + responsibility from repository management. The push access check and fork operation are + natural extensions of the existing repository service. This matches the spec's resolved + question #7 which chose a new IGitHubIssueService. + + - question: "How should the PR be created for the doctor fix — reuse IGitPrService or new method?" + resolved: true + options: + - option: "Reuse IGitPrService.createPr(cwd, prYamlPath)" + description: "Write a temporary pr.yaml file and pass it to the existing method. Reuses existing code but requires constructing a YAML file just to pass structured data, which is indirect." + selected: false + - option: "Add createPrFromArgs() to IGitPrService" + description: "Add a new method to IGitPrService that accepts title, body, labels, base branch, and optional --repo flag as parameters instead of reading from YAML. More flexible and direct." + selected: true + - option: "Put PR creation in IGitHubIssueService" + description: "Handle PR creation alongside issue creation in the same service. Keeps doctor-related GitHub operations together but muddies the issue service with PR concerns." + selected: false + selectionRationale: > + Adding a createPrFromArgs() method to IGitPrService is the cleanest approach. The + existing createPr() method reads from a YAML file because the feature workflow + generates pr.yaml files. Doctor needs to create PRs programmatically with specific + args (title, body, labels, --repo flag for cross-fork). A new method with explicit + parameters is more direct than writing a temp YAML file. + + - question: "How should the fix attempt prompt be structured for the AI agent?" + resolved: true + options: + - option: "Minimal prompt with issue body only" + description: "Pass the issue body text as the prompt. Simple but lacks specific instructions for the agent on how to approach the fix." + selected: false + - option: "Structured prompt with issue context and fix instructions" + description: "Build a prompt that includes: the issue title, the error context from failed runs, specific instructions to analyze the codebase and propose a fix, and guidelines to run tests. Gives the agent clear direction." + selected: true + - option: "Use a prompt template file" + description: "Store the fix prompt template as a file in the codebase and interpolate issue-specific data. More maintainable for complex prompts but adds file management overhead for a single use case." + selected: false + selectionRationale: > + A structured prompt built programmatically in the use case gives the agent the best + chance of producing a useful fix. The prompt includes the issue title (what's wrong), + error context (specific errors and stack traces from failed agent runs), and + instructions (analyze the codebase, identify the root cause, implement a fix, run + tests). This mirrors how the feature agent receives structured prompts with context. + +content: | + ## Technology Decisions + + ### 1. Use Case Orchestration Strategy + + **Chosen:** Single DoctorDiagnoseUseCase with step-based orchestration + + **Rejected:** + - Multiple small use cases (DiagnoseUseCase, CreateIssueUseCase, FixUseCase) — over-fragmented for a sequential workflow; adds DI complexity and cross-use-case coordination without clear benefit + - LangGraph agent workflow — heavyweight for a deterministic sequential flow; doctor's steps are linear, not iterative + + **Rationale:** The existing codebase uses single use cases for multi-step orchestrations. The doctor workflow is inherently sequential with each step depending on the prior step's output. A single use case keeps the flow readable and testable. + + ### 2. GitHub Issue Creation Service Design + + **Chosen:** New IGitHubIssueService interface in application/ports/output/services/ + + **Rejected:** + - Extend IExternalIssueFetcher — violates ISP; the fetcher is read-only by design + - Direct gh CLI calls in the use case — violates clean architecture, untestable + + **Rationale:** Follows the codebase's consistent pattern of service interfaces as output ports with co-located DTOs and error types. The implementation wraps gh CLI calls via ExecFunction. + + ### 3. Push Access Detection + + **Chosen:** Add checkPushAccess(repoNameWithOwner) to IGitHubRepositoryService + + **Rejected:** + - New IPermissionService — over-engineering for a single method + - Check in CLI command layer — violates clean architecture + + **Rationale:** IGitHubRepositoryService already handles GitHub repo operations. Push access detection is a natural extension using `gh api repos/{owner}/{repo} --jq '.permissions.push'`. Returns boolean with false as safe fallback. + + ### 4. Repository Fork Handling + + **Chosen:** Add forkRepository(repoNameWithOwner) to IGitHubRepositoryService + + **Rejected:** + - New IForkService — unnecessary fragmentation + - Inline in use case — violates clean architecture + + **Rationale:** Forking is a GitHub repository operation. `gh repo fork` is idempotent (detects existing forks). Returns fork's nameWithOwner for downstream clone. + + ### 5. Interactive Prompting Library + + **Chosen:** @inquirer/prompts (confirm, input) + + **Rejected:** + - node:readline — lower-level, legacy; only used in one old command + - Custom utility — unnecessary when @inquirer/prompts is already used + + **Rationale:** Established library in the codebase with patterns for confirm(), input(), and Ctrl+C handling. + + ### 6. Diagnostic Context Collection Scope + + **Chosen:** Agent runs + CLI version + system info (OS, Node, gh version) + + **Rejected:** + - User description only — insufficient for meaningful bug reports + - Full dump with settings — risks exposing agent.token and other secrets + + **Rationale:** Recent failed agent runs contain error messages and stack traces — the most actionable data. System info (process.version, process.platform) matches the existing version.command.ts pattern. Settings are excluded per NFR-2. + + ### 7. PR Creation for Cross-Fork PRs + + **Chosen:** Add createPrFromArgs() to IGitPrService with --repo flag support + + **Rejected:** + - Reuse existing createPr(cwd, prYamlPath) — requires writing a temp YAML file, which is indirect + - Put PR creation in IGitHubIssueService — muddies the issue service with PR concerns + + **Rationale:** The existing createPr() reads from pr.yaml files (feature workflow pattern). Doctor needs programmatic PR creation with specific args including --repo for cross-fork PRs. A new method with explicit parameters is cleaner. + + ### 8. Working Directory Management + + **Chosen:** os.tmpdir() with UUID subdirectory, --workdir override + + **Rejected:** + - Current working directory — pollutes user's project + - XDG cache — overly complex for ephemeral data + + **Rationale:** Temp directory is appropriate for an ephemeral clone. UUID prevents collisions. Cleaned up after PR creation (NFR-8). --workdir preserves for power users. + + ### 9. Agent Execution for Fix Attempts + + **Chosen:** IAgentExecutorProvider.getExecutor() with repositoryPath option + + **Rejected:** + - Spawn new shep process — over-engineering; agent executor supports any directory + - Hardcode agent type — violates mandatory agent resolution rule + + **Rationale:** Mandatory rule requires all agent invocations through IAgentExecutorProvider. The executor accepts repositoryPath in AgentExecutionOptions. + + ### 10. Settings Extension for Doctor Configuration + + **Chosen:** Add optional doctorMaxFixAttempts to WorkflowConfig in TypeSpec + + **Rejected:** + - New DoctorConfig section — over-engineering for one field + - Hardcode to 1 — spec resolves to "configurable via settings" + + **Rationale:** WorkflowConfig already has ciMaxFixAttempts. Adding doctorMaxFixAttempts follows the same pattern. Default is 1 for v1. + + ### 11. TypeSpec Domain Model Additions + + **Chosen:** DoctorDiagnosticReport value object + + **Rejected:** + - Plain TypeScript interfaces — violates mandatory TypeSpec-first rule + - Full DoctorSession entity — over-engineering; doctor doesn't need persistence + + **Rationale:** A value object captures diagnostic data (description, failed runs, system info, version). Defined in tsp/domain/value-objects/ and compiled to generated output. + + ## Library Analysis + + | Library | Purpose | Decision | Reasoning | + | ------- | ------- | -------- | --------- | + | @inquirer/prompts | Interactive user input (confirm, input) | Use (existing) | Already a project dependency; used in 5+ commands for confirm/select/input prompts | + | Commander.js | CLI command definition | Use (existing) | Core CLI framework; all commands use it | + | tsyringe | Dependency injection | Use (existing) | Core DI framework; all services and use cases use it | + | gh CLI | GitHub operations (issues, PRs, forks, API) | Use (existing runtime dep) | Required for all GitHub interactions; already used by GitPrService and GitHubRepositoryService | + | node:os | Temp directory, platform info | Use (built-in) | os.tmpdir() for working directory; os.platform()/os.arch() for diagnostics | + | node:crypto | UUID generation for temp directory | Use (built-in) | crypto.randomUUID() for unique temp directory names | + | node:fs/promises | Temp directory creation and cleanup | Use (built-in) | mkdir, rm for temp directory lifecycle | + | picocolors | Terminal color output | Use (existing) | Already used via the colors/fmt utilities in presentation/cli/ui/ | + | @octokit/rest | GitHub API client | Reject | Over-engineering when gh CLI handles all needed operations. Adding a full API client introduces auth complexity and a large dependency. | + | simple-git | Git operations library | Reject | The codebase uses ExecFunction to shell out to git directly. Adding a git library would be inconsistent with the existing pattern. | + | ora | Spinner library | Reject | The codebase has its own spinner utility (src/presentation/cli/ui/spinner.ts) that integrates with the existing UI patterns. | + + ## Security Considerations + + ### Sensitive Data Sanitization (NFR-2) + + The diagnostic report included in the GitHub issue must be sanitized: + + 1. **Agent run records**: Include agentType, agentName, status, error, and timestamps. Exclude prompt and result fields which may contain user code or secrets. + 2. **Settings exclusion**: Never include any settings data. The agent.token field contains API keys. Even "redacted" settings could leak structure information. + 3. **Environment variables**: Do not collect or include environment variables. They commonly contain secrets (API_KEY, TOKEN, etc.). + 4. **File paths**: Include repository paths but redact the user's home directory to prevent leaking usernames (replace with ~/). + 5. **Log files**: Do not include raw log file contents. They may contain agent prompts with user code or API responses with sensitive data. + + ### GitHub Token Permissions + + The gh CLI uses the user's existing GitHub authentication. The doctor feature requires: + - `repo` scope for issue creation and PR creation on shep-ai/cli + - `read:org` scope for permission checks (if shep-ai is an org) + - `workflow` scope is NOT required (doctor doesn't modify GitHub Actions) + + If the user's token lacks required scopes, gh CLI will return appropriate errors which the service handles gracefully. + + ### Cross-Fork PR Security + + When an external contributor forks and opens a PR, the PR content is visible to the upstream maintainers. The agent's fix attempt may include: + - Code changes (intended and safe) + - Commit messages (safe) + - No secrets since the agent runs with the user's local shep-ai/cli clone, not the user's project + + ## Performance Implications + + ### Diagnostic Collection (NFR-6: < 2 seconds) + + - `IAgentRunRepository.list()` returns all records (SELECT * FROM agent_runs). For users with many agent runs, this could be slow. Mitigation: filter in-memory to last 10 failed/errored runs, and consider adding a listRecent(limit, statusFilter) method if performance is an issue. + - `IVersionService.getVersion()` is synchronous (reads from package.json at startup). Instant. + - `process.version`, `process.platform`, `process.arch` are synchronous globals. Instant. + - `gh --version` is a local command, typically < 100ms. + + ### Permission Check (NFR-6: < 3 seconds) + + - `gh api repos/shep-ai/cli --jq '.permissions.push'` is a single HTTP request. Typically 200-500ms. + - Cached for the session duration — only called once. + + ### Network-Bound Operations + + These are inherently slow and get spinners (NFR-7): + - `gh issue create` — 1-3 seconds (API call) + - `gh repo fork` — 5-15 seconds (creates fork on GitHub) + - `gh repo clone` — varies by repo size (shep-ai/cli is moderate) + - Agent execution — 30 seconds to several minutes depending on the agent and model + - `gh pr create` — 1-3 seconds (API call) + + ### Cleanup + + Temp directory cleanup (rm -rf) is fast for the cloned repo. Performed in a finally block to ensure cleanup even on failure. Skipped when --workdir is specified. + + ## Architecture Notes + + ### Layer Mapping + + ``` + Presentation (CLI) + doctor.command.ts + - Resolves DoctorDiagnoseUseCase from container + - Handles --fix/--no-fix/--workdir flags + - Collects user description (arg or @inquirer/prompts input) + - Displays progress via messages.*/spinner() + + Application (Use Cases) + DoctorDiagnoseUseCase + - @inject('IAgentRunRepository') — query failed runs + - @inject('IVersionService') — CLI version + - @inject('IGitHubIssueService') — create issue + - @inject('IGitHubRepositoryService') — push access check, fork, clone + - @inject('IGitPrService') — create PR + - @inject('IAgentExecutorProvider') — get agent for fix attempt + - @inject('ISettingsRepository') — read doctorMaxFixAttempts + + Application (Ports) + IGitHubIssueService (NEW) + - createIssue(repo, title, body, labels) -> { url, number } + - Co-located: GitHubIssueCreateError, GitHubIssueCreateResult + IGitHubRepositoryService (EXTENDED) + - checkPushAccess(repoNameWithOwner) -> boolean + - forkRepository(repoNameWithOwner) -> { nameWithOwner } + IGitPrService (EXTENDED) + - createPrFromArgs(cwd, { title, body, labels, base, repo? }) -> PrCreateResult + + Infrastructure (Implementations) + GitHubIssueService (NEW) — wraps gh issue create + GitHubRepositoryService (EXTENDED) — adds checkPushAccess, forkRepository + GitPrService (EXTENDED) — adds createPrFromArgs + + Domain (TypeSpec) + DoctorDiagnosticReport (NEW value object) + WorkflowConfig (EXTENDED with doctorMaxFixAttempts) + ``` + + ### DI Registration + + New registrations needed in container.ts: + - `container.registerSingleton('IGitHubIssueService', GitHubIssueService)` + - `container.registerSingleton(DoctorDiagnoseUseCase)` + + No new registration for IGitHubRepositoryService or IGitPrService — they are already registered as singletons. The new methods are added to existing implementations. + + ### CLI Command Registration + + In src/presentation/cli/index.ts: + - `import { createDoctorCommand } from './commands/doctor.command.js'` + - `program.addCommand(createDoctorCommand())` + + ### Existing Infrastructure Reuse + + | Component | How It's Reused | + | --------- | --------------- | + | IAgentRunRepository.list() | Query recent failed agent runs for diagnostics | + | IVersionService.getVersion() | Include CLI version in diagnostic report | + | IToolInstallerService.checkAvailability('gh') | Validate gh CLI is installed before starting | + | IGitHubRepositoryService.checkAuth() | Validate gh is authenticated before starting | + | IGitHubRepositoryService.cloneRepository() | Clone shep-ai/cli (or fork) for fix attempt | + | IAgentExecutorProvider.getExecutor() | Get configured AI agent for fix attempt | + | IGitPrService.pushBranch() | Push fix branch to remote | + | spinner() | Show progress for async operations | + | messages.success/error/warning/info() | User-facing status messages | + | colors, symbols, fmt | Consistent CLI output formatting | + | @inquirer/prompts confirm() | Fix attempt confirmation gate | + | @inquirer/prompts input() | Interactive problem description input | + + ### Flow Diagram + + ``` + User: shep doctor ["description"] + | + +- 1. Validate prerequisites (gh installed, gh authenticated) + | + +- 2. Collect problem description (arg or interactive prompt) + | + +- 3. Collect diagnostics + | +-- Query IAgentRunRepository for recent failed runs + | +-- Get CLI version from IVersionService + | +-- Gather system info (Node, OS, gh version) + | + +- 4. Format and create GitHub issue + | +-- Build structured issue body from DoctorDiagnosticReport + | +-- IGitHubIssueService.createIssue() -> issue URL + number + | + +- 5. Fix confirmation gate (--fix / --no-fix / interactive prompt) + | +-- If no-fix -> display issue URL, exit + | + +- 6. Detect push access + | +-- IGitHubRepositoryService.checkPushAccess('shep-ai/cli') + | + +- 7a. Maintainer flow (has push access): + | +-- Clone shep-ai/cli directly + | +-- Create branch: doctor/fix- + | +-- Run agent fix attempt + | +-- Push branch to origin + | +-- Create same-repo PR + | + +- 7b. Contributor flow (no push access): + | +-- Fork shep-ai/cli (idempotent) + | +-- Clone the fork + | +-- Create branch: doctor/fix- + | +-- Run agent fix attempt + | +-- Push branch to fork + | +-- Create cross-fork PR (--repo shep-ai/cli) + | + +- 8. Display results (issue URL, PR URL) and cleanup temp dir + ``` + + --- + + _Research complete — proceed with planning_ diff --git a/specs/073-shep-doctor/spec.yaml b/specs/073-shep-doctor/spec.yaml new file mode 100644 index 000000000..cded9db8f --- /dev/null +++ b/specs/073-shep-doctor/spec.yaml @@ -0,0 +1,136 @@ +name: "shep-doctor" +number: 73 +branch: "feat/073-shep-doctor" +oneLiner: "Diagnose shep operation failures, auto-create GitHub issues, attempt fixes, and open PRs" +userQuery: "I'd like to have a \"shep doctor\" feature that will let me tell shep that something went wrong in its operation - shep should then open an issue, try to fix, and open a PR.\n" +summary: "Add a top-level `shep doctor` CLI command that allows users to report problems with shep's own\noperation. When invoked, doctor collects the user's problem description and recent operation\ncontext (agent run logs, errors), diagnoses the root cause, opens a GitHub issue on the\nshep-ai/cli repository with a structured bug report, invokes an AI agent to attempt a fix in\nthe shep codebase, and opens a PR with the proposed fix. Supports both shep maintainers (who\npush directly to shep-ai/cli) and external contributors (who fork first). This is a self-healing\nmeta-feature where shep fixes itself.\n" +phase: "Requirements" +sizeEstimate: "L" +relatedFeatures: + - "041-ci-watch-fix-loop" +technologies: + - "Commander.js (CLI framework)" + - "tsyringe (dependency injection)" + - "LangGraph (agent workflow orchestration)" + - "gh CLI (GitHub issue/PR creation)" + - "Vitest (testing)" + - "TypeSpec (domain model generation)" +relatedLinks: + - title: "GitHub Issue #436 - Feature request: Shep doctor" + url: "https://github.com/shep-ai/cli/issues/436" +openQuestions: + - question: "How should doctor handle repository access for the fix attempt — fork or push directly?" + resolved: true + options: + - option: "Always fork" + description: "Always fork shep-ai/cli into the user's GitHub account via `gh repo fork`, regardless of whether they have push access. Simple and uniform flow but forces maintainers to work through forks unnecessarily." + selected: false + - option: "Auto-detect push access" + description: "Use `gh api repos/shep-ai/cli --jq '.permissions.push'` to check if the authenticated user has push access. If yes (maintainer/owner), clone shep-ai/cli directly, create a branch, and push. If no (external contributor), fork first then clone the fork. Provides the optimal flow for both user types automatically." + selected: true + - option: "User-specified via flag" + description: "Add a `--fork` / `--no-fork` flag to let the user explicitly choose. Gives full control but adds cognitive overhead — users shouldn't need to think about whether they need to fork." + selected: false + selectionRationale: "Auto-detecting push access provides the best UX for both user types. Shep maintainers/owners get the streamlined direct-push flow without unnecessary forks cluttering their GitHub accounts. External contributors automatically get the fork+PR flow. The `gh api` permissions check is a single lightweight API call. This eliminates the need for users to think about their access level." + answer: "Auto-detect push access" + - question: "How should doctor collect the problem description from the user?" + resolved: true + options: + - option: "CLI argument only" + description: "User passes the problem as a positional argument: `shep doctor \"agent crashed during planning\"`. Simple but limited for multi-line descriptions." + selected: false + - option: "Interactive prompt only" + description: "Doctor always opens an interactive prompt (e.g., via inquirer or readline) for the user to describe the problem. More flexible but cannot be used in scripts or CI." + selected: false + - option: "Argument with interactive fallback" + description: "Accept an optional positional argument or --description flag. If not provided, fall back to an interactive prompt. Supports both scripted and interactive usage." + selected: true + - option: "Editor-based input" + description: "Open the user's $EDITOR for a detailed description (like git commit). Rich input but heavyweight for quick reports." + selected: false + selectionRationale: "Argument-with-fallback follows the existing CLI pattern in the codebase (e.g., `shep feat new` accepts a description argument but could prompt). It supports both quick one-liner reports (`shep doctor \"X broke\"`) and detailed interactive descriptions when no argument is given." + answer: "Argument with interactive fallback" + - question: "Should doctor automatically attempt a fix, or should it ask the user before invoking the AI agent?" + resolved: true + options: + - option: "Always auto-fix" + description: "After creating the issue, immediately invoke the AI agent to attempt a fix. Fastest path to resolution but consumes AI tokens without user consent." + selected: false + - option: "Ask before fixing" + description: "After creating the issue, prompt the user: 'Would you like shep to attempt a fix?' User can opt out and just have the issue filed. Respects user autonomy and token budget." + selected: true + - option: "Flag-controlled (--fix / --no-fix)" + description: "Default to attempting a fix, but allow --no-fix to skip. Or default to issue-only and require --fix to attempt. Scriptable but less discoverable." + selected: false + selectionRationale: "Asking before fixing respects the user's autonomy and token budget. The fix attempt involves cloning a repo, running an AI agent (which costs money), and opening a PR. Users should consciously opt in. A --fix flag can be added alongside for scripted/CI usage to bypass the prompt." + answer: "Ask before fixing" + - question: "What context should doctor automatically collect for diagnosis?" + resolved: true + options: + - option: "Minimal - user description only" + description: "Only use what the user tells us. Simple but may result in low-quality issue reports." + selected: false + - option: "Recent agent runs and errors" + description: "Query IAgentRunRepository for recent failed/errored agent runs, include their error messages, agent type, and prompts. Also include shep CLI version. Provides actionable context without being invasive." + selected: true + - option: "Full diagnostic dump" + description: "Collect agent runs, settings (redacted), system info (OS, Node version, gh version), recent CLI command history, and log files. Comprehensive but may include sensitive data and be overwhelming." + selected: false + selectionRationale: "Recent agent runs and errors provide the most actionable diagnostic context. Failed agent runs contain error messages, stack traces, and the prompts that triggered them -- exactly what a developer needs to reproduce and fix the issue. Adding CLI version gives version context. Full diagnostic dumps risk exposing sensitive settings and are rarely needed for initial triage." + answer: "Recent agent runs and errors" + - question: "How many fix attempts should doctor make before giving up?" + resolved: true + options: + - option: "Single attempt" + description: "One shot -- invoke the agent once, open a PR with whatever it produces. Fast, low cost, but may not succeed." + selected: true + - option: "Up to 3 attempts with CI validation" + description: "Like the ci-watch-fix-loop pattern: attempt a fix, run tests, retry up to 3 times if tests fail. More thorough but much slower and costlier, and the agent is fixing shep itself (not user code), so CI may not be easily runnable in the fork." + selected: false + - option: "Configurable via settings" + description: "Let the user configure max attempts in shep settings. Flexible but adds complexity for a meta-feature that should be simple." + selected: false + selectionRationale: "A single attempt is appropriate for this meta-feature. The doctor is filing a bug report and making a best-effort fix -- it's not a production CI pipeline. The PR will go through shep-ai/cli's own CI when opened, and maintainers will review it. Multiple retry loops would be slow, expensive, and complex to implement for a first iteration. The CI-watch-fix pattern can be added in a future enhancement if needed." + answer: "Single attempt" + - question: "Where should the cloned shep repository be placed for the fix attempt?" + resolved: true + options: + - option: "System temp directory" + description: "Clone into os.tmpdir()/shep-doctor-/. Auto-cleaned by OS, no clutter in user workspace. But user may want to inspect the fix locally." + selected: false + - option: "User-configurable directory" + description: "Default to a temp directory but allow --workdir flag to specify where to clone. Balances convenience with flexibility for power users who want to inspect or modify the fix before PR." + selected: true + - option: "Current working directory" + description: "Clone into ./shep-doctor-fix/ in the current directory. Visible and accessible but pollutes the user's project directory." + selected: false + selectionRationale: "Defaulting to temp with an optional --workdir flag gives the best of both worlds. Most users won't need to inspect the fix locally (they'll see it in the PR), but power users and shep developers can specify a directory to review changes before the PR is opened." + answer: "User-configurable directory" + - question: "Should doctor create a new service interface or extend IExternalIssueFetcher for issue creation?" + resolved: true + options: + - option: "Extend IExternalIssueFetcher" + description: "Add a createGitHubIssue() method to the existing interface. Keeps issue-related operations together but violates the interface segregation principle (fetching vs. creating are different responsibilities)." + selected: false + - option: "New IGitHubIssueService interface" + description: "Create a dedicated service interface for GitHub issue creation/management, separate from the fetcher. Clean separation of concerns, follows ISP. The existing fetcher stays read-only." + selected: true + - option: "Direct gh CLI calls in the use case" + description: "Shell out to gh directly from the use case without a service abstraction. Quick to implement but untestable and violates clean architecture." + selected: false + selectionRationale: "A new IGitHubIssueService interface follows the codebase's clean architecture pattern and interface segregation principle. The existing IExternalIssueFetcher is a read-only fetcher by design -- adding write operations would muddy its contract. A separate service for issue creation is independently testable and can be extended later (e.g., issue updates, label management)." + answer: "New IGitHubIssueService interface" +content: "## Problem Statement\n\nWhen shep encounters operational failures (agent crashes, incorrect outputs, broken workflows),\nusers currently have no built-in way to report these issues or trigger self-healing. Users must\nmanually file GitHub issues and wait for maintainer fixes. The `shep doctor` feature provides a\nself-service mechanism where users can report problems, and shep will automatically:\n\n1. Collect the user's problem description and recent operation context (failed agent runs, errors)\n2. Diagnose the root cause using AI analysis of the collected context\n3. Open a structured GitHub issue on shep-ai/cli describing the failure with full diagnostic data\n4. Optionally invoke an AI agent to attempt a fix in the shep codebase\n5. Open a PR with the proposed fix — either directly (for maintainers) or via fork (for external contributors)\n\nThis closes the feedback loop — shep can fix itself. The feature supports both shep\nmaintainers/owners (who have push access to shep-ai/cli and push directly) and external\ncontributors (who fork first and open cross-fork PRs).\n\nReference: [GitHub Issue #436](https://github.com/shep-ai/cli/issues/436)\n\n## Success Criteria\n\n- [ ] `shep doctor` command is registered and accessible as a top-level CLI command\n- [ ] Running `shep doctor --help` displays usage information with all options\n- [ ] User can provide a problem description via positional argument (`shep doctor \"description\"`)\n- [ ] User is prompted interactively for a description if none is provided as argument\n- [ ] Doctor automatically collects recent failed agent runs from IAgentRunRepository\n- [ ] Doctor collects the current shep CLI version as diagnostic context\n- [ ] Doctor formats a structured GitHub issue with title, description, diagnostic context, and reproduction steps\n- [ ] Doctor creates the issue on shep-ai/cli via `gh issue create` and displays the issue URL to the user\n- [ ] Doctor prompts the user asking whether to attempt a fix (skippable with --fix or --no-fix flags)\n- [ ] Doctor auto-detects whether the user has push access to shep-ai/cli via `gh api` permissions check\n- [ ] When user has push access (maintainer/owner): doctor clones shep-ai/cli directly and pushes the fix branch\n- [ ] When user lacks push access (external contributor): doctor forks shep-ai/cli, clones the fork, and opens a cross-fork PR\n- [ ] Doctor invokes the AI agent (via IAgentExecutorProvider) with the issue context as a fix prompt\n- [ ] Doctor opens a PR referencing the created issue and displays the PR URL\n- [ ] Doctor gracefully handles missing `gh` CLI with a clear error message and installation instructions\n- [ ] Doctor gracefully handles unauthenticated `gh` with a clear error message and `gh auth login` instructions\n- [ ] Doctor gracefully handles agent execution failures without crashing\n- [ ] The --no-fix flag skips the fix attempt and only creates the issue\n- [ ] The --fix flag skips the confirmation prompt and proceeds directly to fix attempt\n- [ ] The --workdir flag allows specifying a custom directory for the cloned repo\n- [ ] Unit tests cover the DoctorDiagnoseUseCase with mocked dependencies\n- [ ] Unit tests cover the IGitHubIssueService implementation\n- [ ] Unit tests cover both the direct-push path (maintainer) and the fork path (external contributor)\n- [ ] Integration tests cover the end-to-end doctor workflow with mocked external services\n\n## Functional Requirements\n\n- **FR-1: CLI Command Registration** — A top-level `shep doctor` command is registered in the CLI\n program via `program.addCommand(createDoctorCommand())`. The command accepts an optional\n positional argument `[description]` for the problem description, and supports `--fix`,\n `--no-fix`, and `--workdir ` options.\n\n- **FR-2: Problem Description Collection** — If no description is provided as an argument, the\n command interactively prompts the user to describe the problem. The prompt should support\n multi-line input (press Enter twice to submit or use a readline-based prompt).\n\n- **FR-3: Diagnostic Context Collection** — Doctor automatically queries `IAgentRunRepository`\n for recent agent runs (last 10, filtered to failed/errored status). For each failed run, it\n collects: agent type, agent name, error message, prompt used, and timestamps. It also collects\n the current shep CLI version via `IVersionService`.\n\n- **FR-4: Issue Formatting** — Doctor formats a structured GitHub issue body containing:\n (a) User-provided problem description,\n (b) Shep CLI version,\n (c) Recent failed agent run summaries (agent type, error, timestamp),\n (d) A \"Reported via `shep doctor`\" footer tag.\n The issue title is derived from the first line/sentence of the user description, prefixed\n with \"[shep doctor]\".\n\n- **FR-5: GitHub Issue Creation** — Doctor creates an issue on the `shep-ai/cli` repository\n using a new `IGitHubIssueService` service that wraps `gh issue create`. The service returns\n the created issue's URL and number. The issue is labeled with `bug` and `shep-doctor`.\n\n- **FR-6: Fix Confirmation Gate** — After issue creation, doctor asks the user whether to\n attempt a fix. The `--fix` flag bypasses this prompt and proceeds automatically. The\n `--no-fix` flag skips the fix attempt entirely. Without either flag, an interactive\n confirmation prompt is shown.\n\n- **FR-7: Push Access Detection** — Before cloning, doctor checks whether the authenticated\n GitHub user has push access to `shep-ai/cli` by calling\n `gh api repos/shep-ai/cli --jq '.permissions.push'`. This determines whether to use the\n direct-push path (maintainer flow) or the fork path (external contributor flow). The\n permission check result is cached for the duration of the doctor session.\n\n- **FR-8: Repository Setup — Maintainer Flow** — When the user has push access to shep-ai/cli,\n doctor clones shep-ai/cli directly into the working directory (temp dir by default, or\n `--workdir` if specified) and creates a fix branch named `doctor/fix-`. After\n the agent completes, doctor pushes the branch directly to shep-ai/cli and opens a same-repo\n PR.\n\n- **FR-9: Repository Setup — External Contributor Flow** — When the user lacks push access,\n doctor forks shep-ai/cli into the user's GitHub account (if not already forked) using\n `gh repo fork`, then clones the fork into the working directory. Creates a fix branch named\n `doctor/fix-`. After the agent completes, doctor pushes to the fork and opens\n a cross-fork PR against shep-ai/cli.\n\n- **FR-10: AI Agent Fix Invocation** — Doctor invokes the configured AI agent via\n `IAgentExecutorProvider.getExecutor()` with a structured prompt containing:\n (a) The issue title and body,\n (b) Instructions to analyze the shep codebase and propose a fix,\n (c) The specific error context from failed agent runs.\n The agent executes in the cloned directory with a `repositoryPath` option. This step is\n identical regardless of whether the maintainer or external contributor flow was used.\n\n- **FR-11: PR Creation** — After the agent completes, doctor commits any changes, pushes to\n the appropriate remote (upstream for maintainers, fork for external contributors), and opens\n a PR using `gh pr create`. The PR title references the issue, the body includes a summary of\n the fix attempt, and it links to the created issue with \"Fixes #N\" or \"Relates to #N\".\n For cross-fork PRs, the `--repo shep-ai/cli` flag is used to target the upstream repository.\n\n- **FR-12: Progress Reporting** — Doctor displays clear progress messages at each step using\n the existing `messages.*()` and `spinner()` utilities: collecting diagnostics, creating\n issue, checking permissions, cloning/forking repo, running agent, creating PR. Each step\n shows success/failure status. The permission check step should indicate which flow was\n selected (e.g., \"You have push access — cloning directly\" or \"Forking shep-ai/cli to\n your account\").\n\n- **FR-13: Prerequisite Validation** — Before starting, doctor validates that `gh` CLI is\n installed and authenticated. If `gh` is not found, it displays an error with installation\n instructions. If `gh` is not authenticated, it displays an error with `gh auth login`\n instructions. This check uses the existing `IToolInstallerService.checkAvailability('gh')`\n pattern.\n\n## Non-Functional Requirements\n\n- **NFR-1: Graceful Degradation** — If any step fails (issue creation, permission check, fork,\n agent execution, PR creation), doctor reports the failure clearly and continues with\n subsequent steps where possible. For example, if the agent fails to produce a fix, doctor\n still reports the issue URL and informs the user that the fix attempt failed. If the\n permission check fails, doctor falls back to the fork path as the safe default.\n\n- **NFR-2: No Sensitive Data Leakage** — Diagnostic context included in the GitHub issue must\n not contain API keys, tokens, passwords, or other secrets from user settings. Agent run\n records should be sanitized to remove any credential-like values before inclusion.\n\n- **NFR-3: Idempotent Fork** — If the user already has a fork of shep-ai/cli, the fork step\n should detect the existing fork and use it rather than failing or creating a duplicate.\n `gh repo fork` handles this natively.\n\n- **NFR-4: Clean Architecture Compliance** — The feature must follow the existing clean\n architecture: domain models in TypeSpec, use case in application layer, service interface\n (port) in application layer, service implementation in infrastructure layer, CLI command\n in presentation layer. No layer may skip or shortcut the dependency direction.\n\n- **NFR-5: Testability** — All external operations (gh CLI, file system, agent execution,\n permission checks) must be injected via interfaces, enabling full unit test coverage with\n mocks. No direct subprocess calls from the use case. Both the maintainer and external\n contributor flows must be independently testable.\n\n- **NFR-6: Performance** — The diagnostic collection step (querying agent runs, version info)\n should complete in under 2 seconds. The permission check (single `gh api` call) should\n complete in under 3 seconds. The overall doctor flow (excluding agent execution and network\n operations) should have no unnecessary delays.\n\n- **NFR-7: User Feedback** — Every operation that takes more than 1 second must show a\n spinner or progress indicator. The user should never see a frozen terminal with no feedback.\n\n- **NFR-8: Cleanup** — If using a temp directory for the clone, it should be cleaned up after\n the PR is created (or after failure). If `--workdir` is specified, the directory is\n preserved for user inspection.\n\n- **NFR-9: Permission Check Resilience** — If the `gh api` permission check fails (network\n error, rate limiting, API change), doctor must not crash. It should fall back to the fork\n path as the safe default and inform the user that permission detection failed.\n\n## Product Questions & AI Recommendations\n\n| # | Question | AI Recommendation | Rationale |\n| - | -------- | ----------------- | --------- |\n| 1 | How to handle repo access for fix? | Auto-detect push access | Maintainers push directly (no unnecessary fork); external contributors auto-fork. Single `gh api` call detects permissions. |\n| 2 | How to collect problem description? | Argument with interactive fallback | Follows existing CLI patterns; supports both scripted and interactive usage |\n| 3 | Auto-fix or ask first? | Ask before fixing | Respects user autonomy and AI token budget; --fix flag available for automation |\n| 4 | What diagnostic context to collect? | Recent agent runs and errors | Most actionable data for bug reports; avoids exposing sensitive settings |\n| 5 | How many fix attempts? | Single attempt | Best-effort fix for a meta-feature; PR goes through upstream CI anyway |\n| 6 | Where to clone for fix? | User-configurable directory | Temp dir default with --workdir override for power users |\n| 7 | New interface or extend existing? | New IGitHubIssueService | Clean separation per ISP; keeps IExternalIssueFetcher read-only |\n\n## Codebase Analysis\n\n### Project Structure\n\nThe codebase follows Clean Architecture with four layers in `packages/core/src/`:\n\n- **domain/** — Core business logic, TypeSpec-generated models (`output.ts`)\n- **application/** — Use cases and output port interfaces (services, repositories)\n- **infrastructure/** — External implementations: SQLite repos, agent services, git/GitHub services\n- **presentation/** — CLI commands (`src/presentation/cli/`), TUI, Web UI\n\nCLI commands live in `src/presentation/cli/commands/` organized by feature group (feat/, agent/, repo/, settings/).\nTop-level commands (run, start, stop, ui, etc.) are standalone files. Commands are registered in\n`src/presentation/cli/index.ts` via `program.addCommand()`.\n\n### Architecture Patterns\n\n- **Use Case Pattern**: Each operation is a `@injectable()` class with a single `execute()` method,\n dependencies injected via `@inject()` tokens. ~58 use cases exist.\n- **Repository Pattern**: SQLite-backed repositories behind interfaces (e.g., `IFeatureRepository`,\n `IAgentRunRepository`).\n- **Agent Executor Abstraction**: All agent invocations go through `IAgentExecutorProvider` →\n `IAgentExecutor`. Agent type resolved from settings, never hardcoded.\n- **Service Interfaces (Output Ports)**: `IGitPrService`, `IGitHubRepositoryService`,\n `IExternalIssueFetcher` — all defined as interfaces in `application/ports/output/services/`.\n- **DI Container**: tsyringe container in `infrastructure/di/container.ts` registers all\n repositories, services, and use cases.\n\n### Existing Infrastructure to Reuse\n\n1. **GitHub Issue Fetcher** (`infrastructure/services/external/github-issue.service.ts`) —\n Currently read-only (fetches issues via `gh issue view`). Informs the design of the new\n `IGitHubIssueService` for issue creation.\n2. **Git PR Service** (`infrastructure/services/git/git-pr.service.ts`) — Already has `createPr()`,\n `pushBranch()`, `getCiStatus()`, `getDiffSummary()`. Reusable for PR creation.\n3. **GitHub Repository Service** (`infrastructure/services/external/github-repository.service.ts`)\n — Has `checkAuth()` for verifying `gh` authentication. Can be extended or a new method added\n to check repository-level push permissions via `gh api`.\n4. **Agent Executor** — `IAgentExecutorProvider.getExecutor()` provides a configured\n `IAgentExecutor` with `execute(prompt, options)`. Ready to use for fix attempts.\n5. **CI Watch/Fix Loop** (`infrastructure/services/agents/feature-agent/nodes/merge/ci-watch-fix-loop.ts`)\n — Pattern reference for iterative fix attempts. Not directly reused in v1 (single attempt)\n but informs future enhancement.\n6. **CLI UI utilities** (`src/presentation/cli/ui/`) — `messages.*()`, `spinner()`, `colors.*()`,\n `renderDetailView()` for consistent CLI output.\n7. **Tool Installer Service** — `IToolInstallerService.checkAvailability()` for validating\n gh CLI installation.\n8. **Version Service** — `IVersionService` for collecting shep CLI version in diagnostics.\n9. **Agent Run Repository** — `IAgentRunRepository.list()` for querying recent failed runs.\n\n## Affected Areas\n\n| Area | Impact | Reasoning |\n| ---- | ------ | --------- |\n| `src/presentation/cli/commands/` | High | New `doctor.command.ts` top-level command |\n| `src/presentation/cli/index.ts` | Low | Register the new doctor command |\n| `packages/core/src/application/use-cases/` | High | New `DoctorDiagnoseUseCase` with diagnose -> issue -> fix -> PR flow |\n| `packages/core/src/application/ports/output/services/` | Medium | New `IGitHubIssueService` interface; possible `IGitHubRepositoryService` extension for permission check |\n| `packages/core/src/infrastructure/services/external/` | Medium | New `GitHubIssueService` implementation wrapping `gh issue create`; permission check implementation |\n| `packages/core/src/infrastructure/services/git/` | Low | Reuse existing `git-pr.service.ts` for PR creation |\n| `packages/core/src/infrastructure/di/container.ts` | Low | Register new use case and IGitHubIssueService |\n| `tsp/` | Low | Optional TypeSpec model for DoctorDiagnosisResult value object |\n| `tests/` | High | Unit tests for use case, service; integration tests for both maintainer and contributor flows |\n\n## Dependencies\n\n- **gh CLI** — Must be installed and authenticated for issue/PR creation, forking, and permission checks\n- **IAgentExecutorProvider** — For invoking AI agent to attempt fixes\n- **IGitPrService** — For creating PRs with fix attempts\n- **IAgentRunRepository** — For querying recent failed agent runs as diagnostic context\n- **IVersionService** — For collecting shep CLI version in diagnostics\n- **IToolInstallerService** — For validating gh CLI availability before starting\n- **IGitHubRepositoryService** — For checking push permissions on shep-ai/cli\n\n## Size Estimate\n\n**L** — This feature requires a new CLI command, a new use case with multi-step orchestration\n(diagnose -> create issue -> check permissions -> clone or fork/clone -> invoke agent -> create PR),\na new service interface and implementation for GitHub issue creation, push access detection logic\nwith two distinct flows (maintainer vs external contributor), and comprehensive testing of both\npaths. The workflow is more complex than a simple CRUD operation but can leverage significant\nexisting infrastructure (agent executor, PR service, CLI patterns, tool installer, GitHub\nrepository service).\n\n---\n\n_Requirements defined — proceed with research_\n" +rejectionFeedback: + - iteration: 1 + message: "shep doctor should support for both shep developers and non shep developers if im shep maintainer/owner I dont need to fork" + phase: "requirements" + timestamp: "2026-03-20T20:47:30.818Z" + - iteration: 2 + message: "shep doctors should support feature spesific issues/error as well so we need to be able to pass it a feature id (optional)" + phase: "merge" + timestamp: "2026-03-22T07:59:40.070Z" + - iteration: 3 + message: "Resolve merge conflicts" + phase: "merge" + timestamp: "2026-03-24T17:26:02.461Z" diff --git a/specs/073-shep-doctor/tasks.yaml b/specs/073-shep-doctor/tasks.yaml new file mode 100644 index 000000000..5505f6348 --- /dev/null +++ b/specs/073-shep-doctor/tasks.yaml @@ -0,0 +1,552 @@ +# Task Breakdown (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: "shep-doctor" +summary: > + 15 tasks across 6 phases implementing the shep doctor feature. Covers TypeSpec domain + models, service interface definitions, infrastructure implementations (GitHub issue creation, + push access detection, fork handling, programmatic PR creation), use case orchestration, + CLI command with interactive prompts, DI wiring, and integration testing. All code tasks + follow TDD with RED-GREEN-REFACTOR cycles. + +relatedFeatures: + - "041-ci-watch-fix-loop" + +technologies: + - "Commander.js" + - "tsyringe" + - "@inquirer/prompts" + - "gh CLI" + - "Vitest" + - "TypeSpec" + +relatedLinks: + - title: "GitHub Issue #436 - Feature request: Shep doctor" + url: "https://github.com/shep-ai/cli/issues/436" + +tasks: + # ── Phase 1: Domain Models & TypeSpec ────────────────────────────────── + + - id: task-1 + phaseId: phase-1 + title: "Create DoctorDiagnosticReport TypeSpec value object" + description: > + Define a DoctorDiagnosticReport value object in TypeSpec that captures + the structured diagnostic data for a doctor session: user description, + failed agent run summaries (agentType, agentName, error, timestamp), + system info (nodeVersion, platform, arch, ghVersion), and CLI version. + This is a transient value object (no BaseEntity) since it is never persisted. + state: "Todo" + dependencies: [] + acceptanceCriteria: + - "tsp/domain/value-objects/doctor-diagnostic-report.tsp exists with DoctorDiagnosticReport model" + - "Model includes userDescription (string), failedRunSummaries (array), systemInfo fields, and cliVersion" + - "FailedRunSummary sub-model includes agentType, agentName, error, and timestamp fields" + - "pnpm tsp:compile succeeds and generates types in domain/generated/output.ts" + - "No existing generated types are broken by the addition" + tdd: + red: + - "Verify that DoctorDiagnosticReport type does not exist in domain/generated/output.ts" + green: + - "Create tsp/domain/value-objects/doctor-diagnostic-report.tsp with the model definition" + - "Run pnpm tsp:compile and verify the type appears in generated output" + refactor: + - "Review field naming for consistency with existing TypeSpec models" + - "Ensure @doc annotations are clear and complete" + estimatedEffort: "30min" + + - id: task-2 + phaseId: phase-1 + title: "Extend WorkflowConfig with doctorMaxFixAttempts" + description: > + Add an optional doctorMaxFixAttempts field (int32, default 1) to WorkflowConfig + in the Settings TypeSpec entity. This controls how many fix attempts the doctor + command makes before giving up. Follows the existing ciMaxFixAttempts pattern. + state: "Todo" + dependencies: + - task-1 + acceptanceCriteria: + - "tsp/domain/entities/settings.tsp WorkflowConfig model has doctorMaxFixAttempts optional int32 field" + - "pnpm tsp:compile succeeds and WorkflowConfig type in generated output includes the new field" + - "Existing WorkflowConfig fields are unchanged" + tdd: + red: + - "Verify that WorkflowConfig in generated output does not have doctorMaxFixAttempts" + green: + - "Add doctorMaxFixAttempts?: int32 with @doc annotation to WorkflowConfig in settings.tsp" + - "Run pnpm tsp:compile and verify the field appears" + refactor: + - "Ensure the @doc annotation clearly describes the default value and purpose" + estimatedEffort: "15min" + + # ── Phase 2: Service Interfaces (Output Ports) ──────────────────────── + + - id: task-3 + phaseId: phase-2 + title: "Define IGitHubIssueService interface" + description: > + Create the IGitHubIssueService output port interface with createIssue() method, + co-located GitHubIssueCreateResult DTO, and GitHubIssueError/GitHubIssueErrorCode + error types. Follows the exact pattern used by IGitPrService and IGitHubRepositoryService. + state: "Todo" + dependencies: + - task-1 + acceptanceCriteria: + - "packages/core/src/application/ports/output/services/github-issue-service.interface.ts exists" + - "IGitHubIssueService interface has createIssue(repo, title, body, labels) returning Promise" + - "GitHubIssueCreateResult has url (string) and number (number) fields" + - "GitHubIssueErrorCode enum covers GH_NOT_FOUND, AUTH_FAILURE, NETWORK_ERROR, CREATE_FAILED" + - "GitHubIssueError extends Error with code and optional cause" + - "Interface is exported from the services index" + tdd: + red: + - "Write a test that imports IGitHubIssueService and asserts the type shape compiles" + - "Write a test that creates a mock implementing IGitHubIssueService and calls createIssue" + green: + - "Create the interface file with all types, DTOs, and error classes" + - "Export from the ports/output/services/ barrel" + refactor: + - "Ensure naming conventions match existing interfaces (e.g., error constructor pattern)" + - "Verify JSDoc comments follow codebase style" + estimatedEffort: "30min" + + - id: task-4 + phaseId: phase-2 + title: "Extend IGitHubRepositoryService with checkPushAccess and forkRepository" + description: > + Add two new method signatures to the existing IGitHubRepositoryService interface: + checkPushAccess(repoNameWithOwner) returning Promise, and + forkRepository(repoNameWithOwner) returning Promise. Add the ForkResult + DTO and any needed error types. checkPushAccess returns false as safe fallback on errors. + state: "Todo" + dependencies: + - task-1 + acceptanceCriteria: + - "IGitHubRepositoryService has checkPushAccess(repoNameWithOwner: string): Promise" + - "IGitHubRepositoryService has forkRepository(repoNameWithOwner: string): Promise" + - "ForkResult interface has nameWithOwner (string) and cloneUrl (string) fields" + - "GitHubForkError extends Error is defined for fork failures" + - "Existing methods are unchanged" + tdd: + red: + - "Write a test that creates a mock implementing the extended IGitHubRepositoryService including new methods" + - "Test fails because the new methods don't exist on the interface yet" + green: + - "Add checkPushAccess and forkRepository method signatures to the interface" + - "Define ForkResult DTO and GitHubForkError class in the same file" + refactor: + - "Ensure new error classes follow the Object.setPrototypeOf pattern from existing errors" + estimatedEffort: "20min" + + - id: task-5 + phaseId: phase-2 + title: "Extend IGitPrService with createPrFromArgs" + description: > + Add a createPrFromArgs() method to the existing IGitPrService interface that accepts + typed parameters (cwd, title, body, labels, base, optional repo for cross-fork targeting) + instead of reading from a pr.yaml file. Returns the existing PrCreateResult type. + state: "Todo" + dependencies: + - task-1 + acceptanceCriteria: + - "IGitPrService has createPrFromArgs(cwd, args: PrCreateArgs): Promise" + - "PrCreateArgs interface has title, body, labels (string[]), base (string), and optional repo (string) fields" + - "Existing createPr method and PrCreateResult type are unchanged" + tdd: + red: + - "Write a test that creates a mock implementing IGitPrService including createPrFromArgs" + - "Test fails because the new method doesn't exist on the interface" + green: + - "Add PrCreateArgs interface and createPrFromArgs method signature to the interface file" + refactor: + - "Verify PrCreateArgs field naming is consistent with existing types" + estimatedEffort: "15min" + + # ── Phase 3: Infrastructure Service Implementations ──────────────────── + + - id: task-6 + phaseId: phase-3 + title: "Implement GitHubIssueService" + description: > + Create the GitHubIssueService class implementing IGitHubIssueService. Wraps + gh issue create with --repo, --title, --body, and --label flags. Parses the + created issue URL and number from gh output. Handles errors (gh not found, + auth failure, network errors) by throwing typed GitHubIssueError instances. + Injected ExecFunction follows the same pattern as GitHubRepositoryService. + state: "Todo" + dependencies: + - task-3 + acceptanceCriteria: + - "packages/core/src/infrastructure/services/external/github-issue.service.ts exists" + - "GitHubIssueService is @injectable() with @inject('ExecFunction') ExecFunction" + - "createIssue calls gh issue create with correct flags and parses the result" + - "Issue URL and number are correctly extracted from gh output" + - "Auth failures, missing gh, and network errors produce typed GitHubIssueError" + - "Unit tests cover success path and all error paths" + tdd: + red: + - "Write tests for createIssue: success returns URL and number, auth failure throws AUTH_FAILURE, missing gh throws GH_NOT_FOUND" + - "Write test that verifies correct gh args are passed (--repo, --title, --body, --label)" + green: + - "Implement GitHubIssueService with ExecFunction injection" + - "Parse issue URL from gh stdout, extract issue number from URL" + - "Map exec errors to GitHubIssueError codes" + refactor: + - "Extract URL/number parsing into a private helper method" + - "Ensure error messages are user-friendly" + estimatedEffort: "1h" + + - id: task-7 + phaseId: phase-3 + title: "Implement checkPushAccess in GitHubRepositoryService" + description: > + Add the checkPushAccess() method to the existing GitHubRepositoryService. Calls + gh api repos/{owner}/{repo} --jq '.permissions.push' and returns the boolean result. + Returns false on any error (network, rate limit, API change) per NFR-9 resilience + requirement. This is the key method that determines maintainer vs contributor flow. + state: "Todo" + dependencies: + - task-4 + acceptanceCriteria: + - "checkPushAccess('shep-ai/cli') calls gh api repos/shep-ai/cli --jq '.permissions.push'" + - "Returns true when gh api returns 'true'" + - "Returns false when gh api returns 'false'" + - "Returns false (not throws) on network errors, rate limiting, or unexpected output" + - "Unit tests cover all three paths (true, false, error fallback)" + tdd: + red: + - "Write test: checkPushAccess returns true when gh api returns 'true'" + - "Write test: checkPushAccess returns false when gh api returns 'false'" + - "Write test: checkPushAccess returns false when gh api throws an error" + - "Write test: checkPushAccess returns false when gh api returns unexpected output" + green: + - "Add checkPushAccess method to GitHubRepositoryService" + - "Call execFile('gh', ['api', 'repos/{nwo}', '--jq', '.permissions.push'])" + - "Parse stdout, return true only if trimmed output is exactly 'true'" + - "Wrap in try/catch, return false on any error" + refactor: + - "Ensure the method is well-documented with JSDoc explaining the fallback behavior" + estimatedEffort: "45min" + + - id: task-8 + phaseId: phase-3 + title: "Implement forkRepository in GitHubRepositoryService" + description: > + Add the forkRepository() method to GitHubRepositoryService. Calls + gh repo fork {nwo} --clone=false and parses the fork's nameWithOwner from output. + Handles the idempotent case where a fork already exists (gh detects and returns it). + Throws GitHubForkError on actual failures. + state: "Todo" + dependencies: + - task-4 + acceptanceCriteria: + - "forkRepository calls gh repo fork with --clone=false flag" + - "Returns ForkResult with fork nameWithOwner and cloneUrl on success" + - "Handles existing fork case (gh detects existing fork) without error" + - "Throws GitHubForkError on actual failures (auth, network)" + - "Unit tests cover new fork, existing fork, and failure cases" + tdd: + red: + - "Write test: forkRepository creates new fork and returns nameWithOwner" + - "Write test: forkRepository detects existing fork and returns it" + - "Write test: forkRepository throws GitHubForkError on auth failure" + green: + - "Add forkRepository method to GitHubRepositoryService" + - "Call execFile('gh', ['repo', 'fork', nwo, '--clone=false'])" + - "Parse fork nameWithOwner from gh output (handles both new and existing fork messages)" + refactor: + - "Extract fork output parsing into a private helper" + estimatedEffort: "45min" + + - id: task-9 + phaseId: phase-3 + title: "Implement createPrFromArgs in GitPrService" + description: > + Add createPrFromArgs() to the existing GitPrService. Builds gh pr create command + with --title, --body, --label (repeated for each label), --base, and optional --repo + flag for cross-fork PRs. Returns PrCreateResult with PR URL and number. Handles + errors using the existing GitPrError pattern. + state: "Todo" + dependencies: + - task-5 + acceptanceCriteria: + - "createPrFromArgs builds correct gh pr create command with all args" + - "Labels are passed as repeated --label flags" + - "Optional repo flag is included only when provided (cross-fork flow)" + - "Returns PrCreateResult with URL and PR number" + - "Errors produce typed GitPrError with appropriate codes" + - "Unit tests cover same-repo PR, cross-fork PR, and error cases" + tdd: + red: + - "Write test: createPrFromArgs creates same-repo PR with correct gh args" + - "Write test: createPrFromArgs creates cross-fork PR with --repo flag" + - "Write test: createPrFromArgs throws GitPrError on auth failure" + green: + - "Add createPrFromArgs method to GitPrService" + - "Build gh args array from PrCreateArgs, conditionally adding --repo" + - "Parse PR URL and number from gh output using existing parsePrNumberFromUrl helper" + refactor: + - "Ensure argument building logic is clean and readable" + estimatedEffort: "45min" + + # ── Phase 4: Use Case Orchestration ──────────────────────────────────── + + - id: task-10 + phaseId: phase-4 + title: "Implement DoctorDiagnoseUseCase - diagnostic collection" + description: > + Create the DoctorDiagnoseUseCase class with the first step of the workflow: + collecting diagnostic context. Query IAgentRunRepository for recent runs, filter + to failed/errored status (last 10), collect CLI version from IVersionService, + gather system info (Node version, platform, arch, gh version). Build a + DoctorDiagnosticReport value object. Sanitize data per NFR-2 (exclude prompts, + results, settings). + state: "Todo" + dependencies: + - task-1 + - task-2 + - task-3 + - task-4 + - task-5 + acceptanceCriteria: + - "DoctorDiagnoseUseCase is @injectable() with all required dependencies injected" + - "execute() method queries IAgentRunRepository.list() and filters to failed/errored runs" + - "Collects at most 10 recent failed runs" + - "Each run summary includes only agentType, agentName, error, and timestamp (no prompt/result)" + - "Collects CLI version from IVersionService" + - "Collects Node version, platform, arch from process globals" + - "Builds DoctorDiagnosticReport with all collected data" + - "Unit tests verify diagnostic collection with mocked repositories" + tdd: + red: + - "Write test: execute() returns diagnostic report with failed agent runs from repository" + - "Write test: only failed/errored runs are included (running/completed excluded)" + - "Write test: at most 10 runs are included even if more exist" + - "Write test: run summaries exclude prompt and result fields" + - "Write test: CLI version and system info are included in report" + green: + - "Create DoctorDiagnoseUseCase with @inject for all dependencies" + - "Implement diagnostic collection in a private collectDiagnostics() method" + - "Filter agent runs by status, limit to 10, map to sanitized summaries" + - "Build and return DoctorDiagnosticReport" + refactor: + - "Extract sanitization logic into a private sanitizeRunSummary method" + estimatedEffort: "1h" + + - id: task-11 + phaseId: phase-4 + title: "Implement DoctorDiagnoseUseCase - issue creation and fix workflow" + description: > + Extend DoctorDiagnoseUseCase with the remaining workflow steps: format and create + GitHub issue (FR-4, FR-5), check push access (FR-7), clone or fork+clone repo + (FR-8, FR-9), invoke AI agent for fix (FR-10), create PR (FR-11), and cleanup + temp directory (NFR-8). Implement graceful degradation (NFR-1) — if any step fails, + report failure and continue where possible. The use case accepts structured input + with description, flags (fix/noFix), and workdir override. + state: "Todo" + dependencies: + - task-10 + acceptanceCriteria: + - "Use case formats issue body with [shep doctor] title prefix, diagnostic context, and footer tag" + - "Issue is created via IGitHubIssueService.createIssue with bug and shep-doctor labels" + - "When fix is requested: push access is checked via IGitHubRepositoryService.checkPushAccess" + - "Maintainer flow: clones shep-ai/cli directly, creates doctor/fix-N branch" + - "Contributor flow: forks first, clones fork, creates doctor/fix-N branch" + - "Agent is invoked via IAgentExecutorProvider with structured prompt and repositoryPath" + - "PR is created via IGitPrService.createPrFromArgs, with --repo for contributor flow" + - "Temp directory is cleaned up in finally block (unless --workdir specified)" + - "Returns result with issueUrl, prUrl (optional), and flow type (maintainer/contributor)" + - "Graceful degradation: agent failure still returns issue URL" + - "Unit tests cover maintainer flow, contributor flow, no-fix flow, and failure scenarios" + tdd: + red: + - "Write test: execute creates issue with formatted body and returns issueUrl" + - "Write test: maintainer flow clones directly, pushes, creates same-repo PR" + - "Write test: contributor flow forks, clones fork, creates cross-fork PR with --repo" + - "Write test: --no-fix skips entire fix workflow and returns only issueUrl" + - "Write test: agent failure returns issueUrl with prUrl undefined" + - "Write test: push access check failure falls back to contributor flow" + - "Write test: temp directory cleanup runs on success" + - "Write test: temp directory cleanup runs on failure" + - "Write test: workdir override skips cleanup" + green: + - "Implement issue formatting with title derivation and body template" + - "Implement fix workflow: check access -> clone/fork -> agent -> PR" + - "Implement branching logic for maintainer vs contributor paths" + - "Add try/finally for temp directory cleanup" + - "Build structured agent prompt with issue context and fix instructions" + refactor: + - "Extract issue formatting into a private formatIssueBody method" + - "Extract repo setup logic into private setupRepository method" + - "Extract agent prompt building into private buildFixPrompt method" + estimatedEffort: "2h" + + # ── Phase 5: CLI Command & DI Wiring ─────────────────────────────────── + + - id: task-12 + phaseId: phase-5 + title: "Create doctor CLI command" + description: > + Create the doctor.command.ts top-level CLI command using Commander.js. Handles: + optional [description] positional argument, --fix/--no-fix flags, --workdir option. + Validates prerequisites (gh installed via IToolInstallerService, gh authenticated via + IGitHubRepositoryService). Falls back to @inquirer/prompts input() for description + if not provided as argument. Shows fix confirmation via confirm() unless --fix/--no-fix. + Displays progress with spinner() and messages.*() at each step. Reports final results + (issue URL, PR URL). + state: "Todo" + dependencies: + - task-11 + acceptanceCriteria: + - "src/presentation/cli/commands/doctor.command.ts exports createDoctorCommand()" + - "Command is registered as 'doctor' with description" + - "Accepts optional [description] positional argument" + - "Supports --fix, --no-fix, and --workdir options" + - "Validates gh CLI availability before proceeding" + - "Validates gh authentication before proceeding" + - "Prompts for description via @inquirer/prompts input() when not provided" + - "Shows fix confirmation via confirm() unless flags override" + - "Displays spinner during async operations" + - "Reports issue URL and PR URL on success" + - "Handles errors with messages.error() and non-zero exit code" + - "Handles Ctrl+C gracefully during prompts" + tdd: + red: + - "Write test: createDoctorCommand returns a Command with name 'doctor'" + - "Write test: command accepts description as positional argument" + - "Write test: command has --fix, --no-fix, and --workdir options" + green: + - "Create doctor.command.ts with createDoctorCommand factory function" + - "Implement prerequisite validation (gh check, auth check)" + - "Implement interactive description collection fallback" + - "Implement fix confirmation gate with flag overrides" + - "Resolve and call DoctorDiagnoseUseCase from container" + - "Display progress and results" + refactor: + - "Ensure error messages match the codebase's messaging style" + - "Verify spinner text is clear and informative at each step" + estimatedEffort: "1h 30min" + + - id: task-13 + phaseId: phase-5 + title: "Register components in DI container and CLI program" + description: > + Register GitHubIssueService as IGitHubIssueService and DoctorDiagnoseUseCase in the + DI container. Add the doctor command to the CLI program in index.ts. No new registrations + needed for IGitHubRepositoryService or IGitPrService since they're already registered + and the new methods are on existing implementations. + state: "Todo" + dependencies: + - task-12 + acceptanceCriteria: + - "container.ts registers GitHubIssueService as singleton for 'IGitHubIssueService' token" + - "container.ts registers DoctorDiagnoseUseCase as singleton" + - "index.ts imports createDoctorCommand and adds it to the program" + - "pnpm build succeeds with no type errors" + tdd: + red: + - "Write test: container.resolve('IGitHubIssueService') returns a GitHubIssueService instance" + - "Write test: container.resolve(DoctorDiagnoseUseCase) returns an instance with all deps" + green: + - "Add IGitHubIssueService registration to container.ts" + - "Add DoctorDiagnoseUseCase registration to container.ts" + - "Add createDoctorCommand import and program.addCommand call to index.ts" + refactor: + - "Ensure registration order follows the existing convention (services before use cases)" + estimatedEffort: "20min" + + # ── Phase 6: Integration Testing & Polish ────────────────────────────── + + - id: task-14 + phaseId: phase-6 + title: "Integration tests for doctor workflow" + description: > + Write integration tests that exercise the DoctorDiagnoseUseCase with all dependencies + mocked at the service boundary (not at the exec level). Test the full sequential flow + for both maintainer and contributor paths, including diagnostic collection, issue creation, + repo setup, agent execution, and PR creation. Verify graceful degradation when individual + steps fail. + state: "Todo" + dependencies: + - task-13 + acceptanceCriteria: + - "Integration test covers complete maintainer flow (push access -> direct clone -> PR)" + - "Integration test covers complete contributor flow (no push access -> fork -> clone -> PR)" + - "Integration test covers issue-only flow (--no-fix)" + - "Integration test covers agent failure graceful degradation" + - "Integration test covers gh not installed error path" + - "Integration test covers gh not authenticated error path" + - "All tests pass with pnpm test:int" + tdd: + red: + - "Write integration test: maintainer flow end-to-end with all service mocks" + - "Write integration test: contributor flow end-to-end with all service mocks" + - "Write integration test: issue-only mode skips fix entirely" + - "Write integration test: agent failure still returns issue URL" + green: + - "Implement test fixtures and mock factories for all services" + - "Wire up use case with mock dependencies and execute full flows" + - "Assert correct service call sequences and return values" + refactor: + - "Extract common mock setup into shared helper functions" + - "Ensure test names clearly describe the scenario" + estimatedEffort: "1h 30min" + + - id: task-15 + phaseId: phase-6 + title: "Build verification and final validation" + description: > + Run full build (pnpm build), type check, lint, and test suite to verify the entire + feature integrates correctly. Fix any issues found. Verify the doctor command appears + in --help output. Verify no regressions in existing tests. + state: "Todo" + dependencies: + - task-14 + acceptanceCriteria: + - "pnpm build succeeds with no errors" + - "pnpm validate succeeds (lint, format, typecheck, tsp)" + - "pnpm test:unit passes with no failures" + - "pnpm test:int passes with no failures" + - "shep doctor --help displays correct usage" + - "No regressions in existing test suites" + tdd: null + estimatedEffort: "30min" + +totalEstimate: "11h 15min" + +openQuestions: [] + +content: | + ## Summary + + The implementation is organized into 15 tasks across 6 phases, building the feature + bottom-up through the Clean Architecture layers. + + First, we establish the domain foundation with TypeSpec models -- DoctorDiagnosticReport + value object and WorkflowConfig extension. These compile to generated types that all + subsequent code depends on. + + Next, we define the service interface contracts: a new IGitHubIssueService for issue + creation, and extensions to IGitHubRepositoryService (push access detection, forking) + and IGitPrService (programmatic PR creation with cross-fork support). These ports are + the contracts both implementations and the use case depend on. + + With interfaces defined, we implement the infrastructure services: GitHubIssueService + wrapping gh issue create, push access detection via gh api, fork handling via gh repo fork, + and createPrFromArgs for programmatic PR creation. Each is independently testable with + a mocked ExecFunction. + + The use case layer ties everything together. DoctorDiagnoseUseCase is split across two + tasks: first diagnostic collection (querying agent runs, gathering system info), then + the full workflow orchestration (issue creation, access detection, repo setup, agent + invocation, PR creation, cleanup). Both maintainer and contributor flows are handled + with graceful degradation throughout. + + Finally, the CLI command and DI wiring connect the feature to users. The doctor command + handles argument parsing, interactive prompts, progress display, and error reporting. + Integration tests validate both flows end-to-end. A final build verification ensures + no regressions. + + --- + + _Task breakdown complete -- ready for implementation_ diff --git a/src/presentation/cli/commands/doctor.command.ts b/src/presentation/cli/commands/doctor.command.ts new file mode 100644 index 000000000..00db1b52c --- /dev/null +++ b/src/presentation/cli/commands/doctor.command.ts @@ -0,0 +1,160 @@ +/** + * Doctor Command + * + * Diagnoses shep operation failures, opens a GitHub issue on shep-ai/cli, + * and optionally invokes an AI agent to attempt a fix and open a PR. + * + * Usage: shep doctor [description] [options] + * + * @example + * $ shep doctor "agent crashed during planning" + * $ shep doctor --fix + * $ shep doctor --no-fix + * $ shep doctor "broken workflow" --fix --workdir /tmp/my-fix + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { DoctorDiagnoseUseCase } from '@/application/use-cases/doctor/doctor-diagnose.use-case.js'; +import type { IToolInstallerService } from '@/application/ports/output/services/tool-installer.service.js'; +import type { IGitHubRepositoryService } from '@/application/ports/output/services/github-repository-service.interface.js'; +import { colors, messages, spinner } from '../ui/index.js'; + +interface DoctorOptions { + fix?: boolean; + workdir?: string; + featureId?: string; +} + +/** + * Create the doctor command + */ +export function createDoctorCommand(): Command { + const cmd = new Command('doctor') + .description('Diagnose shep failures, open a GitHub issue, and optionally attempt a fix') + .argument('[description]', 'Problem description (prompted interactively if omitted)') + .option('--fix', 'Skip confirmation and attempt a fix automatically') + .option('--no-fix', 'Skip the fix attempt entirely (only create the issue)') + .action(async (description: string | undefined, options: DoctorOptions) => { + try { + // Step 1: Validate prerequisites + await validatePrerequisites(); + + // Step 2: Collect description (argument or interactive prompt) + const problemDescription = description ?? (await promptForDescription()); + if (!problemDescription) { + messages.info('No description provided. Cancelled.'); + return; + } + + // Step 3: Determine fix behavior + const shouldFix = await resolveFix(options); + + // Step 4: Run the use case + const useCase = container.resolve(DoctorDiagnoseUseCase); + + messages.newline(); + messages.info('Running shep doctor...'); + messages.newline(); + + const result = await spinner('Collecting diagnostics and creating issue', () => + useCase.execute({ + description: problemDescription, + fix: shouldFix, + workdir: options.workdir, + featureId: options.featureId, + }) + ); + + // Step 5: Display results + messages.newline(); + messages.success(`GitHub issue created: ${colors.accent(result.issueUrl)}`); + + if (result.prUrl) { + messages.success(`Pull request created: ${colors.accent(result.prUrl)}`); + if (result.flowType) { + messages.info( + `Flow: ${result.flowType === 'maintainer' ? 'direct push (maintainer)' : 'fork (contributor)'}` + ); + } + } else if (shouldFix && result.error) { + messages.warning(`Fix attempt failed: ${result.error}`); + messages.info('The issue has been created — a maintainer can review it.'); + } else if (!shouldFix) { + messages.info('Issue created. Use --fix to attempt an automated fix.'); + } + + if (result.cleanedUp) { + messages.info('Temporary working directory cleaned up.'); + } + + messages.newline(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + + // Handle Ctrl+C gracefully + if (err.message.includes('force closed') || err.message.includes('User force closed')) { + messages.info('Cancelled.'); + return; + } + + messages.error('Doctor failed', err); + process.exitCode = 1; + } + }); + + // Commander handles --no-fix by negating the --fix option, but we need + // explicit --workdir and --feature-id as separate options + cmd.option('--workdir ', 'Custom directory for the cloned repository'); + cmd.option('--feature-id ', 'Scope diagnostics to a specific feature (by ID or prefix)'); + + return cmd; +} + +/** + * Validate that gh CLI is installed and authenticated. + */ +async function validatePrerequisites(): Promise { + const toolInstaller = container.resolve('IToolInstallerService'); + const ghStatus = await toolInstaller.checkAvailability('gh'); + + if (ghStatus.status !== 'available') { + throw new Error( + 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/ and run: gh auth login' + ); + } + + const repoService = container.resolve('IGitHubRepositoryService'); + await repoService.checkAuth(); +} + +/** + * Prompt the user interactively for a problem description. + */ +async function promptForDescription(): Promise { + const { input } = await import('@inquirer/prompts'); + const description = await input({ + message: 'Describe the problem you encountered:', + }); + return description.trim() || undefined; +} + +/** + * Determine whether to attempt a fix based on flags or interactive prompt. + */ +async function resolveFix(options: DoctorOptions): Promise { + // --fix explicitly set: always fix + if (options.fix === true) { + return true; + } + // --no-fix explicitly set (Commander sets fix to false): skip fix + if (options.fix === false) { + return false; + } + // Interactive: ask the user + const { confirm } = await import('@inquirer/prompts'); + return confirm({ + message: 'Would you like shep to attempt a fix?', + default: false, + }); +} diff --git a/src/presentation/cli/index.ts b/src/presentation/cli/index.ts index 551d573b5..e2f3ae574 100644 --- a/src/presentation/cli/index.ts +++ b/src/presentation/cli/index.ts @@ -46,6 +46,7 @@ import { createIdeOpenCommand } from './commands/ide-open.command.js'; import { createInstallCommand } from './commands/install.command.js'; import { createUpgradeCommand } from './commands/upgrade.command.js'; import { createToolsCommand } from './commands/tools.command.js'; +import { createDoctorCommand } from './commands/doctor.command.js'; import { messages } from './ui/index.js'; // Daemon lifecycle commands @@ -119,6 +120,7 @@ async function bootstrap() { program.addCommand(createInstallCommand()); program.addCommand(createToolsCommand()); program.addCommand(createUpgradeCommand()); + program.addCommand(createDoctorCommand()); // Daemon lifecycle commands (task-9) program.addCommand(createStartCommand()); diff --git a/tests/integration/application/use-cases/doctor/doctor-workflow.test.ts b/tests/integration/application/use-cases/doctor/doctor-workflow.test.ts new file mode 100644 index 000000000..3fe99e3d6 --- /dev/null +++ b/tests/integration/application/use-cases/doctor/doctor-workflow.test.ts @@ -0,0 +1,1065 @@ +/** + * Doctor Workflow Integration Tests + * + * Tests the DoctorDiagnoseUseCase end-to-end with all dependencies mocked + * at the service interface boundary. Covers: + * - Complete maintainer flow (push access -> direct clone -> PR) + * - Complete contributor flow (no push access -> fork -> clone -> PR) + * - Issue-only flow (--no-fix) + * - Agent failure graceful degradation + * - Prerequisite failures (gh not installed, gh not authenticated) + * - Service call sequencing and data flow between steps + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DoctorDiagnoseUseCase } from '@/application/use-cases/doctor/doctor-diagnose.use-case.js'; +import type { AgentRun } from '@/domain/generated/output.js'; +import { AgentRunStatus, AgentType } from '@/domain/generated/output.js'; +import type { IAgentRunRepository } from '@/application/ports/output/agents/agent-run-repository.interface.js'; +import type { IPhaseTimingRepository } from '@/application/ports/output/agents/phase-timing-repository.interface.js'; +import type { PhaseTiming } from '@/domain/generated/output.js'; +import type { IVersionService } from '@/application/ports/output/services/version-service.interface.js'; +import type { IGitHubIssueService } from '@/application/ports/output/services/github-issue-service.interface.js'; +import type { IGitHubRepositoryService } from '@/application/ports/output/services/github-repository-service.interface.js'; +import type { IGitPrService } from '@/application/ports/output/services/git-pr-service.interface.js'; +import type { IAgentExecutorProvider } from '@/application/ports/output/agents/agent-executor-provider.interface.js'; +import type { IAgentExecutor } from '@/application/ports/output/agents/agent-executor.interface.js'; +import type { IFeatureRepository } from '@/application/ports/output/repositories/feature-repository.interface.js'; +import type { ExecFunction } from '@/infrastructure/services/git/worktree.service.js'; +import { + GitHubIssueError, + GitHubIssueErrorCode, +} from '@/application/ports/output/services/github-issue-service.interface.js'; + +// --------------------------------------------------------------------------- +// Test data builders +// --------------------------------------------------------------------------- + +function createFailedAgentRun(id: string, overrides?: Partial): AgentRun { + return { + id, + agentType: AgentType.ClaudeCode, + agentName: `agent-${id}`, + status: AgentRunStatus.failed, + error: `Error in ${id}: unexpected token`, + prompt: 'Sensitive prompt data — should be excluded', + result: 'Sensitive result data — should be excluded', + threadId: `thread-${id}`, + createdAt: new Date('2025-06-15T10:00:00Z'), + updatedAt: new Date('2025-06-15T10:05:00Z'), + ...overrides, + }; +} + +function createCompletedAgentRun(id: string): AgentRun { + return { + id, + agentType: AgentType.ClaudeCode, + agentName: `agent-${id}`, + status: AgentRunStatus.completed, + prompt: 'some prompt', + threadId: `thread-${id}`, + createdAt: new Date('2025-06-15T09:00:00Z'), + updatedAt: new Date('2025-06-15T09:30:00Z'), + }; +} + +// --------------------------------------------------------------------------- +// Mock factories +// --------------------------------------------------------------------------- + +function createMockAgentRunRepo(runs: AgentRun[] = []): IAgentRunRepository { + return { + create: vi.fn(), + findById: vi.fn(), + findByThreadId: vi.fn(), + updateStatus: vi.fn(), + findRunningByPid: vi.fn(), + list: vi.fn<() => Promise>().mockResolvedValue(runs), + delete: vi.fn(), + }; +} + +function createMockVersionService(version = '2.0.0'): IVersionService { + return { + getVersion: vi.fn().mockReturnValue({ + name: '@shepai/cli', + version, + description: 'Autonomous AI Native SDLC Platform', + }), + }; +} + +function createMockIssueService( + issueUrl = 'https://github.com/shep-ai/cli/issues/100', + issueNumber = 100 +): IGitHubIssueService { + return { + createIssue: vi.fn().mockResolvedValue({ url: issueUrl, number: issueNumber }), + }; +} + +function createMockRepoService(options?: { + hasPushAccess?: boolean; + pushAccessError?: Error; + forkNameWithOwner?: string; +}): IGitHubRepositoryService { + const opts = { + hasPushAccess: false, + forkNameWithOwner: 'contributor/cli', + ...options, + }; + + const checkPushAccess = vi.fn<(repo: string) => Promise>(); + if (opts.pushAccessError) { + checkPushAccess.mockRejectedValue(opts.pushAccessError); + } else { + checkPushAccess.mockResolvedValue(opts.hasPushAccess); + } + + return { + checkAuth: vi.fn().mockResolvedValue(undefined), + cloneRepository: vi.fn().mockResolvedValue(undefined), + listUserRepositories: vi.fn().mockResolvedValue([]), + parseGitHubUrl: vi.fn(), + checkPushAccess, + forkRepository: vi.fn().mockResolvedValue({ + nameWithOwner: opts.forkNameWithOwner, + cloneUrl: `https://github.com/${opts.forkNameWithOwner}.git`, + }), + getViewerPermission: vi.fn().mockResolvedValue('READ'), + }; +} + +function createMockPrService(prUrl = 'https://github.com/shep-ai/cli/pull/50'): IGitPrService { + return { + hasRemote: vi.fn().mockResolvedValue(true), + getRemoteUrl: vi.fn().mockResolvedValue(null), + getDefaultBranch: vi.fn().mockResolvedValue('main'), + revParse: vi.fn().mockResolvedValue('abc123'), + hasUncommittedChanges: vi.fn().mockResolvedValue(true), + commitAll: vi.fn().mockResolvedValue('commit-sha-123'), + push: vi.fn().mockResolvedValue(undefined), + createPr: vi.fn().mockResolvedValue({ url: prUrl, number: 50 }), + createPrFromArgs: vi.fn().mockResolvedValue({ url: prUrl, number: 50 }), + mergePr: vi.fn(), + mergeBranch: vi.fn(), + getCiStatus: vi.fn(), + watchCi: vi.fn(), + deleteBranch: vi.fn(), + getPrDiffSummary: vi.fn(), + getFileDiffs: vi.fn(), + listPrStatuses: vi.fn(), + verifyMerge: vi.fn(), + localMergeSquash: vi.fn(), + getMergeableStatus: vi.fn(), + getFailureLogs: vi.fn(), + syncMain: vi.fn(), + rebaseOnMain: vi.fn(), + getConflictedFiles: vi.fn().mockResolvedValue([]), + stageFiles: vi.fn(), + rebaseContinue: vi.fn(), + rebaseAbort: vi.fn(), + getBranchSyncStatus: vi.fn().mockResolvedValue({ ahead: 0, behind: 0 }), + }; +} + +function createMockAgentExecutorProvider(options?: { executeError?: Error }): { + provider: IAgentExecutorProvider; + executor: IAgentExecutor; +} { + const executor: IAgentExecutor = { + agentType: AgentType.ClaudeCode, + execute: options?.executeError + ? vi.fn().mockRejectedValue(options.executeError) + : vi.fn().mockResolvedValue({ result: 'Fix applied successfully', sessionId: 'session-1' }), + executeStream: vi.fn(), + supportsFeature: vi.fn().mockReturnValue(false), + }; + + const provider: IAgentExecutorProvider = { + getExecutor: vi.fn().mockResolvedValue(executor), + }; + + return { provider, executor }; +} + +function createMockExecFunction(): ExecFunction { + return vi.fn().mockResolvedValue({ stdout: 'gh version 2.50.0\n', stderr: '' }); +} + +function createMockFeatureRepo(): IFeatureRepository { + return { + create: vi.fn(), + findById: vi.fn().mockResolvedValue(null), + findByIdPrefix: vi.fn().mockResolvedValue(null), + findBySlug: vi.fn().mockResolvedValue(null), + findByBranch: vi.fn().mockResolvedValue(null), + list: vi.fn().mockResolvedValue([]), + update: vi.fn(), + findByParentId: vi.fn().mockResolvedValue([]), + delete: vi.fn(), + softDelete: vi.fn(), + }; +} + +function createMockPhaseTimingRepo(): Pick { + return { + findByFeatureId: vi.fn<(featureId: string) => Promise>().mockResolvedValue([]), + }; +} + +// --------------------------------------------------------------------------- +// Helper to build the use case with all mocks +// --------------------------------------------------------------------------- + +interface MockSet { + agentRunRepo: IAgentRunRepository; + versionService: IVersionService; + issueService: IGitHubIssueService; + repoService: IGitHubRepositoryService; + prService: IGitPrService; + agentExecutorProvider: IAgentExecutorProvider; + agentExecutor: IAgentExecutor; + execFunction: ExecFunction; + featureRepo: IFeatureRepository; + phaseTimingRepo: Pick; +} + +function buildUseCase(mocks: MockSet): DoctorDiagnoseUseCase { + return new DoctorDiagnoseUseCase( + mocks.agentRunRepo, + mocks.versionService, + mocks.issueService, + mocks.repoService, + mocks.prService, + mocks.agentExecutorProvider, + mocks.execFunction, + mocks.featureRepo, + mocks.phaseTimingRepo as any + ); +} + +// --------------------------------------------------------------------------- +// Integration Tests +// --------------------------------------------------------------------------- + +describe('DoctorDiagnoseUseCase Integration', () => { + // ----------------------------------------------------------------------- + // 1. Complete Maintainer Flow + // ----------------------------------------------------------------------- + + describe('maintainer flow end-to-end', () => { + let mocks: MockSet; + let useCase: DoctorDiagnoseUseCase; + + beforeEach(() => { + const failedRuns = [ + createFailedAgentRun('run-1', { + agentName: 'analyze-repository', + error: 'TypeError: Cannot read properties of undefined', + }), + createFailedAgentRun('run-2', { + agentName: 'implement-feature', + error: 'SyntaxError: Unexpected token', + }), + ]; + const completedRuns = [createCompletedAgentRun('run-3')]; + + const { provider, executor } = createMockAgentExecutorProvider(); + mocks = { + agentRunRepo: createMockAgentRunRepo([...completedRuns, ...failedRuns]), + versionService: createMockVersionService('2.1.0'), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/200', 200), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService('https://github.com/shep-ai/cli/pull/201'), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + useCase = buildUseCase(mocks); + }); + + it('should execute the full diagnostic -> issue -> clone -> agent -> PR pipeline', async () => { + const result = await useCase.execute({ + description: 'The analyze-repository agent crashes with TypeError', + fix: true, + workdir: '/tmp/doctor-test', + }); + + // 1. Diagnostics were collected + expect(mocks.agentRunRepo.list).toHaveBeenCalledOnce(); + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(2); + expect(result.diagnosticReport.cliVersion).toBe('2.1.0'); + expect(result.diagnosticReport.systemInfo.nodeVersion).toBe(process.version); + + // 2. Issue was created with proper content + expect(mocks.issueService.createIssue).toHaveBeenCalledOnce(); + const [repo, title, body, labels] = vi.mocked(mocks.issueService.createIssue).mock.calls[0]; + expect(repo).toBe('shep-ai/cli'); + expect(title).toContain('[shep doctor]'); + expect(title).toContain('The analyze-repository agent crashes'); + expect(body).toContain('TypeError: Cannot read properties of undefined'); + expect(body).toContain('SyntaxError: Unexpected token'); + expect(body).toContain('2.1.0'); + expect(body).toContain('Reported via `shep doctor`'); + expect(labels).toEqual(['bug', 'shep-doctor']); + + // 3. Push access was checked + expect(mocks.repoService.checkPushAccess).toHaveBeenCalledWith('shep-ai/cli'); + + // 4. NO fork (maintainer has push access) + expect(mocks.repoService.forkRepository).not.toHaveBeenCalled(); + + // 5. Cloned shep-ai/cli directly (not a fork) + expect(mocks.repoService.cloneRepository).toHaveBeenCalledWith( + 'shep-ai/cli', + '/tmp/doctor-test', + undefined + ); + + // 6. Branch was created + expect(mocks.execFunction).toHaveBeenCalledWith( + 'git', + ['checkout', '-b', 'doctor/fix-200'], + expect.objectContaining({ cwd: '/tmp/doctor-test' }) + ); + + // 7. Agent was invoked with proper context + expect(mocks.agentExecutorProvider.getExecutor).toHaveBeenCalledOnce(); + const agentPrompt = vi.mocked(mocks.agentExecutor.execute).mock.calls[0][0]; + expect(agentPrompt).toContain('#200'); + expect(agentPrompt).toContain('The analyze-repository agent crashes'); + const agentOptions = vi.mocked(mocks.agentExecutor.execute).mock.calls[0][1]; + expect(agentOptions?.cwd).toBe('/tmp/doctor-test'); + + // 8. Changes were committed and pushed + expect(mocks.prService.hasUncommittedChanges).toHaveBeenCalledWith('/tmp/doctor-test'); + expect(mocks.prService.commitAll).toHaveBeenCalledWith( + '/tmp/doctor-test', + 'fix: address issue #200 reported via shep doctor' + ); + expect(mocks.prService.push).toHaveBeenCalledWith('/tmp/doctor-test', 'doctor/fix-200', true); + + // 9. PR was created without cross-fork --repo flag + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.title).toContain('#200'); + expect(prArgs.body).toContain('#200'); + expect(prArgs.labels).toEqual(['shep-doctor']); + expect(prArgs.base).toBe('main'); + expect(prArgs.repo).toBeUndefined(); + + // 10. Final result + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/200'); + expect(result.issueNumber).toBe(200); + expect(result.prUrl).toBe('https://github.com/shep-ai/cli/pull/201'); + expect(result.flowType).toBe('maintainer'); + expect(result.error).toBeUndefined(); + }); + + it('should only include failed runs in diagnostics, not completed/running ones', async () => { + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + // 3 total runs but only 2 are failed + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(2); + expect(result.diagnosticReport.failedRunSummaries[0].agentName).toBe('analyze-repository'); + expect(result.diagnosticReport.failedRunSummaries[1].agentName).toBe('implement-feature'); + }); + + it('should sanitize agent run summaries — no prompt or result fields', async () => { + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + for (const summary of result.diagnosticReport.failedRunSummaries) { + expect(summary).not.toHaveProperty('prompt'); + expect(summary).not.toHaveProperty('result'); + const serialized = JSON.stringify(summary); + expect(serialized).not.toContain('Sensitive prompt data'); + expect(serialized).not.toContain('Sensitive result data'); + } + }); + }); + + // ----------------------------------------------------------------------- + // 2. Complete Contributor Flow + // ----------------------------------------------------------------------- + + describe('contributor flow end-to-end', () => { + let mocks: MockSet; + let useCase: DoctorDiagnoseUseCase; + + beforeEach(() => { + const { provider, executor } = createMockAgentExecutorProvider(); + mocks = { + agentRunRepo: createMockAgentRunRepo([ + createFailedAgentRun('run-1', { error: 'Agent timed out' }), + ]), + versionService: createMockVersionService('2.0.0'), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/300', 300), + repoService: createMockRepoService({ + hasPushAccess: false, + forkNameWithOwner: 'contributor/cli', + }), + prService: createMockPrService('https://github.com/shep-ai/cli/pull/301'), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + useCase = buildUseCase(mocks); + }); + + it('should fork, clone fork, and create cross-fork PR with --repo flag', async () => { + const result = await useCase.execute({ + description: 'Agent timed out during code generation', + fix: true, + workdir: '/tmp/contributor-test', + }); + + // 1. Issue created + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/300'); + expect(result.issueNumber).toBe(300); + + // 2. Push access checked (returns false) + expect(mocks.repoService.checkPushAccess).toHaveBeenCalledWith('shep-ai/cli'); + + // 3. Fork was requested + expect(mocks.repoService.forkRepository).toHaveBeenCalledWith('shep-ai/cli'); + + // 4. Cloned the FORK (not shep-ai/cli directly) + expect(mocks.repoService.cloneRepository).toHaveBeenCalledWith( + 'contributor/cli', + '/tmp/contributor-test', + undefined + ); + + // 5. Branch named after issue + expect(mocks.execFunction).toHaveBeenCalledWith( + 'git', + ['checkout', '-b', 'doctor/fix-300'], + expect.objectContaining({ cwd: '/tmp/contributor-test' }) + ); + + // 6. Agent invoked + expect(mocks.agentExecutor.execute).toHaveBeenCalled(); + + // 7. PR created WITH cross-fork --repo flag + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.repo).toBe('shep-ai/cli'); + expect(prArgs.title).toContain('#300'); + expect(prArgs.base).toBe('main'); + + // 8. Final result + expect(result.prUrl).toBe('https://github.com/shep-ai/cli/pull/301'); + expect(result.flowType).toBe('contributor'); + expect(result.error).toBeUndefined(); + }); + }); + + // ----------------------------------------------------------------------- + // 3. Issue-Only Flow (--no-fix) + // ----------------------------------------------------------------------- + + describe('issue-only flow (fix=false)', () => { + let mocks: MockSet; + let useCase: DoctorDiagnoseUseCase; + + beforeEach(() => { + const { provider, executor } = createMockAgentExecutorProvider(); + mocks = { + agentRunRepo: createMockAgentRunRepo([ + createFailedAgentRun('run-1', { error: 'Memory limit exceeded' }), + ]), + versionService: createMockVersionService('1.9.0'), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/400', 400), + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + useCase = buildUseCase(mocks); + }); + + it('should create issue but skip entire fix workflow', async () => { + const result = await useCase.execute({ + description: 'Memory limit exceeded during large repo analysis', + fix: false, + }); + + // Issue was created + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/400'); + expect(result.issueNumber).toBe(400); + expect(mocks.issueService.createIssue).toHaveBeenCalledOnce(); + + // Diagnostic report includes the error context + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(1); + expect(result.diagnosticReport.failedRunSummaries[0].error).toBe('Memory limit exceeded'); + expect(result.diagnosticReport.cliVersion).toBe('1.9.0'); + + // No fix-related operations + expect(mocks.repoService.checkPushAccess).not.toHaveBeenCalled(); + expect(mocks.repoService.forkRepository).not.toHaveBeenCalled(); + expect(mocks.repoService.cloneRepository).not.toHaveBeenCalled(); + expect(mocks.agentExecutorProvider.getExecutor).not.toHaveBeenCalled(); + expect(mocks.prService.createPrFromArgs).not.toHaveBeenCalled(); + expect(mocks.prService.commitAll).not.toHaveBeenCalled(); + expect(mocks.prService.push).not.toHaveBeenCalled(); + + // No PR URL + expect(result.prUrl).toBeUndefined(); + expect(result.flowType).toBeUndefined(); + }); + + it('should include issue body with diagnostic sections when no failed runs exist', async () => { + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue([]); + + const result = await useCase.execute({ + description: 'General performance issue', + fix: false, + }); + + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/400'); + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(0); + + // Issue body should still contain the environment section + const issueBody = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + expect(issueBody).toContain('General performance issue'); + expect(issueBody).toContain('1.9.0'); + expect(issueBody).toContain('Reported via `shep doctor`'); + }); + }); + + // ----------------------------------------------------------------------- + // 4. Agent Failure — Graceful Degradation + // ----------------------------------------------------------------------- + + describe('agent failure graceful degradation', () => { + it('should return issue URL with error message when agent execution fails', async () => { + const { provider, executor } = createMockAgentExecutorProvider({ + executeError: new Error('Agent process terminated unexpectedly'), + }); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/500', 500), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'Something went wrong', + fix: true, + workdir: '/tmp/agent-fail-test', + }); + + // Issue was still created successfully + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/500'); + expect(result.issueNumber).toBe(500); + + // Fix failed gracefully + expect(result.prUrl).toBeUndefined(); + expect(result.error).toContain('Agent process terminated unexpectedly'); + expect(result.flowType).toBe('maintainer'); + + // PR was NOT created (agent failed before that step) + expect(mocks.prService.commitAll).not.toHaveBeenCalled(); + expect(mocks.prService.createPrFromArgs).not.toHaveBeenCalled(); + }); + + it('should return error when agent produces no changes', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const prService = createMockPrService(); + vi.mocked(prService.hasUncommittedChanges).mockResolvedValue(false); + + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/501', 501), + repoService: createMockRepoService({ hasPushAccess: true }), + prService, + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'Minor styling issue', + fix: true, + workdir: '/tmp/no-changes-test', + }); + + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/501'); + expect(result.prUrl).toBeUndefined(); + expect(result.error).toContain('no changes'); + expect(mocks.prService.commitAll).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // 5. Push Access Detection Failure — Falls Back to Contributor + // ----------------------------------------------------------------------- + + describe('push access detection failure', () => { + it('should fall back to contributor flow when push access check fails', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/600', 600), + repoService: createMockRepoService({ + pushAccessError: new Error('Network timeout'), + forkNameWithOwner: 'fallback-user/cli', + }), + prService: createMockPrService('https://github.com/shep-ai/cli/pull/601'), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'Permission check should fall back', + fix: true, + workdir: '/tmp/fallback-test', + }); + + // Should fall back to contributor flow + expect(result.flowType).toBe('contributor'); + expect(mocks.repoService.forkRepository).toHaveBeenCalledWith('shep-ai/cli'); + expect(mocks.repoService.cloneRepository).toHaveBeenCalledWith( + 'fallback-user/cli', + '/tmp/fallback-test', + undefined + ); + + // Cross-fork PR + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.repo).toBe('shep-ai/cli'); + + // Still succeeded + expect(result.prUrl).toBe('https://github.com/shep-ai/cli/pull/601'); + expect(result.error).toBeUndefined(); + }); + }); + + // ----------------------------------------------------------------------- + // 6. Issue Creation Failure + // ----------------------------------------------------------------------- + + describe('issue creation failure', () => { + it('should propagate issue creation errors', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const issueService = createMockIssueService(); + vi.mocked(issueService.createIssue).mockRejectedValue( + new GitHubIssueError('gh CLI not found', GitHubIssueErrorCode.GH_NOT_FOUND) + ); + + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService, + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + await expect(useCase.execute({ description: 'test', fix: false })).rejects.toThrow( + GitHubIssueError + ); + + // No fix operations attempted + expect(mocks.repoService.checkPushAccess).not.toHaveBeenCalled(); + }); + + it('should propagate auth failure errors from issue creation', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const issueService = createMockIssueService(); + vi.mocked(issueService.createIssue).mockRejectedValue( + new GitHubIssueError('Authentication required', GitHubIssueErrorCode.AUTH_FAILURE) + ); + + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService, + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + await expect(useCase.execute({ description: 'test', fix: true })).rejects.toThrow( + 'Authentication required' + ); + }); + }); + + // ----------------------------------------------------------------------- + // 7. Temp Directory Cleanup + // ----------------------------------------------------------------------- + + describe('temp directory cleanup', () => { + it('should clean up temp directory on successful fix (no --workdir)', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/700', 700), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'test cleanup', + fix: true, + // No workdir — use temp + }); + + expect(result.cleanedUp).toBe(true); + }); + + it('should NOT clean up when --workdir is specified', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/701', 701), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'test no cleanup', + fix: true, + workdir: '/tmp/user-workdir', + }); + + expect(result.cleanedUp).toBe(false); + }); + + it('should clean up temp directory even when agent fails', async () => { + const { provider, executor } = createMockAgentExecutorProvider({ + executeError: new Error('Agent crashed'), + }); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/702', 702), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'test cleanup on failure', + fix: true, + // No workdir — use temp + }); + + expect(result.cleanedUp).toBe(true); + expect(result.error).toContain('Agent crashed'); + }); + }); + + // ----------------------------------------------------------------------- + // 8. Service Call Ordering + // ----------------------------------------------------------------------- + + describe('service call ordering and data flow', () => { + it('should pass issue number from issue creation to branch name and PR', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/777', 777), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + await useCase.execute({ + description: 'test data flow', + fix: true, + workdir: '/tmp/data-flow-test', + }); + + // Branch name uses issue number + expect(mocks.execFunction).toHaveBeenCalledWith( + 'git', + ['checkout', '-b', 'doctor/fix-777'], + expect.any(Object) + ); + + // PR title and body reference issue number + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.title).toContain('#777'); + expect(prArgs.body).toContain('#777'); + + // Commit message references issue number + expect(mocks.prService.commitAll).toHaveBeenCalledWith( + expect.any(String), + 'fix: address issue #777 reported via shep doctor' + ); + + // Agent prompt references issue number + const agentPrompt = vi.mocked(executor.execute).mock.calls[0][0]; + expect(agentPrompt).toContain('#777'); + }); + + it('should pass failed run errors into both issue body and agent prompt', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([ + createFailedAgentRun('r1', { + agentName: 'planner', + error: 'ENOMEM: out of memory', + }), + ]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/888', 888), + repoService: createMockRepoService({ hasPushAccess: true }), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + await useCase.execute({ + description: 'Out of memory issue', + fix: true, + workdir: '/tmp/error-flow-test', + }); + + // Issue body contains the error + const issueBody = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + expect(issueBody).toContain('ENOMEM: out of memory'); + expect(issueBody).toContain('planner'); + + // Agent prompt also contains the error context + const agentPrompt = vi.mocked(executor.execute).mock.calls[0][0]; + expect(agentPrompt).toContain('ENOMEM: out of memory'); + expect(agentPrompt).toContain('planner'); + }); + }); + + // ----------------------------------------------------------------------- + // 9. gh Version Collection Failure + // ----------------------------------------------------------------------- + + describe('gh version collection resilience', () => { + it('should handle gh --version failure gracefully and use "unknown"', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const execFunction = createMockExecFunction(); + vi.mocked(execFunction).mockRejectedValue(new Error('gh: command not found')); + + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService('https://github.com/shep-ai/cli/issues/900', 900), + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction, + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'test gh version fallback', + fix: false, + }); + + expect(result.diagnosticReport.systemInfo.ghVersion).toBe('unknown'); + // Issue was still created despite gh version failure + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/900'); + }); + }); + + // ----------------------------------------------------------------------- + // 10. Diagnostic Report Limits + // ----------------------------------------------------------------------- + + describe('diagnostic report limits', () => { + it('should include at most 10 failed runs even with more available', async () => { + const manyRuns = Array.from({ length: 20 }, (_, i) => + createFailedAgentRun(`run-${i}`, { error: `Error ${i}` }) + ); + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo(manyRuns), + versionService: createMockVersionService(), + issueService: createMockIssueService(), + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'many failures', + fix: false, + }); + + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(10); + }); + + it('should truncate long issue titles to prevent GitHub API rejection', async () => { + const { provider, executor } = createMockAgentExecutorProvider(); + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo([]), + versionService: createMockVersionService(), + issueService: createMockIssueService(), + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo: createMockFeatureRepo(), + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const longDescription = + 'This is a very long description that should be truncated when used in the issue title to prevent GitHub API from rejecting it'; + + await useCase.execute({ + description: longDescription, + fix: false, + }); + + const title = vi.mocked(mocks.issueService.createIssue).mock.calls[0][1]; + // Title format: "[shep doctor] ..." + // The prefix is 15 chars, so total should be reasonable + expect(title.startsWith('[shep doctor] ')).toBe(true); + expect(title.length).toBeLessThanOrEqual(80); // prefix + 60 + "..." + }); + }); + + // ----------------------------------------------------------------------- + // 11. Feature-Scoped Rich Diagnostics + // ----------------------------------------------------------------------- + + describe('feature-scoped rich diagnostics', () => { + it('should produce enriched report with all feature context', async () => { + const feature = { + id: 'feat-rich', + name: 'Rich Feature', + specPath: '/nonexistent/specs/042-rich', // won't find files — that's OK (best-effort) + lifecycle: 'Review', + branch: 'feat/rich', + description: 'Feature with full context', + messages: [{ id: 'm1', role: 'user', content: 'Start' }], + plan: { overview: 'Implement rich diagnostics', tasks: [] }, + fast: false, + push: false, + openPr: true, + approvalGates: { allowPrd: true, allowPlan: false, allowMerge: false }, + } as any; + + const { provider, executor } = createMockAgentExecutorProvider(); + const featureRepo = createMockFeatureRepo(); + vi.mocked(featureRepo.findById).mockResolvedValue(feature); + + const runs = [ + createFailedAgentRun('r1', { + featureId: 'feat-rich', + prompt: 'Do analysis', + result: 'Done', + }), + ]; + + const mocks: MockSet = { + agentRunRepo: createMockAgentRunRepo(runs), + versionService: createMockVersionService(), + issueService: createMockIssueService(), + repoService: createMockRepoService(), + prService: createMockPrService(), + agentExecutorProvider: provider, + agentExecutor: executor, + execFunction: createMockExecFunction(), + featureRepo, + phaseTimingRepo: createMockPhaseTimingRepo(), + }; + const useCase = buildUseCase(mocks); + + const result = await useCase.execute({ + description: 'full context test', + fix: false, + featureId: 'feat-rich', + }); + + const report = result.diagnosticReport; + expect(report.featureId).toBe('feat-rich'); + expect(report.featureLifecycle).toBe('Review'); + expect(report.featureBranch).toBe('feat/rich'); + expect(report.conversationMessages).toContain('Start'); + expect(report.featurePlan).toContain('rich diagnostics'); + expect(report.agentRunDetails).toHaveLength(1); + expect(report.agentRunDetails![0].prompt).toBe('Do analysis'); + expect(report.featureWorkflowConfig).toContain('openPr'); + }); + }); +}); diff --git a/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts b/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts index 195820d79..c301c0a77 100644 --- a/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts +++ b/tests/integration/infrastructure/services/agents/graph-state-transitions/setup.ts @@ -198,6 +198,9 @@ export function createStubMergeNodeDeps(featureId?: string): Omit { }); }); +describe('PrCreateArgs type', () => { + it('should hold all required fields', () => { + const args: PrCreateArgs = { + title: 'fix: resolve agent crash', + body: 'Fixes #42\n\nThis fixes the crash...', + labels: ['bug', 'shep-doctor'], + base: 'main', + }; + expect(args.title).toBe('fix: resolve agent crash'); + expect(args.body).toContain('Fixes #42'); + expect(args.labels).toEqual(['bug', 'shep-doctor']); + expect(args.base).toBe('main'); + expect(args.repo).toBeUndefined(); + }); + + it('should accept optional repo for cross-fork PRs', () => { + const args: PrCreateArgs = { + title: 'fix: resolve agent crash', + body: 'Fixes #42', + labels: ['bug'], + base: 'main', + repo: 'shep-ai/cli', + }; + expect(args.repo).toBe('shep-ai/cli'); + }); +}); + describe('IGitPrService', () => { it('should be implementable with all methods', () => { // Compile-time check: a mock class implementing IGitPrService must provide all methods @@ -142,6 +170,7 @@ describe('IGitPrService', () => { /* noop */ }, createPr: async () => ({ url: '', number: 0 }), + createPrFromArgs: async () => ({ url: '', number: 0 }), mergePr: async () => { /* noop */ }, @@ -198,6 +227,7 @@ describe('IGitPrService', () => { 'commitAll', 'push', 'createPr', + 'createPrFromArgs', 'mergePr', 'mergeBranch', 'getCiStatus', @@ -220,7 +250,7 @@ describe('IGitPrService', () => { 'getBranchSyncStatus', ]; - expect(methodNames).toHaveLength(27); + expect(methodNames).toHaveLength(28); for (const name of methodNames) { expect(typeof mock[name]).toBe('function'); } diff --git a/tests/unit/application/ports/output/services/github-issue-service.interface.test.ts b/tests/unit/application/ports/output/services/github-issue-service.interface.test.ts new file mode 100644 index 000000000..5ff302eae --- /dev/null +++ b/tests/unit/application/ports/output/services/github-issue-service.interface.test.ts @@ -0,0 +1,136 @@ +/** + * IGitHubIssueService Interface Tests + * + * Verifies the type shape compiles correctly and that mocks implementing + * the interface work as expected. Also validates error class behavior. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { + IGitHubIssueService, + GitHubIssueCreateResult, +} from '@/application/ports/output/services/github-issue-service.interface.js'; +import { + GitHubIssueError, + GitHubIssueErrorCode, +} from '@/application/ports/output/services/github-issue-service.interface.js'; + +describe('IGitHubIssueService interface', () => { + // ------------------------------------------------------------------------- + // Interface shape + // ------------------------------------------------------------------------- + + it('should allow creating a mock that implements IGitHubIssueService', async () => { + const result: GitHubIssueCreateResult = { + url: 'https://github.com/shep-ai/cli/issues/42', + number: 42, + }; + + const mock: IGitHubIssueService = { + createIssue: vi.fn().mockResolvedValue(result), + }; + + const actual = await mock.createIssue( + 'shep-ai/cli', + '[shep doctor] Agent crashed during planning', + 'The agent failed with error...', + ['bug', 'shep-doctor'] + ); + + expect(actual).toEqual(result); + expect(actual.url).toBe('https://github.com/shep-ai/cli/issues/42'); + expect(actual.number).toBe(42); + expect(mock.createIssue).toHaveBeenCalledWith( + 'shep-ai/cli', + '[shep doctor] Agent crashed during planning', + 'The agent failed with error...', + ['bug', 'shep-doctor'] + ); + }); + + // ------------------------------------------------------------------------- + // GitHubIssueCreateResult DTO + // ------------------------------------------------------------------------- + + it('should have url and number fields on GitHubIssueCreateResult', () => { + const result: GitHubIssueCreateResult = { + url: 'https://github.com/owner/repo/issues/1', + number: 1, + }; + + expect(result.url).toBe('https://github.com/owner/repo/issues/1'); + expect(result.number).toBe(1); + }); + + // ------------------------------------------------------------------------- + // GitHubIssueErrorCode enum + // ------------------------------------------------------------------------- + + it('should define all expected error codes', () => { + expect(GitHubIssueErrorCode.GH_NOT_FOUND).toBe('GH_NOT_FOUND'); + expect(GitHubIssueErrorCode.AUTH_FAILURE).toBe('AUTH_FAILURE'); + expect(GitHubIssueErrorCode.NETWORK_ERROR).toBe('NETWORK_ERROR'); + expect(GitHubIssueErrorCode.CREATE_FAILED).toBe('CREATE_FAILED'); + }); + + // ------------------------------------------------------------------------- + // GitHubIssueError class + // ------------------------------------------------------------------------- + + it('should create GitHubIssueError with message and code', () => { + const error = new GitHubIssueError('Repository not found', GitHubIssueErrorCode.GH_NOT_FOUND); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(GitHubIssueError); + expect(error.message).toBe('Repository not found'); + expect(error.code).toBe(GitHubIssueErrorCode.GH_NOT_FOUND); + expect(error.name).toBe('GitHubIssueError'); + expect(error.cause).toBeUndefined(); + }); + + it('should create GitHubIssueError with cause', () => { + const cause = new Error('network timeout'); + const error = new GitHubIssueError( + 'Failed to create issue', + GitHubIssueErrorCode.NETWORK_ERROR, + cause + ); + + expect(error.cause).toBe(cause); + expect(error.code).toBe(GitHubIssueErrorCode.NETWORK_ERROR); + }); + + it('should be catchable as instanceof GitHubIssueError', () => { + const error = new GitHubIssueError('Auth failed', GitHubIssueErrorCode.AUTH_FAILURE); + + expect(() => { + throw error; + }).toThrow(GitHubIssueError); + }); + + it('should have correct prototype chain for instanceof checks', () => { + const error = new GitHubIssueError('test', GitHubIssueErrorCode.CREATE_FAILED); + + // Object.setPrototypeOf ensures correct prototype chain + expect(error instanceof GitHubIssueError).toBe(true); + expect(error instanceof Error).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Mock rejection with typed error + // ------------------------------------------------------------------------- + + it('should allow mock to reject with GitHubIssueError', async () => { + const mock: IGitHubIssueService = { + createIssue: vi + .fn() + .mockRejectedValue( + new GitHubIssueError('Issue creation failed', GitHubIssueErrorCode.CREATE_FAILED) + ), + }; + + await expect(mock.createIssue('shep-ai/cli', 'title', 'body', ['bug'])).rejects.toThrow( + GitHubIssueError + ); + }); +}); diff --git a/tests/unit/application/ports/output/services/github-repository-service.interface.test.ts b/tests/unit/application/ports/output/services/github-repository-service.interface.test.ts index f1a05c0dc..1b2d3d0e3 100644 --- a/tests/unit/application/ports/output/services/github-repository-service.interface.test.ts +++ b/tests/unit/application/ports/output/services/github-repository-service.interface.test.ts @@ -6,12 +6,14 @@ import type { ListUserRepositoriesOptions, CloneOptions, ParsedGitHubUrl, + ForkResult, } from '@/application/ports/output/services/github-repository-service.interface'; import { GitHubAuthError, GitHubCloneError, GitHubUrlParseError, GitHubRepoListError, + GitHubForkError, } from '@/application/ports/output/services/github-repository-service.interface'; describe('GitHubAuthError', () => { @@ -190,8 +192,52 @@ describe('CloneOptions type', () => { }); }); +describe('ForkResult type', () => { + it('should hold nameWithOwner and cloneUrl', () => { + const result: ForkResult = { + nameWithOwner: 'myuser/cli', + cloneUrl: 'https://github.com/myuser/cli.git', + }; + expect(result.nameWithOwner).toBe('myuser/cli'); + expect(result.cloneUrl).toBe('https://github.com/myuser/cli.git'); + }); +}); + +describe('GitHubForkError', () => { + it('should be an instance of Error', () => { + const error = new GitHubForkError('fork failed'); + expect(error).toBeInstanceOf(Error); + }); + + it('should set name to GitHubForkError', () => { + const error = new GitHubForkError('fork failed'); + expect(error.name).toBe('GitHubForkError'); + }); + + it('should set the message', () => { + const error = new GitHubForkError('Failed to fork shep-ai/cli'); + expect(error.message).toBe('Failed to fork shep-ai/cli'); + }); + + it('should accept an optional cause', () => { + const cause = new Error('permission denied'); + const error = new GitHubForkError('fork failed', cause); + expect(error.cause).toBe(cause); + }); + + it('should work without a cause', () => { + const error = new GitHubForkError('fork failed'); + expect(error.cause).toBeUndefined(); + }); + + it('should maintain instanceof via Object.setPrototypeOf', () => { + const error = new GitHubForkError('test'); + expect(error instanceof GitHubForkError).toBe(true); + }); +}); + describe('IGitHubRepositoryService', () => { - it('should be implementable with all four methods', () => { + it('should be implementable with all six methods', () => { const mock: IGitHubRepositoryService = { checkAuth: async () => { /* noop */ @@ -205,6 +251,11 @@ describe('IGitHubRepositoryService', () => { repo: 'my-project', nameWithOwner: 'octocat/my-project', }), + checkPushAccess: async () => false, + forkRepository: async () => ({ + nameWithOwner: 'user/cli', + cloneUrl: 'https://github.com/user/cli.git', + }), getViewerPermission: async () => 'ADMIN', }; @@ -213,9 +264,12 @@ describe('IGitHubRepositoryService', () => { 'cloneRepository', 'listUserRepositories', 'parseGitHubUrl', + 'checkPushAccess', + 'forkRepository', + 'getViewerPermission', ]; - expect(methodNames).toHaveLength(4); + expect(methodNames).toHaveLength(7); for (const name of methodNames) { expect(typeof mock[name]).toBe('function'); } diff --git a/tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts b/tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts new file mode 100644 index 000000000..8a2f2fece --- /dev/null +++ b/tests/unit/application/use-cases/doctor/doctor-diagnose.use-case.test.ts @@ -0,0 +1,936 @@ +/** + * DoctorDiagnoseUseCase Unit Tests + * + * Tests for the doctor diagnose use case covering: + * - Diagnostic collection (agent runs, version, system info) + * - Issue creation with formatted body + * - Maintainer flow (direct push) + * - Contributor flow (fork + cross-fork PR) + * - No-fix flow (issue only) + * - Graceful degradation on failures + * - Temp directory cleanup + * + * TDD Phase: RED-GREEN-REFACTOR + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DoctorDiagnoseUseCase } from '@/application/use-cases/doctor/doctor-diagnose.use-case.js'; +import type { AgentRun } from '@/domain/generated/output.js'; +import { AgentRunStatus, AgentType } from '@/domain/generated/output.js'; +import type { IAgentRunRepository } from '@/application/ports/output/agents/agent-run-repository.interface.js'; +import type { IPhaseTimingRepository } from '@/application/ports/output/agents/phase-timing-repository.interface.js'; +import type { PhaseTiming } from '@/domain/generated/output.js'; +import type { IVersionService } from '@/application/ports/output/services/version-service.interface.js'; +import type { IGitHubIssueService } from '@/application/ports/output/services/github-issue-service.interface.js'; +import type { IGitHubRepositoryService } from '@/application/ports/output/services/github-repository-service.interface.js'; +import type { IGitPrService } from '@/application/ports/output/services/git-pr-service.interface.js'; +import type { IAgentExecutorProvider } from '@/application/ports/output/agents/agent-executor-provider.interface.js'; +import type { IAgentExecutor } from '@/application/ports/output/agents/agent-executor.interface.js'; +import type { IFeatureRepository } from '@/application/ports/output/repositories/feature-repository.interface.js'; +import type { ExecFunction } from '@/infrastructure/services/git/worktree.service.js'; + +vi.mock('node:fs/promises', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + readFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + }; +}); + +import { readFile } from 'node:fs/promises'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockAgentRun(overrides?: Partial): AgentRun { + return { + id: 'run-1', + agentType: AgentType.ClaudeCode, + agentName: 'analyze-repository', + status: AgentRunStatus.completed, + prompt: 'Analyze this repo', + threadId: 'thread-1', + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date('2025-01-01T00:00:00Z'), + ...overrides, + }; +} + +function createFailedRun(id: string, overrides?: Partial): AgentRun { + return createMockAgentRun({ + id, + status: AgentRunStatus.failed, + error: `Error in run ${id}`, + agentName: `agent-${id}`, + ...overrides, + }); +} + +function createMocks() { + const agentRunRepo: IAgentRunRepository = { + create: vi.fn(), + findById: vi.fn(), + findByThreadId: vi.fn(), + updateStatus: vi.fn(), + findRunningByPid: vi.fn(), + list: vi.fn<() => Promise>().mockResolvedValue([]), + delete: vi.fn(), + }; + + const versionService: IVersionService = { + getVersion: vi.fn().mockReturnValue({ + name: '@shepai/cli', + version: '1.5.0', + description: 'Autonomous AI Native SDLC Platform', + }), + }; + + const issueService: IGitHubIssueService = { + createIssue: vi.fn().mockResolvedValue({ + url: 'https://github.com/shep-ai/cli/issues/42', + number: 42, + }), + }; + + const repoService: IGitHubRepositoryService = { + checkAuth: vi.fn().mockResolvedValue(undefined), + cloneRepository: vi.fn().mockResolvedValue(undefined), + listUserRepositories: vi.fn().mockResolvedValue([]), + parseGitHubUrl: vi.fn(), + checkPushAccess: vi.fn().mockResolvedValue(false), + forkRepository: vi.fn().mockResolvedValue({ + nameWithOwner: 'user/cli', + cloneUrl: 'https://github.com/user/cli.git', + }), + getViewerPermission: vi.fn().mockResolvedValue('READ'), + }; + + const prService: IGitPrService = { + hasRemote: vi.fn().mockResolvedValue(true), + getRemoteUrl: vi.fn().mockResolvedValue(null), + getDefaultBranch: vi.fn().mockResolvedValue('main'), + revParse: vi.fn().mockResolvedValue('abc123'), + hasUncommittedChanges: vi.fn().mockResolvedValue(true), + commitAll: vi.fn().mockResolvedValue('commit-sha'), + push: vi.fn().mockResolvedValue(undefined), + createPr: vi + .fn() + .mockResolvedValue({ url: 'https://github.com/shep-ai/cli/pull/1', number: 1 }), + createPrFromArgs: vi.fn().mockResolvedValue({ + url: 'https://github.com/shep-ai/cli/pull/99', + number: 99, + }), + mergePr: vi.fn(), + mergeBranch: vi.fn(), + getCiStatus: vi.fn(), + watchCi: vi.fn(), + deleteBranch: vi.fn(), + getPrDiffSummary: vi.fn(), + getFileDiffs: vi.fn(), + listPrStatuses: vi.fn(), + verifyMerge: vi.fn(), + localMergeSquash: vi.fn(), + getMergeableStatus: vi.fn(), + getFailureLogs: vi.fn(), + syncMain: vi.fn(), + rebaseOnMain: vi.fn(), + getConflictedFiles: vi.fn().mockResolvedValue([]), + stageFiles: vi.fn(), + rebaseContinue: vi.fn(), + rebaseAbort: vi.fn(), + getBranchSyncStatus: vi.fn().mockResolvedValue({ ahead: 0, behind: 0 }), + }; + + const mockExecutor: IAgentExecutor = { + agentType: AgentType.ClaudeCode, + execute: vi.fn().mockResolvedValue({ result: 'Fix applied', sessionId: 's1' }), + executeStream: vi.fn(), + supportsFeature: vi.fn().mockReturnValue(false), + }; + + const agentExecutorProvider: IAgentExecutorProvider = { + getExecutor: vi.fn().mockResolvedValue(mockExecutor), + }; + + const execFunction: ExecFunction = vi + .fn() + .mockResolvedValue({ stdout: 'gh version 2.40.0\n', stderr: '' }); + + const featureRepo: IFeatureRepository = { + create: vi.fn(), + findById: vi.fn().mockResolvedValue(null), + findByIdPrefix: vi.fn().mockResolvedValue(null), + findBySlug: vi.fn().mockResolvedValue(null), + findByBranch: vi.fn().mockResolvedValue(null), + list: vi.fn().mockResolvedValue([]), + update: vi.fn(), + findByParentId: vi.fn().mockResolvedValue([]), + delete: vi.fn(), + softDelete: vi.fn(), + }; + + const phaseTimingRepo: Pick = { + findByFeatureId: vi.fn<(featureId: string) => Promise>().mockResolvedValue([]), + }; + + return { + agentRunRepo, + versionService, + issueService, + repoService, + prService, + agentExecutorProvider, + execFunction, + mockExecutor, + featureRepo, + phaseTimingRepo, + }; +} + +function createUseCase(mocks: ReturnType) { + return new DoctorDiagnoseUseCase( + mocks.agentRunRepo, + mocks.versionService, + mocks.issueService, + mocks.repoService, + mocks.prService, + mocks.agentExecutorProvider, + mocks.execFunction, + mocks.featureRepo, + mocks.phaseTimingRepo as any + ); +} + +// --------------------------------------------------------------------------- +// Tests: Task 10 — Diagnostic Collection +// --------------------------------------------------------------------------- + +describe('DoctorDiagnoseUseCase', () => { + let mocks: ReturnType; + let useCase: DoctorDiagnoseUseCase; + + beforeEach(() => { + mocks = createMocks(); + useCase = createUseCase(mocks); + }); + + describe('diagnostic collection', () => { + it('should return diagnostic report with failed agent runs from repository', async () => { + const failedRun = createFailedRun('r1'); + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue([failedRun]); + + const result = await useCase.execute({ + description: 'Something broke', + fix: false, + }); + + expect(result.diagnosticReport).toBeDefined(); + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(1); + expect(result.diagnosticReport.failedRunSummaries[0].agentType).toBe(AgentType.ClaudeCode); + expect(result.diagnosticReport.failedRunSummaries[0].error).toBe('Error in run r1'); + }); + + it('should only include failed/errored runs (running/completed excluded)', async () => { + const runs: AgentRun[] = [ + createMockAgentRun({ id: '1', status: AgentRunStatus.completed }), + createMockAgentRun({ id: '2', status: AgentRunStatus.running }), + createFailedRun('3'), + createMockAgentRun({ + id: '4', + status: AgentRunStatus.interrupted, + error: 'Interrupted', + agentName: 'agent-4', + }), + createMockAgentRun({ id: '5', status: AgentRunStatus.pending }), + createMockAgentRun({ + id: '6', + status: AgentRunStatus.cancelled, + error: 'Cancelled', + agentName: 'agent-6', + }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + // Only failed status should be included + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(1); + expect(result.diagnosticReport.failedRunSummaries[0].agentName).toBe('agent-3'); + }); + + it('should include at most 10 runs even if more exist', async () => { + const runs: AgentRun[] = Array.from({ length: 15 }, (_, i) => createFailedRun(`r${i}`)); + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(10); + }); + + it('should exclude prompt and result fields from run summaries', async () => { + const run = createFailedRun('r1', { + prompt: 'SECRET PROMPT DATA', + result: 'SECRET RESULT DATA', + }); + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue([run]); + + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + const summary = result.diagnosticReport.failedRunSummaries[0]; + expect(summary).not.toHaveProperty('prompt'); + expect(summary).not.toHaveProperty('result'); + expect(JSON.stringify(summary)).not.toContain('SECRET PROMPT DATA'); + expect(JSON.stringify(summary)).not.toContain('SECRET RESULT DATA'); + }); + + it('should include CLI version and system info in report', async () => { + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + expect(result.diagnosticReport.cliVersion).toBe('1.5.0'); + expect(result.diagnosticReport.systemInfo.nodeVersion).toBe(process.version); + expect(result.diagnosticReport.systemInfo.platform).toBe(process.platform); + expect(result.diagnosticReport.systemInfo.arch).toBe(process.arch); + expect(result.diagnosticReport.systemInfo.ghVersion).toContain('gh version'); + }); + + it('should include user description in the diagnostic report', async () => { + const result = await useCase.execute({ + description: 'Agent crashed during planning phase', + fix: false, + }); + + expect(result.diagnosticReport.userDescription).toBe('Agent crashed during planning phase'); + }); + + it('should handle gh version check failure gracefully', async () => { + vi.mocked(mocks.execFunction).mockRejectedValue(new Error('gh not found')); + + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + expect(result.diagnosticReport.systemInfo.ghVersion).toBe('unknown'); + }); + }); + + // ------------------------------------------------------------------------- + // Tests: Task 11 — Issue Creation & Fix Workflow + // ------------------------------------------------------------------------- + + describe('issue creation', () => { + it('should create issue with formatted body and return issueUrl', async () => { + const result = await useCase.execute({ + description: 'The agent keeps crashing', + fix: false, + }); + + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/42'); + expect(mocks.issueService.createIssue).toHaveBeenCalledWith( + 'shep-ai/cli', + expect.stringContaining('[shep doctor]'), + expect.stringContaining('The agent keeps crashing'), + ['bug', 'shep-doctor'] + ); + }); + + it('should include [shep doctor] prefix in issue title', async () => { + await useCase.execute({ + description: 'Memory leak in settings service', + fix: false, + }); + + const titleArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][1]; + expect(titleArg).toMatch(/^\[shep doctor\]/); + }); + + it('should include diagnostic context in issue body', async () => { + const failedRun = createFailedRun('r1'); + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue([failedRun]); + + await useCase.execute({ + description: 'Something broke', + fix: false, + }); + + const bodyArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + expect(bodyArg).toContain('Something broke'); + expect(bodyArg).toContain('1.5.0'); + expect(bodyArg).toContain('shep doctor'); + }); + + it('should include footer tag in issue body', async () => { + await useCase.execute({ + description: 'test', + fix: false, + }); + + const bodyArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + expect(bodyArg).toContain('Reported via `shep doctor`'); + }); + }); + + describe('no-fix flow', () => { + it('should skip entire fix workflow and return only issueUrl', async () => { + const result = await useCase.execute({ + description: 'test', + fix: false, + }); + + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/42'); + expect(result.prUrl).toBeUndefined(); + expect(mocks.repoService.checkPushAccess).not.toHaveBeenCalled(); + expect(mocks.repoService.cloneRepository).not.toHaveBeenCalled(); + expect(mocks.agentExecutorProvider.getExecutor).not.toHaveBeenCalled(); + }); + }); + + describe('maintainer flow (has push access)', () => { + beforeEach(() => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + }); + + it('should clone directly, push, and create same-repo PR', async () => { + const result = await useCase.execute({ + description: 'test fix', + fix: true, + workdir: '/tmp/test-workdir', + }); + + // Should check push access + expect(mocks.repoService.checkPushAccess).toHaveBeenCalledWith('shep-ai/cli'); + + // Should NOT fork + expect(mocks.repoService.forkRepository).not.toHaveBeenCalled(); + + // Should clone shep-ai/cli directly + expect(mocks.repoService.cloneRepository).toHaveBeenCalledWith( + 'shep-ai/cli', + expect.stringContaining('/tmp/test-workdir'), + undefined + ); + + // Should invoke the agent + expect(mocks.agentExecutorProvider.getExecutor).toHaveBeenCalled(); + + // PR should NOT have cross-fork --repo flag + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.repo).toBeUndefined(); + + expect(result.prUrl).toBe('https://github.com/shep-ai/cli/pull/99'); + expect(result.flowType).toBe('maintainer'); + }); + + it('should create branch named doctor/fix-', async () => { + await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + // Check that the exec function was called with git checkout -b doctor/fix-42 + expect(mocks.execFunction).toHaveBeenCalledWith( + 'git', + ['checkout', '-b', 'doctor/fix-42'], + expect.objectContaining({ cwd: expect.any(String) }) + ); + }); + }); + + describe('contributor flow (no push access)', () => { + beforeEach(() => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(false); + }); + + it('should fork, clone fork, and create cross-fork PR with --repo', async () => { + const result = await useCase.execute({ + description: 'test fix', + fix: true, + workdir: '/tmp/test-workdir', + }); + + // Should check push access + expect(mocks.repoService.checkPushAccess).toHaveBeenCalledWith('shep-ai/cli'); + + // Should fork + expect(mocks.repoService.forkRepository).toHaveBeenCalledWith('shep-ai/cli'); + + // Should clone the fork (not shep-ai/cli directly) + expect(mocks.repoService.cloneRepository).toHaveBeenCalledWith( + 'user/cli', + expect.any(String), + undefined + ); + + // PR should have cross-fork --repo flag + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.repo).toBe('shep-ai/cli'); + + expect(result.prUrl).toBe('https://github.com/shep-ai/cli/pull/99'); + expect(result.flowType).toBe('contributor'); + }); + }); + + describe('agent invocation', () => { + it('should invoke agent with structured prompt containing issue context', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + + await useCase.execute({ + description: 'Agent crashed during planning', + fix: true, + workdir: '/tmp/test-workdir', + }); + + const prompt = vi.mocked(mocks.mockExecutor.execute).mock.calls[0][0]; + expect(prompt).toContain('Agent crashed during planning'); + expect(prompt).toContain('#42'); + }); + + it('should pass cwd option pointing to cloned directory', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + + await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + const options = vi.mocked(mocks.mockExecutor.execute).mock.calls[0][1]; + expect(options?.cwd).toContain('/tmp/test-workdir'); + }); + }); + + describe('graceful degradation', () => { + it('should return issueUrl with prUrl undefined when agent fails', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + vi.mocked(mocks.mockExecutor.execute).mockRejectedValue(new Error('Agent crashed')); + + const result = await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + expect(result.issueUrl).toBe('https://github.com/shep-ai/cli/issues/42'); + expect(result.prUrl).toBeUndefined(); + expect(result.error).toContain('Agent crashed'); + }); + + it('should fall back to contributor flow when push access check fails', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockRejectedValue(new Error('Network error')); + + const result = await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + // Should fall back to fork flow + expect(mocks.repoService.forkRepository).toHaveBeenCalled(); + expect(result.flowType).toBe('contributor'); + }); + + it('should handle no uncommitted changes after agent run', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + vi.mocked(mocks.prService.hasUncommittedChanges).mockResolvedValue(false); + + const result = await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + // No changes = no PR + expect(mocks.prService.commitAll).not.toHaveBeenCalled(); + expect(result.prUrl).toBeUndefined(); + expect(result.error).toContain('no changes'); + }); + }); + + describe('temp directory cleanup', () => { + it('should clean up temp directory on success when no workdir specified', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + + const result = await useCase.execute({ + description: 'test', + fix: true, + }); + + // Verify cleanup happened (the use case calls fs.rm on the temp dir) + expect(result.cleanedUp).toBe(true); + }); + + it('should clean up temp directory on failure when no workdir specified', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + vi.mocked(mocks.mockExecutor.execute).mockRejectedValue(new Error('Agent crashed')); + + const result = await useCase.execute({ + description: 'test', + fix: true, + }); + + expect(result.cleanedUp).toBe(true); + }); + + it('should NOT clean up when workdir is specified', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + + const result = await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/user-specified', + }); + + expect(result.cleanedUp).toBe(false); + }); + }); + + describe('PR creation details', () => { + it('should reference issue in PR title and body', async () => { + vi.mocked(mocks.repoService.checkPushAccess).mockResolvedValue(true); + + await useCase.execute({ + description: 'test', + fix: true, + workdir: '/tmp/test-workdir', + }); + + const prArgs = vi.mocked(mocks.prService.createPrFromArgs).mock.calls[0][1]; + expect(prArgs.title).toContain('#42'); + expect(prArgs.body).toContain('#42'); + expect(prArgs.base).toBe('main'); + expect(prArgs.labels).toContain('shep-doctor'); + }); + }); + + describe('feature-specific diagnostics', () => { + it('should filter failed runs by featureId when provided', async () => { + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc' }), + createFailedRun('r2', { featureId: 'feat-xyz' }), + createFailedRun('r3', { featureId: 'feat-abc' }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + } as any); + + const result = await useCase.execute({ + description: 'feature broke', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(2); + expect(result.diagnosticReport.featureId).toBe('feat-abc'); + expect(result.diagnosticReport.featureName).toBe('My Feature'); + }); + + it('should resolve feature by ID prefix when findById returns null', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue(null); + vi.mocked(mocks.featureRepo.findByIdPrefix).mockResolvedValue({ + id: 'feat-abc-full-id', + name: 'Prefixed Feature', + } as any); + + const failedRun = createFailedRun('r1', { featureId: 'feat-abc-full-id' }); + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue([failedRun]); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(mocks.featureRepo.findById).toHaveBeenCalledWith('feat-abc'); + expect(mocks.featureRepo.findByIdPrefix).toHaveBeenCalledWith('feat-abc'); + expect(result.diagnosticReport.featureId).toBe('feat-abc-full-id'); + expect(result.diagnosticReport.featureName).toBe('Prefixed Feature'); + }); + + it('should return all failed runs when featureId is not provided', async () => { + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc' }), + createFailedRun('r2', { featureId: 'feat-xyz' }), + createFailedRun('r3'), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + + const result = await useCase.execute({ + description: 'general issue', + fix: false, + }); + + expect(result.diagnosticReport.failedRunSummaries).toHaveLength(3); + expect(result.diagnosticReport.featureId).toBeUndefined(); + expect(result.diagnosticReport.featureName).toBeUndefined(); + }); + + it('should not set featureId in report when feature is not found', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue(null); + vi.mocked(mocks.featureRepo.findByIdPrefix).mockResolvedValue(null); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'nonexistent', + }); + + expect(result.diagnosticReport.featureId).toBeUndefined(); + expect(result.diagnosticReport.featureName).toBeUndefined(); + }); + + it('should include feature context in issue body when featureId is provided', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-123', + name: 'Auth Feature', + } as any); + + await useCase.execute({ + description: 'auth broke', + fix: false, + featureId: 'feat-123', + }); + + const bodyArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + expect(bodyArg).toContain('Feature ID'); + expect(bodyArg).toContain('feat-123'); + expect(bodyArg).toContain('Auth Feature'); + }); + + it('should collect spec YAML files when feature has specPath', async () => { + vi.mocked(readFile).mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('spec.yaml')) return 'name: My Feature Spec'; + if (p.endsWith('research.yaml')) return 'decisions: []'; + if (p.endsWith('plan.yaml')) return 'phases: []'; + if (p.endsWith('tasks.yaml')) return 'tasks: []'; + if (p.endsWith('feature.yaml')) return 'status: active'; + throw new Error('ENOENT'); + }); + + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + specPath: '/repo/specs/042-my-feature', + lifecycle: 'Implementation', + branch: 'feat/my-feature', + description: 'A test feature', + messages: [], + fast: false, + push: false, + openPr: false, + approvalGates: { allowPrd: false, allowPlan: false, allowMerge: false }, + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.specYaml).toBe('name: My Feature Spec'); + expect(result.diagnosticReport.researchYaml).toBe('decisions: []'); + expect(result.diagnosticReport.planYaml).toBe('phases: []'); + expect(result.diagnosticReport.tasksYaml).toBe('tasks: []'); + expect(result.diagnosticReport.featureStatusYaml).toBe('status: active'); + expect(result.diagnosticReport.featureLifecycle).toBe('Implementation'); + expect(result.diagnosticReport.featureBranch).toBe('feat/my-feature'); + expect(result.diagnosticReport.featureDescription).toBe('A test feature'); + }); + + it('should collect worker logs for all feature-scoped agent runs', async () => { + vi.mocked(readFile).mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.includes('worker-r1.log')) return 'Log content for r1'; + if (p.includes('worker-r2.log')) return 'Log content for r2'; + throw new Error('ENOENT'); + }); + + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc' }), + createFailedRun('r2', { featureId: 'feat-abc' }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.workerLogs).toBeDefined(); + expect(result.diagnosticReport.workerLogs).toHaveLength(2); + expect(result.diagnosticReport.workerLogs![0].content).toBe('Log content for r1'); + expect(result.diagnosticReport.workerLogs![1].agentRunId).toBe('r2'); + }); + + it('should collect agent run details with prompts and results for feature-scoped runs', async () => { + const runs: AgentRun[] = [ + createFailedRun('r1', { + featureId: 'feat-abc', + prompt: 'Analyze this', + result: 'Analysis done', + }), + { + ...createFailedRun('r2', { featureId: 'feat-abc', prompt: 'Plan this' }), + status: AgentRunStatus.completed, + }, + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.agentRunDetails).toBeDefined(); + expect(result.diagnosticReport.agentRunDetails!.length).toBe(2); + expect(result.diagnosticReport.agentRunDetails![0].prompt).toBe('Analyze this'); + }); + + it('should collect phase timings when feature is resolved', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + vi.mocked(mocks.phaseTimingRepo.findByFeatureId).mockResolvedValue([ + { id: 'pt-1', phaseName: 'analyze', durationMs: 5000 } as any, + ]); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.phaseTimings).toBeDefined(); + expect(result.diagnosticReport.phaseTimings).toContain('analyze'); + }); + + it('should include conversation messages and feature plan in report', async () => { + const messages = [{ id: 'm1', role: 'user', content: 'Hello' }]; + const plan = { overview: 'Build X', tasks: [] }; + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages, + plan, + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + expect(result.diagnosticReport.conversationMessages).toContain('Hello'); + expect(result.diagnosticReport.featurePlan).toContain('Build X'); + }); + + it('should leave all enriched fields undefined when no featureId is provided', async () => { + const result = await useCase.execute({ + description: 'general issue', + fix: false, + }); + + expect(result.diagnosticReport.featureLifecycle).toBeUndefined(); + expect(result.diagnosticReport.featureBranch).toBeUndefined(); + expect(result.diagnosticReport.featureDescription).toBeUndefined(); + expect(result.diagnosticReport.featureWorkflowConfig).toBeUndefined(); + expect(result.diagnosticReport.specYaml).toBeUndefined(); + expect(result.diagnosticReport.researchYaml).toBeUndefined(); + expect(result.diagnosticReport.planYaml).toBeUndefined(); + expect(result.diagnosticReport.tasksYaml).toBeUndefined(); + expect(result.diagnosticReport.featureStatusYaml).toBeUndefined(); + expect(result.diagnosticReport.agentRunDetails).toBeUndefined(); + expect(result.diagnosticReport.conversationMessages).toBeUndefined(); + expect(result.diagnosticReport.featurePlan).toBeUndefined(); + expect(result.diagnosticReport.workerLogs).toBeUndefined(); + expect(result.diagnosticReport.phaseTimings).toBeUndefined(); + }); + + it('should include enriched sections with details tags in issue body', async () => { + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + specPath: '/repo/specs/042-my-feature', + lifecycle: 'Implementation', + branch: 'feat/my-feature', + description: 'A test feature', + messages: [{ id: 'm1', role: 'user', content: 'Hello' }], + plan: { overview: 'Plan overview' }, + fast: false, + push: true, + openPr: false, + approvalGates: { allowPrd: false, allowPlan: false, allowMerge: false }, + } as any); + + await useCase.execute({ + description: 'enriched test', + fix: false, + featureId: 'feat-abc', + }); + + const bodyArg = vi.mocked(mocks.issueService.createIssue).mock.calls[0][2]; + // Feature context + expect(bodyArg).toContain('Lifecycle'); + expect(bodyArg).toContain('Implementation'); + // Details tags for large sections + expect(bodyArg).toContain('
'); + expect(bodyArg).toContain('Conversation'); + expect(bodyArg).toContain('Plan'); + }); + + it('should truncate agent run prompts exceeding MAX_PROMPT_CHARS', async () => { + const longPrompt = 'x'.repeat(15_000); + const runs: AgentRun[] = [ + createFailedRun('r1', { featureId: 'feat-abc', prompt: longPrompt }), + ]; + vi.mocked(mocks.agentRunRepo.list).mockResolvedValue(runs); + vi.mocked(mocks.featureRepo.findById).mockResolvedValue({ + id: 'feat-abc', + name: 'My Feature', + messages: [], + } as any); + + const result = await useCase.execute({ + description: 'test', + fix: false, + featureId: 'feat-abc', + }); + + const detail = result.diagnosticReport.agentRunDetails![0]; + expect(detail.prompt.length).toBeLessThanOrEqual(10_100); // 10000 + truncation message + expect(detail.prompt).toContain('[truncated'); + }); + }); +}); diff --git a/tests/unit/application/use-cases/features/cleanup-feature-worktree.use-case.test.ts b/tests/unit/application/use-cases/features/cleanup-feature-worktree.use-case.test.ts index 1e0a3d737..79a8fbb50 100644 --- a/tests/unit/application/use-cases/features/cleanup-feature-worktree.use-case.test.ts +++ b/tests/unit/application/use-cases/features/cleanup-feature-worktree.use-case.test.ts @@ -108,6 +108,9 @@ describe('CleanupFeatureWorktreeUseCase', () => { rebaseContinue: vi.fn().mockResolvedValue(undefined), rebaseAbort: vi.fn().mockResolvedValue(undefined), getBranchSyncStatus: vi.fn().mockResolvedValue({ ahead: 0, behind: 0 }), + createPrFromArgs: vi + .fn() + .mockResolvedValue({ url: 'https://github.com/org/repo/pull/1', number: 1 }), }; useCase = new CleanupFeatureWorktreeUseCase( diff --git a/tests/unit/application/use-cases/repositories/import-github-repository.use-case.test.ts b/tests/unit/application/use-cases/repositories/import-github-repository.use-case.test.ts index 00d22ae53..dd4c36773 100644 --- a/tests/unit/application/use-cases/repositories/import-github-repository.use-case.test.ts +++ b/tests/unit/application/use-cases/repositories/import-github-repository.use-case.test.ts @@ -46,6 +46,11 @@ describe('ImportGitHubRepositoryUseCase', () => { repo: 'my-project', nameWithOwner: 'octocat/my-project', }), + checkPushAccess: vi.fn().mockResolvedValue(false), + forkRepository: vi.fn().mockResolvedValue({ + nameWithOwner: 'user/repo', + cloneUrl: 'https://github.com/user/repo.git', + }), getViewerPermission: vi.fn().mockResolvedValue('ADMIN'), }; diff --git a/tests/unit/application/use-cases/repositories/list-github-repositories.use-case.test.ts b/tests/unit/application/use-cases/repositories/list-github-repositories.use-case.test.ts index a9467ee09..5c6fcdcad 100644 --- a/tests/unit/application/use-cases/repositories/list-github-repositories.use-case.test.ts +++ b/tests/unit/application/use-cases/repositories/list-github-repositories.use-case.test.ts @@ -40,6 +40,11 @@ describe('ListGitHubRepositoriesUseCase', () => { .fn<() => Promise>() .mockResolvedValue([createMockRepo()]), parseGitHubUrl: vi.fn(), + checkPushAccess: vi.fn().mockResolvedValue(false), + forkRepository: vi.fn().mockResolvedValue({ + nameWithOwner: 'user/repo', + cloneUrl: 'https://github.com/user/repo.git', + }), getViewerPermission: vi.fn().mockResolvedValue('ADMIN'), }; diff --git a/tests/unit/infrastructure/services/deployment/deployment.service.test.ts b/tests/unit/infrastructure/services/deployment/deployment.service.test.ts index a178d6cc3..a7d4e511b 100644 --- a/tests/unit/infrastructure/services/deployment/deployment.service.test.ts +++ b/tests/unit/infrastructure/services/deployment/deployment.service.test.ts @@ -379,6 +379,18 @@ describe('DeploymentService', () => { }); describe('recoverAll', () => { + const originalSkipRecovery = process.env.SHEP_SKIP_RECOVERY; + beforeEach(() => { + delete process.env.SHEP_SKIP_RECOVERY; + }); + afterEach(() => { + if (originalSkipRecovery !== undefined) { + process.env.SHEP_SKIP_RECOVERY = originalSkipRecovery; + } else { + delete process.env.SHEP_SKIP_RECOVERY; + } + }); + function createMockDb(rows: Record[]) { return { prepare: vi.fn().mockReturnValue({ diff --git a/tests/unit/infrastructure/services/external/github-issue-creator.service.test.ts b/tests/unit/infrastructure/services/external/github-issue-creator.service.test.ts new file mode 100644 index 000000000..4f9e1ad02 --- /dev/null +++ b/tests/unit/infrastructure/services/external/github-issue-creator.service.test.ts @@ -0,0 +1,178 @@ +/** + * GitHubIssueCreatorService Unit Tests + * + * Tests for creating GitHub issues via gh CLI subprocess. + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { GitHubIssueCreatorService } from '@/infrastructure/services/external/github-issue-creator.service.js'; +import { + GitHubIssueError, + GitHubIssueErrorCode, +} from '@/application/ports/output/services/github-issue-service.interface.js'; + +describe('GitHubIssueCreatorService', () => { + let service: GitHubIssueCreatorService; + let mockExecFile: Mock; + + beforeEach(() => { + mockExecFile = vi.fn(); + service = new GitHubIssueCreatorService(mockExecFile as any); + }); + + // --------------------------------------------------------------------------- + // createIssue — success + // --------------------------------------------------------------------------- + + describe('createIssue() — success', () => { + it('should call gh issue create with correct flags and return URL + number', async () => { + mockExecFile.mockResolvedValue({ + stdout: 'https://github.com/shep-ai/cli/issues/42\n', + stderr: '', + }); + + const result = await service.createIssue( + 'shep-ai/cli', + '[shep doctor] Agent crashed during planning', + '## Problem\n\nAgent failed with error...', + ['bug', 'shep-doctor'] + ); + + expect(result.url).toBe('https://github.com/shep-ai/cli/issues/42'); + expect(result.number).toBe(42); + expect(mockExecFile).toHaveBeenCalledWith('gh', [ + 'issue', + 'create', + '--repo', + 'shep-ai/cli', + '--title', + '[shep doctor] Agent crashed during planning', + '--body', + '## Problem\n\nAgent failed with error...', + '--label', + 'bug', + '--label', + 'shep-doctor', + ]); + }); + + it('should handle issue URL without trailing newline', async () => { + mockExecFile.mockResolvedValue({ + stdout: 'https://github.com/shep-ai/cli/issues/123', + stderr: '', + }); + + const result = await service.createIssue('shep-ai/cli', 'Title', 'Body', []); + + expect(result.url).toBe('https://github.com/shep-ai/cli/issues/123'); + expect(result.number).toBe(123); + }); + + it('should create issue with no labels', async () => { + mockExecFile.mockResolvedValue({ + stdout: 'https://github.com/shep-ai/cli/issues/7\n', + stderr: '', + }); + + const result = await service.createIssue('shep-ai/cli', 'Title', 'Body', []); + + expect(result.number).toBe(7); + expect(mockExecFile).toHaveBeenCalledWith('gh', [ + 'issue', + 'create', + '--repo', + 'shep-ai/cli', + '--title', + 'Title', + '--body', + 'Body', + ]); + }); + + it('should return number 0 when URL cannot be parsed', async () => { + mockExecFile.mockResolvedValue({ + stdout: 'some unexpected output\n', + stderr: '', + }); + + const result = await service.createIssue('shep-ai/cli', 'Title', 'Body', []); + + expect(result.url).toBe('some unexpected output'); + expect(result.number).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // createIssue — error handling + // --------------------------------------------------------------------------- + + describe('createIssue() — error handling', () => { + it('should throw GH_NOT_FOUND when gh CLI is not installed', async () => { + const err = new Error('spawn gh ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + mockExecFile.mockRejectedValue(err); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.GH_NOT_FOUND, + }); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toThrow( + GitHubIssueError + ); + }); + + it('should throw AUTH_FAILURE when gh is not authenticated', async () => { + mockExecFile.mockRejectedValue(new Error('Authentication required')); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.AUTH_FAILURE, + }); + }); + + it('should throw AUTH_FAILURE on 403 errors', async () => { + mockExecFile.mockRejectedValue(new Error('HTTP 403: Forbidden')); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.AUTH_FAILURE, + }); + }); + + it('should throw NETWORK_ERROR on connection failures', async () => { + mockExecFile.mockRejectedValue(new Error('network error: ECONNREFUSED')); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.NETWORK_ERROR, + }); + }); + + it('should throw NETWORK_ERROR on timeout', async () => { + mockExecFile.mockRejectedValue(new Error('ETIMEDOUT')); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.NETWORK_ERROR, + }); + }); + + it('should throw CREATE_FAILED for unknown errors', async () => { + mockExecFile.mockRejectedValue(new Error('something unexpected happened')); + + await expect(service.createIssue('shep-ai/cli', 'Title', 'Body', [])).rejects.toMatchObject({ + code: GitHubIssueErrorCode.CREATE_FAILED, + }); + }); + + it('should preserve cause on error', async () => { + const cause = new Error('original error'); + mockExecFile.mockRejectedValue(cause); + + try { + await service.createIssue('shep-ai/cli', 'Title', 'Body', []); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(GitHubIssueError); + expect((err as GitHubIssueError).cause).toBe(cause); + } + }); + }); +}); diff --git a/tests/unit/infrastructure/services/external/github-repository.service.test.ts b/tests/unit/infrastructure/services/external/github-repository.service.test.ts index 55327a15c..d2a9b54dc 100644 --- a/tests/unit/infrastructure/services/external/github-repository.service.test.ts +++ b/tests/unit/infrastructure/services/external/github-repository.service.test.ts @@ -12,6 +12,7 @@ import { GitHubRepositoryService } from '@/infrastructure/services/external/gith import { GitHubAuthError, GitHubCloneError, + GitHubForkError, GitHubRepoListError, GitHubUrlParseError, } from '@/application/ports/output/services/github-repository-service.interface.js'; @@ -340,4 +341,150 @@ describe('GitHubRepositoryService', () => { expect(mockRm).toHaveBeenCalled(); }); }); + + // ------------------------------------------------------------------------- + // checkPushAccess + // ------------------------------------------------------------------------- + + describe('checkPushAccess()', () => { + it('should return true when gh api returns "true"', async () => { + mockExecFile.mockResolvedValue({ stdout: 'true\n', stderr: '' }); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(true); + expect(mockExecFile).toHaveBeenCalledWith('gh', [ + 'api', + 'repos/shep-ai/cli', + '--jq', + '.permissions.push', + ]); + }); + + it('should return false when gh api returns "false"', async () => { + mockExecFile.mockResolvedValue({ stdout: 'false\n', stderr: '' }); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(false); + }); + + it('should return false when gh api throws an error (network failure)', async () => { + mockExecFile.mockRejectedValue(new Error('network error')); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(false); + }); + + it('should return false when gh api throws ENOENT (gh not installed)', async () => { + const err = new Error('spawn gh ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + mockExecFile.mockRejectedValue(err); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(false); + }); + + it('should return false when gh api returns unexpected output', async () => { + mockExecFile.mockResolvedValue({ stdout: 'null\n', stderr: '' }); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(false); + }); + + it('should return false when gh api returns empty string', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + + const result = await service.checkPushAccess('shep-ai/cli'); + + expect(result).toBe(false); + }); + }); + + // ------------------------------------------------------------------------- + // forkRepository + // ------------------------------------------------------------------------- + + describe('forkRepository()', () => { + it('should create a new fork and return nameWithOwner + cloneUrl', async () => { + mockExecFile.mockResolvedValue({ + stdout: JSON.stringify({ + nameWithOwner: 'myuser/cli', + url: 'https://github.com/myuser/cli', + }), + stderr: '', + }); + + const result = await service.forkRepository('shep-ai/cli'); + + expect(result.nameWithOwner).toBe('myuser/cli'); + expect(result.cloneUrl).toBe('https://github.com/myuser/cli.git'); + expect(mockExecFile).toHaveBeenCalledWith('gh', [ + 'repo', + 'fork', + 'shep-ai/cli', + '--clone=false', + '--json', + 'nameWithOwner,url', + ]); + }); + + it('should detect existing fork and return it (idempotent)', async () => { + mockExecFile.mockResolvedValue({ + stdout: JSON.stringify({ + nameWithOwner: 'myuser/cli', + url: 'https://github.com/myuser/cli', + }), + stderr: 'myuser/cli already exists', + }); + + const result = await service.forkRepository('shep-ai/cli'); + + expect(result.nameWithOwner).toBe('myuser/cli'); + expect(result.cloneUrl).toBe('https://github.com/myuser/cli.git'); + }); + + it('should not double-append .git if URL already has it', async () => { + mockExecFile.mockResolvedValue({ + stdout: JSON.stringify({ + nameWithOwner: 'myuser/cli', + url: 'https://github.com/myuser/cli.git', + }), + stderr: '', + }); + + const result = await service.forkRepository('shep-ai/cli'); + + expect(result.cloneUrl).toBe('https://github.com/myuser/cli.git'); + }); + + it('should throw GitHubForkError on auth failure', async () => { + mockExecFile.mockRejectedValue(new Error('HTTP 403: not authorized')); + + await expect(service.forkRepository('shep-ai/cli')).rejects.toThrow(GitHubForkError); + await expect(service.forkRepository('shep-ai/cli')).rejects.toThrow('Failed to fork'); + }); + + it('should throw GitHubForkError on network error', async () => { + mockExecFile.mockRejectedValue(new Error('network timeout')); + + await expect(service.forkRepository('shep-ai/cli')).rejects.toThrow(GitHubForkError); + }); + + it('should preserve error cause', async () => { + const cause = new Error('original fork error'); + mockExecFile.mockRejectedValue(cause); + + try { + await service.forkRepository('shep-ai/cli'); + expect.fail('Should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(GitHubForkError); + expect((err as GitHubForkError).cause).toBe(cause); + } + }); + }); }); diff --git a/tests/unit/infrastructure/services/git/git-pr.service.test.ts b/tests/unit/infrastructure/services/git/git-pr.service.test.ts index a7bf24e4d..6f123a3fd 100644 --- a/tests/unit/infrastructure/services/git/git-pr.service.test.ts +++ b/tests/unit/infrastructure/services/git/git-pr.service.test.ts @@ -1031,4 +1031,157 @@ describe('GitPrService', () => { expect(mockExec).toHaveBeenCalledTimes(3); }); }); + + describe('createPrFromArgs', () => { + it('should create same-repo PR with title, body, base, and labels', async () => { + vi.mocked(mockExec).mockResolvedValueOnce({ + stdout: 'https://github.com/shep-ai/cli/pull/99\n', + stderr: '', + }); + + const result = await service.createPrFromArgs('/repo', { + title: '[shep doctor] Fix agent crash', + body: '## Summary\n\nFixes #42', + labels: ['bug', 'shep-doctor'], + base: 'main', + }); + + expect(result.url).toBe('https://github.com/shep-ai/cli/pull/99'); + expect(result.number).toBe(99); + expect(mockExec).toHaveBeenCalledWith( + 'gh', + [ + 'pr', + 'create', + '--title', + '[shep doctor] Fix agent crash', + '--body', + '## Summary\n\nFixes #42', + '--base', + 'main', + '--label', + 'bug,shep-doctor', + ], + { cwd: '/repo' } + ); + }); + + it('should create cross-fork PR with --repo flag', async () => { + vi.mocked(mockExec).mockResolvedValueOnce({ + stdout: 'https://github.com/shep-ai/cli/pull/100\n', + stderr: '', + }); + + const result = await service.createPrFromArgs('/repo', { + title: 'Fix agent crash', + body: 'Fix body', + labels: ['bug'], + base: 'main', + repo: 'shep-ai/cli', + }); + + expect(result.url).toBe('https://github.com/shep-ai/cli/pull/100'); + expect(result.number).toBe(100); + expect(mockExec).toHaveBeenCalledWith( + 'gh', + [ + 'pr', + 'create', + '--title', + 'Fix agent crash', + '--body', + 'Fix body', + '--base', + 'main', + '--label', + 'bug', + '--repo', + 'shep-ai/cli', + ], + { cwd: '/repo' } + ); + }); + + it('should omit --repo flag when repo is not provided', async () => { + vi.mocked(mockExec).mockResolvedValueOnce({ + stdout: 'https://github.com/shep-ai/cli/pull/1\n', + stderr: '', + }); + + await service.createPrFromArgs('/repo', { + title: 'Title', + body: 'Body', + labels: [], + base: 'main', + }); + + const args = vi.mocked(mockExec).mock.calls[0][1]; + expect(args).not.toContain('--repo'); + }); + + it('should omit --label flag when labels array is empty', async () => { + vi.mocked(mockExec).mockResolvedValueOnce({ + stdout: 'https://github.com/shep-ai/cli/pull/1\n', + stderr: '', + }); + + await service.createPrFromArgs('/repo', { + title: 'Title', + body: 'Body', + labels: [], + base: 'main', + }); + + const args = vi.mocked(mockExec).mock.calls[0][1]; + expect(args).not.toContain('--label'); + }); + + it('should throw GitPrError with GH_NOT_FOUND when gh is not found', async () => { + const error = new Error('ENOENT gh not found'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + vi.mocked(mockExec).mockRejectedValue(error); + + await expect( + service.createPrFromArgs('/repo', { + title: 'Title', + body: 'Body', + labels: [], + base: 'main', + }) + ).rejects.toMatchObject({ + code: GitPrErrorCode.GH_NOT_FOUND, + }); + }); + + it('should throw GitPrError with AUTH_FAILURE on auth errors', async () => { + vi.mocked(mockExec).mockRejectedValue(new Error('Authentication failed for repo')); + + await expect( + service.createPrFromArgs('/repo', { + title: 'Title', + body: 'Body', + labels: [], + base: 'main', + }) + ).rejects.toMatchObject({ + code: GitPrErrorCode.AUTH_FAILURE, + }); + }); + + it('should return number 0 when URL cannot be parsed', async () => { + vi.mocked(mockExec).mockResolvedValueOnce({ + stdout: 'unexpected output\n', + stderr: '', + }); + + const result = await service.createPrFromArgs('/repo', { + title: 'Title', + body: 'Body', + labels: [], + base: 'main', + }); + + expect(result.number).toBe(0); + }); + }); }); diff --git a/tests/unit/infrastructure/services/pr-sync/pr-sync-watcher.service.test.ts b/tests/unit/infrastructure/services/pr-sync/pr-sync-watcher.service.test.ts index 266d28e97..47f970bb1 100644 --- a/tests/unit/infrastructure/services/pr-sync/pr-sync-watcher.service.test.ts +++ b/tests/unit/infrastructure/services/pr-sync/pr-sync-watcher.service.test.ts @@ -106,6 +106,9 @@ function createMockGitPrService(): IGitPrService { rebaseContinue: vi.fn().mockResolvedValue(undefined), rebaseAbort: vi.fn().mockResolvedValue(undefined), getBranchSyncStatus: vi.fn().mockResolvedValue({ ahead: 0, behind: 0 }), + createPrFromArgs: vi + .fn() + .mockResolvedValue({ url: 'https://github.com/org/repo/pull/1', number: 1 }), }; } diff --git a/tests/unit/presentation/cli/commands/doctor.command.test.ts b/tests/unit/presentation/cli/commands/doctor.command.test.ts new file mode 100644 index 000000000..6a99de3a3 --- /dev/null +++ b/tests/unit/presentation/cli/commands/doctor.command.test.ts @@ -0,0 +1,431 @@ +// @vitest-environment node + +/** + * Doctor Command Unit Tests + * + * Tests for the `shep doctor` command: structure, prerequisite validation, + * interactive prompts, fix gate, and result display. + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; + +// --- Mocks --- + +vi.mock('node:child_process', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, spawn: vi.fn(), execFile: vi.fn() }; +}); + +const mockUseCaseExecute = vi.fn(); + +vi.mock('@/infrastructure/di/container.js', () => ({ + container: { + resolve: vi.fn(), + }, +})); + +vi.mock('@/application/use-cases/doctor/doctor-diagnose.use-case.js', () => ({ + DoctorDiagnoseUseCase: class MockDoctorDiagnoseUseCase {}, +})); + +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock('@cli/presentation/cli/ui/index.js', () => ({ + messages: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + newline: vi.fn(), + }, + colors: { + accent: (s: string) => s, + muted: (s: string) => s, + }, + spinner: vi.fn((_label: string, fn: () => Promise) => fn()), +})); + +// Import after mocks +import { createDoctorCommand } from '@cli/presentation/cli/commands/doctor.command.js'; +import { container } from '@/infrastructure/di/container.js'; +import { messages, spinner } from '@cli/presentation/cli/ui/index.js'; +import { input, confirm } from '@inquirer/prompts'; + +// Default successful result +const defaultResult = { + diagnosticReport: { + userDescription: 'test problem', + failedRunSummaries: [], + systemInfo: { + nodeVersion: 'v20.0.0', + platform: 'darwin', + arch: 'arm64', + ghVersion: 'gh 2.40.0', + }, + cliVersion: '1.0.0', + }, + issueUrl: 'https://github.com/shep-ai/cli/issues/42', + issueNumber: 42, + cleanedUp: false, +}; + +describe('Doctor Command', () => { + let mockToolInstaller: { checkAvailability: ReturnType }; + let mockRepoService: { checkAuth: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = 0; + + mockToolInstaller = { checkAvailability: vi.fn().mockResolvedValue({ status: 'available' }) }; + mockRepoService = { checkAuth: vi.fn().mockResolvedValue(undefined) }; + mockUseCaseExecute.mockResolvedValue(defaultResult); + + vi.mocked(container.resolve).mockImplementation((token: unknown) => { + if (token === 'IToolInstallerService') return mockToolInstaller; + if (token === 'IGitHubRepositoryService') return mockRepoService; + // DoctorDiagnoseUseCase — resolved by class reference + return { execute: mockUseCaseExecute }; + }); + }); + + // ----------------------------------------------------------------------- + // Command structure + // ----------------------------------------------------------------------- + + describe('command structure', () => { + it('should create a valid Commander command', () => { + const cmd = createDoctorCommand(); + expect(cmd).toBeInstanceOf(Command); + }); + + it('should have the name "doctor"', () => { + const cmd = createDoctorCommand(); + expect(cmd.name()).toBe('doctor'); + }); + + it('should have a description', () => { + const cmd = createDoctorCommand(); + expect(cmd.description()).toBeTruthy(); + }); + + it('should accept description as an optional positional argument', () => { + const cmd = createDoctorCommand(); + const args = (cmd as any)._args; + expect(args).toHaveLength(1); + expect(args[0].name()).toBe('description'); + expect(args[0].required).toBe(false); + }); + + it('should have --fix option', () => { + const cmd = createDoctorCommand(); + const opt = cmd.options.find((o) => o.long === '--fix'); + expect(opt).toBeDefined(); + }); + + it('should have --no-fix option', () => { + const cmd = createDoctorCommand(); + // Commander auto-generates --no-fix when --fix is boolean + const opt = cmd.options.find((o) => o.long === '--no-fix'); + expect(opt).toBeDefined(); + }); + + it('should have --workdir option', () => { + const cmd = createDoctorCommand(); + const opt = cmd.options.find((o) => o.long === '--workdir'); + expect(opt).toBeDefined(); + }); + + it('should have --feature-id option', () => { + const cmd = createDoctorCommand(); + const opt = cmd.options.find((o) => o.long === '--feature-id'); + expect(opt).toBeDefined(); + }); + }); + + // ----------------------------------------------------------------------- + // Prerequisite validation + // ----------------------------------------------------------------------- + + describe('prerequisite validation', () => { + it('should check gh CLI availability before proceeding', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'test problem', '--no-fix']); + + expect(mockToolInstaller.checkAvailability).toHaveBeenCalledWith('gh'); + }); + + it('should check gh authentication before proceeding', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'test problem', '--no-fix']); + + expect(mockRepoService.checkAuth).toHaveBeenCalled(); + }); + + it('should show error when gh CLI is not installed', async () => { + mockToolInstaller.checkAvailability.mockResolvedValue({ status: 'missing' }); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'test problem', '--no-fix']); + + expect(messages.error).toHaveBeenCalledWith( + 'Doctor failed', + expect.objectContaining({ + message: expect.stringContaining('GitHub CLI (gh) is not installed'), + }) + ); + expect(process.exitCode).toBe(1); + }); + + it('should show error when gh is not authenticated', async () => { + mockRepoService.checkAuth.mockRejectedValue(new Error('not logged in')); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'test problem', '--no-fix']); + + expect(messages.error).toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + }); + + // ----------------------------------------------------------------------- + // Description collection + // ----------------------------------------------------------------------- + + describe('description collection', () => { + it('should use positional argument when provided', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'agent crashed', '--no-fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ description: 'agent crashed' }) + ); + expect(input).not.toHaveBeenCalled(); + }); + + it('should prompt for description when not provided', async () => { + vi.mocked(input).mockResolvedValue('interactive description'); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', '--no-fix']); + + expect(input).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Describe') }) + ); + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ description: 'interactive description' }) + ); + }); + + it('should cancel when interactive description is empty', async () => { + vi.mocked(input).mockResolvedValue(' '); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', '--no-fix']); + + expect(messages.info).toHaveBeenCalledWith( + expect.stringContaining('No description provided') + ); + expect(mockUseCaseExecute).not.toHaveBeenCalled(); + }); + }); + + // ----------------------------------------------------------------------- + // Fix gate + // ----------------------------------------------------------------------- + + describe('fix gate', () => { + it('should pass fix=true when --fix flag is set', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith(expect.objectContaining({ fix: true })); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('should pass fix=false when --no-fix flag is set', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith(expect.objectContaining({ fix: false })); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('should prompt for fix when neither flag is set', async () => { + vi.mocked(confirm).mockResolvedValue(true); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem']); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('attempt a fix') }) + ); + expect(mockUseCaseExecute).toHaveBeenCalledWith(expect.objectContaining({ fix: true })); + }); + + it('should pass fix=false when user declines fix prompt', async () => { + vi.mocked(confirm).mockResolvedValue(false); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith(expect.objectContaining({ fix: false })); + }); + }); + + // ----------------------------------------------------------------------- + // Workdir option + // ----------------------------------------------------------------------- + + describe('workdir option', () => { + it('should pass workdir to use case when specified', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix', '--workdir', '/tmp/my-fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ workdir: '/tmp/my-fix' }) + ); + }); + + it('should pass undefined workdir when not specified', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ workdir: undefined }) + ); + }); + }); + + // ----------------------------------------------------------------------- + // Feature ID option + // ----------------------------------------------------------------------- + + describe('feature-id option', () => { + it('should pass featureId to use case when specified', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix', '--feature-id', 'abc-123']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ featureId: 'abc-123' }) + ); + }); + + it('should pass undefined featureId when not specified', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(mockUseCaseExecute).toHaveBeenCalledWith( + expect.objectContaining({ featureId: undefined }) + ); + }); + }); + + // ----------------------------------------------------------------------- + // Result display + // ----------------------------------------------------------------------- + + describe('result display', () => { + it('should show issue URL on success', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(messages.success).toHaveBeenCalledWith( + expect.stringContaining('https://github.com/shep-ai/cli/issues/42') + ); + }); + + it('should show PR URL when fix succeeds', async () => { + mockUseCaseExecute.mockResolvedValue({ + ...defaultResult, + prUrl: 'https://github.com/shep-ai/cli/pull/43', + flowType: 'maintainer', + }); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--fix']); + + expect(messages.success).toHaveBeenCalledWith( + expect.stringContaining('https://github.com/shep-ai/cli/pull/43') + ); + }); + + it('should show flow type when PR is created', async () => { + mockUseCaseExecute.mockResolvedValue({ + ...defaultResult, + prUrl: 'https://github.com/shep-ai/cli/pull/43', + flowType: 'contributor', + }); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--fix']); + + expect(messages.info).toHaveBeenCalledWith(expect.stringContaining('fork (contributor)')); + }); + + it('should show warning when fix attempt fails', async () => { + mockUseCaseExecute.mockResolvedValue({ + ...defaultResult, + error: 'Agent timed out', + }); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--fix']); + + expect(messages.warning).toHaveBeenCalledWith(expect.stringContaining('Agent timed out')); + }); + + it('should show cleanup message when temp dir is cleaned', async () => { + mockUseCaseExecute.mockResolvedValue({ + ...defaultResult, + cleanedUp: true, + }); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(messages.info).toHaveBeenCalledWith(expect.stringContaining('cleaned up')); + }); + + it('should use spinner during use case execution', async () => { + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(spinner).toHaveBeenCalledWith( + expect.stringContaining('diagnostics'), + expect.any(Function) + ); + }); + }); + + // ----------------------------------------------------------------------- + // Error handling + // ----------------------------------------------------------------------- + + describe('error handling', () => { + it('should handle use case errors gracefully', async () => { + mockUseCaseExecute.mockRejectedValue(new Error('Something broke')); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test', 'problem', '--no-fix']); + + expect(messages.error).toHaveBeenCalledWith('Doctor failed', expect.any(Error)); + expect(process.exitCode).toBe(1); + }); + + it('should handle Ctrl+C gracefully during prompts', async () => { + vi.mocked(input).mockRejectedValue(new Error('User force closed the prompt')); + + const cmd = createDoctorCommand(); + await cmd.parseAsync(['node', 'test']); + + expect(messages.info).toHaveBeenCalledWith('Cancelled.'); + expect(process.exitCode).toBe(0); + }); + }); +}); diff --git a/tests/unit/presentation/cli/commands/repo/add.command.test.ts b/tests/unit/presentation/cli/commands/repo/add.command.test.ts index b15d0a527..388f3b424 100644 --- a/tests/unit/presentation/cli/commands/repo/add.command.test.ts +++ b/tests/unit/presentation/cli/commands/repo/add.command.test.ts @@ -23,6 +23,9 @@ const { mockImportExecute, mockListExecute, mockGitHubService, mockGithubImportW cloneRepository: vi.fn(), listUserRepositories: vi.fn(), parseGitHubUrl: vi.fn(), + checkPushAccess: vi.fn(), + forkRepository: vi.fn(), + getViewerPermission: vi.fn(), }, mockGithubImportWizard: vi.fn(), })); diff --git a/tests/unit/presentation/tui/wizards/github-import.wizard.test.ts b/tests/unit/presentation/tui/wizards/github-import.wizard.test.ts index 060c546c4..9086da474 100644 --- a/tests/unit/presentation/tui/wizards/github-import.wizard.test.ts +++ b/tests/unit/presentation/tui/wizards/github-import.wizard.test.ts @@ -41,6 +41,11 @@ function createMockGitHubService(): IGitHubRepositoryService { repo: 'my-project', nameWithOwner: 'octocat/my-project', }), + checkPushAccess: vi.fn().mockResolvedValue(false), + forkRepository: vi.fn().mockResolvedValue({ + nameWithOwner: 'user/repo', + cloneUrl: 'https://github.com/user/repo.git', + }), getViewerPermission: vi.fn().mockResolvedValue('ADMIN'), }; } diff --git a/tests/unit/use-cases/features/adopt-branch.use-case.test.ts b/tests/unit/use-cases/features/adopt-branch.use-case.test.ts index 0d95c9a5b..ecc639ba5 100644 --- a/tests/unit/use-cases/features/adopt-branch.use-case.test.ts +++ b/tests/unit/use-cases/features/adopt-branch.use-case.test.ts @@ -99,6 +99,9 @@ describe('AdoptBranchUseCase', () => { rebaseContinue: vi.fn().mockResolvedValue(undefined), rebaseAbort: vi.fn().mockResolvedValue(undefined), getBranchSyncStatus: vi.fn().mockResolvedValue({ ahead: 0, behind: 0 }), + createPrFromArgs: vi + .fn() + .mockResolvedValue({ url: 'https://github.com/org/repo/pull/1', number: 1 }), }; useCase = new AdoptBranchUseCase( diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index b6bd117bc..fbf1e04a9 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -337,6 +337,9 @@ model WorkflowConfig { */ @doc("Hide CI status badges from UI (default: true)") hideCiStatus?: boolean; + + @doc("Maximum number of doctor fix attempts before giving up (default: 1)") + doctorMaxFixAttempts?: int32; } @doc("AI coding agent configuration") diff --git a/tsp/domain/value-objects/doctor-diagnostic-report.tsp b/tsp/domain/value-objects/doctor-diagnostic-report.tsp new file mode 100644 index 000000000..96830e17b --- /dev/null +++ b/tsp/domain/value-objects/doctor-diagnostic-report.tsp @@ -0,0 +1,131 @@ +@doc("Summary of a failed agent run for diagnostic reporting") +model FailedRunSummary { + @doc("Type of agent that failed (e.g. claude-code, gemini-cli)") + agentType: string; + + @doc("Name/identifier of the agent run") + agentName: string; + + @doc("Error message from the failed run") + error: string; + + @doc("ISO 8601 timestamp when the failure occurred") + timestamp: string; +} + +@doc("System environment information for diagnostic reporting") +model SystemInfo { + @doc("Node.js version (e.g. v20.11.0)") + nodeVersion: string; + + @doc("Operating system platform (e.g. darwin, linux, win32)") + platform: string; + + @doc("CPU architecture (e.g. x64, arm64)") + arch: string; + + @doc("gh CLI version string") + ghVersion: string; +} + +@doc("Detailed agent run information including prompt and result for diagnostic reporting") +model AgentRunDetail { + @doc("Type of agent (e.g. claude-code, gemini-cli)") + agentType: string; + + @doc("Name/identifier of the agent run") + agentName: string; + + @doc("Input prompt sent to the agent executor") + prompt: string; + + @doc("Final result output from the agent (if available)") + result?: string; + + @doc("Error message if the run failed") + error?: string; + + @doc("ISO 8601 timestamp of the run") + timestamp: string; +} + +@doc("Worker log entry for a specific agent run") +model WorkerLogEntry { + @doc("Agent run ID this log belongs to") + agentRunId: string; + + @doc("Name of the agent that produced this log") + agentName: string; + + @doc("Full log file content (may be truncated)") + content: string; + + @doc("Whether the content was truncated due to size limits") + truncated: boolean; + + @doc("Original character count before truncation (only set when truncated)") + originalLength?: int32; +} + +@doc("Structured diagnostic report collected by shep doctor for issue creation") +model DoctorDiagnosticReport { + @doc("User-provided description of the problem") + userDescription: string; + + @doc("Summaries of recent failed agent runs") + failedRunSummaries: FailedRunSummary[]; + + @doc("System environment information") + systemInfo: SystemInfo; + + @doc("Current shep CLI version") + cliVersion: string; + + @doc("Feature ID when diagnosing a specific feature (optional)") + featureId?: string; + + @doc("Feature name when diagnosing a specific feature (optional)") + featureName?: string; + + @doc("Feature lifecycle phase (e.g. Implementation, Review)") + featureLifecycle?: string; + + @doc("Feature git branch name") + featureBranch?: string; + + @doc("Feature description") + featureDescription?: string; + + @doc("JSON-serialized feature workflow configuration (fast, push, openPr, approvalGates)") + featureWorkflowConfig?: string; + + @doc("Raw spec.yaml content") + specYaml?: string; + + @doc("Raw research.yaml content") + researchYaml?: string; + + @doc("Raw plan.yaml content") + planYaml?: string; + + @doc("Raw tasks.yaml content") + tasksYaml?: string; + + @doc("Raw feature.yaml (status tracking) content") + featureStatusYaml?: string; + + @doc("Detailed agent run information including prompts and results") + agentRunDetails?: AgentRunDetail[]; + + @doc("JSON-serialized conversation messages (Feature.messages[])") + conversationMessages?: string; + + @doc("JSON-serialized feature plan (Feature.plan)") + featurePlan?: string; + + @doc("Worker execution logs for agent runs associated with this feature") + workerLogs?: WorkerLogEntry[]; + + @doc("JSON-serialized phase timing records") + phaseTimings?: string; +} diff --git a/tsp/domain/value-objects/index.tsp b/tsp/domain/value-objects/index.tsp index 4dd06d8ad..47ee96f80 100644 --- a/tsp/domain/value-objects/index.tsp +++ b/tsp/domain/value-objects/index.tsp @@ -45,3 +45,4 @@ import "./pull-request.tsp"; import "./feature-status-metadata.tsp"; import "./attachment.tsp"; import "./evidence.tsp"; +import "./doctor-diagnostic-report.tsp";