diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index c6ef9c89..c20c5251 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -288,6 +288,8 @@ export type AgentConfig = { /** Feature flag key -> variant (evaluated at start of run). */ wizardFlags?: Record; wizardMetadata?: Record; + /** Workflow identifier — selects the model for that workflow. */ + integrationLabel?: string; }; /** @@ -663,10 +665,17 @@ export async function initializeAgent( }); mcpServers['wizard-tools'] = wizardToolsServer; + // audit-3000 needs Opus 4.7's depth for the multi-phase audit chain; + // every other workflow runs on Sonnet 4.6. + const model = + config.integrationLabel === 'audit-3000' + ? 'anthropic/claude-opus-4-7' + : 'anthropic/claude-sonnet-4-6'; + const agentRunConfig: AgentRunConfig = { workingDirectory: config.workingDirectory, mcpServers, - model: 'anthropic/claude-sonnet-4-6', + model, wizardFlags: config.wizardFlags, wizardMetadata: config.wizardMetadata, }; diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index af843abf..d55e4c98 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -312,6 +312,7 @@ export async function runWorkflow( skillsBaseUrl, wizardFlags, wizardMetadata, + integrationLabel: config.integrationLabel, }, sessionToOptions(session), ); diff --git a/src/lib/workflows/agent-skill/index.ts b/src/lib/workflows/agent-skill/index.ts index 2ad4a2ac..ba4cb33c 100644 --- a/src/lib/workflows/agent-skill/index.ts +++ b/src/lib/workflows/agent-skill/index.ts @@ -57,6 +57,7 @@ export function createSkillWorkflow( description: opts.description, flowKey: opts.flowKey, steps: AGENT_SKILL_STEPS, + reportFile: opts.reportFile, run: { skillId: opts.skillId, integrationLabel: opts.integrationLabel, diff --git a/src/lib/workflows/audit-3000/index.ts b/src/lib/workflows/audit-3000/index.ts new file mode 100644 index 00000000..522df9fa --- /dev/null +++ b/src/lib/workflows/audit-3000/index.ts @@ -0,0 +1,251 @@ +import fs from 'fs'; +import path from 'path'; +import { + AGENT_SKILL_STEPS, + createSkillWorkflow, +} from '../agent-skill/index.js'; +import type { Workflow, WorkflowConfig } from '../workflow-step.js'; +import type { WorkflowRun } from '../../agent/agent-runner.js'; +import type { WizardSession } from '../../wizard-session.js'; +import { AUDIT_ABORT_CASES } from '../audit/detect.js'; +import { + AUDIT_CHECKS_FILE, + AUDIT_CHECKS_KEY, + type AuditCheck, +} from '../audit/types.js'; +import { AUDIT_SEED_CHECKS } from '../audit/seed.js'; +import { logToFile } from '../../../utils/debug'; + +const AUDIT3000_REPORT_FILE = 'posthog-audit-3000-report.md'; + +// Extra checks the v3000 audit adds on top of the base 10. IDs must match +// those referenced in the audit-3000 skill's step files (Event Quality, +// stale feature-flag review, session replay [fix + optimize], per-product +// use-case expansion, and phase markers for the post-flags chain). +const AUDIT3000_EXTRA_CHECKS: AuditCheck[] = [ + // ── Event Quality (Step 5) ── + { + id: 'event-naming-standardization', + area: 'Event Quality', + label: 'Event naming convention is consistent', + status: 'pending', + }, + { + id: 'event-duplicates-and-bloat', + area: 'Event Quality', + label: 'No duplicate or bloated event capture', + status: 'pending', + }, + { + id: 'event-quality-context-review', + area: 'Event Quality', + label: 'Event property context reviewed', + status: 'pending', + }, + { + id: 'event-usage-coverage', + area: 'Event Quality', + label: 'Captured events match insights / dashboards usage', + status: 'pending', + }, + // ── Feature Flags (Step 6) ── + { + id: 'stale-feature-flags-reviewed', + area: 'Feature Flags', + label: 'Stale feature flags reviewed', + status: 'pending', + }, + // ── Session Replay — fix (Step 6b) ── + { + id: 'replay-minimum-duration-set', + area: 'Session Replay', + label: 'Minimum duration set on init', + status: 'pending', + }, + { + id: 'replay-mask-config', + area: 'Session Replay', + label: 'Mask config covers sensitive surfaces', + status: 'pending', + }, + { + id: 'replay-disabled-in-test-envs', + area: 'Session Replay', + label: 'Disabled in test / CI environments', + status: 'pending', + }, + { + id: 'replay-strict-minimum-duration', + area: 'Session Replay', + label: 'Strict minimum duration enforced', + status: 'pending', + }, + // ── Session Replay — optimize (Step 6b cost wave) ── + { + id: 'replay-sampling-rate', + area: 'Session Replay — Optimize', + label: 'Sampling rate tuned for cost', + status: 'pending', + }, + { + id: 'replay-triggers-configured', + area: 'Session Replay — Optimize', + label: 'Triggers configured (event / URL / flag)', + status: 'pending', + }, + { + id: 'replay-network-recording-filtered', + area: 'Session Replay — Optimize', + label: 'Network recording filtered', + status: 'pending', + }, + { + id: 'replay-mobile-sampling', + area: 'Session Replay — Optimize', + label: 'Mobile sampling configured', + status: 'pending', + }, + // ── Use Case: Expansion (Step 9) ── + { + id: 'expansion-product-analytics', + area: 'Use Case: Expansion', + label: 'Product analytics coverage', + status: 'pending', + }, + { + id: 'expansion-error-tracking', + area: 'Use Case: Expansion', + label: 'Error tracking coverage', + status: 'pending', + }, + { + id: 'expansion-llm-observability', + area: 'Use Case: Expansion', + label: 'LLM observability coverage', + status: 'pending', + }, + { + id: 'expansion-session-replay', + area: 'Use Case: Expansion', + label: 'Session replay coverage', + status: 'pending', + }, + { + id: 'expansion-feature-flags', + area: 'Use Case: Expansion', + label: 'Feature flags coverage', + status: 'pending', + }, + { + id: 'expansion-surveys', + area: 'Use Case: Expansion', + label: 'Surveys coverage', + status: 'pending', + }, + { + id: 'expansion-logs', + area: 'Use Case: Expansion', + label: 'Logs coverage', + status: 'pending', + }, + { + id: 'expansion-web-analytics', + area: 'Use Case: Expansion', + label: 'Web analytics coverage', + status: 'pending', + }, + // ── Additional Sections (Steps 7, 8, 10 phase markers) ── + // Tracked in the ledger so the UI can surface "did it run / was it + // skipped" alongside the regular checks. use-case-expansion is omitted + // because the eight `expansion-*` checks above cover that phase. + { + id: 'customer-enrichment', + area: 'Additional Sections', + label: 'Customer enrichment (Harmonic + PDL)', + status: 'pending', + }, + { + id: 'use-case-match', + area: 'Additional Sections', + label: 'Use-case match', + status: 'pending', + }, + { + id: 'final-report', + area: 'Additional Sections', + label: 'Final audit report written', + status: 'pending', + }, +]; + +const AUDIT3000_SEED_CHECKS: AuditCheck[] = [ + ...AUDIT_SEED_CHECKS, + ...AUDIT3000_EXTRA_CHECKS, +]; + +// Audit-3000 has its own arcade-flavoured intro / run / outro screens. The +// shared audit screens stay reserved for the original `audit` workflow. +const AUDIT3000_SCREEN_BY_STEP: Record = { + intro: 'audit-3000-intro', + run: 'audit-3000-run', + outro: 'audit-3000-outro', +}; + +const seedAudit3000Ledger = (installDir: string): void => { + const target = path.join(installDir, AUDIT_CHECKS_FILE); + const tmp = `${target}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(AUDIT3000_SEED_CHECKS, null, 2), 'utf8'); + fs.renameSync(tmp, target); + logToFile( + `seedAudit3000Ledger: wrote ${AUDIT3000_SEED_CHECKS.length} entries to ${target}`, + ); +}; + +const seedBeforeAudit3000Run = (session: WizardSession): void => { + seedAudit3000Ledger(session.installDir); + session.frameworkContext[AUDIT_CHECKS_KEY] = AUDIT3000_SEED_CHECKS; +}; + +const withAudit3000Screens = (steps: Workflow): Workflow => + steps.map((step) => { + const override = AUDIT3000_SCREEN_BY_STEP[step.id]; + return override ? { ...step, screen: override } : step; + }); + +const audit3000Steps: Workflow = withAudit3000Screens(AGENT_SKILL_STEPS); + +const baseConfig = createSkillWorkflow({ + skillId: 'audit-3000', + command: 'audit-3000', + flowKey: 'audit-3000', + description: + 'Audit an existing PostHog integration (v3000 — adds event quality, stale-flag hygiene, customer enrichment, use-case match)', + integrationLabel: 'audit-3000', + customPrompt: + 'Run the audit-3000 skill end-to-end. Follow the step chain starting at references/1-version.md. Do not modify any project files — only create the final audit report and (when enrichment is enabled) the enrichment report.', + successMessage: `Audit complete! View the report at ./${AUDIT3000_REPORT_FILE}`, + reportFile: AUDIT3000_REPORT_FILE, + docsUrl: 'https://posthog.com/docs/product-analytics/best-practices', + spinnerMessage: 'Running PostHog Audit 3000...', + estimatedDurationMinutes: 6, + requires: ['posthog-integration'], + abortCases: AUDIT_ABORT_CASES, +}); + +const audit3000Run = async (session: WizardSession): Promise => { + seedBeforeAudit3000Run(session); + + if (!baseConfig.run) { + throw new Error('audit-3000 workflow has no run configuration.'); + } + + return typeof baseConfig.run === 'function' + ? baseConfig.run(session) + : baseConfig.run; +}; + +export const audit3000Config: WorkflowConfig = { + ...baseConfig, + steps: audit3000Steps, + run: audit3000Run, +}; diff --git a/src/lib/workflows/workflow-registry.ts b/src/lib/workflows/workflow-registry.ts index 3a729dad..b4521092 100644 --- a/src/lib/workflows/workflow-registry.ts +++ b/src/lib/workflows/workflow-registry.ts @@ -15,12 +15,14 @@ import type { WorkflowConfig } from './workflow-step.js'; import { posthogIntegrationConfig } from './posthog-integration/index.js'; import { revenueAnalyticsConfig } from './revenue-analytics/index.js'; import { auditConfig } from './audit/index.js'; +import { audit3000Config } from './audit-3000/index.js'; import { posthogDoctorConfig } from './posthog-doctor/index.js'; export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ posthogIntegrationConfig, revenueAnalyticsConfig, auditConfig, + audit3000Config, posthogDoctorConfig, ]; diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index cc7bd0f6..03d302da 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -123,6 +123,12 @@ export interface WorkflowConfig { run?: WorkflowRun | ((session: WizardSession) => Promise); /** Prerequisites: other workflow flowKeys that must have run first */ requires?: string[]; + /** + * Path (relative to installDir) of the report file the workflow writes. + * Mirrors `run.reportFile` but lifted to the top level so UI screens can + * read it synchronously without resolving a deferred `run` function. + */ + reportFile?: string; } /** diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 79a9e0ab..b83446aa 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -27,6 +27,9 @@ export enum Screen { AuditIntro = 'audit-intro', AuditRun = 'audit-run', AuditOutro = 'audit-outro', + Audit3000Intro = 'audit-3000-intro', + Audit3000Run = 'audit-3000-run', + Audit3000Outro = 'audit-3000-outro', HealthCheck = 'health-check', DoctorIntro = 'doctor-intro', DoctorReport = 'doctor-report', @@ -46,6 +49,7 @@ export enum Flow { PostHogIntegration = 'posthog-integration', RevenueAnalyticsSetup = 'revenue-analytics-setup', Audit = 'audit', + Audit3000 = 'audit-3000', PosthogDoctor = 'posthog-doctor', AgentSkill = 'agent-skill', McpAdd = 'mcp-add', diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08128854..819e70f3 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -24,6 +24,9 @@ import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AuditIntroScreen } from './screens/audit/AuditIntroScreen.js'; import { AuditRunScreen } from './screens/audit/AuditRunScreen.js'; import { AuditOutroScreen } from './screens/audit/AuditOutroScreen.js'; +import { Audit3000IntroScreen } from './screens/audit-3000/Audit3000IntroScreen.js'; +import { Audit3000RunScreen } from './screens/audit-3000/Audit3000RunScreen.js'; +import { Audit3000OutroScreen } from './screens/audit-3000/Audit3000OutroScreen.js'; import { SetupScreen } from './screens/SetupScreen.js'; import { AuthScreen } from './screens/AuthScreen.js'; import { RunScreen } from './screens/RunScreen.js'; @@ -63,6 +66,9 @@ export function createScreens( [Screen.AuditIntro]: , [Screen.AuditRun]: , [Screen.AuditOutro]: , + [Screen.Audit3000Intro]: , + [Screen.Audit3000Run]: , + [Screen.Audit3000Outro]: , [Screen.HealthCheck]: , [Screen.DoctorIntro]: , [Screen.DoctorReport]: , diff --git a/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx b/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx new file mode 100644 index 00000000..fd4c4b8b --- /dev/null +++ b/src/ui/tui/screens/audit-3000/Audit3000AreaPane.tsx @@ -0,0 +1,142 @@ +/** + * Audit-3000 right pane — arcade-flavoured fork of `AuditAreaPane`. + * + * Mirrors the audit pane's three-state logic (active slide → empty → + * wrap-up) but routes through the audit-3000 slide registry and uses + * "LEVEL N: " framing instead of "Verifying ...". + */ + +import { Fragment } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { spawn } from 'node:child_process'; +import { Colors } from '../../styles.js'; +import { type AuditCheck } from '../../../../lib/workflows/audit/types.js'; +import { AUDIT_3000_AREA_SLIDES, type AreaSlide } from './slides/index.js'; + +const FINDING_STATUSES: AuditCheck['status'][] = [ + 'error', + 'warning', + 'suggestion', +]; + +const isFinding = (c: AuditCheck) => FINDING_STATUSES.includes(c.status); + +const fallbackSlide = (area: string): AreaSlide => ({ + area, + intro: [`Now playing: ${area.toLowerCase()}\u2026`], + docsUrl: '', +}); + +const openLink = (url: string) => { + const cmd = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; + spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); +}; + +interface Audit3000AreaPaneProps { + checks: AuditCheck[]; + reportPath: string; +} + +export const Audit3000AreaPane = ({ + checks, + reportPath, +}: Audit3000AreaPaneProps) => { + const pendingChecks = checks.filter((c) => c.status === 'pending'); + const activeArea = pendingChecks[0]?.area; + const slide = activeArea + ? AUDIT_3000_AREA_SLIDES.find((s) => s.area === activeArea) ?? + fallbackSlide(activeArea) + : null; + + const levelIndex = activeArea + ? AUDIT_3000_AREA_SLIDES.findIndex((s) => s.area === activeArea) + : -1; + const level = levelIndex >= 0 ? levelIndex + 1 : null; + + useInput((input) => { + if (input.toLowerCase() === 'o' && slide?.docsUrl) { + openLink(slide.docsUrl); + } + }); + + if (slide) { + const hasFindings = checks.some(isFinding); + return ( + + ); + } + + if (checks.length === 0) { + return null; + } + + return ; +}; + +const ActiveSlide = ({ + slide, + level, + hasFindings, +}: { + slide: AreaSlide; + level: number | null; + hasFindings: boolean; +}) => ( + + + {level ? `LEVEL ${level}: ` : ''} + {slide.area.toUpperCase()} + + + + {slide.visual} + {slide.intro.map((paragraph, i) => ( + + {i > 0 && } + {paragraph} + + ))} + + + + {slide.docsUrl && ( + <> + [O] Learn more + + )} + {hasFindings && ( + <> + {slide.docsUrl && ' '}[ + {'\u2192'}] View issues + + )} + + + +); + +const WritingReport = ({ reportPath }: { reportPath: string }) => ( + + + STAGE CLEAR. + + + + All checks resolved. Compiling your high-score reel at{' '} + {reportPath}. + + + + The report covers everything we checked, what we found, and what to do + next. + + + {'Stand by\u2026'} + +); diff --git a/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx b/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx new file mode 100644 index 00000000..5944b9f4 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/Audit3000ChecksPanel.tsx @@ -0,0 +1,171 @@ +/** + * Audit-3000 left pane on the Run screen. Arcade-flavoured fork of the + * audit workflow's `PendingChecksList`: a running score banner sits on + * top, then the area-level "level" headers underneath. + * + * Per-check rows are deliberately omitted here — the Hi-score Table tab + * has the full check-by-check breakdown. This pane is the at-a-glance + * stage overview. + */ + +import { Box, Text } from 'ink'; +import { Spinner } from '@inkjs/ui'; +import { + type AuditCheck, + type AuditStatus, +} from '../../../../lib/workflows/audit/types.js'; +import { Colors, Icons } from '../../styles.js'; +import { LoadingBox } from '../../primitives/index.js'; + +const NEON_PINK = '#F54E00'; +const NEON_GOLD = '#F9BD2B'; +const NEON_BLUE = '#1D4AFF'; + +interface Audit3000ChecksPanelProps { + checks: AuditCheck[]; +} + +interface Group { + area: string; + checks: AuditCheck[]; +} + +function groupByArea(checks: AuditCheck[]): Group[] { + const order: string[] = []; + const map = new Map(); + for (const c of checks) { + if (!map.has(c.area)) { + map.set(c.area, []); + order.push(c.area); + } + map.get(c.area)!.push(c); + } + return order.map((area) => ({ area, checks: map.get(area)! })); +} + +function countByStatus(checks: AuditCheck[]): Record { + const counts: Record = { + pending: 0, + pass: 0, + error: 0, + warning: 0, + suggestion: 0, + }; + for (const c of checks) counts[c.status] += 1; + return counts; +} + +const ScoreBanner = ({ checks }: { checks: AuditCheck[] }) => { + const counts = countByStatus(checks); + const resolved = checks.length - counts.pending; + const issues = counts.error + counts.warning + counts.suggestion; + + return ( + + + + {'SCORE '} + + + {resolved.toString().padStart(2, '0')} + + {' / '} + {checks.length.toString().padStart(2, '0')} + + + {`PASS \u25B2 ${counts.pass}`} + {' '} + {`MISS \u25BC ${issues}`} + {' '} + {`QUEUE \u25CB ${counts.pending}`} + + + ); +}; + +function groupIcon(group: Group): { icon: string; color: string } { + const total = group.checks.length; + const complete = group.checks.filter((c) => c.status !== 'pending').length; + if (complete === 0) return { icon: Icons.squareOpen, color: Colors.muted }; + if (complete === total) + return { icon: Icons.squareFilled, color: Colors.success }; + return { icon: Icons.triangleRight, color: Colors.primary }; +} + +const GroupHeader = ({ + group, + level, + showIcon, + isActive, +}: { + group: Group; + level: number; + showIcon: boolean; + isActive: boolean; +}) => { + const complete = group.checks.filter((c) => c.status !== 'pending').length; + const total = group.checks.length; + const { icon, color } = groupIcon(group); + return ( + + {isActive ? ( + + + + ) : showIcon ? ( + + {icon}{' '} + + ) : null} + + {`L${level} `} + {group.area}{' '} + + ({complete}/{total}) + + + + ); +}; + +export const Audit3000ChecksPanel = ({ checks }: Audit3000ChecksPanelProps) => { + if (checks.length === 0) { + return ( + + AUDIT-3000 + + + + ); + } + + const groups = groupByArea(checks); + const activeIndex = groups.findIndex((g) => + g.checks.some((c) => c.status === 'pending'), + ); + + return ( + + + AUDIT-3000 + + + + {groups.map((group, i) => ( + + ))} + + + Full breakdown: Hi-score table (report){' '} + tab + + + + ); +}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx new file mode 100644 index 00000000..ff4efb6a --- /dev/null +++ b/src/ui/tui/screens/audit-3000/Audit3000IntroScreen.tsx @@ -0,0 +1,164 @@ +import { Box, Text } from 'ink'; +import { useEffect, useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '../../store.js'; +import { IntroScreenLayout } from '../IntroScreenLayout.js'; +import { SkillSourceInfo, useSkillEntry } from '../SkillSourceInfo.js'; +import { NEON_BLUE, NEON_GOLD, NEON_PINK } from './arcade-colors.js'; + +const AUDIT3000_SKILL_ID = 'audit-3000'; + +const ArcadeBanner = () => { + // Blink the "INSERT COIN" tagline once per 600ms — classic attract-mode + // pacing without burning Ink with rapid re-renders. + const [blinkOn, setBlinkOn] = useState(true); + useEffect(() => { + const id = setInterval(() => setBlinkOn((v) => !v), 600); + return () => clearInterval(id); + }, []); + + const top = '\u250F' + '\u2501'.repeat(32) + '\u2513'; + const bottom = '\u2517' + '\u2501'.repeat(32) + '\u251B'; + + return ( + + + {top} + + + + {'\u2503'} + + + {' A U D I T '} + + + {'-'} + + + {' 3 0 0 0 '} + + + {'\u2503'} + + + + + {'\u2503'} + + + {' \u25B6 INSERT COIN TO PLAY \u25C0 '} + + + {'\u2503'} + + + + {bottom} + + + ); +}; + +interface Audit3000IntroScreenProps { + store: WizardStore; +} + +export const Audit3000IntroScreen = ({ store }: Audit3000IntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [showingMoreInfo, setShowingMoreInfo] = useState(false); + const { session } = store; + const { skillEntry, fetchFailed } = useSkillEntry( + AUDIT3000_SKILL_ID, + session.localMcp, + ); + + const body = showingMoreInfo ? ( + + + + The wizard is an agent that executes PostHog tasks. Its code is open + source: https://github.com/PostHog/wizard + + + + + The{' '} + + {AUDIT3000_SKILL_ID} + {' '} + workflow reviews your PostHog integration across 34 checks — SDK + install, identification, event capture, event quality, stale feature + flag hygiene, session replay (fix + optimize), and use-case expansion + across 8 PostHog products. When enrichment is available it also produces + a company profile and use-case match. Nothing in your project is + modified. + + + + Results stream live to the{' '} + + Hi-score Table + {' '} + tab during the run — that's your live report. When the audit + finishes, the same report is also exported to{' '} + ./posthog-audit-3000-report.md in your + project folder. + + + + + + + ) : ( + + + + 34 checks. 9 levels. 1 final report. + + High-score your PostHog integration before the boss fight. + + + + Live report: Hi-score Table tab · + Export: ./posthog-audit-3000-report.md + + + + + ); + + const menuOptions = showingMoreInfo + ? [{ label: 'Back', value: 'back' }] + : [ + { label: 'PRESS START', value: 'continue' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (value: string) => { + if (value === 'cancel') process.exit(0); + else if (value === 'more-info') setShowingMoreInfo(true); + else if (value === 'back') setShowingMoreInfo(false); + else store.completeSetup(); + }; + + return ( + + ); +}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx new file mode 100644 index 00000000..d8fe7d41 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/Audit3000OutroScreen.tsx @@ -0,0 +1,213 @@ +/** + * Audit3000OutroScreen — high-score-style summary after a v3000 audit run. + * + * On success: arcade FINAL SCORE banner with pass / miss tallies, the + * absolute report path, and the standard problematic-items list. + * + * Error and cancel branches mirror `AuditOutroScreen` so failure modes + * stay legible without arcade dressing. + */ + +import { join } from 'node:path'; +import { Box, Text, useInput } from 'ink'; +import { useSyncExternalStore } from 'react'; +import type { WizardStore } from '../../store.js'; +import { OutroKind } from '../../../../lib/wizard-session.js'; +import { Colors } from '../../styles.js'; +import { + getAuditChecks, + type AuditCheck, + type AuditStatus, +} from '../../../../lib/workflows/audit/types.js'; +import { AuditChecksOutroSection } from '../audit/AuditChecksOutroSection.js'; + +const NEON_PINK = '#F54E00'; +const NEON_GOLD = '#F9BD2B'; +const NEON_BLUE = '#1D4AFF'; + +const PANEL_WIDTH = 48; + +const padCenter = (s: string, width: number): string => { + if (s.length >= width) return s; + const total = width - s.length; + const left = Math.floor(total / 2); + const right = total - left; + return ' '.repeat(left) + s + ' '.repeat(right); +}; + +function countByStatus(checks: AuditCheck[]): Record { + const counts: Record = { + pending: 0, + pass: 0, + error: 0, + warning: 0, + suggestion: 0, + }; + for (const c of checks) counts[c.status] += 1; + return counts; +} + +const FinalScorePanel = ({ checks }: { checks: AuditCheck[] }) => { + const counts = countByStatus(checks); + const resolved = checks.length - counts.pending; + const issues = counts.error + counts.warning + counts.suggestion; + + const top = '\u250F' + '\u2501'.repeat(PANEL_WIDTH) + '\u2513'; + const bottom = '\u2517' + '\u2501'.repeat(PANEL_WIDTH) + '\u251B'; + const sep = '\u2520' + '\u2500'.repeat(PANEL_WIDTH) + '\u2528'; + + const row = (content: string) => ( + + + {'\u2503'} + + {content} + + {'\u2503'} + + + ); + + return ( + + + {top} + + {row(padCenter('GAME OVER', PANEL_WIDTH))} + + + {'\u2503'} + + + {padCenter( + `FINAL SCORE ${resolved} / ${checks.length}`, + PANEL_WIDTH, + )} + + + {'\u2503'} + + + {sep} + + + {'\u2503'} + + + {padCenter(`PASS \u25B2 ${counts.pass}`, PANEL_WIDTH)} + + + {'\u2503'} + + + + + {'\u2503'} + + + {padCenter(`MISS \u25BC ${issues}`, PANEL_WIDTH)} + + + {'\u2503'} + + + + {bottom} + + + ); +}; + +interface Audit3000OutroScreenProps { + store: WizardStore; +} + +export const Audit3000OutroScreen = ({ store }: Audit3000OutroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + useInput(() => { + store.setOutroDismissed(); + }); + + const outroData = store.session.outroData; + + if (!outroData) { + return ( + + {'Counting your tokens\u2026'} + + ); + } + + const checks = getAuditChecks(store.session); + + return ( + + {outroData.kind === OutroKind.Success && ( + + + + + + {'\u2714'} {outroData.message || 'AUDIT-3000 complete!'} + + + + {outroData.reportFile && ( + + + High-score reel saved to: + + + {join(store.session.installDir, outroData.reportFile)} + + + A markdown file in your project folder — open it in any editor + to read the full audit. + + + )} + + + + {outroData.docsUrl && ( + + + Learn more: {outroData.docsUrl} + + + )} + + )} + + {outroData.kind === OutroKind.Error && ( + + + {'\u2718'} {outroData.message || 'An error occurred'} + + {outroData.body && ( + + {outroData.body} + + )} + + )} + + {outroData.kind === OutroKind.Cancel && ( + + {'\u25A0'} {outroData.message || 'Cancelled'} + + )} + + + Press any key to continue + + + ); +}; diff --git a/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx b/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx new file mode 100644 index 00000000..c9247000 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/Audit3000RunScreen.tsx @@ -0,0 +1,102 @@ +import { useState, useSyncExternalStore } from 'react'; +import { join } from 'node:path'; +import { Box } from 'ink'; +import type { WizardStore } from '../../store.js'; +import { + TabContainer, + SplitView, + LogViewer, + HNViewer, +} from '../../primitives/index.js'; +import { useStdoutDimensions } from '../../hooks/useStdoutDimensions.js'; +import { useFileWatcher } from '../../hooks/file-watcher.js'; +import { AuditChecksViewer } from '../audit/AuditChecksViewer/AuditChecksViewer.js'; +import { Audit3000AreaPane } from './Audit3000AreaPane.js'; +import { Audit3000ChecksPanel } from './Audit3000ChecksPanel.js'; +import { HedgehogRunner } from './HedgehogRunner.js'; +import { initialState } from './hedgehog-runner-engine.js'; +import { + AUDIT_CHECKS_FILE, + AUDIT_CHECKS_KEY, + coerceAuditChecks, + getAuditChecks, +} from '../../../../lib/workflows/audit/types.js'; +import { getWorkflowConfig } from '../../../../lib/workflows/workflow-registry.js'; +import { WIZARD_LOG_FILE } from '../../../../utils/paths.js'; + +const AUDIT_3000_REPORT_FILE_FALLBACK = 'posthog-audit-3000-report.md'; + +interface Audit3000RunScreenProps { + store: WizardStore; +} + +export const Audit3000RunScreen = ({ store }: Audit3000RunScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + // Mirror the agent's audit ledger into the store. The audit-3000 skill + // writes to the same `.posthog-audit-checks.json` path the original + // audit uses, so the file watcher key is shared. + useFileWatcher(join(store.session.installDir, AUDIT_CHECKS_FILE), (parsed) => + store.setFrameworkContext(AUDIT_CHECKS_KEY, coerceAuditChecks(parsed)), + ); + + const statuses = + store.statusMessages.length > 0 ? store.statusMessages : undefined; + + const [columns] = useStdoutDimensions(); + // Game state is lifted here so it survives tab switches — the HedgehogRunner + // unmounts whenever the user views another tab, but the score / position / + // obstacles stay frozen until they switch back. + const [gameState, setGameState] = useState(() => initialState()); + const checks = getAuditChecks(store.session); + const reportFile = + getWorkflowConfig(store.router.activeFlow)?.reportFile ?? + AUDIT_3000_REPORT_FILE_FALLBACK; + const reportPath = `./${reportFile}`; + const checksPanel = ; + const areaPane = ( + + ); + + // Narrow terminals: drop the area pane. + const statusComponent = + columns < 80 ? ( + + {checksPanel} + + ) : ( + + ); + + const tabs = [ + { id: 'status', label: 'Arcade', component: statusComponent }, + { + id: 'audit-checks', + label: 'Hi-score table (report)', + component: , + }, + { + id: 'play', + label: 'Play', + component: , + }, + { + id: 'logs', + label: 'Tail logs', + component: , + }, + { id: 'hn', label: 'HN', component: }, + ]; + + return ( + + ); +}; diff --git a/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx b/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx new file mode 100644 index 00000000..df9481fb --- /dev/null +++ b/src/ui/tui/screens/audit-3000/HedgehogRunner.tsx @@ -0,0 +1,149 @@ +/** + * HedgehogRunner — playable arcade game shown while the audit runs. + * + * Game state lives in the parent (Audit3000RunScreen) so it survives tab + * switches. This component owns the render loop (setInterval) and key + * bindings; when the user switches tabs the component unmounts, the + * interval clears, and state freezes in the parent — free pause behaviour. + */ + +import { Box, Text } from 'ink'; +import { Fragment, useEffect, type Dispatch, type SetStateAction } from 'react'; +import { Colors } from '../../styles.js'; +import { NEON_BLUE, NEON_GOLD, NEON_PINK } from './arcade-colors.js'; +import { useStdoutDimensions } from '../../hooks/useStdoutDimensions.js'; +import { + useKeyBindings, + KeyMatch, + type KeyBinding, +} from '../../hooks/useKeyBindings.js'; +import { + HEDGEHOG_COL, + PLAYFIELD_WIDTH, + jump, + restart, + tick, + type GameState, +} from './hedgehog-runner-engine.js'; + +const TICK_MS = 150; +const PLAYFIELD_ROWS = 3; +const MIN_TERMINAL_COLUMNS = 50; +const HEDGEHOG_GLYPH = 'O'; +const SPIKE_GLYPH = '^'; +const RING_GLYPH = 'o'; +const GROUND_GLYPH = '='; + +interface HedgehogRunnerProps { + state: GameState; + onChange: Dispatch>; +} + +const pad4 = (n: number) => String(n).padStart(4, '0'); + +export const HedgehogRunner = ({ state, onChange }: HedgehogRunnerProps) => { + const [columns] = useStdoutDimensions(); + + useEffect(() => { + const id = setInterval(() => { + onChange((prev) => tick(prev)); + }, TICK_MS); + return () => clearInterval(id); + }, [onChange]); + + const bindings: KeyBinding[] = [ + { + match: KeyMatch.Space, + label: 'space', + action: 'jump', + handler: () => onChange((prev) => jump(prev)), + }, + { + match: 'r', + label: 'r', + action: 'restart', + handler: () => + onChange((prev) => (prev.isGameOver ? restart(prev) : prev)), + }, + ]; + useKeyBindings('hedgehog-runner', bindings); + + if (columns < MIN_TERMINAL_COLUMNS) { + return ( + + + Widen the terminal to at least {MIN_TERMINAL_COLUMNS} columns to play + Hedgehog Runner. + + + ); + } + + return ( + + + + SCORE {pad4(state.score)} + + {' '} + + HI {pad4(state.hiScore)} + + {state.isGameOver && ( + <> + {' '} + + ✱ GAME OVER ✱ + + + )} + + + {Array.from({ length: PLAYFIELD_ROWS }, (_, row) => ( + + ))} + + {GROUND_GLYPH.repeat(PLAYFIELD_WIDTH)} + + ); +}; + +interface PlayfieldRowProps { + row: number; + state: GameState; +} + +const PlayfieldRow = ({ row, state }: PlayfieldRowProps) => { + const cells: Array<{ ch: string; color?: string; bold?: boolean }> = []; + for (let x = 0; x < PLAYFIELD_WIDTH; x++) { + if (x === HEDGEHOG_COL && row === state.hedgehogRow) { + cells.push({ ch: HEDGEHOG_GLYPH, color: NEON_PINK, bold: true }); + continue; + } + const obstacle = state.obstacles.find((o) => o.x === x && o.row === row); + if (obstacle) { + cells.push( + obstacle.kind === 'spike' + ? { ch: SPIKE_GLYPH, color: 'red', bold: true } + : { ch: RING_GLYPH, color: NEON_GOLD, bold: true }, + ); + continue; + } + cells.push({ ch: ' ' }); + } + return ( + + {cells.map((c, i) => ( + + {c.color ? ( + + {c.ch} + + ) : ( + c.ch + )} + + ))} + + ); +}; diff --git a/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts b/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts new file mode 100644 index 00000000..bdde67f0 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/__tests__/hedgehog-runner-engine.test.ts @@ -0,0 +1,138 @@ +import { + AIR_ROW, + GROUND_ROW, + HEDGEHOG_COL, + JUMP_DURATION_TICKS, + RING_VALUE, + initialState, + jump, + restart, + tick, + type GameState, +} from '../hedgehog-runner-engine.js'; + +describe('hedgehog-runner-engine', () => { + describe('initialState', () => { + it('starts grounded with score zero', () => { + const s = initialState(); + expect(s.hedgehogState).toBe('grounded'); + expect(s.hedgehogRow).toBe(GROUND_ROW); + expect(s.score).toBe(0); + expect(s.isGameOver).toBe(false); + expect(s.obstacles).toEqual([]); + }); + + it('accepts an initial hi-score so it survives restarts', () => { + expect(initialState(42).hiScore).toBe(42); + }); + }); + + describe('jump', () => { + it('lifts the hedgehog from the ground row to the air row', () => { + const next = jump(initialState()); + expect(next.hedgehogState).toBe('jumping'); + expect(next.hedgehogRow).toBe(AIR_ROW); + expect(next.jumpFramesRemaining).toBe(JUMP_DURATION_TICKS); + }); + + it('is a no-op while already jumping so held space keys do not stack', () => { + const airborne = jump(initialState()); + expect(jump(airborne)).toBe(airborne); + }); + + it('is a no-op after game over', () => { + const gameOver: GameState = { ...initialState(), isGameOver: true }; + expect(jump(gameOver)).toBe(gameOver); + }); + }); + + describe('tick', () => { + it('increments score by one each tick while alive', () => { + const t1 = tick(initialState()); + expect(t1.score).toBe(1); + expect(t1.tick).toBe(1); + }); + + it('moves obstacles one column left per tick', () => { + const seeded: GameState = { + ...initialState(), + obstacles: [{ kind: 'spike', x: 20, row: GROUND_ROW }], + }; + const next = tick(seeded); + const spike = next.obstacles.find((o) => o.kind === 'spike'); + expect(spike?.x).toBe(19); + }); + + it('drops obstacles once they leave the playfield on the left', () => { + const seeded: GameState = { + ...initialState(), + obstacles: [{ kind: 'spike', x: 0, row: GROUND_ROW }], + }; + const next = tick(seeded); + expect(next.obstacles.find((o) => o.kind === 'spike')).toBeUndefined(); + }); + + it('returns the hedgehog to the ground after the jump duration elapses', () => { + let state = jump(initialState()); + for (let i = 0; i < JUMP_DURATION_TICKS; i++) { + state = tick(state); + } + expect(state.hedgehogState).toBe('grounded'); + expect(state.hedgehogRow).toBe(GROUND_ROW); + }); + + it('ends the game and updates hi-score on spike collision', () => { + const seeded: GameState = { + ...initialState(7), + obstacles: [{ kind: 'spike', x: HEDGEHOG_COL + 1, row: GROUND_ROW }], + score: 12, + }; + const next = tick(seeded); + expect(next.isGameOver).toBe(true); + expect(next.hiScore).toBe(13); // 12 + 1 survival tick + }); + + it('does not regress an existing higher hi-score on death', () => { + const seeded: GameState = { + ...initialState(100), + obstacles: [{ kind: 'spike', x: HEDGEHOG_COL + 1, row: GROUND_ROW }], + score: 5, + }; + expect(tick(seeded).hiScore).toBe(100); + }); + + it('collects rings by adding their value and removing them', () => { + // Hedgehog mid-jump on AIR_ROW; ring sits one column to the right. + const seeded: GameState = { + ...jump(initialState()), + obstacles: [{ kind: 'ring', x: HEDGEHOG_COL + 1, row: AIR_ROW }], + }; + const next = tick(seeded); + expect(next.isGameOver).toBe(false); + expect(next.score).toBe(1 + RING_VALUE); + expect(next.obstacles.find((o) => o.kind === 'ring')).toBeUndefined(); + }); + + it('ignores ticks while the game is over so the loop can idle safely', () => { + const gameOver: GameState = { ...initialState(), isGameOver: true }; + expect(tick(gameOver)).toBe(gameOver); + }); + }); + + describe('restart', () => { + it('resets the run but preserves the hi-score', () => { + const ended: GameState = { + ...initialState(), + score: 99, + hiScore: 250, + isGameOver: true, + obstacles: [{ kind: 'spike', x: 3, row: GROUND_ROW }], + }; + const fresh = restart(ended); + expect(fresh.score).toBe(0); + expect(fresh.isGameOver).toBe(false); + expect(fresh.obstacles).toEqual([]); + expect(fresh.hiScore).toBe(250); + }); + }); +}); diff --git a/src/ui/tui/screens/audit-3000/arcade-colors.ts b/src/ui/tui/screens/audit-3000/arcade-colors.ts new file mode 100644 index 00000000..f95aff3e --- /dev/null +++ b/src/ui/tui/screens/audit-3000/arcade-colors.ts @@ -0,0 +1,5 @@ +// PostHog brand palette, tuned for the arcade theme. Shared by the +// audit-3000 intro panel and the hedgehog runner mini-game. +export const NEON_PINK = '#F54E00'; +export const NEON_BLUE = '#1D4AFF'; +export const NEON_GOLD = '#F9BD2B'; diff --git a/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts b/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts new file mode 100644 index 00000000..9b030c71 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/hedgehog-runner-engine.ts @@ -0,0 +1,158 @@ +/** + * Hedgehog Runner — pure game engine. + * + * No Ink, React, or stdout imports. All state transitions are pure functions + * so the game is deterministic given an RNG seed and unit-testable in + * isolation from the TUI. + * + * The playfield is a fixed grid: + * row 0: sky (rings can spawn here when hedgehog is mid-jump) + * row 1: air (hedgehog occupies this row mid-jump; rings spawn here) + * row 2: ground (hedgehog default position; spikes spawn here) + * + * Obstacles enter at the right edge (PLAYFIELD_WIDTH - 1) and move left one + * column per tick. The hedgehog sits at HEDGEHOG_COL. Collision triggers when + * an obstacle reaches HEDGEHOG_COL while occupying the same row as the + * hedgehog. + */ + +export const PLAYFIELD_WIDTH = 40; +export const HEDGEHOG_COL = 4; +export const GROUND_ROW = 2; +export const AIR_ROW = 1; +export const JUMP_DURATION_TICKS = 8; +export const SPAWN_COOLDOWN_MIN = 6; +export const SPAWN_COOLDOWN_MAX = 14; +export const RING_VALUE = 5; + +export type HedgehogState = 'grounded' | 'jumping'; + +export interface Obstacle { + kind: 'spike' | 'ring'; + x: number; + row: number; +} + +export interface GameState { + hedgehogState: HedgehogState; + hedgehogRow: number; + jumpFramesRemaining: number; + obstacles: Obstacle[]; + score: number; + hiScore: number; + isGameOver: boolean; + tick: number; + ticksUntilNextSpawn: number; + rngSeed: number; +} + +// Mulberry32 — deterministic PRNG, used so tests can assert exact sequences. +function nextRandom(seed: number): { value: number; nextSeed: number } { + let t = (seed + 0x6d2b79f5) >>> 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const value = ((t ^ (t >>> 14)) >>> 0) / 4294967296; + return { value, nextSeed: t >>> 0 }; +} + +function randomInt(seed: number, min: number, max: number) { + const { value, nextSeed } = nextRandom(seed); + return { + value: min + Math.floor(value * (max - min + 1)), + nextSeed, + }; +} + +export function initialState(hiScore = 0, rngSeed = 1): GameState { + return { + hedgehogState: 'grounded', + hedgehogRow: GROUND_ROW, + jumpFramesRemaining: 0, + obstacles: [], + score: 0, + hiScore, + isGameOver: false, + tick: 0, + ticksUntilNextSpawn: SPAWN_COOLDOWN_MIN, + rngSeed, + }; +} + +export function jump(state: GameState): GameState { + if (state.isGameOver) return state; + if (state.hedgehogState !== 'grounded') return state; + return { + ...state, + hedgehogState: 'jumping', + hedgehogRow: AIR_ROW, + jumpFramesRemaining: JUMP_DURATION_TICKS, + }; +} + +export function restart(state: GameState): GameState { + return initialState(state.hiScore, state.rngSeed); +} + +export function tick(state: GameState): GameState { + if (state.isGameOver) return state; + + let { hedgehogState, hedgehogRow, jumpFramesRemaining } = state; + if (hedgehogState === 'jumping') { + jumpFramesRemaining -= 1; + if (jumpFramesRemaining <= 0) { + hedgehogState = 'grounded'; + hedgehogRow = GROUND_ROW; + jumpFramesRemaining = 0; + } + } + + const movedObstacles: Obstacle[] = []; + let scoreDelta = 1; + let hit = false; + for (const obs of state.obstacles) { + const next = { ...obs, x: obs.x - 1 }; + if (next.x < 0) continue; + if (next.x === HEDGEHOG_COL && next.row === hedgehogRow) { + if (next.kind === 'spike') { + hit = true; + movedObstacles.push(next); + continue; + } + // Ring collected — score it and drop from the field. + scoreDelta += RING_VALUE; + continue; + } + movedObstacles.push(next); + } + + let rngSeed = state.rngSeed; + let ticksUntilNextSpawn = state.ticksUntilNextSpawn - 1; + if (ticksUntilNextSpawn <= 0) { + const kindRoll = nextRandom(rngSeed); + rngSeed = kindRoll.nextSeed; + const kind: Obstacle['kind'] = kindRoll.value < 0.65 ? 'spike' : 'ring'; + const row = kind === 'spike' ? GROUND_ROW : AIR_ROW; + movedObstacles.push({ kind, x: PLAYFIELD_WIDTH - 1, row }); + + const cooldown = randomInt(rngSeed, SPAWN_COOLDOWN_MIN, SPAWN_COOLDOWN_MAX); + rngSeed = cooldown.nextSeed; + ticksUntilNextSpawn = cooldown.value; + } + + const score = state.score + scoreDelta; + const isGameOver = hit; + const hiScore = isGameOver ? Math.max(state.hiScore, score) : state.hiScore; + + return { + hedgehogState, + hedgehogRow, + jumpFramesRemaining, + obstacles: movedObstacles, + score, + hiScore, + isGameOver, + tick: state.tick + 1, + ticksUntilNextSpawn, + rngSeed, + }; +} diff --git a/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx b/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx new file mode 100644 index 00000000..e028ad78 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/slides/eventQuality.tsx @@ -0,0 +1,34 @@ +import { Text } from 'ink'; +import { VisualBox, type AreaSlide } from '../../audit/slides/shared.js'; + +const EventQualityVisual = () => ( + + + {'event_clicked '} + {'\u2713'} + + + {'eventClicked '} + {'~ duplicate?'} + + + {'click_event '} + {'~ duplicate?'} + + + {'big_kitchen_sink '} + {'\u2717 22 props'} + + +); + +export const EventQualitySlide: AreaSlide = { + area: 'Event Quality', + intro: [ + 'LEVEL 5: EVENT QUALITY. The capture call-sites are clean. The events themselves are the real boss fight.', + 'Scanning for: naming inconsistencies, semantic duplicates, kitchen-sink event payloads, and (if your PostHog project is linked) which captured events actually drive insights and dashboards.', + '4 subagents fan out in parallel. The ticker shows them clearing checks live.', + ], + visual: , + docsUrl: 'https://posthog.com/docs/product-analytics/best-practices', +}; diff --git a/src/ui/tui/screens/audit-3000/slides/expansion.tsx b/src/ui/tui/screens/audit-3000/slides/expansion.tsx new file mode 100644 index 00000000..8a9ab8e4 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/slides/expansion.tsx @@ -0,0 +1,37 @@ +import { Text } from 'ink'; +import { VisualBox, type AreaSlide } from '../../audit/slides/shared.js'; + +const ExpansionVisual = () => ( + + + {'product analytics '} + {'\u25A0\u25A0\u25A0\u25A0\u25A0'} + + + {'error tracking '} + {'\u25A1\u25A1\u25A1\u25A1\u25A1'} + {' sentry detected'} + + + {'session replay '} + {'\u25A0\u25A0\u25A1\u25A1\u25A1'} + {' partial'} + + + {'llm observability '} + {'\u25A1\u25A1\u25A1\u25A1\u25A1'} + {' greenfield'} + + +); + +export const ExpansionSlide: AreaSlide = { + area: 'Use Case: Expansion', + intro: [ + 'BONUS ROUND: EXPANSION. You might be paying for tools PostHog covers natively.', + 'Scanning for competitive SDKs (Sentry, LaunchDarkly, Mixpanel, Datadog, OpenTelemetry, GA4) and PostHog coverage gaps across 8 product surfaces.', + '8 subagents in two waves of 4. Each one returns one of: cross-sell, greenfield, gap, or pass.', + ], + visual: , + docsUrl: 'https://posthog.com/docs', +}; diff --git a/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx b/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx new file mode 100644 index 00000000..9d1cffa1 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/slides/featureFlags.tsx @@ -0,0 +1,33 @@ +import { Text } from 'ink'; +import { VisualBox, type AreaSlide } from '../../audit/slides/shared.js'; + +const FeatureFlagsVisual = () => ( + + + {'new-checkout-v2 '} + {'no code refs '} + {'DROP'} + + + {'beta-dashboard '} + {'1 ref, 100% on '} + {'REVIEW'} + + + {'killswitch-payments'} + {'live experiment'} + {'KEEP'} + + +); + +export const FeatureFlagsSlide: AreaSlide = { + area: 'Feature Flags', + intro: [ + 'LEVEL 6: STALE FLAGS. Old flags add evaluation overhead and confuse the next engineer who wonders if a flag is still live.', + "Cross-referencing PostHog's stale-flag classification against your source tree. Each flag scored: safe-to-disable, needs-review, or unknown.", + 'The final report ships with a copy-paste cleanup prompt. We never touch a flag.', + ], + visual: , + docsUrl: 'https://posthog.com/docs/feature-flags', +}; diff --git a/src/ui/tui/screens/audit-3000/slides/index.ts b/src/ui/tui/screens/audit-3000/slides/index.ts new file mode 100644 index 00000000..e8bd32c5 --- /dev/null +++ b/src/ui/tui/screens/audit-3000/slides/index.ts @@ -0,0 +1,24 @@ +/** + * Audit-3000 slide registry. Re-uses the original audit slides for the + * shared areas (Installation, Identification, Event Capture) and adds + * arcade-flavoured slides for the three new areas the v3000 audit covers. + */ + +import type { AreaSlide } from '../../audit/slides/shared.js'; +import { InstallationSlide } from '../../audit/slides/installation.js'; +import { IdentificationSlide } from '../../audit/slides/identification.js'; +import { EventCaptureSlide } from '../../audit/slides/eventCapture.js'; +import { EventQualitySlide } from './eventQuality.js'; +import { FeatureFlagsSlide } from './featureFlags.js'; +import { ExpansionSlide } from './expansion.js'; + +export type { AreaSlide }; + +export const AUDIT_3000_AREA_SLIDES: AreaSlide[] = [ + InstallationSlide, + IdentificationSlide, + EventCaptureSlide, + EventQualitySlide, + FeatureFlagsSlide, + ExpansionSlide, +]; diff --git a/src/ui/tui/screens/audit/AuditChecksViewer/AreaHeaderRow.tsx b/src/ui/tui/screens/audit/AuditChecksViewer/AreaHeaderRow.tsx new file mode 100644 index 00000000..4b2968ad --- /dev/null +++ b/src/ui/tui/screens/audit/AuditChecksViewer/AreaHeaderRow.tsx @@ -0,0 +1,23 @@ +import { Box, Text } from 'ink'; + +interface AreaHeaderRowProps { + area: string; + resolved: number; + total: number; +} + +/** Sub-header row inside the scrollable body — one per area group. */ +export const AreaHeaderRow = ({ + area, + resolved, + total, +}: AreaHeaderRowProps) => ( + + + {area}{' '} + + + ({resolved}/{total}) + + +); diff --git a/src/ui/tui/screens/audit/AuditChecksViewer/AuditChecksViewer.tsx b/src/ui/tui/screens/audit/AuditChecksViewer/AuditChecksViewer.tsx index 45d663fe..e8560ca3 100644 --- a/src/ui/tui/screens/audit/AuditChecksViewer/AuditChecksViewer.tsx +++ b/src/ui/tui/screens/audit/AuditChecksViewer/AuditChecksViewer.tsx @@ -1,9 +1,10 @@ /** * AuditChecksViewer — "Audit plan" tab. * - * Renders the full audit ledger as a scrollable table grouped by status: - * resolved checks (issues + passes, sorted by severity) on top, pending - * checks at the bottom, separated by a blank row. + * Renders the full audit ledger as a scrollable, area-grouped list that + * mirrors the structure of the final report. Each area gets a sub-header + * with a resolved/total count; checks within an area are sorted by + * severity (error → warning → suggestion → pass → pending). * * Two interactions, both registered via `useKeyBindings`: * - `e` — toggle detail rows (file:line + agent's `details` text) @@ -23,12 +24,13 @@ import { type KeyBinding, } from '../../../hooks/useKeyBindings.js'; import type { AuditCheck } from '../../../../../lib/workflows/audit/types.js'; +import { AreaHeaderRow } from './AreaHeaderRow.js'; import { CheckRow } from './CheckRow.js'; import { DetailRow } from './DetailRow.js'; import { Footer } from './Footer.js'; -import { Header, statusCounts } from './Header.js'; +import { Header, Summary, statusCounts } from './Header.js'; import { computeLayout } from './layout.js'; -import { sortChecks } from './sort.js'; +import { groupChecksByArea } from './sort.js'; interface AuditChecksViewerProps { checks: AuditCheck[]; @@ -36,61 +38,51 @@ interface AuditChecksViewerProps { export const AuditChecksViewer = ({ checks }: AuditChecksViewerProps) => { // ── Layout ───────────────────────────────────────────────────────── - // Recompute on every render against current terminal size so the viewer - // reflows on resize. `viewerChrome` is the row count consumed by header, - // dividers, scroll markers, legend, and counts; `visibleHeight` is what - // remains for the scrollable body. const [rawCols, termRows] = useStdoutDimensions(); const layout = computeLayout(rawCols, termRows); const totalHeight = layout.visibleHeight + layout.viewerChrome; - // ── Sort + section split ─────────────────────────────────────────── - // Issues + passes on top, pending at the bottom. The JSX renders the - // two sections in that order with a blank-line separator between them. - const sorted = useMemo(() => sortChecks(checks), [checks]); - const resolved = sorted.filter((c) => c.status !== 'pending'); - const pending = sorted.filter((c) => c.status === 'pending'); + // ── Group by area ────────────────────────────────────────────────── + const groups = useMemo(() => groupChecksByArea(checks), [checks]); + const counts = useMemo(() => statusCounts(checks), [checks]); // ── Expand state ─────────────────────────────────────────────────── - const hasExpandable = sorted.some((c) => Boolean(c.details || c.file)); - const hasIssues = sorted.some( + const hasExpandable = checks.some((c) => Boolean(c.details || c.file)); + const hasIssues = checks.some( (c) => c.status === 'error' || c.status === 'warning' || c.status === 'suggestion', ); - // Auto-expand when there are issues — the AuditAreaPane's `[→] View - // issues` hint sends users here specifically to read details. const [expanded, setExpanded] = useState(hasIssues && hasExpandable); // ── Flat row list ────────────────────────────────────────────────── - // Build one ReactNode per visible terminal row so scroll math is a - // single number. CheckRow = 1 row; an expanded DetailRow ≈ 1 (long - // details that wrap will overflow — we don't track exact heights, the - // approximation is fine for a status pane). + // One ReactNode per visible terminal row so scroll math stays simple. + // Sub-header + check rows + (optional) detail rows interleave here. const allRows = useMemo(() => { const rows: ReactNode[] = []; - const buildRow = (item: AuditCheck) => { - rows.push(); - if (expanded && (item.details || item.file)) { - rows.push( - , - ); + for (const group of groups) { + rows.push( + , + ); + for (const item of group.checks) { + rows.push(); + if (expanded && (item.details || item.file)) { + rows.push( + , + ); + } } - }; - resolved.forEach(buildRow); - if (resolved.length > 0 && pending.length > 0) { - rows.push(); } - pending.forEach(buildRow); return rows; - }, [resolved, pending, expanded, layout]); + }, [groups, expanded, layout]); // ── Scroll viewport ──────────────────────────────────────────────── - // `offset` is the index of the first visible row. Clamped on every - // render so resizing or collapsing details can't leave us scrolled - // past the new end. `hiddenAbove` / `hiddenBelow` drive the - // "↑ N more" / "↓ N more" markers above and below the body. const [offset, setOffset] = useState(0); const maxOffset = Math.max(0, allRows.length - layout.visibleHeight); const clampedOffset = Math.min(offset, maxOffset); @@ -101,10 +93,6 @@ export const AuditChecksViewer = ({ checks }: AuditChecksViewerProps) => { ); // ── Key bindings ─────────────────────────────────────────────────── - // `e` toggles detail rows (only registered when there's something to - // expand). `↑`/`↓` always register so the hints bar consistently - // advertises scroll, even when content fits and the handler is a - // no-op via the clamp. const bindings: KeyBinding[] = []; if (hasExpandable) { bindings.push({ @@ -131,21 +119,29 @@ export const AuditChecksViewer = ({ checks }: AuditChecksViewerProps) => { clampedOffset + layout.visibleHeight, ); + // Dynamic subtitle — lists the actual areas in the ledger. + const subtitle = + groups.length === 0 + ? 'No checks yet.' + : `Review across ${groups.length} ${ + groups.length === 1 ? 'area' : 'areas' + } — mirrors the final report.`; + return ( - {/* Title */} + {/* Title + dynamic subtitle */} Audit plan - - Read-only review of installation, identification, and event capture - + {subtitle} + + {/* Top summary — same as Footer summary, promoted here for at-a-glance */} + {/* Column headers + divider */}
{'─'.repeat(layout.dividerWidth)} - {/* Scroll-up marker (renders a blank row when nothing is hidden - above so the body's vertical position stays stable) */} + {/* Scroll-up marker */} {hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : ' '} {/* Scrollable body */} @@ -159,11 +155,11 @@ export const AuditChecksViewer = ({ checks }: AuditChecksViewerProps) => { ))} - {/* Scroll-down marker (mirror of the above) */} + {/* Scroll-down marker */} {hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : ' '} - {/* Legend + count summary */} -