diff --git a/apps/backend/runners/roadmap/competitor_analyzer.py b/apps/backend/runners/roadmap/competitor_analyzer.py index d4fdbe12..6ea4bddf 100644 --- a/apps/backend/runners/roadmap/competitor_analyzer.py +++ b/apps/backend/runners/roadmap/competitor_analyzer.py @@ -31,6 +31,7 @@ class CompetitorAnalyzer: self.refresh = refresh self.agent_executor = agent_executor self.analysis_file = output_dir / "competitor_analysis.json" + self.manual_competitors_file = output_dir / "manual_competitors.json" self.discovery_file = output_dir / "roadmap_discovery.json" self.project_index_file = output_dir / "project_index.json" @@ -119,26 +120,47 @@ class CompetitorAnalyzer: ) def _get_manual_competitors(self) -> list[dict]: - """Extract manually-added competitors from the existing analysis file. + """Extract manually-added competitors from the dedicated manual file and analysis file. + Reads from manual_competitors.json (primary, never overwritten by agent) and + falls back to competitor_analysis.json. Deduplicates by competitor ID. Returns a list of competitor dicts where source == 'manual'. - Returns an empty list if the file doesn't exist or is malformed. """ - if not self.analysis_file.exists(): - return [] + competitors_by_id: dict[str, dict] = {} - try: - with open(self.analysis_file, encoding="utf-8") as f: - data = json.load(f) + # Primary source: dedicated manual competitors file (never overwritten by agent) + if self.manual_competitors_file.exists(): + try: + with open(self.manual_competitors_file, encoding="utf-8") as f: + data = json.load(f) + for c in data.get("competitors", []): + if isinstance(c, dict) and c.get("id"): + competitors_by_id[c["id"]] = c + except (json.JSONDecodeError, OSError) as e: + print_status( + f"Warning: could not read manual competitors file: {e}", "warning" + ) - return [ - c - for c in data.get("competitors", []) - if isinstance(c, dict) and c.get("source") == "manual" - ] - except (json.JSONDecodeError, OSError) as e: - print_status(f"Warning: could not read manual competitors: {e}", "warning") - return [] + # Fallback: also check analysis file for manual competitors + if self.analysis_file.exists(): + try: + with open(self.analysis_file, encoding="utf-8") as f: + data = json.load(f) + for c in data.get("competitors", []): + if ( + isinstance(c, dict) + and c.get("source") == "manual" + and c.get("id") + and c["id"] not in competitors_by_id + ): + competitors_by_id[c["id"]] = c + except (json.JSONDecodeError, OSError) as e: + print_status( + f"Warning: could not read manual competitors from analysis: {e}", + "warning", + ) + + return list(competitors_by_id.values()) def _merge_manual_competitors(self, manual_competitors: list[dict]) -> None: """Merge manual competitors back into the newly-generated analysis file. diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index b8b02785..5ee26ec6 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -882,6 +882,44 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" ); }); + // Also persist manual competitors to a separate file that the backend + // agent never overwrites, preventing data loss during concurrent analysis + const manualCompetitors = competitorAnalysis.competitors.filter( + (c) => c.source === "manual" + ); + if (manualCompetitors.length > 0) { + const manualCompetitorsPath = path.join( + roadmapDir, + AUTO_BUILD_PATHS.MANUAL_COMPETITORS + ); + const manualSerialized = { + competitors: manualCompetitors.map((c) => ({ + id: c.id, + name: c.name, + url: c.url, + description: c.description, + relevance: c.relevance, + pain_points: c.painPoints.map((p) => ({ + id: p.id, + description: p.description, + source: p.source, + severity: p.severity, + frequency: p.frequency, + opportunity: p.opportunity, + })), + strengths: c.strengths, + market_position: c.marketPosition, + source: c.source, + })), + updated_at: new Date().toISOString(), + }; + await writeFileWithRetry( + manualCompetitorsPath, + JSON.stringify(manualSerialized, null, 2), + { encoding: "utf-8" } + ); + } + debugLog("[Roadmap Handler] Saved competitor analysis:", { projectId }); return { success: true }; } catch (error) { diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 5f21054a..9b4c1bcc 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -71,8 +71,6 @@ interface RoadmapState { updateFeaturePhase: (featureId: string, newPhaseId: string) => void; addFeature: (feature: Omit) => string; addCompetitor: (input: ManualCompetitorInput) => string; - removeCompetitor: (competitorId: string) => void; - updateCompetitorAnalysis: (analysis: CompetitorAnalysis) => void; } const initialGenerationStatus: RoadmapGenerationStatus = { @@ -322,23 +320,6 @@ export const useRoadmapStore = create((set) => ({ return newId; }, - // Remove a competitor by ID (used for rollback on save failure) - removeCompetitor: (competitorId) => { - set((state) => { - if (!state.competitorAnalysis) return state; - return { - competitorAnalysis: { - ...state.competitorAnalysis, - competitors: state.competitorAnalysis.competitors.filter( - (c) => c.id !== competitorId - ) - } - }; - }); - }, - - // Replace the entire competitor analysis object - updateCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }) })); /** diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index a9ae47eb..c6cf888b 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -111,6 +111,7 @@ export const AUTO_BUILD_PATHS = { ROADMAP_FILE: 'roadmap.json', ROADMAP_DISCOVERY: 'roadmap_discovery.json', COMPETITOR_ANALYSIS: 'competitor_analysis.json', + MANUAL_COMPETITORS: 'manual_competitors.json', IDEATION_FILE: 'ideation.json', IDEATION_CONTEXT: 'ideation_context.json', PROJECT_INDEX: '.auto-claude/project_index.json',