diff --git a/BUILD_NOTES.md b/BUILD_NOTES.md
new file mode 100644
index 0000000..719250c
--- /dev/null
+++ b/BUILD_NOTES.md
@@ -0,0 +1,152 @@
+# SourceKit Recruiter OS — Build Notes
+
+## What Was Built
+
+A fully separate recruiter product experience at `/recruiter` within the existing SourceKit SPA. This is not a reskin — it's a distinct product boundary with its own layout, navigation, data model, routing, and screen copy, built on SourceKit infrastructure.
+
+**Product Name:** SourceKit Recruiter OS
+**Route Prefix:** `/recruiter`
+**Target Users:** Heads of Talent, technical recruiters, sourcers, founder-operators hiring AI-native technical talent
+
+## How to Access
+
+Navigate to `/recruiter` when authenticated. The original SourceKit app at `/` remains fully intact.
+
+## Architecture
+
+- **Route Group:** `/recruiter/*` route group inside existing SPA via React Router v6
+- **Lazy Loading:** All recruiter pages are lazy-loaded for code splitting
+- **Isolation:** Separate layout component (`RecruiterLayout`), dedicated CSS scope (`.recruiter-os` class), own page components, hooks, services, and types
+- **Shared Infra:** Supabase client, auth, React Query provider, shadcn/ui primitives, and existing API utilities are reused
+
+## Sections Built
+
+| Section | Route | Description |
+|---------|-------|-------------|
+| Command Center | `/recruiter` | Operational dashboard: stats, review queue, agent runs, scorecards, pipeline snapshot |
+| Search Lab | `/recruiter/search` | Role-brief-driven search workspace with archetypes, signals, source toggles, search modes |
+| Candidate Intel | `/recruiter/candidates` | Dense sortable/filterable candidate table |
+| Candidate Profile | `/recruiter/candidates/:id` | Full profile with 6-dimension scoring, evidence tabs, outreach history, notes |
+| Team Pipeline | `/recruiter/pipeline` | Board + table views with tier columns, stage grouping, bulk actions |
+| Outreach Studio | `/recruiter/outreach` | Three-column outreach workspace: queue, editor with evidence grounding, candidate evidence |
+| Role Scorecards | `/recruiter/scorecards` | CRUD for reusable role definitions with signals, weights, suppressions, eval questions |
+| Scorecard Detail | `/recruiter/scorecards/:id` | Full scorecard editor with launch-search integration |
+| Agent Runs | `/recruiter/agents` | Run log with expandable detail, error display, filtering, auto-refresh |
+| Reports | `/recruiter/reports` | 8 report types with data adapters and graceful degradation |
+| Settings | `/recruiter/settings` | ATS integration, API keys, notifications, scoring defaults, team, appearance |
+
+## Transparent Scoring Model
+
+Six scoring dimensions, each with evidence citations:
+- **EEA Score** — Evidence of Exceptional Ability (USCIS criteria)
+- **Builder Score** — Shipping velocity, ownership, project completion
+- **AI Recency Score** — Recent frontier AI work
+- **Systems Depth Score** — Infrastructure and architecture signals
+- **Product Instinct Score** — User-facing craft and product thinking
+- **Hidden Gem Score** — High proof-to-visibility ratio
+
+Scores are structured as `{ score: number, evidence: [], confidence: 'high'|'medium'|'low', reason: string }` and designed to be replaceable by backend-calculated values.
+
+## Files Created
+
+### Core Structure
+```
+src/recruiter/
+├── RecruiterLayout.tsx # App shell with left nav
+├── RecruiterNav.tsx # Persistent sidebar navigation
+├── routes.tsx # Route definitions with lazy loading
+├── pages/
+│ ├── CommandCenter.tsx # /recruiter
+│ ├── SearchLab.tsx # /recruiter/search
+│ ├── CandidateIntelList.tsx # /recruiter/candidates
+│ ├── CandidateIntelProfile.tsx# /recruiter/candidates/:id
+│ ├── TeamPipeline.tsx # /recruiter/pipeline
+│ ├── OutreachStudio.tsx # /recruiter/outreach
+│ ├── RoleScorecardList.tsx # /recruiter/scorecards
+│ ├── RoleScorecardDetail.tsx # /recruiter/scorecards/:id
+│ ├── AgentRuns.tsx # /recruiter/agents
+│ ├── Reports.tsx # /recruiter/reports
+│ └── RecruiterSettings.tsx # /recruiter/settings
+├── components/
+│ ├── TierBadge.tsx # Tier classification badge
+│ ├── ScoreCard.tsx # Score display with evidence expand
+│ ├── StatusBadge.tsx # Stage, contact, run status, ATS, priority badges
+│ ├── PageHeader.tsx # Page header with title and actions
+│ ├── StatCard.tsx # Stat card for command center
+│ └── EmptyState.tsx # Empty state pattern
+├── hooks/
+│ ├── useRecruiterCandidates.ts# Candidate CRUD + pipeline stats
+│ ├── useRecruiterScorecard.ts # Scorecard CRUD
+│ ├── useAgentRuns.ts # Agent run queries + polling
+│ ├── useRecruiterOutreach.ts # Outreach CRUD
+│ ├── useRecruiterNotes.ts # Candidate notes
+│ └── useRecruiterSavedSearches.ts # Saved search management
+├── services/
+│ ├── scoring.ts # Client-side scoring utilities
+│ └── export.ts # CSV export
+├── lib/
+│ ├── types.ts # All TypeScript interfaces
+│ └── constants.ts # Tier/stage/score/report configs
+└── styles/
+ └── recruiter-tokens.css # CSS custom properties + animations
+```
+
+### Database Migration
+```
+supabase/migrations/20260323_recruiter_os_schema.sql
+```
+
+7 new tables:
+- `recruiter_scorecards` — Role scorecard definitions
+- `recruiter_candidates` — Extended candidate model with multi-surface data
+- `recruiter_candidate_notes` — Recruiter notes per candidate
+- `recruiter_outreach` — Outreach messages with grounding artifacts
+- `recruiter_agent_runs` — Agent job tracking
+- `recruiter_saved_searches` — Saved search configurations
+- `recruiter_sequence_templates` — Outreach sequence templates
+
+All tables have:
+- UUID primary keys
+- User-scoped RLS policies
+- Appropriate indexes
+- Updated_at triggers
+
+## Files Modified
+
+| File | Change |
+|------|--------|
+| `src/App.tsx` | Added `/recruiter/*` route group with lazy-loaded RecruiterLayout |
+| `tailwind.config.ts` | Added `ros-fade-in` keyframe and animation |
+| `vite.config.ts` | Added recruiter chunk naming for code splitting |
+
+## Design System
+
+- **Dark mode default** (forced via `.recruiter-os` CSS scope)
+- **Fonts:** DM Sans (body) + JetBrains Mono (labels, scores, data)
+- **Accent:** `#00e5a0` (same as SourceKit primary)
+- **Background:** `#0a0a0f` (deeper than default dark mode)
+- **Information density:** Compact tables, small text, sticky headers
+- **CSS variables:** All recruiter tokens scoped under `.recruiter-os` class
+
+## Backend Assumptions / TODOs
+
+1. **Database migration** needs to be applied to Supabase: `supabase db push` or run the SQL manually
+2. **Search execution** — Search Lab UI is wired but `recruiter-search-orchestrator` edge function needs to be built to orchestrate multi-source search
+3. **Scoring backend** — `recruiter-scoring` edge function needs to be built for server-side multi-dimensional scoring with Claude
+4. **Outreach generation** — `recruiter-outreach-gen` edge function needs to be built for artifact-grounded message generation
+5. **Enrichment** — `recruiter-enrichment` edge function for multi-surface candidate enrichment (LinkedIn, HuggingFace, web)
+6. **ATS sync** — Webhook-based export is stubbed in Settings, needs `recruiter-ats-sync` edge function
+7. **Dedup** — Cross-search deduplication needs `recruiter-dedup` edge function
+8. **Reports** — Some report types show placeholder structures pending sufficient data
+9. **Drag-and-drop** — Pipeline board uses explicit actions (not DnD) for reliability
+10. **Team features** — Data model supports `user_id` scoping; multi-user/team features deferred
+
+## Running the App
+
+```bash
+npm install
+npm run dev
+# Navigate to http://localhost:8080/recruiter
+```
+
+The original SourceKit app continues to work at `http://localhost:8080/`.
diff --git a/src/App.tsx b/src/App.tsx
index 9485a60..52e913f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -12,6 +12,11 @@ import DeveloperProfile from "./pages/DeveloperProfile";
import Auth from "./pages/Auth";
import NotFound from "./pages/NotFound";
import { Analytics } from "@vercel/analytics/react";
+import { lazy, Suspense } from "react";
+
+// Recruiter OS — lazy-loaded route group
+const RecruiterLayout = lazy(() => import("./recruiter/RecruiterLayout"));
+import { recruiterRoutes } from "./recruiter/routes";
const queryClient = new QueryClient();
@@ -68,6 +73,17 @@ const App = () => {
<>
} />
} />
+
+
+
+ }>
+
+
+ }>
+ {recruiterRoutes}
+
} />
} />
>
diff --git a/src/recruiter/RecruiterLayout.tsx b/src/recruiter/RecruiterLayout.tsx
new file mode 100644
index 0000000..d294c12
--- /dev/null
+++ b/src/recruiter/RecruiterLayout.tsx
@@ -0,0 +1,59 @@
+import { Outlet } from 'react-router-dom';
+import { useState } from 'react';
+import { Menu, X } from 'lucide-react';
+import RecruiterNav from './RecruiterNav';
+import '../recruiter/styles/recruiter-tokens.css';
+
+export default function RecruiterLayout() {
+ const [mobileOpen, setMobileOpen] = useState(false);
+
+ return (
+
+ {/* Desktop sidebar */}
+
+
+ {/* Mobile sidebar */}
+ {mobileOpen && (
+ <>
+
setMobileOpen(false)}
+ />
+
+ >
+ )}
+
+ {/* Main content */}
+
+ {/* Mobile header */}
+
+
+
+
+ SK
+
+
Recruiter OS
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/recruiter/RecruiterNav.tsx b/src/recruiter/RecruiterNav.tsx
new file mode 100644
index 0000000..7bfeed8
--- /dev/null
+++ b/src/recruiter/RecruiterNav.tsx
@@ -0,0 +1,99 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import {
+ LayoutDashboard, Search, Users, Kanban, Mail,
+ ClipboardList, Bot, BarChart2, Settings, LogOut,
+} from 'lucide-react';
+import { supabase } from '@/integrations/supabase/client';
+
+const NAV_ITEMS = [
+ { path: '/recruiter', label: 'Command Center', icon: LayoutDashboard, exact: true },
+ { path: '/recruiter/search', label: 'Search Lab', icon: Search },
+ { path: '/recruiter/candidates', label: 'Candidate Intel', icon: Users },
+ { path: '/recruiter/pipeline', label: 'Team Pipeline', icon: Kanban },
+ { path: '/recruiter/outreach', label: 'Outreach Studio', icon: Mail },
+ { path: '/recruiter/scorecards', label: 'Role Scorecards', icon: ClipboardList },
+ { path: '/recruiter/agents', label: 'Agent Runs', icon: Bot },
+ { path: '/recruiter/reports', label: 'Reports', icon: BarChart2 },
+ { path: '/recruiter/settings', label: 'Settings', icon: Settings },
+] as const;
+
+export default function RecruiterNav() {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const isActive = (item: typeof NAV_ITEMS[number]) => {
+ if (item.exact) return location.pathname === item.path;
+ return location.pathname.startsWith(item.path);
+ };
+
+ return (
+
+ {/* Logo */}
+
+
+ SK
+
+
+
+ SourceKit
+
+
+ Recruiter OS
+
+
+
+
+ {/* Navigation */}
+
+
+ {/* Bottom */}
+
+
+
+
+
+ );
+}
diff --git a/src/recruiter/components/EmptyState.tsx b/src/recruiter/components/EmptyState.tsx
new file mode 100644
index 0000000..57b3fdf
--- /dev/null
+++ b/src/recruiter/components/EmptyState.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon?: ReactNode;
+ title: string;
+ description: string;
+ action?: ReactNode;
+}
+
+export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
+ return (
+
+ {icon &&
{icon}
}
+
{title}
+
{description}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/src/recruiter/components/PageHeader.tsx b/src/recruiter/components/PageHeader.tsx
new file mode 100644
index 0000000..f48b567
--- /dev/null
+++ b/src/recruiter/components/PageHeader.tsx
@@ -0,0 +1,25 @@
+import type { ReactNode } from 'react';
+
+interface PageHeaderProps {
+ title: string;
+ subtitle?: string;
+ actions?: ReactNode;
+}
+
+export default function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {actions &&
{actions}
}
+
+ );
+}
diff --git a/src/recruiter/components/ScoreCard.tsx b/src/recruiter/components/ScoreCard.tsx
new file mode 100644
index 0000000..7aab6cd
--- /dev/null
+++ b/src/recruiter/components/ScoreCard.tsx
@@ -0,0 +1,105 @@
+import { useState } from 'react';
+import { ChevronDown, ChevronUp } from 'lucide-react';
+import { getScoreColor, getScoreBgColor } from '../services/scoring';
+import { SCORE_DIMENSIONS } from '../lib/constants';
+import type { DimensionScore, ScoreDimension } from '../lib/types';
+
+interface ScoreCardProps {
+ dimension: ScoreDimension;
+ score: DimensionScore | null;
+ compact?: boolean;
+}
+
+export default function ScoreCard({ dimension, score, compact }: ScoreCardProps) {
+ const [expanded, setExpanded] = useState(false);
+ const config = SCORE_DIMENSIONS[dimension];
+ const value = score?.score ?? 0;
+ const color = getScoreColor(value);
+ const bgColor = getScoreBgColor(value);
+
+ if (compact) {
+ return (
+
+ {config.label}
+ {value}
+
+ );
+ }
+
+ return (
+
+
+
+ {config.label}
+
+
+
+
+
+ {value}
+ /100
+
+
+ {score?.reason && (
+
+ {score.reason}
+
+ )}
+
+ {/* Confidence indicator */}
+ {score && (
+
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
+
+ {score.confidence}
+
+
+ )}
+
+ {/* Evidence expansion */}
+ {expanded && score?.evidence && score.evidence.length > 0 && (
+
+ {score.evidence.map((ev, i) => (
+
+
+
+ {ev.source}
+
+ {ev.artifact}
+
+
{ev.detail}
+ {ev.url && (
+
+ {ev.url}
+
+ )}
+
+ ))}
+
+ )}
+
+ {expanded && (!score?.evidence || score.evidence.length === 0) && (
+
+ No evidence indexed yet. Run enrichment to surface artifacts.
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/components/StatCard.tsx b/src/recruiter/components/StatCard.tsx
new file mode 100644
index 0000000..342f738
--- /dev/null
+++ b/src/recruiter/components/StatCard.tsx
@@ -0,0 +1,30 @@
+interface StatCardProps {
+ label: string;
+ value: string | number;
+ sublabel?: string;
+ accent?: boolean;
+}
+
+export default function StatCard({ label, value, sublabel, accent }: StatCardProps) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/components/StatusBadge.tsx b/src/recruiter/components/StatusBadge.tsx
new file mode 100644
index 0000000..ce4b2e2
--- /dev/null
+++ b/src/recruiter/components/StatusBadge.tsx
@@ -0,0 +1,79 @@
+import { STAGE_CONFIG, CONTACT_STATUS_CONFIG, AGENT_RUN_STATUS_CONFIG } from '../lib/constants';
+import type { PipelineStage, ContactStatus, AgentRunStatus, ATSSyncStatus } from '../lib/types';
+
+interface StageBadgeProps {
+ stage: PipelineStage;
+}
+
+export function StageBadge({ stage }: StageBadgeProps) {
+ const config = STAGE_CONFIG[stage];
+ return (
+
+ {config.label}
+
+ );
+}
+
+interface ContactBadgeProps {
+ status: ContactStatus;
+}
+
+export function ContactBadge({ status }: ContactBadgeProps) {
+ const config = CONTACT_STATUS_CONFIG[status];
+ return (
+
+ {config.label}
+
+ );
+}
+
+interface RunStatusBadgeProps {
+ status: AgentRunStatus;
+}
+
+export function RunStatusBadge({ status }: RunStatusBadgeProps) {
+ const config = AGENT_RUN_STATUS_CONFIG[status];
+ return (
+
+ {status === 'running' && }
+ {config.label}
+
+ );
+}
+
+interface ATSBadgeProps {
+ status: ATSSyncStatus;
+}
+
+export function ATSBadge({ status }: ATSBadgeProps) {
+ const colors: Record
= {
+ not_synced: 'text-zinc-500',
+ synced: 'text-blue-400',
+ sync_failed: 'text-red-400',
+ };
+ const labels: Record = {
+ not_synced: 'Not synced',
+ synced: 'ATS synced',
+ sync_failed: 'Sync failed',
+ };
+ return (
+ {labels[status]}
+ );
+}
+
+interface PriorityBadgeProps {
+ priority: 'high' | 'medium' | 'low';
+}
+
+export function PriorityBadge({ priority }: PriorityBadgeProps) {
+ const colors = {
+ high: 'text-emerald-400 bg-emerald-500/15',
+ medium: 'text-amber-400 bg-amber-500/15',
+ low: 'text-zinc-400 bg-zinc-500/15',
+ };
+ return (
+
+ {priority}
+
+ );
+}
diff --git a/src/recruiter/components/TierBadge.tsx b/src/recruiter/components/TierBadge.tsx
new file mode 100644
index 0000000..7430446
--- /dev/null
+++ b/src/recruiter/components/TierBadge.tsx
@@ -0,0 +1,18 @@
+import { TIER_CONFIG } from '../lib/constants';
+import type { CandidateTier } from '../lib/types';
+
+interface TierBadgeProps {
+ tier: CandidateTier;
+ size?: 'sm' | 'md';
+}
+
+export default function TierBadge({ tier, size = 'sm' }: TierBadgeProps) {
+ const config = TIER_CONFIG[tier];
+ const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-1';
+
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/src/recruiter/hooks/useAgentRuns.ts b/src/recruiter/hooks/useAgentRuns.ts
new file mode 100644
index 0000000..e4f5e09
--- /dev/null
+++ b/src/recruiter/hooks/useAgentRuns.ts
@@ -0,0 +1,87 @@
+// SourceKit Recruiter OS — Agent Runs hook
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { AgentRun, AgentRunType, AgentRunStatus } from '../lib/types';
+
+const TABLE = 'recruiter_agent_runs';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+export function useAgentRuns(filters?: {
+ type?: AgentRunType;
+ status?: AgentRunStatus;
+ limit?: number;
+}) {
+ return useQuery({
+ queryKey: ['recruiter-agent-runs', filters],
+ queryFn: async () => {
+ const userId = await getUserId();
+ let query = supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .order('started_at', { ascending: false });
+
+ if (filters?.type) query = query.eq('type', filters.type);
+ if (filters?.status) query = query.eq('status', filters.status);
+ if (filters?.limit) query = query.limit(filters.limit);
+
+ const { data, error } = await query;
+ if (error) throw error;
+ return (data ?? []) as AgentRun[];
+ },
+ refetchInterval: 10000, // Poll every 10s for running jobs
+ });
+}
+
+export function useCreateAgentRun() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (run: Partial) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({
+ user_id: userId,
+ type: run.type ?? 'search',
+ status: 'running',
+ inputs: run.inputs ?? {},
+ outputs: {},
+ errors: [],
+ candidate_ids: [],
+ scorecard_id: run.scorecard_id ?? null,
+ })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as AgentRun;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-agent-runs'] });
+ },
+ });
+}
+
+export function useUpdateAgentRun() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => {
+ const { data, error } = await supabase
+ .from(TABLE)
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single();
+ if (error) throw error;
+ return data as AgentRun;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-agent-runs'] });
+ },
+ });
+}
diff --git a/src/recruiter/hooks/useRecruiterCandidates.ts b/src/recruiter/hooks/useRecruiterCandidates.ts
new file mode 100644
index 0000000..a2650d1
--- /dev/null
+++ b/src/recruiter/hooks/useRecruiterCandidates.ts
@@ -0,0 +1,130 @@
+// SourceKit Recruiter OS — Candidate data hook
+// Connects to recruiter_candidates table via Supabase
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { RecruiterCandidate, CandidateTier, PipelineStage, ContactStatus } from '../lib/types';
+
+const TABLE = 'recruiter_candidates';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+// Fetch all candidates for the current user
+export function useRecruiterCandidates(filters?: {
+ tier?: CandidateTier;
+ stage?: PipelineStage;
+ tags?: string[];
+ needs_review?: boolean;
+}) {
+ return useQuery({
+ queryKey: ['recruiter-candidates', filters],
+ queryFn: async () => {
+ const userId = await getUserId();
+ let query = supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .order('composite_score', { ascending: false });
+
+ if (filters?.tier) query = query.eq('tier', filters.tier);
+ if (filters?.stage) query = query.eq('pipeline_stage', filters.stage);
+ if (filters?.needs_review) query = query.eq('needs_review', true);
+ if (filters?.tags?.length) query = query.overlaps('tags', filters.tags);
+
+ const { data, error } = await query;
+ if (error) throw error;
+ return (data ?? []) as RecruiterCandidate[];
+ },
+ });
+}
+
+// Fetch a single candidate by ID
+export function useRecruiterCandidate(id: string | undefined) {
+ return useQuery({
+ queryKey: ['recruiter-candidate', id],
+ queryFn: async () => {
+ if (!id) return null;
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('id', id)
+ .single();
+ if (error) throw error;
+ return data as RecruiterCandidate;
+ },
+ enabled: !!id,
+ });
+}
+
+// Update candidate fields
+export function useUpdateCandidate() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => {
+ const { data, error } = await supabase
+ .from(TABLE)
+ .update({ ...updates, updated_at: new Date().toISOString() })
+ .eq('id', id)
+ .select()
+ .single();
+ if (error) throw error;
+ return data as RecruiterCandidate;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-candidates'] });
+ qc.invalidateQueries({ queryKey: ['recruiter-candidate'] });
+ },
+ });
+}
+
+// Create a new candidate
+export function useCreateCandidate() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (candidate: Partial) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({ ...candidate, user_id: userId })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as RecruiterCandidate;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-candidates'] });
+ },
+ });
+}
+
+// Pipeline stats for command center
+export function useRecruiterPipelineStats() {
+ return useQuery({
+ queryKey: ['recruiter-pipeline-stats'],
+ queryFn: async () => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('tier, pipeline_stage, contact_status, needs_review, ats_sync_status')
+ .eq('user_id', userId);
+ if (error) throw error;
+
+ const candidates = data ?? [];
+ return {
+ total: candidates.length,
+ tier_1: candidates.filter(c => c.tier === 'tier_1').length,
+ tier_2: candidates.filter(c => c.tier === 'tier_2').length,
+ borderline: candidates.filter(c => c.tier === 'borderline').length,
+ below_bar: candidates.filter(c => c.tier === 'below_bar').length,
+ needs_review: candidates.filter(c => c.needs_review).length,
+ outreach_sent: candidates.filter(c => c.pipeline_stage === 'outreach_sent').length,
+ ats_synced: candidates.filter(c => c.ats_sync_status === 'synced').length,
+ hidden_gems: candidates.filter(c => c.tier === 'tier_1' || c.tier === 'tier_2').length, // TODO: use hidden_gem_score
+ };
+ },
+ });
+}
diff --git a/src/recruiter/hooks/useRecruiterNotes.ts b/src/recruiter/hooks/useRecruiterNotes.ts
new file mode 100644
index 0000000..b26ab8e
--- /dev/null
+++ b/src/recruiter/hooks/useRecruiterNotes.ts
@@ -0,0 +1,51 @@
+// SourceKit Recruiter OS — Candidate Notes hook
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { CandidateNote } from '../lib/types';
+
+const TABLE = 'recruiter_candidate_notes';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+export function useRecruiterNotes(candidateId: string | undefined) {
+ return useQuery({
+ queryKey: ['recruiter-notes', candidateId],
+ queryFn: async () => {
+ if (!candidateId) return [];
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .eq('candidate_id', candidateId)
+ .order('created_at', { ascending: false });
+ if (error) throw error;
+ return (data ?? []) as CandidateNote[];
+ },
+ enabled: !!candidateId,
+ });
+}
+
+export function useCreateNote() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ candidateId, content }: { candidateId: string; content: string }) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({ user_id: userId, candidate_id: candidateId, content })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as CandidateNote;
+ },
+ onSuccess: (_, { candidateId }) => {
+ qc.invalidateQueries({ queryKey: ['recruiter-notes', candidateId] });
+ },
+ });
+}
diff --git a/src/recruiter/hooks/useRecruiterOutreach.ts b/src/recruiter/hooks/useRecruiterOutreach.ts
new file mode 100644
index 0000000..94c1ce5
--- /dev/null
+++ b/src/recruiter/hooks/useRecruiterOutreach.ts
@@ -0,0 +1,81 @@
+// SourceKit Recruiter OS — Outreach hook
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { OutreachMessage } from '../lib/types';
+
+const TABLE = 'recruiter_outreach';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+export function useRecruiterOutreach(candidateId?: string) {
+ return useQuery({
+ queryKey: ['recruiter-outreach', candidateId],
+ queryFn: async () => {
+ const userId = await getUserId();
+ let query = supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .order('created_at', { ascending: false });
+
+ if (candidateId) query = query.eq('candidate_id', candidateId);
+
+ const { data, error } = await query;
+ if (error) throw error;
+ return (data ?? []) as OutreachMessage[];
+ },
+ });
+}
+
+export function useCreateOutreach() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (outreach: Partial) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({
+ user_id: userId,
+ candidate_id: outreach.candidate_id,
+ scorecard_id: outreach.scorecard_id ?? null,
+ message: outreach.message ?? '',
+ grounding_artifacts: outreach.grounding_artifacts ?? [],
+ tone: outreach.tone ?? 'professional',
+ sequence_step: outreach.sequence_step ?? 'initial',
+ channel: outreach.channel ?? 'email',
+ status: outreach.status ?? 'draft',
+ })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as OutreachMessage;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-outreach'] });
+ },
+ });
+}
+
+export function useUpdateOutreach() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => {
+ const { data, error } = await supabase
+ .from(TABLE)
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single();
+ if (error) throw error;
+ return data as OutreachMessage;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-outreach'] });
+ },
+ });
+}
diff --git a/src/recruiter/hooks/useRecruiterSavedSearches.ts b/src/recruiter/hooks/useRecruiterSavedSearches.ts
new file mode 100644
index 0000000..8c05061
--- /dev/null
+++ b/src/recruiter/hooks/useRecruiterSavedSearches.ts
@@ -0,0 +1,53 @@
+// SourceKit Recruiter OS — Saved Searches hook
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { SavedSearch, RecruiterSearchConfig } from '../lib/types';
+
+const TABLE = 'recruiter_saved_searches';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+export function useRecruiterSavedSearches() {
+ return useQuery({
+ queryKey: ['recruiter-saved-searches'],
+ queryFn: async () => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .order('updated_at', { ascending: false });
+ if (error) throw error;
+ return (data ?? []) as SavedSearch[];
+ },
+ });
+}
+
+export function useSaveSearch() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ name, config, scorecardId }: { name: string; config: RecruiterSearchConfig; scorecardId?: string }) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({
+ user_id: userId,
+ name,
+ scorecard_id: scorecardId ?? null,
+ config,
+ })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as SavedSearch;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-saved-searches'] });
+ },
+ });
+}
diff --git a/src/recruiter/hooks/useRecruiterScorecard.ts b/src/recruiter/hooks/useRecruiterScorecard.ts
new file mode 100644
index 0000000..61de2b5
--- /dev/null
+++ b/src/recruiter/hooks/useRecruiterScorecard.ts
@@ -0,0 +1,109 @@
+// SourceKit Recruiter OS — Scorecard CRUD hook
+
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { supabase } from '@/integrations/supabase/client';
+import type { RoleScorecard } from '../lib/types';
+
+const TABLE = 'recruiter_scorecards';
+
+async function getUserId(): Promise {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (!session?.user?.id) throw new Error('Not authenticated');
+ return session.user.id;
+}
+
+export function useRecruiterScorecards() {
+ return useQuery({
+ queryKey: ['recruiter-scorecards'],
+ queryFn: async () => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('user_id', userId)
+ .order('updated_at', { ascending: false });
+ if (error) throw error;
+ return (data ?? []) as RoleScorecard[];
+ },
+ });
+}
+
+export function useRecruiterScorecard(id: string | undefined) {
+ return useQuery({
+ queryKey: ['recruiter-scorecard', id],
+ queryFn: async () => {
+ if (!id) return null;
+ const { data, error } = await supabase
+ .from(TABLE)
+ .select('*')
+ .eq('id', id)
+ .single();
+ if (error) throw error;
+ return data as RoleScorecard;
+ },
+ enabled: !!id,
+ });
+}
+
+export function useCreateScorecard() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (scorecard: Partial) => {
+ const userId = await getUserId();
+ const { data, error } = await supabase
+ .from(TABLE)
+ .insert({
+ user_id: userId,
+ name: scorecard.name ?? 'Untitled Scorecard',
+ status: scorecard.status ?? 'draft',
+ talent_thesis: scorecard.talent_thesis ?? '',
+ must_have_signals: scorecard.must_have_signals ?? [],
+ nice_to_have_signals: scorecard.nice_to_have_signals ?? [],
+ suppressions: scorecard.suppressions ?? [],
+ scoring_weights: scorecard.scoring_weights ?? { eea: 25, builder: 20, ai_recency: 20, systems_depth: 15, product_instinct: 10, hidden_gem: 10 },
+ outreach_tone: scorecard.outreach_tone ?? 'professional',
+ evaluation_questions: scorecard.evaluation_questions ?? [],
+ })
+ .select()
+ .single();
+ if (error) throw error;
+ return data as RoleScorecard;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] });
+ },
+ });
+}
+
+export function useUpdateScorecard() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => {
+ const { data, error } = await supabase
+ .from(TABLE)
+ .update({ ...updates, updated_at: new Date().toISOString() })
+ .eq('id', id)
+ .select()
+ .single();
+ if (error) throw error;
+ return data as RoleScorecard;
+ },
+ onSuccess: (_, { id }) => {
+ qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] });
+ qc.invalidateQueries({ queryKey: ['recruiter-scorecard', id] });
+ },
+ });
+}
+
+export function useDeleteScorecard() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error } = await supabase.from(TABLE).delete().eq('id', id);
+ if (error) throw error;
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] });
+ },
+ });
+}
diff --git a/src/recruiter/lib/constants.ts b/src/recruiter/lib/constants.ts
new file mode 100644
index 0000000..3ee9b87
--- /dev/null
+++ b/src/recruiter/lib/constants.ts
@@ -0,0 +1,168 @@
+// SourceKit Recruiter OS — Constants
+
+import type { CandidateTier, PipelineStage, ContactStatus, AgentRunType, AgentRunStatus, ScoreDimension, ReportType } from './types';
+
+// --- Tier Configuration ---
+
+export const TIER_CONFIG: Record = {
+ tier_1: { label: 'Tier 1', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15', borderColor: 'border-emerald-500/30', order: 0 },
+ tier_2: { label: 'Tier 2', color: 'text-indigo-400', bgColor: 'bg-indigo-500/15', borderColor: 'border-indigo-500/30', order: 1 },
+ borderline: { label: 'Borderline', color: 'text-yellow-400', bgColor: 'bg-yellow-500/15', borderColor: 'border-yellow-500/30', order: 2 },
+ below_bar: { label: 'Below Bar', color: 'text-red-400', bgColor: 'bg-red-500/15', borderColor: 'border-red-500/30', order: 3 },
+ false_positive: { label: 'False Positive', color: 'text-zinc-400', bgColor: 'bg-zinc-500/15', borderColor: 'border-zinc-500/30', order: 4 },
+ nurture: { label: 'Nurture', color: 'text-violet-400', bgColor: 'bg-violet-500/15', borderColor: 'border-violet-500/30', order: 5 },
+ ats_synced: { label: 'ATS Synced', color: 'text-blue-400', bgColor: 'bg-blue-500/15', borderColor: 'border-blue-500/30', order: 6 },
+ unclassified: { label: 'Unclassified', color: 'text-zinc-500', bgColor: 'bg-zinc-500/10', borderColor: 'border-zinc-500/20', order: 7 },
+};
+
+export const PIPELINE_TIERS: CandidateTier[] = ['tier_1', 'tier_2', 'borderline', 'below_bar'];
+
+// --- Pipeline Stages ---
+
+export const STAGE_CONFIG: Record = {
+ new: { label: 'New', color: 'text-sky-400', bgColor: 'bg-sky-500/15', borderColor: 'border-sky-500/30', order: 0 },
+ under_review: { label: 'Under Review', color: 'text-amber-400', bgColor: 'bg-amber-500/15', borderColor: 'border-amber-500/30', order: 1 },
+ outreach_sent: { label: 'Outreach Sent', color: 'text-indigo-400', bgColor: 'bg-indigo-500/15', borderColor: 'border-indigo-500/30', order: 2 },
+ replied: { label: 'Replied', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15', borderColor: 'border-emerald-500/30', order: 3 },
+ screening: { label: 'Screening', color: 'text-purple-400', bgColor: 'bg-purple-500/15', borderColor: 'border-purple-500/30', order: 4 },
+ moved_to_ats: { label: 'Moved to ATS', color: 'text-blue-400', bgColor: 'bg-blue-500/15', borderColor: 'border-blue-500/30', order: 5 },
+ hold: { label: 'On Hold', color: 'text-zinc-400', bgColor: 'bg-zinc-500/15', borderColor: 'border-zinc-500/30', order: 6 },
+ suppressed: { label: 'Suppressed', color: 'text-red-400', bgColor: 'bg-red-500/15', borderColor: 'border-red-500/30', order: 7 },
+};
+
+// --- Contact Status ---
+
+export const CONTACT_STATUS_CONFIG: Record = {
+ not_contacted: { label: 'Not Contacted', color: 'text-zinc-500', icon: 'circle' },
+ contacted: { label: 'Contacted', color: 'text-amber-400', icon: 'send' },
+ replied: { label: 'Replied', color: 'text-emerald-400', icon: 'message-circle' },
+ not_interested: { label: 'Not Interested', color: 'text-red-400', icon: 'x-circle' },
+ scheduled: { label: 'Scheduled', color: 'text-blue-400', icon: 'calendar' },
+};
+
+// --- Score Dimensions ---
+
+export const SCORE_DIMENSIONS: Record = {
+ eea: { label: 'EEA Score', description: 'Evidence of Exceptional Ability across USCIS criteria', icon: 'award' },
+ builder: { label: 'Builder Score', description: 'Shipping velocity, ownership signals, project completion', icon: 'hammer' },
+ ai_recency: { label: 'AI Recency', description: 'Recent work with frontier AI tools, models, and frameworks', icon: 'sparkles' },
+ systems_depth: { label: 'Systems Depth', description: 'Infrastructure, architecture, and low-level engineering', icon: 'layers' },
+ product_instinct: { label: 'Product Instinct', description: 'User-facing craft, design sense, product thinking', icon: 'lightbulb' },
+ hidden_gem: { label: 'Hidden Gem', description: 'High proof-to-visibility ratio, underrecognized talent', icon: 'gem' },
+};
+
+// --- Score Thresholds ---
+
+export const SCORE_THRESHOLDS = {
+ high: 80,
+ medium: 50,
+ low: 0,
+} as const;
+
+// --- Agent Run Types ---
+
+export const AGENT_RUN_TYPE_CONFIG: Record = {
+ search: { label: 'Search', icon: 'search' },
+ enrichment: { label: 'Enrichment', icon: 'database' },
+ scoring: { label: 'Scoring', icon: 'bar-chart-2' },
+ outreach_generation: { label: 'Outreach Generation', icon: 'mail' },
+ company_mapping: { label: 'Company Mapping', icon: 'building-2' },
+ monitor: { label: 'Monitor', icon: 'eye' },
+ dedup_ats_sync: { label: 'Dedup / ATS Sync', icon: 'git-merge' },
+};
+
+export const AGENT_RUN_STATUS_CONFIG: Record = {
+ running: { label: 'Running', color: 'text-blue-400', bgColor: 'bg-blue-500/15' },
+ complete: { label: 'Complete', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15' },
+ failed: { label: 'Failed', color: 'text-red-400', bgColor: 'bg-red-500/15' },
+ partial: { label: 'Partial', color: 'text-amber-400', bgColor: 'bg-amber-500/15' },
+};
+
+// --- Report Types ---
+
+export const REPORT_CONFIG: Record = {
+ candidate_volume_by_tier: { label: 'Candidate Volume by Tier', description: 'Tier distribution over time', icon: 'bar-chart-2' },
+ source_quality: { label: 'Source Quality', description: 'Which surfaces produce top-tier candidates', icon: 'target' },
+ artifact_recency: { label: 'Artifact Recency', description: 'Evidence freshness across the pipeline', icon: 'clock' },
+ response_rates: { label: 'Response Rates', description: 'Reply rates by sequence and tone', icon: 'mail-check' },
+ hidden_gem_yield: { label: 'Hidden Gem Yield', description: 'Hidden gem conversion vs. standard candidates', icon: 'gem' },
+ recruiter_throughput: { label: 'Recruiter Throughput', description: 'Candidates processed per week', icon: 'trending-up' },
+ ats_export_volume: { label: 'ATS Export Volume', description: 'Candidates pushed to ATS by role', icon: 'upload' },
+ stale_candidates: { label: 'Stale Candidates', description: 'Candidates with no activity in 14+ days', icon: 'alert-triangle' },
+};
+
+// --- Default Scoring Weights ---
+
+export const DEFAULT_SCORING_WEIGHTS = {
+ eea: 25,
+ builder: 20,
+ ai_recency: 20,
+ systems_depth: 15,
+ product_instinct: 10,
+ hidden_gem: 10,
+} as const;
+
+// --- Default Search Config ---
+
+export const DEFAULT_SEARCH_SOURCES = {
+ github: true,
+ linkedin: false,
+ web_blog: false,
+ huggingface: false,
+ conference_talks: false,
+ company_mapping: false,
+ exa_websets: false,
+} as const;
+
+export const DEFAULT_SEARCH_MODES = {
+ standard: true,
+ hidden_gem: false,
+ company_mapping: false,
+ artifact_led: false,
+} as const;
+
+// --- Artifact Types ---
+
+export const ARTIFACT_TYPE_CONFIG = {
+ repo: { label: 'Repository', icon: 'git-branch', color: 'text-emerald-400' },
+ paper: { label: 'Paper', icon: 'file-text', color: 'text-blue-400' },
+ blog_post: { label: 'Blog Post', icon: 'pen-tool', color: 'text-purple-400' },
+ talk: { label: 'Talk', icon: 'mic', color: 'text-amber-400' },
+ demo: { label: 'Demo', icon: 'play-circle', color: 'text-pink-400' },
+ model: { label: 'Model', icon: 'cpu', color: 'text-indigo-400' },
+ package: { label: 'Package', icon: 'package', color: 'text-teal-400' },
+ other: { label: 'Other', icon: 'link', color: 'text-zinc-400' },
+} as const;
+
+// --- Outreach Tones ---
+
+export const OUTREACH_TONES = [
+ { id: 'professional', label: 'Professional' },
+ { id: 'warm', label: 'Warm' },
+ { id: 'direct', label: 'Direct' },
+ { id: 'technical', label: 'Technical' },
+] as const;
+
+export const SEQUENCE_STEPS = [
+ { id: 'initial', label: 'Initial Outreach' },
+ { id: 'follow_up_1', label: 'Follow-up 1' },
+ { id: 'follow_up_2', label: 'Follow-up 2' },
+ { id: 'breakup', label: 'Breakup' },
+] as const;
+
+// --- Archetype Suggestions ---
+
+export const SUGGESTED_ARCHETYPES = [
+ 'ML infra builder',
+ 'Full-stack AI founder',
+ 'Systems generalist with AI pivot',
+ 'Applied ML engineer',
+ 'LLM fine-tuning specialist',
+ 'AI product engineer',
+ 'Data platform architect',
+ 'Robotics / embodied AI',
+ 'Research engineer (NLP)',
+ 'Multimodal ML engineer',
+ 'MLOps / ML platform',
+ 'Compiler / runtime engineer',
+] as const;
diff --git a/src/recruiter/lib/types.ts b/src/recruiter/lib/types.ts
new file mode 100644
index 0000000..445d888
--- /dev/null
+++ b/src/recruiter/lib/types.ts
@@ -0,0 +1,310 @@
+// SourceKit Recruiter OS — Core Type Definitions
+
+// --- Scoring ---
+
+export interface ScoreEvidence {
+ source: string; // e.g. "github", "linkedin", "blog", "huggingface"
+ artifact: string;
+ url?: string;
+ detail: string;
+}
+
+export interface DimensionScore {
+ score: number; // 0-100
+ evidence: ScoreEvidence[];
+ confidence: 'high' | 'medium' | 'low';
+ reason: string;
+}
+
+export interface RecruiterScores {
+ eea: DimensionScore;
+ builder: DimensionScore;
+ ai_recency: DimensionScore;
+ systems_depth: DimensionScore;
+ product_instinct: DimensionScore;
+ hidden_gem: DimensionScore;
+}
+
+export type ScoreDimension = keyof RecruiterScores;
+
+// --- Tiering ---
+
+export type CandidateTier =
+ | 'tier_1'
+ | 'tier_2'
+ | 'borderline'
+ | 'below_bar'
+ | 'false_positive'
+ | 'nurture'
+ | 'ats_synced'
+ | 'unclassified';
+
+export type PipelineStage =
+ | 'new'
+ | 'under_review'
+ | 'outreach_sent'
+ | 'replied'
+ | 'screening'
+ | 'moved_to_ats'
+ | 'hold'
+ | 'suppressed';
+
+export type ContactStatus =
+ | 'not_contacted'
+ | 'contacted'
+ | 'replied'
+ | 'not_interested'
+ | 'scheduled';
+
+export type ATSSyncStatus = 'not_synced' | 'synced' | 'sync_failed';
+
+export type OutreachPriority = 'high' | 'medium' | 'low';
+
+// --- Candidate ---
+
+export interface RecruiterCandidate {
+ id: string;
+ user_id: string;
+
+ // Identity
+ name: string | null;
+ avatar_url: string | null;
+ current_title: string | null;
+ current_company: string | null;
+ location: string | null;
+ bio: string | null;
+
+ // Links
+ github_username: string | null;
+ linkedin_url: string | null;
+ portfolio_url: string | null;
+ huggingface_url: string | null;
+ blog_url: string | null;
+ personal_site_url: string | null;
+ email: string | null;
+
+ // Scores
+ eea_score: DimensionScore | null;
+ builder_score: DimensionScore | null;
+ ai_recency_score: DimensionScore | null;
+ systems_depth_score: DimensionScore | null;
+ product_instinct_score: DimensionScore | null;
+ hidden_gem_score: DimensionScore | null;
+ composite_score: number | null;
+ outreach_priority: OutreachPriority;
+
+ // Classification
+ tier: CandidateTier;
+ pipeline_stage: PipelineStage;
+
+ // Artifacts & evidence
+ artifacts: CandidateArtifact[];
+ sourcing_rationale: string | null;
+ hidden_gem_reasons: string[];
+
+ // Contact
+ contact_status: ContactStatus;
+
+ // Meta
+ tags: string[];
+ needs_review: boolean;
+ search_ids: string[];
+ scorecard_id: string | null;
+ ats_sync_status: ATSSyncStatus;
+ ats_synced_at: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CandidateArtifact {
+ id: string;
+ type: 'repo' | 'paper' | 'blog_post' | 'talk' | 'demo' | 'model' | 'package' | 'other';
+ title: string;
+ url: string;
+ source: string;
+ date: string | null;
+ relevance: string;
+ description: string;
+}
+
+// --- Scorecard ---
+
+export interface ScorecardSignal {
+ id: string;
+ name: string;
+ description: string;
+ weight: number; // 0-100
+ evidence_type: string; // github, linkedin, publication, etc.
+}
+
+export interface ScoringWeights {
+ eea: number;
+ builder: number;
+ ai_recency: number;
+ systems_depth: number;
+ product_instinct: number;
+ hidden_gem: number;
+}
+
+export interface EvaluationQuestion {
+ id: string;
+ text: string;
+ dimension: string;
+ good_answer: string;
+ bad_answer: string;
+}
+
+export type ScorecardStatus = 'draft' | 'active' | 'archived';
+
+export interface RoleScorecard {
+ id: string;
+ user_id: string;
+ name: string;
+ status: ScorecardStatus;
+ talent_thesis: string | null;
+ must_have_signals: ScorecardSignal[];
+ nice_to_have_signals: ScorecardSignal[];
+ suppressions: string[];
+ scoring_weights: ScoringWeights;
+ outreach_tone: string;
+ evaluation_questions: EvaluationQuestion[];
+ created_at: string;
+ updated_at: string;
+}
+
+// --- Outreach ---
+
+export type OutreachStatus = 'draft' | 'ready' | 'personalize' | 'hold' | 'sent' | 'replied';
+export type OutreachChannel = 'email' | 'linkedin' | 'other';
+export type SequenceStep = 'initial' | 'follow_up_1' | 'follow_up_2' | 'breakup';
+
+export interface OutreachMessage {
+ id: string;
+ user_id: string;
+ candidate_id: string;
+ scorecard_id: string | null;
+ message: string;
+ grounding_artifacts: ScoreEvidence[];
+ tone: string;
+ sequence_step: SequenceStep;
+ channel: OutreachChannel;
+ status: OutreachStatus;
+ sent_at: string | null;
+ created_at: string;
+}
+
+// --- Agent Runs ---
+
+export type AgentRunType =
+ | 'search'
+ | 'enrichment'
+ | 'scoring'
+ | 'outreach_generation'
+ | 'company_mapping'
+ | 'monitor'
+ | 'dedup_ats_sync';
+
+export type AgentRunStatus = 'running' | 'complete' | 'failed' | 'partial';
+
+export interface AgentRun {
+ id: string;
+ user_id: string;
+ type: AgentRunType;
+ status: AgentRunStatus;
+ inputs: Record;
+ outputs: Record;
+ errors: Array<{ message: string; timestamp: string; context?: string }>;
+ candidate_ids: string[];
+ scorecard_id: string | null;
+ started_at: string;
+ completed_at: string | null;
+ duration_ms: number | null;
+}
+
+// --- Saved Search ---
+
+export interface SearchMode {
+ standard: boolean;
+ hidden_gem: boolean;
+ company_mapping: boolean;
+ artifact_led: boolean;
+}
+
+export interface SearchSource {
+ github: boolean;
+ linkedin: boolean;
+ web_blog: boolean;
+ huggingface: boolean;
+ conference_talks: boolean;
+ company_mapping: boolean;
+ exa_websets: boolean;
+}
+
+export interface RecruiterSearchConfig {
+ role_name: string;
+ role_brief: string;
+ scorecard_id: string | null;
+ archetypes: string[];
+ must_have_signals: ScorecardSignal[];
+ nice_to_have_signals: ScorecardSignal[];
+ sources: SearchSource;
+ recency_months: number;
+ location_preference: string;
+ seniority_band: string;
+ suppressions: string[];
+ score_floor: number;
+ modes: SearchMode;
+}
+
+export interface SavedSearch {
+ id: string;
+ user_id: string;
+ name: string;
+ scorecard_id: string | null;
+ config: RecruiterSearchConfig;
+ result_count: number;
+ last_run_at: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+// --- Sequence Templates ---
+
+export interface SequenceTemplateStep {
+ step: SequenceStep;
+ tone: string;
+ template: string;
+}
+
+export interface SequenceTemplate {
+ id: string;
+ user_id: string;
+ scorecard_id: string | null;
+ name: string;
+ steps: SequenceTemplateStep[];
+ created_at: string;
+ updated_at: string;
+}
+
+// --- Notes ---
+
+export interface CandidateNote {
+ id: string;
+ user_id: string;
+ candidate_id: string;
+ content: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// --- Reports ---
+
+export type ReportType =
+ | 'candidate_volume_by_tier'
+ | 'source_quality'
+ | 'artifact_recency'
+ | 'response_rates'
+ | 'hidden_gem_yield'
+ | 'recruiter_throughput'
+ | 'ats_export_volume'
+ | 'stale_candidates';
diff --git a/src/recruiter/pages/AgentRuns.tsx b/src/recruiter/pages/AgentRuns.tsx
new file mode 100644
index 0000000..e55de77
--- /dev/null
+++ b/src/recruiter/pages/AgentRuns.tsx
@@ -0,0 +1,365 @@
+// SourceKit Recruiter OS — Agent Runs Log
+
+import { useState } from 'react';
+import { useAgentRuns } from '../hooks/useAgentRuns';
+import PageHeader from '../components/PageHeader';
+import { RunStatusBadge } from '../components/StatusBadge';
+import EmptyState from '../components/EmptyState';
+import { AGENT_RUN_TYPE_CONFIG, AGENT_RUN_STATUS_CONFIG } from '../lib/constants';
+import type { AgentRunType, AgentRunStatus, AgentRun } from '../lib/types';
+import {
+ Bot,
+ Filter,
+ ChevronDown,
+ ChevronRight,
+ RefreshCw,
+ Clock,
+ AlertTriangle,
+} from 'lucide-react';
+
+// --- Formatters ---
+
+function formatDuration(ms: number | null): string {
+ if (ms === null || ms === undefined) return '--';
+ if (ms < 1000) return '< 1s';
+ const totalSec = Math.floor(ms / 1000);
+ if (totalSec < 60) return `${totalSec}s`;
+ const min = Math.floor(totalSec / 60);
+ const sec = totalSec % 60;
+ return `${min}m ${sec}s`;
+}
+
+function formatDateTime(date: string | null): string {
+ if (!date) return '--';
+ const d = new Date(date);
+ return d.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }) + ' ' + d.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function truncateJson(obj: Record, maxLen = 80): string {
+ const str = JSON.stringify(obj);
+ if (str.length <= maxLen) return str;
+ return str.slice(0, maxLen) + '...';
+}
+
+// --- Type filter options ---
+
+const ALL_TYPES = Object.entries(AGENT_RUN_TYPE_CONFIG) as Array<
+ [AgentRunType, { label: string; icon: string }]
+>;
+
+const ALL_STATUSES = Object.entries(AGENT_RUN_STATUS_CONFIG) as Array<
+ [AgentRunStatus, { label: string; color: string; bgColor: string }]
+>;
+
+export default function AgentRuns() {
+ const [typeFilter, setTypeFilter] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [expandedIds, setExpandedIds] = useState>(new Set());
+
+ const { data: runs, isLoading, dataUpdatedAt } = useAgentRuns({
+ type: typeFilter || undefined,
+ status: statusFilter || undefined,
+ });
+
+ const toggleExpand = (id: string) => {
+ setExpandedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const list = runs ?? [];
+
+ return (
+
+
+ {dataUpdatedAt > 0 && (
+
+
+ Auto-refreshing
+
+ )}
+
+ }
+ />
+
+ {/* Filter bar */}
+
+
+
+
+
+
+ {/* Content */}
+ {isLoading ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : list.length === 0 ? (
+ }
+ title="No agent runs yet"
+ description="Runs are created when you search, enrich, score, or generate outreach."
+ />
+ ) : (
+
+ {/* Table header */}
+
+
+ Type
+ Status
+ Started
+ Duration
+ Input Summary
+ Output Summary
+ Cands
+ Errors
+
+
+ {/* Rows */}
+ {list.map((run) => {
+ const isExpanded = expandedIds.has(run.id);
+ const typeCfg = AGENT_RUN_TYPE_CONFIG[run.type];
+ const errorCount = run.errors?.length ?? 0;
+
+ return (
+
+ {/* Row */}
+
toggleExpand(run.id)}
+ >
+
+ {isExpanded ? : }
+
+
+ {typeCfg?.label ?? run.type}
+
+
+
+
+
+ {formatDateTime(run.started_at)}
+
+
+ {formatDuration(run.duration_ms)}
+
+
+ {truncateJson(run.inputs ?? {})}
+
+
+ {truncateJson(run.outputs ?? {})}
+
+
+ {run.candidate_ids?.length ?? 0}
+
+ 0 ? '#f87171' : 'var(--ros-text-muted)' }}
+ >
+ {errorCount}
+
+
+
+ {/* Expanded detail */}
+ {isExpanded && (
+
+ {/* Input params */}
+
+
+ Input Parameters
+
+
+ {JSON.stringify(run.inputs, null, 2)}
+
+
+
+ {/* Output summary */}
+
+
+ Output Summary
+
+
+ {JSON.stringify(run.outputs, null, 2)}
+
+
+
+ {/* Errors */}
+ {errorCount > 0 && (
+
+
+
+ Errors ({errorCount})
+
+
+ {run.errors.map((err, i) => (
+
+ {err.message}
+ {err.timestamp && (
+
+ {formatDateTime(err.timestamp)}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Meta row */}
+
+
+ Affected candidates: {run.candidate_ids?.length ?? 0}
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/pages/CandidateIntelList.tsx b/src/recruiter/pages/CandidateIntelList.tsx
new file mode 100644
index 0000000..717b380
--- /dev/null
+++ b/src/recruiter/pages/CandidateIntelList.tsx
@@ -0,0 +1,456 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useRecruiterCandidates } from '../hooks/useRecruiterCandidates';
+import PageHeader from '../components/PageHeader';
+import TierBadge from '../components/TierBadge';
+import { StageBadge, ContactBadge, PriorityBadge } from '../components/StatusBadge';
+import EmptyState from '../components/EmptyState';
+import { getScoreColor } from '../services/scoring';
+import { TIER_CONFIG, STAGE_CONFIG } from '../lib/constants';
+import { candidatesToCSV, downloadCSV } from '../services/export';
+import type { CandidateTier, PipelineStage, RecruiterCandidate } from '../lib/types';
+import {
+ Users,
+ Search,
+ Filter,
+ Download,
+ ChevronUp,
+ ChevronDown,
+ X,
+} from 'lucide-react';
+
+// --- Sorting ---
+
+type SortField = 'composite_score' | 'eea_score' | 'builder_score' | 'ai_recency_score';
+type SortDir = 'asc' | 'desc';
+
+function getSortValue(c: RecruiterCandidate, field: SortField): number {
+ if (field === 'composite_score') return c.composite_score ?? 0;
+ if (field === 'eea_score') return c.eea_score?.score ?? 0;
+ if (field === 'builder_score') return c.builder_score?.score ?? 0;
+ if (field === 'ai_recency_score') return c.ai_recency_score?.score ?? 0;
+ return 0;
+}
+
+function compareCandidates(a: RecruiterCandidate, b: RecruiterCandidate, field: SortField, dir: SortDir): number {
+ const av = getSortValue(a, field);
+ const bv = getSortValue(b, field);
+ return dir === 'asc' ? av - bv : bv - av;
+}
+
+// --- Component ---
+
+export default function CandidateIntelList() {
+ const navigate = useNavigate();
+
+ // Filters
+ const [searchText, setSearchText] = useState('');
+ const [tierFilter, setTierFilter] = useState('');
+ const [stageFilter, setStageFilter] = useState('');
+
+ // Sorting
+ const [sortField, setSortField] = useState('composite_score');
+ const [sortDir, setSortDir] = useState('desc');
+
+ // Selection
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+
+ const { data: candidates, isLoading } = useRecruiterCandidates({
+ tier: tierFilter || undefined,
+ stage: stageFilter || undefined,
+ });
+
+ // Client-side text filter
+ const filtered = (candidates ?? []).filter((c) => {
+ if (!searchText) return true;
+ const q = searchText.toLowerCase();
+ return (
+ (c.name ?? '').toLowerCase().includes(q) ||
+ (c.current_title ?? '').toLowerCase().includes(q) ||
+ (c.current_company ?? '').toLowerCase().includes(q) ||
+ c.tags.some((t) => t.toLowerCase().includes(q))
+ );
+ });
+
+ // Sort
+ const sorted = [...filtered].sort((a, b) => compareCandidates(a, b, sortField, sortDir));
+
+ const hasFilters = searchText || tierFilter || stageFilter;
+
+ function clearFilters() {
+ setSearchText('');
+ setTierFilter('');
+ setStageFilter('');
+ }
+
+ function toggleSort(field: SortField) {
+ if (sortField === field) {
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortField(field);
+ setSortDir('desc');
+ }
+ }
+
+ function toggleSelect(id: string) {
+ setSelectedIds((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ function toggleSelectAll() {
+ if (selectedIds.size === sorted.length) {
+ setSelectedIds(new Set());
+ } else {
+ setSelectedIds(new Set(sorted.map((c) => c.id)));
+ }
+ }
+
+ function handleExportAll() {
+ if (!candidates?.length) return;
+ const csv = candidatesToCSV(candidates);
+ downloadCSV(csv, 'candidates-all.csv');
+ }
+
+ function handleExportSelected() {
+ const selected = sorted.filter((c) => selectedIds.has(c.id));
+ if (!selected.length) return;
+ const csv = candidatesToCSV(selected);
+ downloadCSV(csv, 'candidates-selected.csv');
+ }
+
+ // Sort header helper
+ function SortHeader({ label, field }: { label: string; field: SortField }) {
+ const active = sortField === field;
+ return (
+
+ );
+ }
+
+ // Score cell helper
+ function ScoreCell({ value }: { value: number | null | undefined }) {
+ const v = value ?? 0;
+ return (
+
+ {v}
+
+ );
+ }
+
+ // --- Skeleton ---
+ function SkeletonRows() {
+ return (
+ <>
+ {[1, 2, 3].map((i) => (
+
+ {Array.from({ length: 10 }).map((_, j) => (
+ |
+
+ |
+ ))}
+
+ ))}
+ >
+ );
+ }
+
+ return (
+
+
+
+ {filtered.length} candidate{filtered.length !== 1 ? 's' : ''}
+
+
+
+ }
+ />
+
+ {/* Sticky filter bar */}
+
+
+
+ setSearchText(e.target.value)}
+ placeholder="Search by name, title, company, or tag..."
+ className="w-full pl-7 pr-2 py-1.5 text-xs rounded border outline-none transition-colors"
+ style={{
+ background: 'var(--ros-bg-secondary)',
+ borderColor: 'var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ {hasFilters && (
+
+ )}
+
+
+ {/* Batch action bar */}
+ {selectedIds.size > 0 && (
+
+
+ {selectedIds.size} selected
+
+
+
+ )}
+
+ {/* Table */}
+ {!isLoading && sorted.length === 0 ? (
+ }
+ title="No candidates found"
+ description="No candidates found. Run a search to populate your pipeline."
+ />
+ ) : (
+
+
+
+
+ |
+ 0 && selectedIds.size === sorted.length}
+ onChange={toggleSelectAll}
+ className="rounded"
+ />
+ |
+
+
+ Name
+
+ |
+
+
+ Tier
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ Contact
+
+ |
+
+
+ Priority
+
+ |
+
+
+ Tags
+
+ |
+
+
+
+ {isLoading ? (
+
+ ) : (
+ sorted.map((c) => (
+ navigate(`/recruiter/candidates/${c.id}`)}
+ onMouseEnter={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'var(--ros-bg-hover)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLElement).style.background = 'transparent';
+ }}
+ >
+ | e.stopPropagation()}>
+ toggleSelect(c.id)}
+ className="rounded"
+ />
+ |
+
+
+
+ {(c.name ?? '?')[0].toUpperCase()}
+
+
+
+ {c.name ?? 'Unknown'}
+
+ {c.current_company && (
+
+ {c.current_company}
+
+ )}
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ {c.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+ {c.tags.length > 3 && (
+
+ +{c.tags.length - 3}
+
+ )}
+
+ |
+
+ ))
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/pages/CandidateIntelProfile.tsx b/src/recruiter/pages/CandidateIntelProfile.tsx
new file mode 100644
index 0000000..6601f3f
--- /dev/null
+++ b/src/recruiter/pages/CandidateIntelProfile.tsx
@@ -0,0 +1,662 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useRecruiterCandidate, useUpdateCandidate } from '../hooks/useRecruiterCandidates';
+import { useRecruiterOutreach } from '../hooks/useRecruiterOutreach';
+import { useRecruiterNotes, useCreateNote } from '../hooks/useRecruiterNotes';
+import ScoreCard from '../components/ScoreCard';
+import TierBadge from '../components/TierBadge';
+import { StageBadge, ContactBadge, ATSBadge, PriorityBadge } from '../components/StatusBadge';
+import PageHeader from '../components/PageHeader';
+import { TIER_CONFIG, SCORE_DIMENSIONS, ARTIFACT_TYPE_CONFIG } from '../lib/constants';
+import type { CandidateTier, ScoreDimension } from '../lib/types';
+import {
+ ArrowLeft,
+ Github,
+ Linkedin,
+ Globe,
+ Mail,
+ Send,
+ Tag,
+ ExternalLink,
+ Plus,
+ FileText,
+ Clock,
+ MessageSquare,
+ Activity,
+} from 'lucide-react';
+
+type Tab = 'evidence' | 'profile' | 'sourcing' | 'outreach' | 'notes';
+
+const TABS: { id: Tab; label: string }[] = [
+ { id: 'evidence', label: 'Evidence' },
+ { id: 'profile', label: 'Profile' },
+ { id: 'sourcing', label: 'Sourcing' },
+ { id: 'outreach', label: 'Outreach' },
+ { id: 'notes', label: 'Notes' },
+];
+
+export default function CandidateIntelProfile() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const { data: candidate, isLoading, error } = useRecruiterCandidate(id);
+ const updateCandidate = useUpdateCandidate();
+ const { data: outreachMessages } = useRecruiterOutreach(id);
+ const { data: notes } = useRecruiterNotes(id);
+ const createNote = useCreateNote();
+
+ const [activeTab, setActiveTab] = useState('evidence');
+ const [showTierDropdown, setShowTierDropdown] = useState(false);
+ const [tagInput, setTagInput] = useState('');
+ const [noteText, setNoteText] = useState('');
+
+ // --- Loading skeleton ---
+ if (isLoading) {
+ return (
+
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ // --- Missing candidate ---
+ if (!candidate || error) {
+ return (
+
+
+
+
+ Candidate not found. It may have been removed or you may not have access.
+
+
+
+ );
+ }
+
+ // --- Handlers ---
+
+ function handleTierChange(tier: CandidateTier) {
+ updateCandidate.mutate({ id: candidate!.id, updates: { tier } });
+ setShowTierDropdown(false);
+ }
+
+ function handleAddTag() {
+ const tag = tagInput.trim();
+ if (!tag || candidate!.tags.includes(tag)) {
+ setTagInput('');
+ return;
+ }
+ updateCandidate.mutate({
+ id: candidate!.id,
+ updates: { tags: [...candidate!.tags, tag] },
+ });
+ setTagInput('');
+ }
+
+ function handleSaveNote() {
+ const content = noteText.trim();
+ if (!content) return;
+ createNote.mutate({ candidateId: candidate!.id, content });
+ setNoteText('');
+ }
+
+ // --- Links ---
+ const links = [
+ { url: candidate.github_username ? `https://github.com/${candidate.github_username}` : null, icon: Github, label: 'GitHub' },
+ { url: candidate.linkedin_url, icon: Linkedin, label: 'LinkedIn' },
+ { url: candidate.portfolio_url, icon: Globe, label: 'Portfolio' },
+ { url: candidate.blog_url, icon: FileText, label: 'Blog' },
+ { url: candidate.personal_site_url, icon: Globe, label: 'Site' },
+ ].filter((l) => l.url);
+
+ // --- Score dimensions ---
+ const scoreDimensions: { dimension: ScoreDimension; score: typeof candidate.eea_score }[] = [
+ { dimension: 'eea', score: candidate.eea_score },
+ { dimension: 'builder', score: candidate.builder_score },
+ { dimension: 'ai_recency', score: candidate.ai_recency_score },
+ { dimension: 'systems_depth', score: candidate.systems_depth_score },
+ { dimension: 'product_instinct', score: candidate.product_instinct_score },
+ { dimension: 'hidden_gem', score: candidate.hidden_gem_score },
+ ];
+
+ return (
+
+ {/* 1. Back + breadcrumb */}
+
+
+
+ Candidate Intel
+
+
/
+
+ {candidate.name ?? 'Unknown'}
+
+
+
+ {/* 2. Header bar */}
+
+
+ {/* Avatar */}
+
+ {(candidate.name ?? '?')[0].toUpperCase()}
+
+
+
+
+
+ {candidate.name ?? 'Unknown'}
+
+
+
+ {(candidate.current_title || candidate.current_company) && (
+
+ {candidate.current_title}
+ {candidate.current_title && candidate.current_company ? ' at ' : ''}
+ {candidate.current_company}
+
+ )}
+
+
+
+ {/* Quick actions row */}
+
+
+
+ {/* Tier dropdown */}
+
+
+ {showTierDropdown && (
+
+ {(Object.keys(TIER_CONFIG) as CandidateTier[]).map((t) => (
+
+ ))}
+
+ )}
+
+
+ {/* Add tag */}
+
+
+
setTagInput(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAddTag()}
+ placeholder="Add tag..."
+ className="text-xs px-2 py-1 rounded border outline-none w-24"
+ style={{
+ background: 'var(--ros-bg-secondary)',
+ borderColor: 'var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
+
+
+
+
+
+ {/* Links row */}
+ {links.length > 0 && (
+
+ )}
+
+
+ {/* 3. Score panel */}
+
+ {scoreDimensions.map(({ dimension, score }) => (
+
+
+
+ ))}
+
+
+ Outreach Priority
+
+
+
+
+
+ {/* 4. Tabbed content */}
+
+ {/* Tab bar */}
+
+ {TABS.map((tab) => (
+
+ ))}
+
+
+ {/* Tab content */}
+
+ {/* Evidence tab */}
+ {activeTab === 'evidence' && (
+
+
+ Strongest Artifacts
+
+ {candidate.artifacts.length === 0 ? (
+
+ No artifacts indexed yet. Run enrichment to surface this candidate's work.
+
+ ) : (
+ candidate.artifacts.map((artifact) => {
+ const artConfig = ARTIFACT_TYPE_CONFIG[artifact.type];
+ return (
+
+
+
+ {artifact.source}
+ {artifact.date && (
+ <>
+ -
+ {artifact.date}
+ >
+ )}
+
+ {artifact.relevance}
+
+
+
+ {artifact.description}
+
+
+ );
+ })
+ )}
+
+ )}
+
+ {/* Profile tab */}
+ {activeTab === 'profile' && (
+
+ {candidate.bio && (
+
+
+ Bio
+
+
+ {candidate.bio}
+
+
+ )}
+ {candidate.location && (
+
+
+ Location
+
+
+ {candidate.location}
+
+
+ )}
+
+
+ Skills / Tags
+
+ {candidate.tags.length > 0 ? (
+
+ {candidate.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : (
+
No tags.
+ )}
+
+
+
+ Links
+
+ {links.length > 0 ? (
+
+ {links.map((link) => {
+ const Icon = link.icon;
+ return (
+
+
+ {link.url}
+
+ );
+ })}
+
+ ) : (
+
No links available.
+ )}
+
+
+ )}
+
+ {/* Sourcing tab */}
+ {activeTab === 'sourcing' && (
+
+
+
+ Sourcing Rationale
+
+
+ {candidate.sourcing_rationale ?? 'No sourcing rationale recorded.'}
+
+
+
+
+ Hidden Gem Reasons
+
+ {candidate.hidden_gem_reasons.length > 0 ? (
+
+ {candidate.hidden_gem_reasons.map((reason, i) => (
+ -
+ -
+ {reason}
+
+ ))}
+
+ ) : (
+
None identified.
+ )}
+
+
+
+ Search IDs
+
+ {candidate.search_ids.length > 0 ? (
+
+ {candidate.search_ids.map((sid) => (
+
+ {sid}
+
+ ))}
+
+ ) : (
+
No searches linked.
+ )}
+
+
+ )}
+
+ {/* Outreach tab */}
+ {activeTab === 'outreach' && (
+
+
+
+ Outreach Messages
+
+
+
+ {(!outreachMessages || outreachMessages.length === 0) ? (
+
+ No outreach generated. Draft a message grounded in this candidate's evidence.
+
+ ) : (
+ outreachMessages.map((msg) => (
+
+
+
+ {msg.channel}
+
+
+ {msg.tone}
+
+
+
+ {msg.status}
+
+
+
+ {new Date(msg.created_at).toLocaleDateString()}
+
+
+
+ {msg.message}
+
+
+ ))
+ )}
+
+ )}
+
+ {/* Notes tab */}
+ {activeTab === 'notes' && (
+
+
+
+
+ {(!notes || notes.length === 0) ? (
+
+ No notes yet.
+
+ ) : (
+
+ {notes.map((note) => (
+
+
+ {note.content}
+
+
+
+ {new Date(note.created_at).toLocaleString()}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/CommandCenter.tsx b/src/recruiter/pages/CommandCenter.tsx
new file mode 100644
index 0000000..6ff3a16
--- /dev/null
+++ b/src/recruiter/pages/CommandCenter.tsx
@@ -0,0 +1,433 @@
+// SourceKit Recruiter OS — Command Center (Home Screen)
+
+import { useNavigate } from 'react-router-dom';
+import {
+ Search,
+ Users,
+ Star,
+ Gem,
+ Mail,
+ Database,
+ AlertCircle,
+ ChevronRight,
+ Plus,
+ FileText,
+ Send,
+ Download,
+ Activity,
+ ClipboardList,
+} from 'lucide-react';
+import { useRecruiterPipelineStats, useRecruiterCandidates } from '../hooks/useRecruiterCandidates';
+import { useAgentRuns } from '../hooks/useAgentRuns';
+import { useRecruiterScorecards } from '../hooks/useRecruiterScorecard';
+import PageHeader from '../components/PageHeader';
+import StatCard from '../components/StatCard';
+import TierBadge from '../components/TierBadge';
+import { RunStatusBadge } from '../components/StatusBadge';
+import EmptyState from '../components/EmptyState';
+import { AGENT_RUN_TYPE_CONFIG, TIER_CONFIG } from '../lib/constants';
+import type { CandidateTier } from '../lib/types';
+
+function timeAgo(date: string): string {
+ const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
+ if (seconds < 60) return 'just now';
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+}
+
+function formatDate(date: string): string {
+ return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+function SkeletonBlock({ className = '' }: { className?: string }) {
+ return (
+
+ );
+}
+
+const TIER_BAR_COLORS: Record = {
+ tier_1: '#34d399',
+ tier_2: '#818cf8',
+ borderline: '#fbbf24',
+ below_bar: '#f87171',
+};
+
+export default function CommandCenter() {
+ const navigate = useNavigate();
+ const { data: stats, isLoading: statsLoading } = useRecruiterPipelineStats();
+ const { data: reviewCandidates, isLoading: reviewLoading } = useRecruiterCandidates({ needs_review: true });
+ const { data: agentRuns, isLoading: runsLoading } = useAgentRuns({ limit: 5 });
+ const { data: scorecards, isLoading: scorecardsLoading } = useRecruiterScorecards();
+
+ const reviewQueue = (reviewCandidates ?? []).slice(0, 10);
+ const topScorecards = (scorecards ?? []).slice(0, 5);
+
+ return (
+
+
+
+ {/* Stats Row */}
+
+ {statsLoading ? (
+ Array.from({ length: 8 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Two-column grid */}
+
+ {/* Left: Review Queue */}
+
+
+
+
+ Review Queue
+
+
+ {reviewQueue.length}
+
+
+
+
+ {reviewLoading ? (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ ) : reviewQueue.length === 0 ? (
+
}
+ title="All clear"
+ description="No candidates awaiting review."
+ />
+ ) : (
+
+ {reviewQueue.map((candidate) => {
+ const initial = (candidate.name ?? 'U')[0].toUpperCase();
+ return (
+
{
+ (e.currentTarget as HTMLDivElement).style.background = 'var(--ros-bg-hover)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
+ }}
+ onClick={() => navigate(`/recruiter/candidates/${candidate.id}`)}
+ >
+ {/* Avatar */}
+
+ {initial}
+
+
+ {/* Name & title */}
+
+
+ {candidate.name ?? 'Unknown'}
+
+
+ {candidate.current_title ?? 'No title'}
+
+
+
+ {/* Tier badge */}
+
+
+ {/* Review button */}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Right column */}
+
+ {/* Recent Agent Runs */}
+
+
+
+ Recent Agent Runs
+
+
+
+
+ {runsLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : (agentRuns ?? []).length === 0 ? (
+
}
+ title="No runs"
+ description="No agent runs yet."
+ />
+ ) : (
+
+ {(agentRuns ?? []).map((run) => {
+ const typeConfig = AGENT_RUN_TYPE_CONFIG[run.type];
+ return (
+
+
+ {typeConfig?.label ?? run.type}
+
+
+
+ {timeAgo(run.started_at)}
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Top Role Scorecards */}
+
+
+
+ Role Scorecards
+
+
+
+
+ {scorecardsLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : topScorecards.length === 0 ? (
+
}
+ title="No scorecards"
+ description="No scorecards created."
+ />
+ ) : (
+
+ {topScorecards.map((sc) => {
+ const statusColors: Record
= {
+ draft: { color: 'text-zinc-400', bg: 'bg-zinc-500/15' },
+ active: { color: 'text-emerald-400', bg: 'bg-emerald-500/15' },
+ archived: { color: 'text-amber-400', bg: 'bg-amber-500/15' },
+ };
+ const st = statusColors[sc.status] ?? statusColors.draft;
+ return (
+ {
+ (e.currentTarget as HTMLDivElement).style.background = 'var(--ros-bg-hover)';
+ }}
+ onMouseLeave={(e) => {
+ (e.currentTarget as HTMLDivElement).style.background = 'transparent';
+ }}
+ onClick={() => navigate(`/recruiter/scorecards`)}
+ >
+
+ {sc.name}
+
+
+ {sc.status}
+
+
+ {formatDate(sc.created_at)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Pipeline Snapshot */}
+ {stats && stats.total > 0 && (
+
+
+ Pipeline Snapshot
+
+
+ {(
+ [
+ { key: 'tier_1' as CandidateTier, count: stats.tier_1 },
+ { key: 'tier_2' as CandidateTier, count: stats.tier_2 },
+ { key: 'borderline' as CandidateTier, count: stats.borderline },
+ { key: 'below_bar' as CandidateTier, count: stats.below_bar },
+ ] as const
+ )
+ .filter((seg) => seg.count > 0)
+ .map((seg) => {
+ const pct = (seg.count / stats.total) * 100;
+ return (
+
0 ? '24px' : '0',
+ }}
+ title={`${TIER_CONFIG[seg.key].label}: ${seg.count}`}
+ >
+ {pct >= 8 ? seg.count : ''}
+
+ );
+ })}
+
+
+ {(
+ [
+ { key: 'tier_1' as CandidateTier, count: stats.tier_1 },
+ { key: 'tier_2' as CandidateTier, count: stats.tier_2 },
+ { key: 'borderline' as CandidateTier, count: stats.borderline },
+ { key: 'below_bar' as CandidateTier, count: stats.below_bar },
+ ] as const
+ )
+ .filter((seg) => seg.count > 0)
+ .map((seg) => (
+
+
+
+ {TIER_CONFIG[seg.key].label} ({seg.count})
+
+
+ ))}
+
+
+ )}
+
+ {/* Quick Actions */}
+
+
+ Quick Actions
+
+
+ {[
+ { label: 'New Search', icon:
, to: '/recruiter/search' },
+ { label: 'Create Scorecard', icon:
, to: '/recruiter/scorecards' },
+ { label: 'Generate Outreach', icon:
, to: '/recruiter/outreach' },
+ { label: 'Export Pipeline', icon:
, to: null },
+ ].map((action) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/OutreachStudio.tsx b/src/recruiter/pages/OutreachStudio.tsx
new file mode 100644
index 0000000..3eba4db
--- /dev/null
+++ b/src/recruiter/pages/OutreachStudio.tsx
@@ -0,0 +1,895 @@
+// SourceKit Recruiter OS — Outreach Studio
+
+import { useState } from 'react';
+import { useRecruiterCandidates } from '../hooks/useRecruiterCandidates';
+import {
+ useRecruiterOutreach,
+ useCreateOutreach,
+ useUpdateOutreach,
+} from '../hooks/useRecruiterOutreach';
+import PageHeader from '../components/PageHeader';
+import TierBadge from '../components/TierBadge';
+import { PriorityBadge } from '../components/StatusBadge';
+import EmptyState from '../components/EmptyState';
+import { OUTREACH_TONES, SEQUENCE_STEPS } from '../lib/constants';
+import type { OutreachMessage, RecruiterCandidate } from '../lib/types';
+import {
+ Mail,
+ Send,
+ Pause,
+ Copy,
+ Edit3,
+ ChevronRight,
+ Sparkles,
+ FileText,
+ User,
+ X,
+ Check,
+ Clock,
+} from 'lucide-react';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const STATUS_COLORS: Record = {
+ draft: '#a1a1aa',
+ ready: '#34d399',
+ personalize: '#818cf8',
+ hold: '#fbbf24',
+ sent: '#60a5fa',
+ replied: '#34d399',
+};
+
+function formatDate(date: string): string {
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+function truncateMsg(msg: string, max = 80): string {
+ return msg.length > max ? msg.slice(0, max) + '...' : msg;
+}
+
+function buildDraftMessage(c: RecruiterCandidate): string {
+ const name = c.name ?? '[Name]';
+ const artifacts =
+ c.artifacts.length > 0
+ ? c.artifacts
+ .slice(0, 2)
+ .map((a) => a.title)
+ .join(' and ')
+ : '[notable projects]';
+ const reason =
+ c.sourcing_rationale ?? 'your technical depth and shipping velocity';
+ const role = c.current_title ?? '[this role]';
+ const company = c.current_company ?? '[our company]';
+ const skills =
+ c.tags.length > 0 ? c.tags.slice(0, 3).join(', ') : '[relevant skills]';
+
+ return `Hi ${name},
+
+I came across your work on ${artifacts} and was impressed by ${reason}.
+
+We're building ${role} at ${company} and your background in ${skills} stands out.
+
+Would you be open to a brief conversation?
+
+Best,
+[Your name]`;
+}
+
+const SCORE_DIMENSIONS: {
+ key: string;
+ label: string;
+ field: keyof RecruiterCandidate;
+}[] = [
+ { key: 'eea', label: 'EEA', field: 'eea_score' },
+ { key: 'builder', label: 'Builder', field: 'builder_score' },
+ { key: 'ai_recency', label: 'AI Recency', field: 'ai_recency_score' },
+ { key: 'systems', label: 'Systems', field: 'systems_depth_score' },
+ { key: 'product', label: 'Product', field: 'product_instinct_score' },
+ { key: 'gem', label: 'Hidden Gem', field: 'hidden_gem_score' },
+];
+
+function scoreColor(v: number): string {
+ if (v >= 80) return '#34d399';
+ if (v >= 50) return '#fbbf24';
+ return '#f87171';
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export default function OutreachStudio() {
+ const { data: allCandidates = [], isLoading: candidatesLoading } =
+ useRecruiterCandidates();
+ const createOutreach = useCreateOutreach();
+ const updateOutreach = useUpdateOutreach();
+
+ // State
+ const [selectedId, setSelectedId] = useState(null);
+ const [queueFilter, setQueueFilter] = useState<
+ 'all' | 'draft' | 'ready' | 'hold'
+ >('all');
+ const [tone, setTone] = useState(OUTREACH_TONES[0].id);
+ const [sequenceStep, setSequenceStep] = useState(SEQUENCE_STEPS[0].id);
+ const [message, setMessage] = useState('');
+ const [evidenceOpen, setEvidenceOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ // Queue: high/medium priority or outreach_sent stage
+ const queue = allCandidates.filter(
+ (c) =>
+ c.outreach_priority === 'high' ||
+ c.outreach_priority === 'medium' ||
+ c.pipeline_stage === 'outreach_sent',
+ );
+
+ const selectedCandidate = selectedId
+ ? allCandidates.find((c) => c.id === selectedId) ?? null
+ : null;
+
+ // Outreach history for selected candidate
+ const { data: outreachHistory = [] } = useRecruiterOutreach(
+ selectedCandidate?.id,
+ );
+
+ // Filter queue for left column status filter
+ const filteredQueue =
+ queueFilter === 'all'
+ ? queue
+ : queue.filter((c) => {
+ const latest = outreachHistory.find(
+ (o) => o.candidate_id === c.id,
+ );
+ if (queueFilter === 'draft')
+ return !latest || latest.status === 'draft';
+ if (queueFilter === 'ready') return latest?.status === 'ready';
+ if (queueFilter === 'hold') return latest?.status === 'hold';
+ return true;
+ });
+
+ // ---------------------------------------------------------------------------
+ // Actions
+ // ---------------------------------------------------------------------------
+
+ function selectCandidate(c: RecruiterCandidate) {
+ setSelectedId(c.id);
+ setMessage('');
+ setEvidenceOpen(false);
+ setCopied(false);
+ }
+
+ function handleDraftFromEvidence() {
+ if (!selectedCandidate) return;
+ setMessage(buildDraftMessage(selectedCandidate));
+ }
+
+ function handleSendNow() {
+ if (!selectedCandidate || !message.trim()) return;
+ createOutreach.mutate({
+ candidate_id: selectedCandidate.id,
+ message,
+ tone,
+ sequence_step: sequenceStep as OutreachMessage['sequence_step'],
+ channel: 'email',
+ status: 'sent',
+ sent_at: new Date().toISOString(),
+ });
+ setMessage('');
+ }
+
+ function handlePersonalize() {
+ if (!selectedCandidate || !message.trim()) return;
+ createOutreach.mutate({
+ candidate_id: selectedCandidate.id,
+ message,
+ tone,
+ sequence_step: sequenceStep as OutreachMessage['sequence_step'],
+ channel: 'email',
+ status: 'personalize',
+ });
+ }
+
+ function handleHold() {
+ if (!selectedCandidate || !message.trim()) return;
+ createOutreach.mutate({
+ candidate_id: selectedCandidate.id,
+ message,
+ tone,
+ sequence_step: sequenceStep as OutreachMessage['sequence_step'],
+ channel: 'email',
+ status: 'hold',
+ });
+ }
+
+ function handleCopy() {
+ if (!message.trim()) return;
+ navigator.clipboard.writeText(message);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Style helpers
+ // ---------------------------------------------------------------------------
+
+ const colStyle = (flex: string): React.CSSProperties => ({
+ flex,
+ minWidth: 0,
+ });
+
+ const panelStyle: React.CSSProperties = {
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ borderRadius: 10,
+ };
+
+ const btnBase: React.CSSProperties = {
+ border: 'none',
+ cursor: 'pointer',
+ borderRadius: 6,
+ fontSize: 12,
+ fontWeight: 500,
+ padding: '6px 14px',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 6,
+ };
+
+ // ---------------------------------------------------------------------------
+ // Render
+ // ---------------------------------------------------------------------------
+
+ return (
+
+
+
+
+ {/* ---- LEFT: Candidate Queue ---- */}
+
+
+
+ Queue
+
+
+ {queue.length}
+
+
+
+ {/* Filter tabs */}
+
+ {(['all', 'draft', 'ready', 'hold'] as const).map((f) => (
+
+ ))}
+
+
+ {/* Queue list */}
+
+ {candidatesLoading ? (
+
+ Loading...
+
+ ) : filteredQueue.length === 0 ? (
+
+ }
+ title="No candidates queued"
+ description="Add candidates from Search Lab or Team Pipeline."
+ />
+
+ ) : (
+ filteredQueue.map((c) => {
+ const isActive = selectedId === c.id;
+ return (
+
selectCandidate(c)}
+ >
+
+ {c.name ?? 'Unknown'}
+
+
+
+
+ {c.pipeline_stage === 'outreach_sent' && (
+
+ sent
+
+ )}
+
+
+ );
+ })
+ )}
+
+
+
+ {/* ---- CENTER: Message Editor ---- */}
+
+ {!selectedCandidate ? (
+
+ }
+ title="Select a candidate"
+ description="Select a candidate from the queue to draft outreach."
+ />
+
+ ) : (
+
+ {/* Header */}
+
+
+ {selectedCandidate.name ?? 'Unknown'}
+
+
+
+
+
+ {/* Tone selector */}
+
+
+ Tone
+
+
+ {OUTREACH_TONES.map((t) => (
+
+ ))}
+
+
+
+ {/* Sequence step */}
+
+
+ Sequence Step
+
+
+
+
+ {/* Draft from evidence */}
+
+
+ {/* Textarea */}
+
+
+ {/* Outreach history */}
+
+
+ Outreach History
+
+ {outreachHistory.length === 0 ? (
+
+ No previous outreach for this candidate.
+
+ ) : (
+
+ {outreachHistory.map((o) => (
+
+
+
+ {truncateMsg(o.message)}
+
+
+ {o.tone}
+
+
+ {o.channel}
+
+
+ {formatDate(o.created_at)}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+ {/* ---- RIGHT: Candidate Evidence ---- */}
+
+ {!selectedCandidate ? (
+
+ ) : (
+
+ {/* Identity */}
+
+
+ {selectedCandidate.name ?? 'Unknown'}
+
+
+ {selectedCandidate.current_title ?? ''}
+ {selectedCandidate.current_company
+ ? ` @ ${selectedCandidate.current_company}`
+ : ''}
+
+
+
+ {/* Score summary */}
+
+
+ Scores
+
+
+ {SCORE_DIMENSIONS.map(({ key, label, field }) => {
+ const dimScore = selectedCandidate[field] as {
+ score: number;
+ } | null;
+ const val = dimScore?.score ?? 0;
+ return (
+
+
+ {label}
+
+
+ {val}
+
+
+ );
+ })}
+
+
+
+ {/* Artifacts */}
+
+
+ Top Artifacts
+
+ {selectedCandidate.artifacts.length === 0 ? (
+
+ No artifacts found.
+
+ ) : (
+
+ {selectedCandidate.artifacts.slice(0, 5).map((a) => (
+
+
+
+
+ {a.title}
+
+
+
+ {a.source}
+
+ {a.date && (
+
+ {formatDate(a.date)}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Links */}
+
+
+ Links
+
+
+ {selectedCandidate.github_username && (
+
+ GitHub
+
+ )}
+ {selectedCandidate.linkedin_url && (
+
+ LinkedIn
+
+ )}
+ {selectedCandidate.portfolio_url && (
+
+ Portfolio
+
+ )}
+ {!selectedCandidate.github_username &&
+ !selectedCandidate.linkedin_url &&
+ !selectedCandidate.portfolio_url && (
+
+ No links available.
+
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/RecruiterSettings.tsx b/src/recruiter/pages/RecruiterSettings.tsx
new file mode 100644
index 0000000..7424023
--- /dev/null
+++ b/src/recruiter/pages/RecruiterSettings.tsx
@@ -0,0 +1,283 @@
+import { useState, useEffect } from 'react';
+import { Settings, Key, Bell, Users, Database, Palette, Save, Eye, EyeOff } from 'lucide-react';
+import PageHeader from '../components/PageHeader';
+import { supabase } from '@/integrations/supabase/client';
+import { DEFAULT_SCORING_WEIGHTS } from '../lib/constants';
+import type { ScoringWeights } from '../lib/types';
+
+interface SettingsSection {
+ id: string;
+ label: string;
+ icon: React.ElementType;
+}
+
+const SECTIONS: SettingsSection[] = [
+ { id: 'ats', label: 'ATS Integration', icon: Database },
+ { id: 'api_keys', label: 'API Keys', icon: Key },
+ { id: 'notifications', label: 'Notifications', icon: Bell },
+ { id: 'scoring', label: 'Default Scoring', icon: Settings },
+ { id: 'team', label: 'Team', icon: Users },
+ { id: 'appearance', label: 'Appearance', icon: Palette },
+];
+
+export default function RecruiterSettings() {
+ const [activeSection, setActiveSection] = useState('ats');
+ const [saved, setSaved] = useState(false);
+
+ // ATS settings
+ const [atsWebhookUrl, setAtsWebhookUrl] = useState('');
+ const [atsFieldMapping, setAtsFieldMapping] = useState('');
+ const [atsSyncAuto, setAtsSyncAuto] = useState(false);
+
+ // API key display (masked)
+ const [showKeys, setShowKeys] = useState>({});
+
+ // Notification settings
+ const [slackWebhook, setSlackWebhook] = useState('');
+ const [emailAlerts, setEmailAlerts] = useState(true);
+
+ // Default scoring weights
+ const [weights, setWeights] = useState({ ...DEFAULT_SCORING_WEIGHTS });
+
+ // Density preference
+ const [density, setDensity] = useState<'compact' | 'comfortable'>('compact');
+
+ const handleSave = () => {
+ // TODO: Persist to recruiter-specific settings table or user settings
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2000);
+ };
+
+ const toggleKeyVisibility = (key: string) => {
+ setShowKeys(prev => ({ ...prev, [key]: !prev[key] }));
+ };
+
+ const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
+
+ return (
+
+
+
+ {saved ? 'Saved' : 'Save Changes'}
+
+ }
+ />
+
+
+ {/* Left nav */}
+
+ {SECTIONS.map(section => (
+
+ ))}
+
+
+ {/* Right content */}
+
+ {activeSection === 'ats' && (
+
+
ATS Integration
+
Configure webhook-based ATS export for candidate data.
+
+
+
+
+ setAtsWebhookUrl(e.target.value)}
+ placeholder="https://your-ats.com/api/webhook/candidates"
+ className="w-full px-3 py-2 rounded-lg text-sm font-mono border outline-none focus:ring-1"
+ style={{ background: 'var(--ros-bg-card)', borderColor: 'var(--ros-border)', color: 'var(--ros-text-primary)', outlineColor: 'var(--ros-accent)' }}
+ />
+
+
+
+
+
+
+ Auto-sync Tier 1 candidates to ATS
+
+
+
+ )}
+
+ {activeSection === 'api_keys' && (
+
+
API Keys
+
Keys are stored server-side. Manage them in the original SourceKit Settings.
+
+ {['Exa API Key', 'Harmonic API Key', 'Claude API Key'].map(keyName => (
+
+
+
{keyName}
+
+ {showKeys[keyName] ? 'sk-...configured-in-settings' : '••••••••••••'}
+
+
+
+
+ ))}
+
+ )}
+
+ {activeSection === 'notifications' && (
+
+
Notifications
+
+
+
+ setSlackWebhook(e.target.value)}
+ placeholder="https://hooks.slack.com/services/..."
+ className="w-full px-3 py-2 rounded-lg text-sm font-mono border outline-none"
+ style={{ background: 'var(--ros-bg-card)', borderColor: 'var(--ros-border)', color: 'var(--ros-text-primary)' }}
+ />
+
+
+
+ Email alerts for Tier 1 candidate matches
+
+
+
+ )}
+
+ {activeSection === 'scoring' && (
+
+
Default Scoring Weights
+
+ Global defaults applied when no role scorecard specifies weights. Total: {totalWeight}
+
+
+ {(Object.keys(weights) as (keyof ScoringWeights)[]).map(dim => (
+
+
+ {dim.replace('_', ' ')}
+
+ setWeights(prev => ({ ...prev, [dim]: parseInt(e.target.value) }))}
+ className="flex-1 h-1.5 rounded-full appearance-none"
+ style={{ accentColor: 'var(--ros-accent)' }}
+ />
+
+ {weights[dim]}
+
+
+ ))}
+
+
+ )}
+
+ {activeSection === 'team' && (
+
+
Team Management
+
+ Team features are planned for a future release. Currently operating in single-user mode.
+
+
+
+
+ Invite team members, assign roles, and share pipelines.
+
+
Coming soon
+
+
+ )}
+
+ {activeSection === 'appearance' && (
+
+
Appearance
+
+
+
+
+ {(['compact', 'comfortable'] as const).map(d => (
+
+ ))}
+
+
+
+
+ Recruiter OS uses dark mode by default for extended screen time. Light mode is available in the original SourceKit.
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/Reports.tsx b/src/recruiter/pages/Reports.tsx
new file mode 100644
index 0000000..546f0f4
--- /dev/null
+++ b/src/recruiter/pages/Reports.tsx
@@ -0,0 +1,720 @@
+// SourceKit Recruiter OS — Reports Dashboard
+
+import { useState } from 'react';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import { useRecruiterCandidates } from '../hooks/useRecruiterCandidates';
+import { useAgentRuns } from '../hooks/useAgentRuns';
+import { useRecruiterOutreach } from '../hooks/useRecruiterOutreach';
+import { REPORT_CONFIG, TIER_CONFIG } from '../lib/constants';
+import type { ReportType, RecruiterCandidate } from '../lib/types';
+import { BarChart2, Download, Calendar, Filter } from 'lucide-react';
+
+// --- Helpers ---
+
+const REPORT_TYPES = Object.entries(REPORT_CONFIG) as Array<
+ [ReportType, { label: string; description: string; icon: string }]
+>;
+
+function daysSince(date: string): number {
+ return Math.floor((Date.now() - new Date(date).getTime()) / (1000 * 60 * 60 * 24));
+}
+
+function formatPct(count: number, total: number): string {
+ if (total === 0) return '0%';
+ return `${Math.round((count / total) * 100)}%`;
+}
+
+function exportCsv(filename: string, headers: string[], rows: string[][]) {
+ const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+function isThisWeek(dateStr: string): boolean {
+ const d = new Date(dateStr);
+ const now = new Date();
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ return d >= weekAgo;
+}
+
+// --- Bar chart (div-based) ---
+
+function HorizontalBar({
+ label,
+ value,
+ maxValue,
+ color,
+ displayValue,
+}: {
+ label: string;
+ value: number;
+ maxValue: number;
+ color: string;
+ displayValue: string;
+}) {
+ const width = maxValue > 0 ? Math.max((value / maxValue) * 100, 2) : 0;
+ return (
+
+
+ {label}
+
+
+
+ {displayValue}
+
+
+ );
+}
+
+// --- Stat card ---
+
+function StatBlock({ label, value }: { label: string; value: string | number }) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+// --- Placeholder report ---
+
+function PlaceholderReport({
+ message,
+ tableHeaders,
+ tableRows,
+}: {
+ message: string;
+ tableHeaders?: string[];
+ tableRows?: string[][];
+}) {
+ return (
+
+
+ {message}
+
+ {tableHeaders && (
+
+
+ {tableHeaders.map((h) => (
+ {h}
+ ))}
+
+ {(tableRows ?? []).map((row, i) => (
+
+ {row.map((cell, j) => (
+ {cell}
+ ))}
+
+ ))}
+ {(!tableRows || tableRows.length === 0) && (
+
+ Insufficient data
+
+ )}
+
+ )}
+
+ );
+}
+
+// --- Tier bar colors ---
+
+const TIER_BAR_COLORS: Record = {
+ tier_1: '#34d399',
+ tier_2: '#818cf8',
+ borderline: '#fbbf24',
+ below_bar: '#f87171',
+ false_positive: '#a1a1aa',
+ nurture: '#a78bfa',
+ ats_synced: '#60a5fa',
+ unclassified: '#71717a',
+};
+
+// --- Main Component ---
+
+export default function Reports() {
+ const [activeReport, setActiveReport] = useState('candidate_volume_by_tier');
+ const [dateFrom, setDateFrom] = useState('');
+ const [dateTo, setDateTo] = useState('');
+ const [roleFilter, setRoleFilter] = useState('');
+
+ const { data: candidates } = useRecruiterCandidates();
+ const { data: agentRuns } = useAgentRuns();
+ const { data: outreach } = useRecruiterOutreach();
+
+ const allCandidates = candidates ?? [];
+ const allRuns = agentRuns ?? [];
+ const allOutreach = outreach ?? [];
+
+ // Date-filtered candidates
+ const filteredCandidates = allCandidates.filter((c) => {
+ if (dateFrom && new Date(c.created_at) < new Date(dateFrom)) return false;
+ if (dateTo && new Date(c.created_at) > new Date(dateTo)) return false;
+ return true;
+ });
+
+ // --- Report renderers ---
+
+ function renderCandidateVolumeByTier() {
+ const tierEntries = Object.entries(TIER_CONFIG) as Array<
+ [string, { label: string; order: number }]
+ >;
+ tierEntries.sort((a, b) => a[1].order - b[1].order);
+
+ const counts: Record = {};
+ tierEntries.forEach(([key]) => {
+ counts[key] = filteredCandidates.filter((c) => c.tier === key).length;
+ });
+ const maxCount = Math.max(...Object.values(counts), 1);
+ const total = filteredCandidates.length;
+
+ const handleExport = () => {
+ exportCsv(
+ 'candidate_volume_by_tier.csv',
+ ['Tier', 'Count', 'Percentage'],
+ tierEntries.map(([key, cfg]) => [cfg.label, String(counts[key]), formatPct(counts[key], total)]),
+ );
+ };
+
+ return (
+
+
+
+ Candidate Volume by Tier
+
+
+
+
+ {total === 0 ? (
+
+ No candidates in the pipeline yet.
+
+ ) : (
+ <>
+
+ {tierEntries.map(([key, cfg]) => (
+
+ ))}
+
+
+ {/* Table */}
+
+
+ Tier
+ Count
+ Percentage
+
+ {tierEntries.map(([key, cfg]) => (
+
+ {cfg.label}
+ {counts[key]}
+ {formatPct(counts[key], total)}
+
+ ))}
+
+ >
+ )}
+
+ );
+ }
+
+ function renderSourceQuality() {
+ return (
+
+ );
+ }
+
+ function renderArtifactRecency() {
+ return (
+
+ );
+ }
+
+ function renderResponseRates() {
+ return (
+
+ );
+ }
+
+ function renderHiddenGemYield() {
+ const gems = filteredCandidates.filter(
+ (c) => c.hidden_gem_score?.score != null && c.hidden_gem_score.score >= 70,
+ );
+ const total = filteredCandidates.length;
+ const gemsByTier: Record = {};
+ gems.forEach((c) => {
+ gemsByTier[c.tier] = (gemsByTier[c.tier] ?? 0) + 1;
+ });
+
+ const handleExport = () => {
+ exportCsv(
+ 'hidden_gem_yield.csv',
+ ['Metric', 'Value'],
+ [
+ ['Total Hidden Gems', String(gems.length)],
+ ['Pipeline Percentage', formatPct(gems.length, total)],
+ ...Object.entries(gemsByTier).map(([tier, count]) => [
+ `Tier: ${TIER_CONFIG[tier as keyof typeof TIER_CONFIG]?.label ?? tier}`,
+ String(count),
+ ]),
+ ],
+ );
+ };
+
+ return (
+
+
+
+ Hidden Gem Yield
+
+
+
+
+
+
+
+
+
+
+ {gems.length > 0 ? (
+
+
+ Hidden Gem Tier Distribution
+
+
+ {Object.entries(gemsByTier).map(([tier, count]) => (
+
+ ))}
+
+
+ ) : (
+
+ No hidden gems detected yet. Candidates with a hidden gem score of 70+ will appear here.
+
+ )}
+
+ );
+ }
+
+ function renderRecruiterThroughput() {
+ const candidatesThisWeek = filteredCandidates.filter((c) => isThisWeek(c.created_at)).length;
+ const outreachThisWeek = allOutreach.filter(
+ (o) => o.status === 'sent' && o.sent_at && isThisWeek(o.sent_at),
+ ).length;
+
+ const handleExport = () => {
+ exportCsv(
+ 'recruiter_throughput.csv',
+ ['Metric', 'Value'],
+ [
+ ['Candidates Added (This Week)', String(candidatesThisWeek)],
+ ['Outreach Sent (This Week)', String(outreachThisWeek)],
+ ],
+ );
+ };
+
+ return (
+
+
+
+ Recruiter Throughput
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ function renderAtsExportVolume() {
+ const byStatus: Record = {};
+ filteredCandidates.forEach((c) => {
+ byStatus[c.ats_sync_status] = (byStatus[c.ats_sync_status] ?? 0) + 1;
+ });
+
+ const statusLabels: Record = {
+ not_synced: 'Not Synced',
+ synced: 'Synced',
+ sync_failed: 'Sync Failed',
+ };
+
+ const entries = Object.entries(byStatus);
+ const maxCount = Math.max(...entries.map(([, v]) => v), 1);
+
+ const handleExport = () => {
+ exportCsv(
+ 'ats_export_volume.csv',
+ ['Status', 'Count'],
+ entries.map(([key, count]) => [statusLabels[key] ?? key, String(count)]),
+ );
+ };
+
+ return (
+
+
+
+ ATS Export Volume
+
+
+
+
+ {entries.length === 0 ? (
+
+ No candidates to analyze.
+
+ ) : (
+
+ {entries.map(([key, count]) => (
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ function renderStaleCandidates() {
+ const stale = filteredCandidates.filter((c) => daysSince(c.updated_at) > 14);
+ stale.sort((a, b) => daysSince(b.updated_at) - daysSince(a.updated_at));
+
+ const handleExport = () => {
+ exportCsv(
+ 'stale_candidates.csv',
+ ['Name', 'Tier', 'Days Since Update', 'Stage'],
+ stale.map((c) => [
+ c.name ?? 'Unknown',
+ TIER_CONFIG[c.tier]?.label ?? c.tier,
+ String(daysSince(c.updated_at)),
+ c.pipeline_stage,
+ ]),
+ );
+ };
+
+ return (
+
+
+
+ Stale Candidates
+
+
+
+
+
+ Candidates with no updates in 14+ days ({stale.length} found)
+
+
+ {stale.length === 0 ? (
+
+ No stale candidates. All candidates have been updated recently.
+
+ ) : (
+
+
+ Name
+ Tier
+ Days
+ Stage
+
+ {stale.slice(0, 50).map((c) => (
+
+
+ {c.name ?? 'Unknown'}
+
+ {TIER_CONFIG[c.tier]?.label ?? c.tier}
+
+ {daysSince(c.updated_at)}d
+
+ {c.pipeline_stage}
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // --- Report router ---
+
+ const REPORT_RENDERERS: Record React.ReactNode> = {
+ candidate_volume_by_tier: renderCandidateVolumeByTier,
+ source_quality: renderSourceQuality,
+ artifact_recency: renderArtifactRecency,
+ response_rates: renderResponseRates,
+ hidden_gem_yield: renderHiddenGemYield,
+ recruiter_throughput: renderRecruiterThroughput,
+ ats_export_volume: renderAtsExportVolume,
+ stale_candidates: renderStaleCandidates,
+ };
+
+ return (
+
+
+
+
+ {/* Sidebar: report type selector */}
+
+
+ {REPORT_TYPES.map(([key, cfg]) => (
+
+ ))}
+
+
+
+ {/* Main content */}
+
+ {/* Top controls */}
+
+
+ {/* Report content */}
+
+ {REPORT_RENDERERS[activeReport]?.() ?? (
+
+ Report not available.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/RoleScorecardDetail.tsx b/src/recruiter/pages/RoleScorecardDetail.tsx
new file mode 100644
index 0000000..ccb90a7
--- /dev/null
+++ b/src/recruiter/pages/RoleScorecardDetail.tsx
@@ -0,0 +1,588 @@
+// SourceKit Recruiter OS — Role Scorecard Detail / Edit
+
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import {
+ useRecruiterScorecard,
+ useUpdateScorecard,
+} from '../hooks/useRecruiterScorecard';
+import PageHeader from '../components/PageHeader';
+import { DEFAULT_SCORING_WEIGHTS, OUTREACH_TONES } from '../lib/constants';
+import type {
+ RoleScorecard,
+ ScorecardSignal,
+ ScoringWeights,
+ EvaluationQuestion,
+} from '../lib/types';
+import { ArrowLeft, Save, Rocket, Plus, Trash2, GripVertical } from 'lucide-react';
+
+// --- Helpers ---
+
+function newSignal(): ScorecardSignal {
+ return {
+ id: crypto.randomUUID(),
+ name: '',
+ description: '',
+ weight: 50,
+ evidence_type: 'github',
+ };
+}
+
+function newQuestion(): EvaluationQuestion {
+ return {
+ id: crypto.randomUUID(),
+ text: '',
+ dimension: '',
+ good_answer: '',
+ bad_answer: '',
+ };
+}
+
+const EVIDENCE_TYPES = ['github', 'linkedin', 'publication', 'web', 'other'];
+const STATUS_OPTIONS: Array<{ value: RoleScorecard['status']; label: string }> = [
+ { value: 'draft', label: 'Draft' },
+ { value: 'active', label: 'Active' },
+ { value: 'archived', label: 'Archived' },
+];
+
+const WEIGHT_KEYS: Array<{ key: keyof ScoringWeights; label: string }> = [
+ { key: 'eea', label: 'EEA Score' },
+ { key: 'builder', label: 'Builder Score' },
+ { key: 'ai_recency', label: 'AI Recency' },
+ { key: 'systems_depth', label: 'Systems Depth' },
+ { key: 'product_instinct', label: 'Product Instinct' },
+ { key: 'hidden_gem', label: 'Hidden Gem' },
+];
+
+// --- Section Card wrapper ---
+
+function SectionCard({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+// --- Signal List ---
+
+function SignalList({
+ signals,
+ onChange,
+}: {
+ signals: ScorecardSignal[];
+ onChange: (signals: ScorecardSignal[]) => void;
+}) {
+ const update = (idx: number, patch: Partial) => {
+ const next = signals.map((s, i) => (i === idx ? { ...s, ...patch } : s));
+ onChange(next);
+ };
+ const remove = (idx: number) => onChange(signals.filter((_, i) => i !== idx));
+ const add = () => onChange([...signals, newSignal()]);
+
+ return (
+
+ {signals.map((sig, idx) => (
+
+
+
+
update(idx, { name: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
update(idx, { description: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
+
+ update(idx, { weight: Number(e.target.value) })}
+ className="flex-1 h-1 accent-current"
+ style={{ accentColor: 'var(--ros-accent)' }}
+ />
+
+ {sig.weight}
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+// --- Main Component ---
+
+export default function RoleScorecardDetail() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { data: scorecard, isLoading } = useRecruiterScorecard(id);
+ const updateScorecard = useUpdateScorecard();
+
+ // Form state
+ const [name, setName] = useState('');
+ const [status, setStatus] = useState('draft');
+ const [talentThesis, setTalentThesis] = useState('');
+ const [mustHaves, setMustHaves] = useState([]);
+ const [niceToHaves, setNiceToHaves] = useState([]);
+ const [suppressionsText, setSuppressionsText] = useState('');
+ const [weights, setWeights] = useState({ ...DEFAULT_SCORING_WEIGHTS });
+ const [outreachTone, setOutreachTone] = useState('professional');
+ const [evalQuestions, setEvalQuestions] = useState([]);
+
+ // Seed form from loaded scorecard
+ useEffect(() => {
+ if (!scorecard) return;
+ setName(scorecard.name);
+ setStatus(scorecard.status);
+ setTalentThesis(scorecard.talent_thesis ?? '');
+ setMustHaves(scorecard.must_have_signals ?? []);
+ setNiceToHaves(scorecard.nice_to_have_signals ?? []);
+ setSuppressionsText((scorecard.suppressions ?? []).join('\n'));
+ setWeights(scorecard.scoring_weights ?? { ...DEFAULT_SCORING_WEIGHTS });
+ setOutreachTone(scorecard.outreach_tone ?? 'professional');
+ setEvalQuestions(scorecard.evaluation_questions ?? []);
+ }, [scorecard]);
+
+ const handleSave = () => {
+ if (!id) return;
+ updateScorecard.mutate({
+ id,
+ updates: {
+ name,
+ status,
+ talent_thesis: talentThesis,
+ must_have_signals: mustHaves,
+ nice_to_have_signals: niceToHaves,
+ suppressions: suppressionsText
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ scoring_weights: weights,
+ outreach_tone: outreachTone,
+ evaluation_questions: evalQuestions,
+ },
+ });
+ };
+
+ const updateWeight = (key: keyof ScoringWeights, value: number) => {
+ setWeights((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const weightsTotal = Object.values(weights).reduce((a, b) => a + b, 0);
+
+ // Question helpers
+ const updateQuestion = (idx: number, patch: Partial) => {
+ setEvalQuestions((prev) =>
+ prev.map((q, i) => (i === idx ? { ...q, ...patch } : q)),
+ );
+ };
+ const removeQuestion = (idx: number) => {
+ setEvalQuestions((prev) => prev.filter((_, i) => i !== idx));
+ };
+
+ // Loading
+ if (isLoading) {
+ return (
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ // Not found
+ if (!scorecard) {
+ return (
+
+
+ Scorecard not found.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+
+ /
+
+
+ {name || 'Untitled'}
+
+
+
+ {/* Header controls */}
+
+
setName(e.target.value)}
+ className="text-lg font-semibold bg-transparent outline-none flex-1 w-full"
+ style={{
+ color: 'var(--ros-text-primary)',
+ borderBottom: '1px solid var(--ros-border)',
+ }}
+ placeholder="Role name..."
+ />
+
+
+
+
+
+
+
+ {/* Save success indicator */}
+ {updateScorecard.isSuccess && (
+
+ Saved successfully
+
+ )}
+
+
+ {/* Talent Thesis */}
+
+
+
+ {/* Must-Have Signals */}
+
+
+
+
+ {/* Nice-to-Have Signals */}
+
+
+
+
+ {/* Suppressions */}
+
+
+ One suppression per line (companies, domains, or keywords to exclude)
+
+
+
+ {/* Scoring Weights */}
+
+
+ {WEIGHT_KEYS.map(({ key, label }) => (
+
+
+ {label}
+
+ updateWeight(key, Number(e.target.value))}
+ className="flex-1 h-1"
+ style={{ accentColor: 'var(--ros-accent)' }}
+ />
+
+ {weights[key]}
+
+
+ ))}
+
+
+ Total
+
+
+ {weightsTotal} / 100
+
+
+
+
+
+ {/* Outreach Tone */}
+
+
+
+
+ {/* Evaluation Questions */}
+
+
+ {evalQuestions.map((q, idx) => (
+
+
+
updateQuestion(idx, { text: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full sm:col-span-2"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
updateQuestion(idx, { dimension: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
+
updateQuestion(idx, { good_answer: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
updateQuestion(idx, { bad_answer: e.target.value })}
+ className="text-xs px-2 py-1.5 rounded-md w-full"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ }}
+ />
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/recruiter/pages/RoleScorecardList.tsx b/src/recruiter/pages/RoleScorecardList.tsx
new file mode 100644
index 0000000..a01a500
--- /dev/null
+++ b/src/recruiter/pages/RoleScorecardList.tsx
@@ -0,0 +1,201 @@
+// SourceKit Recruiter OS — Role Scorecard List
+
+import { useNavigate } from 'react-router-dom';
+import {
+ useRecruiterScorecards,
+ useCreateScorecard,
+ useDeleteScorecard,
+} from '../hooks/useRecruiterScorecard';
+import PageHeader from '../components/PageHeader';
+import EmptyState from '../components/EmptyState';
+import type { RoleScorecard } from '../lib/types';
+import { ClipboardList, Plus, Search, Trash2, Edit, Rocket } from 'lucide-react';
+
+function formatDate(date: string): string {
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+const STATUS_STYLES: Record = {
+ draft: { label: 'Draft', bg: 'rgba(251,191,36,0.12)', text: '#fbbf24', border: 'rgba(251,191,36,0.3)' },
+ active: { label: 'Active', bg: 'rgba(52,211,153,0.12)', text: '#34d399', border: 'rgba(52,211,153,0.3)' },
+ archived: { label: 'Archived', bg: 'rgba(161,161,170,0.12)', text: '#a1a1aa', border: 'rgba(161,161,170,0.3)' },
+};
+
+function signalSummary(scorecard: RoleScorecard): string {
+ const must = scorecard.must_have_signals?.length ?? 0;
+ const nice = scorecard.nice_to_have_signals?.length ?? 0;
+ return `${must} must-have${must !== 1 ? 's' : ''}, ${nice} nice-to-have${nice !== 1 ? 's' : ''}`;
+}
+
+export default function RoleScorecardList() {
+ const navigate = useNavigate();
+ const { data: scorecards, isLoading } = useRecruiterScorecards();
+ const createScorecard = useCreateScorecard();
+ const deleteScorecard = useDeleteScorecard();
+
+ const handleCreate = async () => {
+ const result = await createScorecard.mutateAsync({ name: 'New Scorecard' });
+ navigate(`/recruiter/scorecards/${result.id}`);
+ };
+
+ const handleDelete = (id: string, name: string) => {
+ if (window.confirm(`Delete scorecard "${name}"? This cannot be undone.`)) {
+ deleteScorecard.mutate(id);
+ }
+ };
+
+ const list = scorecards ?? [];
+
+ return (
+
+
+
+ Create Scorecard
+
+ }
+ />
+
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : list.length === 0 ? (
+ }
+ title="No scorecards created"
+ description="Create a role scorecard to define your ideal candidate profile."
+ action={
+
+ }
+ />
+ ) : (
+
+ {list.map((sc) => {
+ const statusStyle = STATUS_STYLES[sc.status] ?? STATUS_STYLES.draft;
+ return (
+
+ {/* Header */}
+
+
+ {sc.name}
+
+
+ {statusStyle.label}
+
+
+
+ {/* Talent thesis excerpt */}
+ {sc.talent_thesis ? (
+
+ {sc.talent_thesis}
+
+ ) : (
+
+ No talent thesis defined
+
+ )}
+
+ {/* Signal count */}
+
+ {signalSummary(sc)}
+
+
+ {/* Created date */}
+
+ Created {formatDate(sc.created_at)}
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/pages/SearchLab.tsx b/src/recruiter/pages/SearchLab.tsx
new file mode 100644
index 0000000..16ce05d
--- /dev/null
+++ b/src/recruiter/pages/SearchLab.tsx
@@ -0,0 +1,804 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import PageHeader from '../components/PageHeader'
+import TierBadge from '../components/TierBadge'
+import { useRecruiterScorecards } from '../hooks/useRecruiterScorecard'
+import { useRecruiterSavedSearches } from '../hooks/useRecruiterSavedSearches'
+import {
+ Search,
+ Sliders,
+ Play,
+ Save,
+ Bookmark,
+ ChevronDown,
+ ChevronRight,
+ X,
+ ToggleLeft,
+ ToggleRight,
+ GitBranch,
+ Eye,
+ Sparkles,
+ Building2,
+ FileText,
+ Globe,
+ Database,
+} from 'lucide-react'
+import { SUGGESTED_ARCHETYPES, DEFAULT_SEARCH_SOURCES, DEFAULT_SEARCH_MODES, OUTREACH_TONES } from '../lib/constants'
+import type { RecruiterSearchConfig, SearchSource, SearchMode } from '../lib/types'
+
+type SectionKey = 'roleBrief' | 'archetypes' | 'proofSignals' | 'sources' | 'filters'
+
+interface ProofSignal {
+ id: string
+ name: string
+ weight: number
+}
+
+const SOURCE_LABELS: { key: keyof SearchSource; label: string; icon: typeof GitBranch }[] = [
+ { key: 'github', label: 'GitHub', icon: GitBranch },
+ { key: 'linkedin', label: 'LinkedIn', icon: Globe },
+ { key: 'web_blog', label: 'Web / Blog', icon: FileText },
+ { key: 'huggingface', label: 'HuggingFace', icon: Database },
+ { key: 'conference_talks', label: 'Conference Talks', icon: Eye },
+ { key: 'company_mapping', label: 'Company Mapping', icon: Building2 },
+ { key: 'exa_websets', label: 'Exa Websets', icon: Sparkles },
+]
+
+const MODE_LABELS: { key: keyof SearchMode; label: string }[] = [
+ { key: 'standard', label: 'Standard Search' },
+ { key: 'hidden_gem', label: 'Hidden Gem Mode' },
+ { key: 'company_mapping', label: 'Company Mapping' },
+ { key: 'artifact_led', label: 'Artifact-Led' },
+]
+
+const SENIORITY_OPTIONS = ['Junior', 'Mid', 'Senior', 'Staff', 'Principal', 'Lead']
+
+let signalIdCounter = 0
+function nextSignalId() {
+ signalIdCounter += 1
+ return `signal-${signalIdCounter}`
+}
+
+export default function SearchLab() {
+ const navigate = useNavigate()
+ const { data: scorecards } = useRecruiterScorecards()
+ const { data: savedSearches } = useRecruiterSavedSearches()
+
+ // --- Section collapse state ---
+ const [openSections, setOpenSections] = useState>(
+ new Set(['roleBrief'])
+ )
+
+ // --- Search config state ---
+ const [roleName, setRoleName] = useState('')
+ const [scorecardId, setScorecardId] = useState(null)
+ const [roleBrief, setRoleBrief] = useState('')
+ const [archetypes, setArchetypes] = useState([])
+ const [customArchetype, setCustomArchetype] = useState('')
+ const [proofSignals, setProofSignals] = useState([])
+ const [sources, setSources] = useState({ ...DEFAULT_SEARCH_SOURCES })
+ const [recencyMonths, setRecencyMonths] = useState(12)
+ const [location, setLocation] = useState('')
+ const [seniority, setSeniority] = useState('Senior')
+ const [scoreFloor, setScoreFloor] = useState(0)
+ const [suppressions, setSuppressions] = useState('')
+ const [modes, setModes] = useState({ ...DEFAULT_SEARCH_MODES })
+
+ // --- UI state ---
+ const [parseMessage, setParseMessage] = useState(null)
+ const [showLoadDropdown, setShowLoadDropdown] = useState(false)
+
+ function toggleSection(key: SectionKey) {
+ setOpenSections((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ return next
+ })
+ }
+
+ function isSectionOpen(key: SectionKey) {
+ return openSections.has(key)
+ }
+
+ function addArchetype(a: string) {
+ const trimmed = a.trim()
+ if (trimmed && !archetypes.includes(trimmed)) {
+ setArchetypes((prev) => [...prev, trimmed])
+ }
+ }
+
+ function removeArchetype(a: string) {
+ setArchetypes((prev) => prev.filter((x) => x !== a))
+ }
+
+ function addProofSignal() {
+ setProofSignals((prev) => [...prev, { id: nextSignalId(), name: '', weight: 50 }])
+ }
+
+ function updateSignal(id: string, field: 'name' | 'weight', value: string | number) {
+ setProofSignals((prev) =>
+ prev.map((s) => (s.id === id ? { ...s, [field]: value } : s))
+ )
+ }
+
+ function removeSignal(id: string) {
+ setProofSignals((prev) => prev.filter((s) => s.id !== id))
+ }
+
+ function toggleSource(key: keyof SearchSource) {
+ setSources((prev) => ({ ...prev, [key]: !prev[key] }))
+ }
+
+ function toggleMode(key: keyof SearchMode) {
+ setModes((prev) => ({ ...prev, [key]: !prev[key] }))
+ }
+
+ function handleParseAI() {
+ setParseMessage('AI parsing is not yet connected. Role brief saved locally.')
+ setTimeout(() => setParseMessage(null), 3000)
+ }
+
+ function handleRunSearch() {
+ // TODO: Wire to recruiter-search-orchestrator edge function
+ }
+
+ function handleSaveSearch() {
+ // TODO: Save via useSaveSearch mutation
+ }
+
+ function handleLoadSearch(config: RecruiterSearchConfig) {
+ setRoleName(config.role_name)
+ setRoleBrief(config.role_brief)
+ setScorecardId(config.scorecard_id)
+ setArchetypes([...config.archetypes])
+ setSources({ ...config.sources })
+ setRecencyMonths(config.recency_months)
+ setLocation(config.location_preference)
+ setSeniority(config.seniority_band)
+ setScoreFloor(config.score_floor)
+ setSuppressions(config.suppressions.join('\n'))
+ setModes({ ...config.modes })
+ setShowLoadDropdown(false)
+ }
+
+ // --- Shared styles ---
+ const inputStyle: React.CSSProperties = {
+ background: 'var(--ros-bg-secondary)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ borderRadius: 6,
+ }
+
+ const sectionHeaderStyle: React.CSSProperties = {
+ color: 'var(--ros-text-primary)',
+ borderBottom: '1px solid var(--ros-border)',
+ cursor: 'pointer',
+ userSelect: 'none',
+ }
+
+ function SectionHeader({ sectionKey, label }: { sectionKey: SectionKey; label: string }) {
+ const open = isSectionOpen(sectionKey)
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ Recruiter Search Workspace
+
+
+ }
+ />
+
+ {/* Parse AI toast */}
+ {parseMessage && (
+
+
+ {parseMessage}
+
+ )}
+
+
+ {/* ====== LEFT PANEL — SEARCH CONFIG ====== */}
+
+
+ {/* --- 1. Role Brief (always open) --- */}
+
+ {isSectionOpen('roleBrief') && (
+
+
+
+ setRoleName(e.target.value)}
+ placeholder="e.g. Senior ML Engineer"
+ className="w-full px-3 py-2 text-sm rounded"
+ style={inputStyle}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* --- 2. Target Archetypes --- */}
+
+ {isSectionOpen('archetypes') && (
+
+ {/* Selected archetypes as pills */}
+ {archetypes.length > 0 && (
+
+ {archetypes.map((a) => (
+
+ ))}
+
+ )}
+
+ {/* Suggested archetypes */}
+
+
+ Suggested
+
+
+ {SUGGESTED_ARCHETYPES.filter((a) => !archetypes.includes(a)).map((a) => (
+
+ ))}
+
+
+
+ {/* Custom archetype input */}
+
+ setCustomArchetype(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ addArchetype(customArchetype)
+ setCustomArchetype('')
+ }
+ }}
+ placeholder="Add custom archetype..."
+ className="flex-1 px-3 py-1.5 text-xs rounded"
+ style={inputStyle}
+ />
+
+
+
+ )}
+
+ {/* --- 3. Proof Signals --- */}
+
+ {isSectionOpen('proofSignals') && (
+
+ )}
+
+ {/* --- 4. Source Selection --- */}
+
+ {isSectionOpen('sources') && (
+
+ {SOURCE_LABELS.map(({ key, label, icon: Icon }) => {
+ const active = sources[key]
+ return (
+
+ )
+ })}
+
+ )}
+
+ {/* --- 5. Filters --- */}
+
+ {isSectionOpen('filters') && (
+
+
+
+ setRecencyMonths(Number(e.target.value))}
+ min={1}
+ className="w-full px-3 py-2 text-sm rounded"
+ style={inputStyle}
+ />
+
+
+
+
+ setLocation(e.target.value)}
+ placeholder="e.g. SF Bay Area, Remote US"
+ className="w-full px-3 py-2 text-sm rounded"
+ style={inputStyle}
+ />
+
+
+
+
+
+
+
+
+
+ setScoreFloor(Math.min(100, Math.max(0, Number(e.target.value))))}
+ min={0}
+ max={100}
+ className="w-full px-3 py-2 text-sm rounded"
+ style={inputStyle}
+ />
+
+
+
+
+
+
+ )}
+
+ {/* --- 6. Search Modes (toggle row) --- */}
+
+
+ Search Modes
+
+
+ {MODE_LABELS.map(({ key, label }) => {
+ const active = modes[key]
+ return (
+
+ )
+ })}
+
+
+
+
+ {/* --- Action Bar (sticky bottom) --- */}
+
+
+
+
+
+
+
+
+ {showLoadDropdown && (
+
+ {savedSearches && savedSearches.length > 0 ? (
+
+ {savedSearches.map((ss) => (
+
+ ))}
+
+ ) : (
+
+ No saved searches yet
+
+ )}
+
+ )}
+
+
+
+
+ {/* ====== RIGHT PANEL — RESULTS FEED ====== */}
+
+ {/* Results header */}
+
+
+
+ Results
+
+
+ 0
+
+
+
+
+ {/* Empty state */}
+
+
+
+
+
+ Configure your search and hit Run to see results.
+
+
+ {'// TODO: Wire to recruiter-search-orchestrator edge function'}
+
+
+ {/* Mock result card preview */}
+
+
+ Result card preview
+
+
+
+
+
+ builder: 92
+
+
+ ai_recency: 88
+
+
+
+ {['Review', 'Shortlist', 'Suppress'].map((action) => (
+
+ {action}
+
+ ))}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/recruiter/pages/TeamPipeline.tsx b/src/recruiter/pages/TeamPipeline.tsx
new file mode 100644
index 0000000..5f3dcf6
--- /dev/null
+++ b/src/recruiter/pages/TeamPipeline.tsx
@@ -0,0 +1,945 @@
+// SourceKit Recruiter OS — Team Pipeline
+
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ useRecruiterCandidates,
+ useUpdateCandidate,
+} from '../hooks/useRecruiterCandidates';
+import PageHeader from '../components/PageHeader';
+import TierBadge from '../components/TierBadge';
+import { StageBadge, ContactBadge } from '../components/StatusBadge';
+import EmptyState from '../components/EmptyState';
+import { getScoreColor } from '../services/scoring';
+import { candidatesToCSV, downloadCSV } from '../services/export';
+import { TIER_CONFIG, STAGE_CONFIG, PIPELINE_TIERS } from '../lib/constants';
+import type {
+ CandidateTier,
+ PipelineStage,
+ RecruiterCandidate,
+} from '../lib/types';
+import {
+ Kanban,
+ Table2,
+ Filter,
+ Download,
+ ChevronDown,
+ Users,
+ Search,
+ X,
+ ArrowRight,
+} from 'lucide-react';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const TIER_BORDER_COLORS: Record = {
+ tier_1: '#34d399',
+ tier_2: '#818cf8',
+ borderline: '#fbbf24',
+ below_bar: '#f87171',
+};
+
+const STALE_DAYS = 14;
+
+function daysSince(date: string): number {
+ return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
+}
+
+function isStale(c: RecruiterCandidate): boolean {
+ return daysSince(c.updated_at) >= STALE_DAYS;
+}
+
+function truncate(text: string | null, max: number): string {
+ if (!text) return '';
+ return text.length > max ? text.slice(0, max) + '...' : text;
+}
+
+const ALL_STAGES = Object.keys(STAGE_CONFIG) as PipelineStage[];
+const ALL_TIERS = Object.keys(TIER_CONFIG) as CandidateTier[];
+const CONTACT_STATUSES = [
+ 'not_contacted',
+ 'contacted',
+ 'replied',
+ 'not_interested',
+ 'scheduled',
+] as const;
+
+type ViewMode = 'board' | 'table';
+type SortDir = 'asc' | 'desc';
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export default function TeamPipeline() {
+ const navigate = useNavigate();
+ const { data: candidates = [], isLoading } = useRecruiterCandidates();
+ const updateCandidate = useUpdateCandidate();
+
+ // View
+ const [viewMode, setViewMode] = useState('board');
+
+ // Filters
+ const [tierFilter, setTierFilter] = useState('');
+ const [stageFilter, setStageFilter] = useState('');
+ const [contactFilter, setContactFilter] = useState('');
+ const [tagSearch, setTagSearch] = useState('');
+ const [staleOnly, setStaleOnly] = useState(false);
+
+ // Table state
+ const [scoreSortDir, setScoreSortDir] = useState('desc');
+ const [selected, setSelected] = useState>(new Set());
+ const [openMenuId, setOpenMenuId] = useState(null);
+
+ const hasFilters =
+ !!tierFilter || !!stageFilter || !!contactFilter || !!tagSearch || staleOnly;
+
+ function clearFilters() {
+ setTierFilter('');
+ setStageFilter('');
+ setContactFilter('');
+ setTagSearch('');
+ setStaleOnly(false);
+ }
+
+ // Apply filters
+ const filtered = candidates.filter((c) => {
+ if (tierFilter && c.tier !== tierFilter) return false;
+ if (stageFilter && c.pipeline_stage !== stageFilter) return false;
+ if (contactFilter && c.contact_status !== contactFilter) return false;
+ if (tagSearch) {
+ const q = tagSearch.toLowerCase();
+ if (!c.tags.some((t) => t.toLowerCase().includes(q))) return false;
+ }
+ if (staleOnly && !isStale(c)) return false;
+ return true;
+ });
+
+ // ---------------------------------------------------------------------------
+ // Batch actions
+ // ---------------------------------------------------------------------------
+
+ function toggleSelect(id: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ function toggleSelectAll() {
+ if (selected.size === filtered.length) {
+ setSelected(new Set());
+ } else {
+ setSelected(new Set(filtered.map((c) => c.id)));
+ }
+ }
+
+ function batchChangeTier(tier: CandidateTier) {
+ selected.forEach((id) => {
+ updateCandidate.mutate({ id, updates: { tier } });
+ });
+ setSelected(new Set());
+ }
+
+ function exportSelected() {
+ const rows = candidates.filter((c) => selected.has(c.id));
+ if (!rows.length) return;
+ downloadCSV(candidatesToCSV(rows), 'pipeline-export.csv');
+ }
+
+ function exportAll() {
+ if (!filtered.length) return;
+ downloadCSV(candidatesToCSV(filtered), 'pipeline-export.csv');
+ }
+
+ // ---------------------------------------------------------------------------
+ // Card actions (board)
+ // ---------------------------------------------------------------------------
+
+ function handleCardAction(
+ action: string,
+ candidate: RecruiterCandidate,
+ tier?: CandidateTier,
+ ) {
+ setOpenMenuId(null);
+ switch (action) {
+ case 'review':
+ navigate(`/recruiter/candidates/${candidate.id}`);
+ break;
+ case 'outreach':
+ navigate(`/recruiter/candidates/${candidate.id}`);
+ break;
+ case 'change_tier':
+ if (tier) updateCandidate.mutate({ id: candidate.id, updates: { tier } });
+ break;
+ case 'hold':
+ updateCandidate.mutate({
+ id: candidate.id,
+ updates: { pipeline_stage: 'hold' },
+ });
+ break;
+ case 'suppress':
+ updateCandidate.mutate({
+ id: candidate.id,
+ updates: { pipeline_stage: 'suppressed' },
+ });
+ break;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render helpers
+ // ---------------------------------------------------------------------------
+
+ const selectBtnStyle: React.CSSProperties = {
+ background: 'var(--ros-bg-secondary)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-secondary)',
+ borderRadius: 6,
+ padding: '4px 10px',
+ fontSize: 12,
+ cursor: 'pointer',
+ outline: 'none',
+ };
+
+ // Board view: group candidates per tier, then per stage within each tier
+ function renderBoard() {
+ return (
+
+ {PIPELINE_TIERS.map((tier) => {
+ const tierCandidates = filtered.filter((c) => c.tier === tier);
+ const grouped = ALL_STAGES.reduce
>(
+ (acc, stage) => {
+ const matches = tierCandidates.filter((c) => c.pipeline_stage === stage);
+ if (matches.length) acc[stage] = matches;
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return (
+
+ {/* Column header */}
+
+
+ {TIER_CONFIG[tier].label}
+
+
+ {tierCandidates.length}
+
+
+
+ {/* Scrollable body */}
+
+ {Object.entries(grouped).map(([stage, group]) => (
+
+
+ {STAGE_CONFIG[stage as PipelineStage].label}
+
+
+ {group.map((c) => (
+
navigate(`/recruiter/candidates/${c.id}`)}
+ >
+ {/* Action menu trigger */}
+
+
+ {/* Action dropdown */}
+ {openMenuId === c.id && (
+
e.stopPropagation()}
+ >
+
+
+ {/* Change tier submenu */}
+
+
+ Change Tier
+
+
+ {ALL_TIERS.filter((t) => t !== c.tier).map((t) => (
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Card body */}
+
+ {c.name ?? 'Unknown'}
+
+
+ {truncate(c.current_title, 30)}
+ {c.current_company ? ` @ ${truncate(c.current_company, 20)}` : ''}
+
+
+
+ {c.composite_score ?? '--'}
+
+
+ {daysSince(c.updated_at)}d in stage
+
+
+
+ {c.tags.length > 0 && (
+
+ {c.tags.slice(0, 2).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ ))}
+ {tierCandidates.length === 0 && (
+
+ No candidates
+
+ )}
+
+
+ );
+ })}
+
+ );
+ }
+
+ // Table view
+ function renderTable() {
+ const sorted = [...filtered].sort((a, b) => {
+ const diff = (a.composite_score ?? 0) - (b.composite_score ?? 0);
+ return scoreSortDir === 'asc' ? diff : -diff;
+ });
+
+ return (
+
+
+
+
+
+ |
+ 0}
+ onChange={toggleSelectAll}
+ style={{ accentColor: 'var(--ros-accent)' }}
+ />
+ |
+
+ Name
+ |
+
+ Tier
+ |
+
+ Stage
+ |
+
+ setScoreSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
+ }
+ >
+ Score {scoreSortDir === 'asc' ? '\u2191' : '\u2193'}
+ |
+
+ Contact
+ |
+
+ Tags
+ |
+
+ Days
+ |
+
+ Actions
+ |
+
+
+
+ {sorted.map((c) => (
+ navigate(`/recruiter/candidates/${c.id}`)}
+ >
+ | e.stopPropagation()}>
+ toggleSelect(c.id)}
+ style={{ accentColor: 'var(--ros-accent)' }}
+ />
+ |
+
+
+ {c.name ?? 'Unknown'}
+
+
+ {truncate(c.current_title, 28)}
+ {c.current_company ? ` @ ${truncate(c.current_company, 18)}` : ''}
+
+ |
+ e.stopPropagation()}>
+
+ |
+ e.stopPropagation()}>
+
+ |
+
+
+ {c.composite_score ?? '--'}
+
+ |
+
+
+ |
+
+
+ {c.tags.slice(0, 2).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ |
+
+ {daysSince(c.created_at)}
+ |
+ e.stopPropagation()}>
+
+ |
+
+ ))}
+
+
+
+
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Main render
+ // ---------------------------------------------------------------------------
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!candidates.length) {
+ return (
+
+
+
}
+ title="No candidates in pipeline"
+ description="Search for candidates to build your pipeline."
+ action={
+
+ }
+ />
+
+ );
+ }
+
+ return (
+
+
+ {/* View toggle */}
+
+
+
+
+ {/* Export */}
+
+
+ }
+ />
+
+ {/* Filter bar */}
+
+
+
+
+
+
+
+
+
+
+
+ setTagSearch(e.target.value)}
+ className="text-xs pl-6 pr-2 py-1 rounded-md"
+ style={{
+ background: 'var(--ros-bg-card)',
+ border: '1px solid var(--ros-border)',
+ color: 'var(--ros-text-primary)',
+ outline: 'none',
+ width: 130,
+ }}
+ />
+
+
+
+
+ {hasFilters && (
+
+ )}
+
+
+ {/* Content */}
+ {filtered.length === 0 ? (
+ }
+ title="No matching candidates"
+ description="Try adjusting your filters."
+ />
+ ) : viewMode === 'board' ? (
+ renderBoard()
+ ) : (
+ renderTable()
+ )}
+
+ {/* Batch action bar */}
+ {selected.size > 0 && (
+
+
+ {selected.size} selected
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/recruiter/routes.tsx b/src/recruiter/routes.tsx
new file mode 100644
index 0000000..fdd8d3c
--- /dev/null
+++ b/src/recruiter/routes.tsx
@@ -0,0 +1,46 @@
+import { lazy, Suspense } from 'react';
+import { Route } from 'react-router-dom';
+
+// Lazy-load all recruiter pages for code splitting
+const CommandCenter = lazy(() => import('./pages/CommandCenter'));
+const SearchLab = lazy(() => import('./pages/SearchLab'));
+const CandidateIntelList = lazy(() => import('./pages/CandidateIntelList'));
+const CandidateIntelProfile = lazy(() => import('./pages/CandidateIntelProfile'));
+const TeamPipeline = lazy(() => import('./pages/TeamPipeline'));
+const OutreachStudio = lazy(() => import('./pages/OutreachStudio'));
+const RoleScorecardList = lazy(() => import('./pages/RoleScorecardList'));
+const RoleScorecardDetail = lazy(() => import('./pages/RoleScorecardDetail'));
+const AgentRuns = lazy(() => import('./pages/AgentRuns'));
+const Reports = lazy(() => import('./pages/Reports'));
+const RecruiterSettings = lazy(() => import('./pages/RecruiterSettings'));
+
+function RecruiterLoading() {
+ return (
+
+ );
+}
+
+function Lazy({ children }: { children: React.ReactNode }) {
+ return }>{children};
+}
+
+export const recruiterRoutes = (
+ <>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ >
+);
diff --git a/src/recruiter/services/export.ts b/src/recruiter/services/export.ts
new file mode 100644
index 0000000..058cb4a
--- /dev/null
+++ b/src/recruiter/services/export.ts
@@ -0,0 +1,48 @@
+// SourceKit Recruiter OS — Export utilities
+
+import type { RecruiterCandidate } from '../lib/types';
+import { TIER_CONFIG, STAGE_CONFIG, CONTACT_STATUS_CONFIG } from '../lib/constants';
+
+export function candidatesToCSV(candidates: RecruiterCandidate[]): string {
+ const headers = [
+ 'Name', 'Title', 'Company', 'Location', 'Tier', 'Stage',
+ 'Composite Score', 'EEA', 'Builder', 'AI Recency', 'Systems Depth',
+ 'Product Instinct', 'Hidden Gem', 'Contact Status', 'Tags',
+ 'GitHub', 'LinkedIn', 'Email', 'Created',
+ ];
+
+ const rows = candidates.map(c => [
+ c.name ?? '',
+ c.current_title ?? '',
+ c.current_company ?? '',
+ c.location ?? '',
+ TIER_CONFIG[c.tier]?.label ?? c.tier,
+ STAGE_CONFIG[c.pipeline_stage]?.label ?? c.pipeline_stage,
+ c.composite_score?.toString() ?? '',
+ c.eea_score?.score?.toString() ?? '',
+ c.builder_score?.score?.toString() ?? '',
+ c.ai_recency_score?.score?.toString() ?? '',
+ c.systems_depth_score?.score?.toString() ?? '',
+ c.product_instinct_score?.score?.toString() ?? '',
+ c.hidden_gem_score?.score?.toString() ?? '',
+ CONTACT_STATUS_CONFIG[c.contact_status]?.label ?? c.contact_status,
+ c.tags.join('; '),
+ c.github_username ? `https://github.com/${c.github_username}` : '',
+ c.linkedin_url ?? '',
+ c.email ?? '',
+ c.created_at,
+ ]);
+
+ const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
+ return [headers.map(escape).join(','), ...rows.map(r => r.map(escape).join(','))].join('\n');
+}
+
+export function downloadCSV(content: string, filename: string) {
+ const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/recruiter/services/scoring.ts b/src/recruiter/services/scoring.ts
new file mode 100644
index 0000000..dad8a56
--- /dev/null
+++ b/src/recruiter/services/scoring.ts
@@ -0,0 +1,81 @@
+// SourceKit Recruiter OS — Client-side scoring utilities
+// Structured to be replaceable by backend-calculated values
+
+import type { RecruiterScores, DimensionScore, ScoringWeights, CandidateTier, OutreachPriority } from '../lib/types';
+import { DEFAULT_SCORING_WEIGHTS, SCORE_THRESHOLDS } from '../lib/constants';
+
+/**
+ * Compute composite score from dimension scores using weights.
+ * Returns 0-100.
+ */
+export function computeCompositeScore(
+ scores: Partial,
+ weights: ScoringWeights = DEFAULT_SCORING_WEIGHTS
+): number {
+ const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
+ if (totalWeight === 0) return 0;
+
+ let weightedSum = 0;
+ const dimensions = Object.keys(weights) as (keyof ScoringWeights)[];
+
+ for (const dim of dimensions) {
+ const dimScore = scores[dim];
+ if (dimScore) {
+ weightedSum += dimScore.score * (weights[dim] / totalWeight);
+ }
+ }
+
+ return Math.round(weightedSum);
+}
+
+/**
+ * Auto-assign tier based on composite score.
+ * Thresholds: Tier 1 >= 80, Tier 2 >= 60, Borderline >= 40, Below Bar < 40
+ */
+export function assignTier(compositeScore: number): CandidateTier {
+ if (compositeScore >= 80) return 'tier_1';
+ if (compositeScore >= 60) return 'tier_2';
+ if (compositeScore >= 40) return 'borderline';
+ return 'below_bar';
+}
+
+/**
+ * Compute outreach priority from tier and hidden gem status.
+ */
+export function computeOutreachPriority(
+ tier: CandidateTier,
+ hiddenGemScore: number
+): OutreachPriority {
+ if (tier === 'tier_1') return 'high';
+ if (tier === 'tier_2' && hiddenGemScore >= 70) return 'high';
+ if (tier === 'tier_2') return 'medium';
+ return 'low';
+}
+
+/**
+ * Get score color class based on value.
+ */
+export function getScoreColor(score: number): string {
+ if (score >= SCORE_THRESHOLDS.high) return 'text-emerald-400';
+ if (score >= SCORE_THRESHOLDS.medium) return 'text-amber-400';
+ return 'text-red-400';
+}
+
+export function getScoreBgColor(score: number): string {
+ if (score >= SCORE_THRESHOLDS.high) return 'bg-emerald-500/15';
+ if (score >= SCORE_THRESHOLDS.medium) return 'bg-amber-500/15';
+ return 'bg-red-500/15';
+}
+
+/**
+ * Create a placeholder dimension score for display scaffolding.
+ * TODO: Replace with backend-calculated scores from recruiter-scoring edge function
+ */
+export function placeholderDimensionScore(score: number, reason: string): DimensionScore {
+ return {
+ score,
+ evidence: [],
+ confidence: score > 70 ? 'high' : score > 40 ? 'medium' : 'low',
+ reason,
+ };
+}
diff --git a/src/recruiter/styles/recruiter-tokens.css b/src/recruiter/styles/recruiter-tokens.css
new file mode 100644
index 0000000..cf3c528
--- /dev/null
+++ b/src/recruiter/styles/recruiter-tokens.css
@@ -0,0 +1,144 @@
+/* SourceKit Recruiter OS — Design Tokens
+ Applied via .recruiter-os class on the layout root */
+
+.recruiter-os {
+ --ros-bg-primary: #0a0a0f;
+ --ros-bg-secondary: #111118;
+ --ros-bg-tertiary: #1a1a24;
+ --ros-bg-card: #14141e;
+ --ros-bg-hover: #1e1e2a;
+
+ --ros-accent: #00e5a0;
+ --ros-accent-hover: #00cc8e;
+ --ros-accent-muted: rgba(0, 229, 160, 0.15);
+
+ --ros-text-primary: #f0f0f5;
+ --ros-text-secondary: #8888a0;
+ --ros-text-muted: #55556a;
+
+ --ros-border: #2a2a3a;
+ --ros-border-active: #00e5a0;
+
+ --ros-tier-1: #00e5a0;
+ --ros-tier-2: #6366f1;
+ --ros-borderline: #eab308;
+ --ros-below-bar: #ef4444;
+ --ros-false-positive: #6b7280;
+ --ros-nurture: #8b5cf6;
+ --ros-ats-synced: #3b82f6;
+
+ --ros-score-high: #00e5a0;
+ --ros-score-medium: #eab308;
+ --ros-score-low: #ef4444;
+
+ --ros-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3);
+ --ros-shadow-elevated: 0 4px 12px rgba(0, 0, 0, 0.4);
+
+ --ros-duration-fast: 150ms;
+ --ros-duration-normal: 250ms;
+ --ros-ease: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+/* Recruiter OS overrides dark theme variables within its scope */
+.recruiter-os {
+ --background: 240 20% 4%;
+ --foreground: 240 5% 95%;
+ --card: 240 15% 8%;
+ --card-foreground: 240 5% 95%;
+ --popover: 240 15% 8%;
+ --popover-foreground: 240 5% 95%;
+ --primary: 162 100% 45%;
+ --primary-foreground: 240 20% 4%;
+ --secondary: 240 12% 10%;
+ --secondary-foreground: 240 5% 65%;
+ --muted: 240 10% 13%;
+ --muted-foreground: 240 5% 55%;
+ --accent: 162 100% 45%;
+ --accent-foreground: 240 20% 4%;
+ --border: 240 12% 16%;
+ --input: 240 12% 16%;
+ --ring: 162 100% 45%;
+}
+
+/* Fade-in animation for cards and sections */
+@keyframes ros-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.ros-fade-in {
+ animation: ros-fade-in var(--ros-duration-normal) var(--ros-ease) both;
+}
+
+/* Staggered fade-in for lists */
+.ros-stagger > * {
+ animation: ros-fade-in var(--ros-duration-normal) var(--ros-ease) both;
+}
+.ros-stagger > *:nth-child(1) { animation-delay: 0ms; }
+.ros-stagger > *:nth-child(2) { animation-delay: 50ms; }
+.ros-stagger > *:nth-child(3) { animation-delay: 100ms; }
+.ros-stagger > *:nth-child(4) { animation-delay: 150ms; }
+.ros-stagger > *:nth-child(5) { animation-delay: 200ms; }
+.ros-stagger > *:nth-child(6) { animation-delay: 250ms; }
+.ros-stagger > *:nth-child(7) { animation-delay: 300ms; }
+.ros-stagger > *:nth-child(8) { animation-delay: 350ms; }
+
+/* Dense table styling for recruiter views */
+.ros-table {
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+}
+
+.ros-table th {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--ros-text-muted);
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--ros-border);
+ position: sticky;
+ top: 0;
+ background: var(--ros-bg-secondary);
+ z-index: 10;
+}
+
+.ros-table td {
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid rgba(42, 42, 58, 0.5);
+ white-space: nowrap;
+}
+
+.ros-table tbody tr {
+ transition: background-color var(--ros-duration-fast) ease;
+}
+
+.ros-table tbody tr:hover {
+ background-color: var(--ros-bg-hover);
+}
+
+/* Scrollbar styling for recruiter views */
+.recruiter-os ::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.recruiter-os ::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.recruiter-os ::-webkit-scrollbar-thumb {
+ background: var(--ros-border);
+ border-radius: 3px;
+}
+
+.recruiter-os ::-webkit-scrollbar-thumb:hover {
+ background: var(--ros-text-muted);
+}
diff --git a/supabase/migrations/20260323_recruiter_os_schema.sql b/supabase/migrations/20260323_recruiter_os_schema.sql
new file mode 100644
index 0000000..3c7b179
--- /dev/null
+++ b/supabase/migrations/20260323_recruiter_os_schema.sql
@@ -0,0 +1,229 @@
+-- SourceKit Recruiter OS — Database Schema Migration
+-- Date: 2026-03-23
+-- Description: Creates all tables required for the Recruiter OS product
+
+-- ============================================================
+-- 1. Role Scorecards
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_scorecards (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ name TEXT NOT NULL,
+ status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
+ talent_thesis TEXT,
+ must_have_signals JSONB DEFAULT '[]',
+ nice_to_have_signals JSONB DEFAULT '[]',
+ suppressions JSONB DEFAULT '[]',
+ scoring_weights JSONB DEFAULT '{"eea": 25, "builder": 20, "ai_recency": 20, "systems_depth": 15, "product_instinct": 10, "hidden_gem": 10}',
+ outreach_tone TEXT DEFAULT 'professional',
+ evaluation_questions JSONB DEFAULT '[]',
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 2. Recruiter Candidates
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_candidates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+
+ -- Identity
+ name TEXT,
+ avatar_url TEXT,
+ current_title TEXT,
+ current_company TEXT,
+ location TEXT,
+ bio TEXT,
+
+ -- Links
+ github_username TEXT,
+ linkedin_url TEXT,
+ portfolio_url TEXT,
+ huggingface_url TEXT,
+ blog_url TEXT,
+ personal_site_url TEXT,
+ email TEXT,
+
+ -- Scores (JSONB with {score, evidence[], confidence, reason})
+ eea_score JSONB,
+ builder_score JSONB,
+ ai_recency_score JSONB,
+ systems_depth_score JSONB,
+ product_instinct_score JSONB,
+ hidden_gem_score JSONB,
+ composite_score NUMERIC,
+ outreach_priority TEXT DEFAULT 'medium' CHECK (outreach_priority IN ('high', 'medium', 'low')),
+
+ -- Tiering
+ tier TEXT DEFAULT 'unclassified' CHECK (tier IN ('tier_1', 'tier_2', 'borderline', 'below_bar', 'false_positive', 'nurture', 'ats_synced', 'unclassified')),
+
+ -- Pipeline
+ pipeline_stage TEXT DEFAULT 'new' CHECK (pipeline_stage IN ('new', 'under_review', 'outreach_sent', 'replied', 'screening', 'moved_to_ats', 'hold', 'suppressed')),
+
+ -- Artifacts & evidence
+ artifacts JSONB DEFAULT '[]',
+ sourcing_rationale TEXT,
+ hidden_gem_reasons JSONB DEFAULT '[]',
+
+ -- Contact
+ contact_status TEXT DEFAULT 'not_contacted' CHECK (contact_status IN ('not_contacted', 'contacted', 'replied', 'not_interested', 'scheduled')),
+
+ -- Meta
+ tags TEXT[] DEFAULT '{}',
+ needs_review BOOLEAN DEFAULT false,
+ search_ids UUID[] DEFAULT '{}',
+ scorecard_id UUID REFERENCES recruiter_scorecards(id),
+ ats_sync_status TEXT DEFAULT 'not_synced' CHECK (ats_sync_status IN ('not_synced', 'synced', 'sync_failed')),
+ ats_synced_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 3. Candidate Notes
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_candidate_notes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ candidate_id UUID REFERENCES recruiter_candidates(id) ON DELETE CASCADE NOT NULL,
+ content TEXT,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 4. Outreach Messages
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_outreach (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ candidate_id UUID REFERENCES recruiter_candidates(id) ON DELETE CASCADE NOT NULL,
+ scorecard_id UUID REFERENCES recruiter_scorecards(id),
+ message TEXT NOT NULL,
+ grounding_artifacts JSONB DEFAULT '[]',
+ tone TEXT,
+ sequence_step TEXT DEFAULT 'initial',
+ channel TEXT DEFAULT 'email' CHECK (channel IN ('email', 'linkedin', 'other')),
+ status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'ready', 'personalize', 'hold', 'sent', 'replied')),
+ sent_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 5. Agent Runs
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_agent_runs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ type TEXT NOT NULL CHECK (type IN ('search', 'enrichment', 'scoring', 'outreach_generation', 'company_mapping', 'monitor', 'dedup_ats_sync')),
+ status TEXT DEFAULT 'running' CHECK (status IN ('running', 'complete', 'failed', 'partial')),
+ inputs JSONB NOT NULL DEFAULT '{}',
+ outputs JSONB DEFAULT '{}',
+ errors JSONB DEFAULT '[]',
+ candidate_ids UUID[] DEFAULT '{}',
+ scorecard_id UUID REFERENCES recruiter_scorecards(id),
+ started_at TIMESTAMPTZ DEFAULT now(),
+ completed_at TIMESTAMPTZ,
+ duration_ms INTEGER
+);
+
+-- ============================================================
+-- 6. Saved Searches
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_saved_searches (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ name TEXT NOT NULL,
+ scorecard_id UUID REFERENCES recruiter_scorecards(id),
+ config JSONB NOT NULL,
+ result_count INTEGER DEFAULT 0,
+ last_run_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- 7. Sequence Templates
+-- ============================================================
+CREATE TABLE IF NOT EXISTS recruiter_sequence_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES auth.users(id) NOT NULL,
+ scorecard_id UUID REFERENCES recruiter_scorecards(id),
+ name TEXT NOT NULL,
+ steps JSONB NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- ============================================================
+-- Indexes
+-- ============================================================
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_user ON recruiter_candidates(user_id);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_tier ON recruiter_candidates(user_id, tier);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_stage ON recruiter_candidates(user_id, pipeline_stage);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_composite ON recruiter_candidates(user_id, composite_score DESC);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_github ON recruiter_candidates(github_username);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_linkedin ON recruiter_candidates(linkedin_url);
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_needs_review ON recruiter_candidates(user_id, needs_review) WHERE needs_review = true;
+CREATE INDEX IF NOT EXISTS idx_recruiter_candidates_tags ON recruiter_candidates USING GIN(tags);
+CREATE INDEX IF NOT EXISTS idx_recruiter_agent_runs_user ON recruiter_agent_runs(user_id);
+CREATE INDEX IF NOT EXISTS idx_recruiter_agent_runs_status ON recruiter_agent_runs(user_id, status);
+CREATE INDEX IF NOT EXISTS idx_recruiter_outreach_candidate ON recruiter_outreach(candidate_id);
+CREATE INDEX IF NOT EXISTS idx_recruiter_scorecards_user ON recruiter_scorecards(user_id);
+CREATE INDEX IF NOT EXISTS idx_recruiter_saved_searches_user ON recruiter_saved_searches(user_id);
+
+-- ============================================================
+-- Row Level Security
+-- ============================================================
+ALTER TABLE recruiter_scorecards ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own scorecards" ON recruiter_scorecards FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_candidates ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own candidates" ON recruiter_candidates FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_candidate_notes ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own notes" ON recruiter_candidate_notes FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_outreach ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own outreach" ON recruiter_outreach FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_agent_runs ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own agent runs" ON recruiter_agent_runs FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_saved_searches ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own saved searches" ON recruiter_saved_searches FOR ALL USING (auth.uid() = user_id);
+
+ALTER TABLE recruiter_sequence_templates ENABLE ROW LEVEL SECURITY;
+CREATE POLICY "Users manage own sequence templates" ON recruiter_sequence_templates FOR ALL USING (auth.uid() = user_id);
+
+-- ============================================================
+-- Updated_at triggers
+-- ============================================================
+CREATE OR REPLACE FUNCTION update_recruiter_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_recruiter_scorecards_updated
+ BEFORE UPDATE ON recruiter_scorecards
+ FOR EACH ROW EXECUTE FUNCTION update_recruiter_updated_at();
+
+CREATE TRIGGER trg_recruiter_candidates_updated
+ BEFORE UPDATE ON recruiter_candidates
+ FOR EACH ROW EXECUTE FUNCTION update_recruiter_updated_at();
+
+CREATE TRIGGER trg_recruiter_saved_searches_updated
+ BEFORE UPDATE ON recruiter_saved_searches
+ FOR EACH ROW EXECUTE FUNCTION update_recruiter_updated_at();
+
+CREATE TRIGGER trg_recruiter_sequence_templates_updated
+ BEFORE UPDATE ON recruiter_sequence_templates
+ FOR EACH ROW EXECUTE FUNCTION update_recruiter_updated_at();
+
+CREATE TRIGGER trg_recruiter_candidate_notes_updated
+ BEFORE UPDATE ON recruiter_candidate_notes
+ FOR EACH ROW EXECUTE FUNCTION update_recruiter_updated_at();
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 83dba64..0b8e00e 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -85,10 +85,15 @@ export default {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
+ "ros-fade-in": {
+ from: { opacity: "0", transform: "translateY(4px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
+ "ros-fade-in": "ros-fade-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) both",
},
},
},
diff --git a/vite.config.ts b/vite.config.ts
index 7c33afa..0d42ee0 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -25,6 +25,13 @@ export default defineConfig(({ mode }) => ({
"query-vendor": ["@tanstack/react-query"],
"ui-vendor": ["lucide-react"],
},
+ chunkFileNames(chunkInfo) {
+ // Recruiter OS gets its own chunk namespace
+ if (chunkInfo.name?.startsWith('recruiter') || chunkInfo.facadeModuleId?.includes('/recruiter/')) {
+ return 'assets/recruiter-[name]-[hash].js';
+ }
+ return 'assets/[name]-[hash].js';
+ },
},
},
},