From 3427bf260d85e428884702a07058b77bef350e9e Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 13:22:52 -0700 Subject: [PATCH 1/7] feat(feature-flags): add the wizard feature-flags command Adds `wizard feature-flags`: an orchestrator flow with bundled prompts that creates one example flag per side of the app (backend, frontend), inactive at 0%, evaluates each once on its own side, and writes a report with an optional check that the evaluations reach PostHog. --- bin.ts | 2 + e2e-harness/profiles.ts | 3 + .../architecture/known-violations.json | 3 + src/__tests__/headless-scope.test.ts | 5 + .../__tests__/agent-prompt-loader.test.ts | 31 ++ src/agent/agent-prompt-loader.ts | 8 + .../runner/__tests__/switchboard.test.ts | 16 +- .../orchestrator/orchestrator-runner.ts | 23 +- src/agent/runner/shared/types.ts | 1 + .../switchboard/flags/__tests__/flags.test.ts | 6 +- src/agent/runner/switchboard/index.ts | 5 + src/commands/feature-flags.ts | 7 + .../__snapshots__/flow-traces.test.ts.snap | 53 ++++ .../post-auth-gates.test.ts.snap | 1 + src/programs/__tests__/feature-flags.test.ts | 89 ++++++ src/programs/feature-flags/index.ts | 50 +++ src/programs/feature-flags/prompts.ts | 284 ++++++++++++++++++ src/programs/feature-flags/test/e2e.json | 40 +++ src/programs/oauth/program-scopes.ts | 1 + src/programs/program-registry.ts | 3 + src/programs/program-step.ts | 6 + src/programs/run-agent-legacy.ts | 1 + src/programs/run-program.ts | 2 + 23 files changed, 629 insertions(+), 11 deletions(-) create mode 100644 src/commands/feature-flags.ts create mode 100644 src/programs/__tests__/feature-flags.test.ts create mode 100644 src/programs/feature-flags/index.ts create mode 100644 src/programs/feature-flags/prompts.ts create mode 100644 src/programs/feature-flags/test/e2e.json diff --git a/bin.ts b/bin.ts index 3b61c3c9f..1d3fe9683 100644 --- a/bin.ts +++ b/bin.ts @@ -81,6 +81,7 @@ import { selfDrivingCommand } from './src/commands/self-driving'; import { slackCommand } from './src/commands/slack'; import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps'; import { errorTrackingCommand } from './src/commands/error-tracking'; +import { featureFlagsCommand } from './src/commands/feature-flags'; import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from '@shared/claude-settings'; @@ -116,5 +117,6 @@ Wizard.use(basicIntegrationCommand) .use(slackCommand) .use(uploadSourcemapsCommand) .use(errorTrackingCommand) + .use(featureFlagsCommand) .use(skillCommand) .init(); diff --git a/e2e-harness/profiles.ts b/e2e-harness/profiles.ts index d0c343e6e..600cd835a 100644 --- a/e2e-harness/profiles.ts +++ b/e2e-harness/profiles.ts @@ -26,6 +26,7 @@ import replayVisionE2e from '@programs/replay-vision/test/e2e.json'; import selfDrivingE2e from '@programs/self-driving/test/e2e.json'; import sourceMapsE2e from '@programs/error-tracking-upload-source-maps/test/e2e.json'; import errorTrackingE2e from '@programs/error-tracking/test/e2e.json'; +import featureFlagsE2e from '@programs/feature-flags/test/e2e.json'; import warehouseSourceE2e from '@programs/warehouse-source/test/e2e.json'; import auditE2e from '@programs/audit/test/e2e.json'; @@ -39,6 +40,7 @@ const PROFILES: Partial> = { [Program.ErrorTrackingUploadSourceMaps]: sourceMapsE2e.profile as WizardE2eProfile, [Program.ErrorTracking]: errorTrackingE2e.profile as WizardE2eProfile, + [Program.FeatureFlags]: featureFlagsE2e.profile as WizardE2eProfile, [Program.WarehouseSource]: warehouseSourceE2e.profile as WizardE2eProfile, [Program.Audit]: auditE2e.profile as WizardE2eProfile, }; @@ -51,6 +53,7 @@ const VARIATIONS: Partial> = { [Program.Metrics]: metricsE2e.variations as WizardE2eVariation[], [Program.ReplayVision]: replayVisionE2e.variations as WizardE2eVariation[], [Program.ErrorTracking]: errorTrackingE2e.variations as WizardE2eVariation[], + [Program.FeatureFlags]: featureFlagsE2e.variations as WizardE2eVariation[], [Program.WarehouseSource]: warehouseSourceE2e.variations as WizardE2eVariation[], [Program.Audit]: auditE2e.variations as WizardE2eVariation[], diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 13cfc727c..3eb1f1588 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -12,6 +12,7 @@ "src/commands/error-tracking.ts -> src/programs/error-tracking/index.ts", "src/commands/factories/family-command-factory.ts -> src/programs/dispatch-family.ts", "src/commands/factories/family-picker.tsx -> src/commands/command.ts", + "src/commands/feature-flags.ts -> src/programs/feature-flags/index.ts", "src/commands/mcp-analytics.ts -> src/programs/mcp-analytics/index.ts", "src/commands/metrics.ts -> src/programs/metrics/index.ts", "src/commands/migrate.ts -> src/programs/migration/index.ts", @@ -67,6 +68,8 @@ "src/programs/error-tracking/index.ts -> src/ui/tui/decks/error-tracking/tips.ts", "src/programs/events-audit/index.ts -> src/lib/wizard-session.ts", "src/programs/events-audit/steps.ts -> src/lib/wizard-session.ts", + "src/programs/feature-flags/index.ts -> src/lib/headless-mode.ts", + "src/programs/feature-flags/index.ts -> src/lib/wizard-session.ts", "src/programs/frameworks/astro/astro-wizard-agent.ts -> src/ui/index.ts", "src/programs/frameworks/django/utils.ts -> src/ui/index.ts", "src/programs/frameworks/fastapi/utils.ts -> src/ui/index.ts", diff --git a/src/__tests__/headless-scope.test.ts b/src/__tests__/headless-scope.test.ts index 5f69ebb2e..dae5ce901 100644 --- a/src/__tests__/headless-scope.test.ts +++ b/src/__tests__/headless-scope.test.ts @@ -1,6 +1,7 @@ import { auditCommand } from '../commands/audit'; import { aiObservabilityCommand } from '../commands/ai-observability'; import { basicIntegrationCommand } from '../commands/basic-integration'; +import { featureFlagsCommand } from '../commands/feature-flags'; import { revenueCommand } from '../commands/revenue'; import { HEADLESS_FLAG } from '../lib/headless-mode'; import { GLOBAL_OPTIONS } from '../wizard'; @@ -26,6 +27,10 @@ describe('headless flag scope', () => { expect(aiObservabilityCommand.options).toHaveProperty(HEADLESS_FLAG); }); + test('is declared on the feature-flags command', () => { + expect(featureFlagsCommand.options).toHaveProperty(HEADLESS_FLAG); + }); + test('is NOT declared on an unrelated native command', () => { expect(revenueCommand.options ?? {}).not.toHaveProperty(HEADLESS_FLAG); }); diff --git a/src/agent/__tests__/agent-prompt-loader.test.ts b/src/agent/__tests__/agent-prompt-loader.test.ts index 876364226..bb3becef8 100644 --- a/src/agent/__tests__/agent-prompt-loader.test.ts +++ b/src/agent/__tests__/agent-prompt-loader.test.ts @@ -7,6 +7,7 @@ import { assembleSeedPrompt, assembleTaskPrompt, buildRegistry, + loadAgentRegistry, parseAgentPrompt, promptModelFor, queueTools, @@ -678,3 +679,33 @@ describe('allowsPostHogMcp', () => { expect(allowsPostHogMcp(allowedTools)).toBe(true); }); }); + +describe('loadAgentRegistry', () => { + const bundledSeed = `--- +type: plan +flow: bundled +seed: true +--- +Plan.`; + const bundledTask = `--- +type: report +flow: bundled +sink: true +--- +Report.`; + + it('builds bundled prompts without fetching the agent menu', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + + const registry = await loadAgentRegistry( + 'http://skills.invalid', + 'bundled', + undefined, + [bundledSeed, bundledTask], + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(registry.seed?.type).toBe('plan'); + expect(registry.sinkTypes).toEqual(['report']); + }); +}); diff --git a/src/agent/agent-prompt-loader.ts b/src/agent/agent-prompt-loader.ts index c7b7bea92..69354af3f 100644 --- a/src/agent/agent-prompt-loader.ts +++ b/src/agent/agent-prompt-loader.ts @@ -455,7 +455,15 @@ export async function loadAgentRegistry( skillsBaseUrl: string, flow: string, opts?: Parameters[2], + bundledPrompts?: readonly string[], ): Promise { + if (bundledPrompts) { + const prompts = bundledPrompts.map((text) => + parseAgentPrompt(text, flow, flow), + ); + return buildRegistry(prompts, flow, opts); + } + const menuRaw = await fetchText(`${skillsBaseUrl}/agent-menu.json`); const menu = JSON.parse(menuRaw) as AgentMenu; diff --git a/src/agent/runner/__tests__/switchboard.test.ts b/src/agent/runner/__tests__/switchboard.test.ts index 0a16a2048..1ad61a4ed 100644 --- a/src/agent/runner/__tests__/switchboard.test.ts +++ b/src/agent/runner/__tests__/switchboard.test.ts @@ -69,6 +69,7 @@ describe('switchboard PROGRAM_BINDINGS', () => { if (program === 'metrics') continue; // pinned below if (program === 'replay-vision') continue; // pinned below if (program === 'error-tracking') continue; // pinned below + if (program === 'feature-flags') continue; // pinned below expect(resolveBinding({ program, flags: {} })).toEqual(DEFAULT_RESOLVED); } }); @@ -129,6 +130,17 @@ describe('switchboard PROGRAM_BINDINGS', () => { }, trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, }, + { + name: 'binds feature-flags to the orchestrator on pi; stage models come from the bundled prompts', + ctx: { program: 'feature-flags', flags: {} }, + binding: { + sequence: Sequence.orchestrator, + harness: Harness.pi, + model: DEFAULT_AGENT_MODEL, + thinkingLevel: undefined, + }, + trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, + }, { name: 'falls back to DEFAULT_BINDING for an unmapped program', ctx: { program: 'not-a-program', flags: {} }, @@ -243,7 +255,9 @@ describe('switchboard composed clamp', () => { model: GPT5_6_TERRA_MODEL, thinkingLevel: 'high', } - : program === 'metrics' || program === 'error-tracking' + : program === 'metrics' || + program === 'error-tracking' || + program === 'feature-flags' ? { ...DEFAULT_RESOLVED, model: DEFAULT_AGENT_MODEL, diff --git a/src/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 36b2125bb..1c73d0698 100644 --- a/src/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -623,15 +623,20 @@ async function executeOrchestrator( // once up front: its types drive enqueue validation, and resolving a task to // its run config is then synchronous, with no mid-drain network latency. const flow = config.agentFlow ?? programId; - const registry = await loadAgentRegistry(boot.skillsBaseUrl, flow, { - exclude: effectiveExcludedTaskTypes(config, boot.wizardFlags), - // Baked into the prompts at load, so enqueue, dispatch, and telemetry all read one effective spec. - overrides: resolveStageOverrides( - programId, - boot.wizardFlags, - boot.wizardFlagPayloads, - ), - }); + const registry = await loadAgentRegistry( + boot.skillsBaseUrl, + flow, + { + exclude: effectiveExcludedTaskTypes(config, boot.wizardFlags), + // Baked into the prompts at load, so enqueue, dispatch, and telemetry all read one effective spec. + overrides: resolveStageOverrides( + programId, + boot.wizardFlags, + boot.wizardFlagPayloads, + ), + }, + config.agentPrompts, + ); if (signal?.aborted) return cancelledRun(); const seedPrompt = registry.seed; if (!seedPrompt) { diff --git a/src/agent/runner/shared/types.ts b/src/agent/runner/shared/types.ts index d466bf010..b98650610 100644 --- a/src/agent/runner/shared/types.ts +++ b/src/agent/runner/shared/types.ts @@ -176,6 +176,7 @@ export interface RunConfig { disallowedTools?: readonly string[]; /** Context-mill flow the orchestrator loads. Defaults to `programId`. */ agentFlow?: string; + agentPrompts?: readonly string[]; /** Task types the program excludes for these flags. The orchestrator adds the CI gates. */ excludedTaskTypes?: (flags: Record) => readonly string[]; /** Tasks to queue before the orchestrator's planner runs. */ diff --git a/src/agent/runner/switchboard/flags/__tests__/flags.test.ts b/src/agent/runner/switchboard/flags/__tests__/flags.test.ts index 8c04f0983..665cd7990 100644 --- a/src/agent/runner/switchboard/flags/__tests__/flags.test.ts +++ b/src/agent/runner/switchboard/flags/__tests__/flags.test.ts @@ -279,7 +279,11 @@ describe('isolation — everything on at once', () => { model: GPT5_6_TERRA_MODEL, thinkingLevel: 'high', }); - } else if (program === 'metrics' || program === 'error-tracking') { + } else if ( + program === 'metrics' || + program === 'error-tracking' || + program === 'feature-flags' + ) { // Orchestrator + pi from their OWN bindings, not the flag; stage // models are pinned context-mill side in the flow frontmatter. expect(resolved).toEqual({ diff --git a/src/agent/runner/switchboard/index.ts b/src/agent/runner/switchboard/index.ts index b2d84add2..1ad30a0fe 100644 --- a/src/agent/runner/switchboard/index.ts +++ b/src/agent/runner/switchboard/index.ts @@ -156,6 +156,11 @@ export const PROGRAM_BINDINGS: Partial> = { harness: Harness.pi, model: DEFAULT_AGENT_MODEL, }, + 'feature-flags': { + sequence: Sequence.orchestrator, + harness: Harness.pi, + model: DEFAULT_AGENT_MODEL, + }, 'ai-observability': { sequence: Sequence.linear, harness: Harness.pi, diff --git a/src/commands/feature-flags.ts b/src/commands/feature-flags.ts new file mode 100644 index 000000000..c165dffaf --- /dev/null +++ b/src/commands/feature-flags.ts @@ -0,0 +1,7 @@ +import { featureFlagsConfig } from '@programs/feature-flags/index'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +export const featureFlagsCommand: Command = + nativeCommandFactory(featureFlagsConfig); diff --git a/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap index cc4a39428..cd6ae8f7e 100644 --- a/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap +++ b/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap @@ -324,6 +324,59 @@ exports[`flow traces per program > events-audit (node) 1`] = ` } `; +exports[`flow traces per program > feature-flags (node) 1`] = ` +{ + "events": [ + { + "event": "screen health-check", + "from": "agent-skill-intro", + "program": "feature-flags", + }, + { + "event": "screen auth", + "from": "health-check", + "program": "feature-flags", + }, + { + "event": "screen run", + "from": "auth", + "program": "feature-flags", + }, + { + "event": "screen ai-opt-in", + "from": "run", + "program": "feature-flags", + }, + { + "event": "screen run", + "from": "ai-opt-in", + "program": "feature-flags", + }, + { + "event": "screen outro", + "from": "run", + "program": "feature-flags", + }, + { + "event": "screen keep-skills", + "from": "outro", + "program": "feature-flags", + }, + ], + "program": "feature-flags", + "screens": [ + "agent-skill-intro", + "health-check", + "auth", + "ai-opt-in", + "run", + "outro", + "keep-skills", + ], + "stoppedOn": null, +} +`; + exports[`flow traces per program > mcp-add (node) 1`] = ` { "events": [ diff --git a/src/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap b/src/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap index 764f5263a..0502fa292 100644 --- a/src/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap +++ b/src/programs/__tests__/__snapshots__/post-auth-gates.test.ts.snap @@ -10,6 +10,7 @@ exports[`post-auth gate ids per program > match the golden 1`] = ` "detect", ], "events-audit": [], + "feature-flags": [], "mcp-add": [], "mcp-analytics": [], "mcp-remove": [], diff --git a/src/programs/__tests__/feature-flags.test.ts b/src/programs/__tests__/feature-flags.test.ts new file mode 100644 index 000000000..b82311649 --- /dev/null +++ b/src/programs/__tests__/feature-flags.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + buildRegistry, + parseAgentPrompt, +} from '@agent/agent-prompt-loader'; +import { Integration } from '@shared/constants'; +import { featureFlagsConfig } from '@programs/feature-flags/index'; +import { FEATURE_FLAGS_PROMPTS } from '@programs/feature-flags/prompts'; +import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; +import type { ProgramConfig } from '@programs/program-step'; +import type { WizardSession } from '@lib/wizard-session'; +import { testCiRunnerContext } from '../../../test/runner-context'; + +const bundledRegistry = () => + buildRegistry( + FEATURE_FLAGS_PROMPTS.map((text) => + parseAgentPrompt(text, 'feature-flags', 'feature-flags'), + ), + 'feature-flags', + ); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('feature-flags program', () => { + test('runs its own bundled flow with no program skill id', () => { + expect(featureFlagsConfig.agentFlow).toBe('feature-flags'); + expect(featureFlagsConfig.agentPrompts).toBe(FEATURE_FLAGS_PROMPTS); + expect(featureFlagsConfig.skillId).toBeUndefined(); + }); + + test('detects the framework before the intro', () => { + const stepIds = featureFlagsConfig.steps.map((step) => step.id); + expect(stepIds.indexOf('detect')).toBeLessThan(stepIds.indexOf('intro')); + }); + + test('headless pre-run sets the skill id to the detected framework', async () => { + vi.spyOn( + posthogIntegrationConfig as Required, + 'ciPreRun', + ).mockImplementation((session: WizardSession) => { + session.integration = Integration.nextjs; + return Promise.resolve(); + }); + const session = { + integration: null, + skillId: null, + } as unknown as WizardSession; + + await featureFlagsConfig.ciPreRun?.(session, testCiRunnerContext()); + + expect(session.skillId).toBe(Integration.nextjs); + }); +}); + +describe('feature-flags bundled prompts', () => { + test('have one seed and end in the report sink', () => { + const registry = bundledRegistry(); + expect(registry.seed?.type).toBe('setup-feature-flags'); + expect(registry.types).toEqual([ + 'install', + 'init', + 'create-flag', + 'evaluate', + 'report', + ]); + expect(registry.sinkTypes).toEqual(['report']); + }); + + test('evaluate the flag with the context-mill step skill', () => { + expect(bundledRegistry().get('evaluate')?.skills).toEqual([ + 'integration-v2-feature-flags-step', + ]); + }); + + test('pin pi models only', () => { + const registry = bundledRegistry(); + for (const prompt of [ + registry.seed, + ...registry.types.map((type) => registry.get(type)), + ]) { + expect(prompt?.modelPi).toMatch(/^openai\/gpt-5\.6-/); + expect(prompt?.effortPi).toBeDefined(); + expect(prompt?.modelSdk).toBeUndefined(); + } + }); +}); diff --git a/src/programs/feature-flags/index.ts b/src/programs/feature-flags/index.ts new file mode 100644 index 000000000..f4bfd681f --- /dev/null +++ b/src/programs/feature-flags/index.ts @@ -0,0 +1,50 @@ +import { headlessOption, regionOption } from '@lib/headless-mode'; +import { AGENT_SKILL_STEPS } from '@programs/agent-skill/steps'; +import { detectPostHogIntegration } from '@programs/posthog-integration/detect'; +import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; +import type { ProgramConfig, ProgramStep } from '@programs/program-step'; +import type { CiRunnerContext } from '@programs/runner-context'; +import type { WizardSession } from '@lib/wizard-session'; +import { FEATURE_FLAGS_PROMPTS, FEATURE_FLAGS_REPORT_FILE } from './prompts.js'; + +const FEATURE_FLAGS_DOCS_URL = 'https://posthog.com/docs/feature-flags'; + +const DETECT_FRAMEWORK_STEP: ProgramStep = { + id: 'detect', + label: 'Detecting framework', + onReady: detectPostHogIntegration, +}; + +export const featureFlagsConfig: ProgramConfig = { + command: 'feature-flags', + description: 'Set up example PostHog feature flags', + id: 'feature-flags', + agentFlow: 'feature-flags', + agentPrompts: FEATURE_FLAGS_PROMPTS, + steps: [DETECT_FRAMEWORK_STEP, ...AGENT_SKILL_STEPS], + reportFile: FEATURE_FLAGS_REPORT_FILE, + cliOptions: { ...headlessOption, ...regionOption }, + run: { + integrationLabel: 'feature-flags', + successMessage: `Feature flags wired in! View the report at ./${FEATURE_FLAGS_REPORT_FILE}`, + reportFile: FEATURE_FLAGS_REPORT_FILE, + docsUrl: FEATURE_FLAGS_DOCS_URL, + spinnerMessage: 'Setting up your first feature flags...', + estimatedDurationMinutes: 5, + buildOutroNextSteps: () => ({ + heading: 'Next steps', + items: [ + `Read ./${FEATURE_FLAGS_REPORT_FILE} for each flag's link and call site`, + 'Enable the example flags in PostHog when you are ready to roll them out', + ], + }), + }, + + ciPreRun: async ( + session: WizardSession, + runner: CiRunnerContext, + ): Promise => { + await posthogIntegrationConfig.ciPreRun?.(session, runner); + if (session.integration) session.skillId = session.integration; + }, +}; diff --git a/src/programs/feature-flags/prompts.ts b/src/programs/feature-flags/prompts.ts new file mode 100644 index 000000000..6e9f07d4c --- /dev/null +++ b/src/programs/feature-flags/prompts.ts @@ -0,0 +1,284 @@ +export const FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flags-report.md'; +export const FRONTEND_FLAG_KEY = 'wizard-example-frontend-flag'; +export const BACKEND_FLAG_KEY = 'wizard-example-backend-flag'; + +const SEED_PROMPT = `--- +type: setup-feature-flags +flow: feature-flags +seed: true +model_pi: openai/gpt-5.6-terra +effort_pi: medium +skills: [] +allowedTools: [Read, Glob, Grep] +disallowedTools: [Write, Edit, Bash, complete_task] +dependsOn: [] +--- + +## Goal + +Plan example PostHog feature flags for this project and seed the task queue. +The end state: one example flag per side of the app that exists, each +evaluated once in one place on its own side and reported back to PostHog, and +a report telling the user how to turn the flags on. + +First establish two facts from the repo. + +**1. Which sides does the app have?** + +- \`backend\`: server code that handles requests, such as route handlers, API + views, controllers, server components, or server loaders. +- \`frontend\`: code the app owns that runs in a browser or on a device, such + as client components, a single-page app, scripts in its page templates, or + mobile screens. + +A side counts only when the app already has code there to put an evaluation +in. Never add a side to host a flag. Full-stack frameworks usually have both; +an API has only a backend; a mobile app or a static single-page app has only a +frontend. + +**2. Is PostHog already integrated on each of those sides?** Look for the +PostHog SDK for that side in the dependency manifests (or, for template +scripts, the PostHog snippet) and an init call in the source. An init counts +only when the key it reads is defined in the repo's committed env template or +build config. When you cannot tell, treat it as missing: the init task +re-checks and leaves a complete init alone. + +Then seed the graph. Give every task the same input: \`sides\`, the list of +sides you found (\`["backend", "frontend"]\`, \`["backend"]\`, or +\`["frontend"]\`). Pass no other inputs; each task learns the rest from the +handoffs before it. + +- \`install\`, only when an SDK a side needs is missing from the manifest. +- \`init\`, whenever an init is missing or unproven on either side. +- \`create-flag\`, with no dependencies. It only talks to PostHog. +- \`evaluate\`, after \`create-flag\` and after whichever of \`install\` and + \`init\` you queued. +- \`report\`, after every other task. + +Never plan capture, dashboard, or session-replay work. This run sets up the +example flags, nothing else. + +## How you know you succeeded + +Every task above is queued with that dependency shape and the \`sides\` input, +\`report\` depends on every other task, and your plan states which sides the +app has, whether PostHog is integrated on each, and the files that told you. +Keep labels short. +`; + +const INSTALL_PROMPT = `--- +type: install +flow: feature-flags +label: Add the PostHog SDKs to the manifest +model_pi: openai/gpt-5.6-terra +effort_pi: low +skills: [integration-v2-install] +allowedTools: [Read, Edit, Glob, Grep, Bash] +disallowedTools: [enqueue_task] +dependsOn: [] +--- + +## Goal + +Make sure each side in your \`sides\` input has its PostHog SDK in the +manifest: the server library for \`backend\`, the browser or mobile library +for \`frontend\`. Leave an SDK that is already there alone and say so. +Install a missing one following your skill, which owns the package manager +and version rules. + +A frontend that is only scripts in server-rendered templates has no package +to install: the init task adds the PostHog snippet there. Install nothing for +that side, and install nothing beyond these SDKs. + +## How you know you succeeded + +Each side's SDK is declared in the manifest at a real version, or your +handoff says plainly why the environment stopped you. Your handoff names the +manifest and package for each side. +`; + +const INIT_PROMPT = `--- +type: init +flow: feature-flags +label: Set up PostHog initialization +model_pi: openai/gpt-5.6-terra +effort_pi: low +skills: [integration-v2-init, posthog-best-practices] +allowedTools: [Read, Write, Edit, Glob, Grep, check_env_keys, set_env_values] +disallowedTools: [enqueue_task] +dependsOn: [] +--- + +## Goal + +Make sure PostHog is initialized on each side in your \`sides\` input. An +existing init counts only when the env variable it reads is defined: check it +with \`check_env_keys\`. When a side's init is complete, leave it alone and +say so. Otherwise create or finish it following your skill, including the +env wiring and \`.env.example\`. A template-script frontend gets the PostHog +snippet in its base template. + +Initialize each SDK so a flag can be evaluated, and stop. Do not evaluate any +flag; the evaluate task owns that. + +You do not install packages, run builds, linters, or tests, or start the app. +The install task owns packages, and your edits just need to be right by +reading. + +## How you know you succeeded + +PostHog initializes from a defined key on every side in \`sides\`. Your +handoff names, for each side, the init file, the client the evaluate task +should use, and the env variable names (never values). +`; + +const CREATE_FLAG_PROMPT = `--- +type: create-flag +flow: feature-flags +label: Create the example flags in PostHog +model_pi: openai/gpt-5.6-terra +effort_pi: low +skills: [integration-v2-mcp] +allowedTools: [posthog_exec] +disallowedTools: [Read, Write, Edit, Bash, enqueue_task] +dependsOn: [] +--- + +## Goal + +Make sure this PostHog project has one example flag for each side in your +\`sides\` input, and none for a side that is not listed: + +| Side | Key | Name | +|---|---|---| +| \`backend\` | \`${BACKEND_FLAG_KEY}\` | Wizard example backend flag | +| \`frontend\` | \`${FRONTEND_FLAG_KEY}\` | Wizard example frontend flag | + +For each flag you need: + +1. Look it up by key with \`feature-flag-get-definition-by-key\`. One keyed + lookup per flag; never list every flag in the project. +2. Found: reuse it as it is. Do not change its rollout or its active state. +3. Not found: create it with \`create-feature-flag\`, using the key and name + above, a boolean flag with one release condition at 0% rollout, and + \`active: false\`. New flags are active by default, so pass + \`active: false\` explicitly. The user turns it on when they are ready. + +Run \`info\` on each tool before calling it; never guess the input shape. + +If a call is rejected for permissions, or the tool is hidden for a missing +scope, stop and say so in your handoff: the credentials need +\`feature_flag:read\` and \`feature_flag:write\`. Do not retry with other +tools. + +## How you know you succeeded + +Your handoff gives, for each flag: its side, key, id, whether you created or +reused it, whether it is active, and its URL in PostHog (the tool result's +URL, verbatim). +`; + +const EVALUATE_PROMPT = `--- +type: evaluate +flow: feature-flags +label: Evaluate the flags in the app +model_pi: openai/gpt-5.6-sol +effort_pi: medium +skills: [integration-v2-feature-flags-step] +allowedTools: [Read, Write, Edit, Glob, Grep] +disallowedTools: [enqueue_task] +dependsOn: [] +--- + +## Goal + +For each flag the create-flag handoff names, add one evaluation on that +flag's side of this app: the backend flag in server code through the server +SDK, the frontend flag in browser or device code through the client SDK. +Never evaluate a flag on the other side. Your skill carries the SDK flag docs +and the best-practices page; follow them for the exact calls. + +The SDKs are installed and initialized, either already or by the tasks before +you. Build on that; do not re-check it. + +For each flag, choose a call site where a real request or screen passes +through. On the backend, evaluate once for that request and pass the value +down; never re-evaluate deeper in the call stack. Put the keys in a constants +module the call site imports, one module per language; a template script that +cannot import it gets the key from the server's constant, never a literal. +Evaluate with the identified user's distinct id, the same id on both sides, +and report each evaluation back to PostHog the way the docs show for that SDK. +Gate something harmless on each value, such as a log line or a small label, +so the app behaves the same while the flags are off. + +This is an edit-only task. Do not install dependencies, run the build, or +start the app. + +## How you know you succeeded + +Each flag is evaluated once, at one call site on its own side, with its key +imported from a constants module, and its evaluation is reported. Your +handoff names, for each flag, the files changed and the call site, plus how +the distinct id is chosen on each side. +`; + +const REPORT_PROMPT = `--- +type: report +flow: feature-flags +label: Report and verify +sink: true +model_pi: openai/gpt-5.6-sol +effort_pi: medium +skills: [integration-v2-mcp] +allowedTools: [Read, Glob, Grep, Write, Edit, posthog_exec, wizard_ask] +disallowedTools: [enqueue_task] +dependsOn: [] +--- + +## Goal + +Write the hand-off first, then offer one check that the evaluations reach +PostHog. + +**1. Write the report.** From \`read_handoffs\` only, write +\`./${FEATURE_FLAGS_REPORT_FILE}\`, with one section per flag: + +- The flag: its side, key, URL, and that it is off until the user enables it. +- Where the app evaluates it: files and call site, the constants module, and + how the distinct id is chosen. + +Then: + +- If the run installed or initialized an SDK, say which and on which side. +- A "Verify" section, filled in by step 2. + +Write the file before anything else, so a user who walks away still has it. + +**2. Offer the check.** Ask once with \`wizard_ask\`: +\`{ id: "verify-flag", prompt: "The flags are wired in and the report is in ${FEATURE_FLAGS_REPORT_FILE}. Run the app and open the pages or endpoints that evaluate them, then choose Check.", kind: "single", options: [{ label: "Check", value: "check" }, { label: "Skip", value: "skip" }] }\` + +- "check": query PostHog once through \`execute-sql\` for the latest + \`$feature_flag_called\` event in the last hour for each flag key, grouped + by \`$feature_flag\`. Write "Verified" with the event time for each flag + seen, and "Not seen yet" with what to check next for each flag missing. +- "skip", no answer, or \`wizard_ask\` unavailable (headless runs): write + "Verify later" with the same query in words and where to look in PostHog. + +Update the Verify section with the outcome, then give the same summary in +chat. + +## How you know you succeeded + +\`${FEATURE_FLAGS_REPORT_FILE}\` exists, and a user who reads only it knows +which flags were created, which side evaluates each and where, how to turn +them on, and whether an evaluation has reached PostHog yet. +`; + +export const FEATURE_FLAGS_PROMPTS: readonly string[] = [ + SEED_PROMPT, + INSTALL_PROMPT, + INIT_PROMPT, + CREATE_FLAG_PROMPT, + EVALUATE_PROMPT, + REPORT_PROMPT, +]; diff --git a/src/programs/feature-flags/test/e2e.json b/src/programs/feature-flags/test/e2e.json new file mode 100644 index 000000000..e291d2695 --- /dev/null +++ b/src/programs/feature-flags/test/e2e.json @@ -0,0 +1,40 @@ +{ + "program": "feature-flags", + "summary": "Happy path: detect framework, confirm intro, run the feature-flags orchestrator flow (install/init when PostHog is absent, create one example flag per side (frontend, backend), evaluate each once on its side, report), delete installed skills. The driver answers the report's verify ask with \"skip\" because no one runs the app during e2e, so the report ends in \"Verify later\".", + "profile": { + "setup": "first", + "healthCheck": "dismiss", + "mcp": "skip", + "slack": "skip", + "skills": "delete", + "ask": "first", + "askAnswers": [{ "match": "verify-flag", "value": "skip" }] + }, + "variations": [ + { + "name": "default", + "summary": "orchestrator / pi; stage models from the bundled prompts (terra seed, install, init and create-flag, sol evaluate and report)" + } + ], + "path": [ + { + "screen": "detect", + "auto": "(headless) framework detection puts the framework id on session.skillId" + }, + { "screen": "agent-skill-intro", "auto": "confirm & continue" }, + { + "screen": "health-check", + "auto": "dismiss outage and proceed even if the readiness probe flags an issue" + }, + { + "screen": "auth", + "auto": "(external) the runner resolves credentials from the phx key" + }, + { + "screen": "run", + "auto": "(external) the orchestrator flow; the profile answers wizard_ask \"verify-flag\" with \"skip\"" + }, + { "screen": "outro", "auto": "dismiss" }, + { "screen": "keep-skills", "auto": "delete the installed skills" } + ] +} diff --git a/src/programs/oauth/program-scopes.ts b/src/programs/oauth/program-scopes.ts index 8d8f57d2f..665283261 100644 --- a/src/programs/oauth/program-scopes.ts +++ b/src/programs/oauth/program-scopes.ts @@ -268,6 +268,7 @@ const PROGRAM_SCOPE_ADDITIONS: Partial> = { // ever changes, this line will fail to type-check. 'mcp-tutorial': MCP_TUTORIAL_SCOPE_ADDITIONS, 'agent-skill': AGENT_SKILL_SCOPE_ADDITIONS, + 'feature-flags': AGENT_SKILL_SCOPE_ADDITIONS, 'self-driving': SELF_DRIVING_SCOPE_ADDITIONS, 'warehouse-source': WAREHOUSE_SOURCE_SCOPE_ADDITIONS, // The integration run carries the Slack outro step, and — when detection diff --git a/src/programs/program-registry.ts b/src/programs/program-registry.ts index be63b4b8a..d09f5ce91 100644 --- a/src/programs/program-registry.ts +++ b/src/programs/program-registry.ts @@ -22,6 +22,7 @@ import { webAnalyticsDoctorConfig } from './web-analytics-doctor/index.js'; import { migrationConfig } from './migration/index.js'; import { errorTrackingUploadSourceMapsConfig } from './error-tracking-upload-source-maps/index.js'; import { errorTrackingConfig } from './error-tracking/index.js'; +import { featureFlagsConfig } from './feature-flags/index.js'; import { selfDrivingConfig } from './self-driving/index.js'; import { AGENT_SKILL_STEPS } from './agent-skill/index.js'; import { getContentBlocks as agentSkillContentBlocks } from '../ui/tui/decks/agent-skill/index.js'; @@ -72,6 +73,7 @@ export const PROGRAM_REGISTRY = [ warehouseSourceConfig, errorTrackingUploadSourceMapsConfig, errorTrackingConfig, + featureFlagsConfig, auditConfig, eventsAuditConfig, posthogDoctorConfig, @@ -100,6 +102,7 @@ export const Program = { WarehouseSource: warehouseSourceConfig.id, ErrorTrackingUploadSourceMaps: errorTrackingUploadSourceMapsConfig.id, ErrorTracking: errorTrackingConfig.id, + FeatureFlags: featureFlagsConfig.id, Migration: migrationConfig.id, Audit: auditConfig.id, EventsAudit: eventsAuditConfig.id, diff --git a/src/programs/program-step.ts b/src/programs/program-step.ts index c1274f3b9..7c2734a03 100644 --- a/src/programs/program-step.ts +++ b/src/programs/program-step.ts @@ -220,6 +220,12 @@ export interface ProgramConfig { * the content-mill flow name diverges from the program id. */ agentFlow?: string; + /** + * Agent prompts bundled with the wizard for `agentFlow`. When set, the + * orchestrator builds the registry from these and does not fetch + * `agent-menu.json`. + */ + agentPrompts?: readonly string[]; /** * Whether this program's agent run requires third-party AI services. * diff --git a/src/programs/run-agent-legacy.ts b/src/programs/run-agent-legacy.ts index 1eb881dcb..dd9473947 100644 --- a/src/programs/run-agent-legacy.ts +++ b/src/programs/run-agent-legacy.ts @@ -200,6 +200,7 @@ async function runSessionProgram( agentFlow: programConfig.agentFlow, allowedTools: programConfig.allowedTools, disallowedTools: programConfig.disallowedTools, + agentPrompts: programConfig.agentPrompts, excludedTaskTypes: programConfig.excludedTaskTypes, postAuthGates: postAuthGateSteps(programConfig.steps).map( (step) => step.id, diff --git a/src/programs/run-program.ts b/src/programs/run-program.ts index 6c36aa9cd..e6cb0bf84 100644 --- a/src/programs/run-program.ts +++ b/src/programs/run-program.ts @@ -57,6 +57,7 @@ export type WizardFlagSnapshot = { export type ProgramSettings = { requiresAi?: boolean; // false skips the AI-processing approval agentFlow?: string; // context-mill flow; defaults to the program ID + agentPrompts?: RunConfig['agentPrompts']; allowedTools?: RunConfig['allowedTools']; // added to the base tools disallowedTools?: RunConfig['disallowedTools']; // removed from the base tools excludedTaskTypes?: RunConfig['excludedTaskTypes']; // task types to skip for these flags @@ -323,6 +324,7 @@ export async function runProgram( allowedTools: program.allowedTools, disallowedTools: program.disallowedTools, agentFlow: program.agentFlow, + agentPrompts: program.agentPrompts, excludedTaskTypes: program.excludedTaskTypes, seedTasks: input.seedTasks, hooks: input.hooks, From 3cb77afbfa7a5e9e71734e4dd35d32aeafab8371 Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 13:43:58 -0700 Subject: [PATCH 2/7] feat(feature-flags): add the feature-flags intro, learn deck, and tips The feature-flags program gets its own intro screen, a run-screen learn deck explaining what flags are and what the run sets up, and sidebar tips, in place of the generic agent-skill screens. --- e2e-harness/action-registry.ts | 1 + e2e-harness/e2e-profile.ts | 1 + .../architecture/known-violations.json | 2 + .../__snapshots__/flow-traces.test.ts.snap | 4 +- src/programs/feature-flags/index.ts | 11 +- src/programs/feature-flags/test/e2e.json | 2 +- .../frames/feature-flags-intro-120x40.txt | 39 +++++ .../frames/feature-flags-intro-80x28.txt | 27 ++++ src/ui/tui/__tests__/frames.test.tsx | 1 + src/ui/tui/decks/feature-flags/index.tsx | 148 ++++++++++++++++++ src/ui/tui/decks/feature-flags/tips.ts | 24 +++ src/ui/tui/screen-registry.tsx | 2 + src/ui/tui/screen-sequences.ts | 1 + .../tui/screens/FeatureFlagsIntroScreen.tsx | 101 ++++++++++++ 14 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-120x40.txt create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-80x28.txt create mode 100644 src/ui/tui/decks/feature-flags/index.tsx create mode 100644 src/ui/tui/decks/feature-flags/tips.ts create mode 100644 src/ui/tui/screens/FeatureFlagsIntroScreen.tsx diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 2449deee4..7bff21fe6 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -110,6 +110,7 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.AiObservabilityIntro]: [confirmSetupAction], [ScreenId.MetricsIntro]: [confirmSetupAction], [ScreenId.ErrorTrackingIntro]: [confirmSetupAction], + [ScreenId.FeatureFlagsIntro]: [confirmSetupAction], [ScreenId.AuditIntro]: [confirmSetupAction], [ScreenId.DoctorIntro]: [confirmSetupAction], [ScreenId.WarehouseIntro]: [confirmSetupAction], diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 051fab496..2d0d6e2d8 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -284,6 +284,7 @@ export function decideE2eAction( case ScreenId.AiObservabilityIntro: case ScreenId.MetricsIntro: case ScreenId.ErrorTrackingIntro: + case ScreenId.FeatureFlagsIntro: case ScreenId.AuditIntro: case ScreenId.SourceMapsIntro: case ScreenId.DoctorIntro: diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 3eb1f1588..4a31ab417 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -70,6 +70,8 @@ "src/programs/events-audit/steps.ts -> src/lib/wizard-session.ts", "src/programs/feature-flags/index.ts -> src/lib/headless-mode.ts", "src/programs/feature-flags/index.ts -> src/lib/wizard-session.ts", + "src/programs/feature-flags/index.ts -> src/ui/tui/decks/feature-flags/index.tsx", + "src/programs/feature-flags/index.ts -> src/ui/tui/decks/feature-flags/tips.ts", "src/programs/frameworks/astro/astro-wizard-agent.ts -> src/ui/index.ts", "src/programs/frameworks/django/utils.ts -> src/ui/index.ts", "src/programs/frameworks/fastapi/utils.ts -> src/ui/index.ts", diff --git a/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap b/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap index cd6ae8f7e..593a173af 100644 --- a/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap +++ b/src/programs/__tests__/__snapshots__/flow-traces.test.ts.snap @@ -329,7 +329,7 @@ exports[`flow traces per program > feature-flags (node) 1`] = ` "events": [ { "event": "screen health-check", - "from": "agent-skill-intro", + "from": "feature-flags-intro", "program": "feature-flags", }, { @@ -365,7 +365,7 @@ exports[`flow traces per program > feature-flags (node) 1`] = ` ], "program": "feature-flags", "screens": [ - "agent-skill-intro", + "feature-flags-intro", "health-check", "auth", "ai-opt-in", diff --git a/src/programs/feature-flags/index.ts b/src/programs/feature-flags/index.ts index f4bfd681f..d7f272345 100644 --- a/src/programs/feature-flags/index.ts +++ b/src/programs/feature-flags/index.ts @@ -5,6 +5,8 @@ import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; import type { ProgramConfig, ProgramStep } from '@programs/program-step'; import type { CiRunnerContext } from '@programs/runner-context'; import type { WizardSession } from '@lib/wizard-session'; +import { getContentBlocks } from '@ui/tui/decks/feature-flags/index'; +import { getTips } from '@ui/tui/decks/feature-flags/tips'; import { FEATURE_FLAGS_PROMPTS, FEATURE_FLAGS_REPORT_FILE } from './prompts.js'; const FEATURE_FLAGS_DOCS_URL = 'https://posthog.com/docs/feature-flags'; @@ -21,8 +23,15 @@ export const featureFlagsConfig: ProgramConfig = { id: 'feature-flags', agentFlow: 'feature-flags', agentPrompts: FEATURE_FLAGS_PROMPTS, - steps: [DETECT_FRAMEWORK_STEP, ...AGENT_SKILL_STEPS], + steps: [ + DETECT_FRAMEWORK_STEP, + ...AGENT_SKILL_STEPS.map((step) => + step.id === 'intro' ? { ...step, screenId: 'feature-flags-intro' } : step, + ), + ], reportFile: FEATURE_FLAGS_REPORT_FILE, + getContentBlocks, + getTips, cliOptions: { ...headlessOption, ...regionOption }, run: { integrationLabel: 'feature-flags', diff --git a/src/programs/feature-flags/test/e2e.json b/src/programs/feature-flags/test/e2e.json index e291d2695..62abf1d05 100644 --- a/src/programs/feature-flags/test/e2e.json +++ b/src/programs/feature-flags/test/e2e.json @@ -21,7 +21,7 @@ "screen": "detect", "auto": "(headless) framework detection puts the framework id on session.skillId" }, - { "screen": "agent-skill-intro", "auto": "confirm & continue" }, + { "screen": "feature-flags-intro", "auto": "confirm & continue" }, { "screen": "health-check", "auto": "dismiss outage and proceed even if the readiness probe flags an issue" diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-120x40.txt new file mode 100644 index 000000000..2045011c5 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's add your first PostHog feature flags, so you can ship code and turn it on when you're ready. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-80x28.txt new file mode 100644 index 000000000..d09820263 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Let's add your first PostHog feature flags, so you can ship code and turn it + on when you're ready. + + Directory ✔ /app + + ▸ Continue + More info + Privacy & data + Cancel + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/frames.test.tsx b/src/ui/tui/__tests__/frames.test.tsx index f8700cf03..2a1e73918 100644 --- a/src/ui/tui/__tests__/frames.test.tsx +++ b/src/ui/tui/__tests__/frames.test.tsx @@ -380,6 +380,7 @@ const FIXTURES: Record = { [ScreenId.AiObservabilityIntro]: { program: Program.AiObservability }, [ScreenId.MetricsIntro]: { program: Program.Metrics }, [ScreenId.ErrorTrackingIntro]: { program: Program.ErrorTracking }, + [ScreenId.FeatureFlagsIntro]: { program: Program.FeatureFlags }, [ScreenId.ErrorTrackingDetect]: { program: Program.ErrorTracking, arrange: authed, diff --git a/src/ui/tui/decks/feature-flags/index.tsx b/src/ui/tui/decks/feature-flags/index.tsx new file mode 100644 index 000000000..7f3476d84 --- /dev/null +++ b/src/ui/tui/decks/feature-flags/index.tsx @@ -0,0 +1,148 @@ +import { Text } from 'ink'; +import { Colors } from '@ui/tui/styles'; +import type { WizardStore } from '@ui/tui/store'; +import { TextRevealMode } from '@ui/tui/primitives/TextBlock'; +import type { ContentBlock } from '@ui/tui/primitives/content-types'; +import { StatusPeekTrigger } from '@ui/tui/components/StatusPeekTrigger'; + +const GATED_CODE: ContentBlock = { + type: 'lines', + interval: 400, + pause: 7000, + lines: [ + {'example — a flag around new code'}, + + {'if'} + {" (flags.isEnabled('new-checkout')) {"} + , + {' showNewCheckout()'}, + {'}'}, + ], +}; + +const GRADUAL_ROLLOUT: ContentBlock = { + type: 'lines', + interval: 700, + pause: 7000, + lines: [ + {'example — new-checkout rollout'}, + + {'▓'} + {'░░░░░░░░░'} + {' 5% internal team'} + , + + {'▓▓▓▓▓'} + {'░░░░░'} + {' 50% half of users'} + , + + {'▓▓▓▓▓▓▓▓▓▓'} + {' 100% everyone'} + , + ], +}; + +const RUN_PLAN: ContentBlock = { + type: 'lines', + interval: 600, + pause: 8000, + lines: [ + + {'1 '} + {'Create example flags, off at 0%'} + , + + {'2 '} + {'Evaluate the backend flag on the server'} + , + + {'3 '} + {'Evaluate the frontend flag in the UI'} + , + + {'4 '} + {'Write a report on turning them on'} + , + ], +}; + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => [ + { + content: 'Welcome.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + { content: "I'm adding PostHog feature flags to your app.", pause: 5000 }, + + { type: 'clear', pause: 1500 }, + + { + content: + 'A feature flag is a switch in your code that you flip from PostHog, with no deploy.', + pause: 6000, + }, + GATED_CODE, + { + content: 'Ship new code turned off, then turn it on when you are ready.', + pause: 6000, + }, + + { type: 'clear', pause: 1500 }, + + { content: 'Roll out gradually instead of all at once:', pause: 2500 }, + GRADUAL_ROLLOUT, + { + content: + 'Target who sees it: your team, a beta cohort, a plan, or a country.', + pause: 6000, + }, + { + content: + 'Something breaks? Turn the flag off in seconds. No rollback, no redeploy.', + pause: 7000, + }, + + { type: 'clear', pause: 1500 }, + + { + content: + 'Each evaluation is reported to PostHog, so you can see who got which value.', + pause: 6500, + }, + { + content: + 'The same flags run experiments: split users between variants and measure the result.', + pause: 7000, + }, + + { type: 'clear', pause: 1500 }, + + { content: "Here's what I'm doing now:", pause: 2000 }, + RUN_PLAN, + { + content: 'Each side gets a flag only when your app has code there.', + pause: 6000, + }, + + { type: 'clear', pause: 1500 }, + + { + pause: 5000, + persist: true, + content: , + }, + { + pause: 90000, + content: ( + + Press{' '} + + S + {' '} + to follow along — or sit tight, I'll let you know when it's done. + + ), + }, +]; diff --git a/src/ui/tui/decks/feature-flags/tips.ts b/src/ui/tui/decks/feature-flags/tips.ts new file mode 100644 index 000000000..ba04eaca1 --- /dev/null +++ b/src/ui/tui/decks/feature-flags/tips.ts @@ -0,0 +1,24 @@ +import type { Tip } from '@ui/tui/components/TipsCard'; + +export const FEATURE_FLAGS_TIPS: Tip[] = [ + { + id: 'payloads', + title: 'Ship settings, not just on and off', + description: + 'Attach a JSON payload to a flag to change copy, limits, or config without a deploy.', + }, + { + id: 'local-evaluation', + title: 'Check flags without a network call', + description: + 'Server SDKs can download your flag definitions and evaluate them locally, so a check adds no round trip.', + }, + { + id: 'experiments', + title: 'Turn a flag into an experiment', + description: + 'Give a flag variants and PostHog measures which one wins on the metrics you pick.', + }, +]; + +export const getTips = (): Tip[] => FEATURE_FLAGS_TIPS; diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index c3b7d9e37..2b07da37c 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -33,6 +33,7 @@ import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AiObservabilityIntroScreen } from './screens/AiObservabilityIntroScreen.js'; import { MetricsIntroScreen } from './screens/MetricsIntroScreen.js'; import { ErrorTrackingIntroScreen } from './screens/ErrorTrackingIntroScreen.js'; +import { FeatureFlagsIntroScreen } from './screens/FeatureFlagsIntroScreen.js'; import { ErrorTrackingDetectScreen } from './screens/ErrorTrackingDetectScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; import { SelfDrivingIntegrationCheckScreen } from './screens/SelfDrivingIntegrationCheckScreen.js'; @@ -116,6 +117,7 @@ export function createScreens( ), [ScreenId.MetricsIntro]: , [ScreenId.ErrorTrackingIntro]: , + [ScreenId.FeatureFlagsIntro]: , [ScreenId.ErrorTrackingDetect]: , [ScreenId.SelfDrivingIntro]: , [ScreenId.SelfDrivingIntegrationCheck]: ( diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 1ee869245..1fcf29e86 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -24,6 +24,7 @@ export enum ScreenId { AiObservabilityIntro = 'ai-observability-intro', MetricsIntro = 'metrics-intro', ErrorTrackingIntro = 'error-tracking-intro', + FeatureFlagsIntro = 'feature-flags-intro', ErrorTrackingDetect = 'error-tracking-detect', SelfDrivingIntro = 'self-driving-intro', SelfDrivingIntegrationCheck = 'self-driving-integration-check', diff --git a/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx new file mode 100644 index 000000000..3348453de --- /dev/null +++ b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx @@ -0,0 +1,101 @@ +import { Box, Text } from 'ink'; +import { useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { IntroScreenLayout } from '@ui/tui/screens/IntroScreenLayout'; +import { + SkillSourceInfo, + useSkillEntry, +} from '@ui/tui/screens/SkillSourceInfo'; + +interface FeatureFlagsIntroScreenProps { + store: WizardStore; +} + +const FEATURE_FLAGS_STEP_SKILL_ID = 'integration-v2-feature-flags-step'; + +export const FeatureFlagsIntroScreen = ({ + store, +}: FeatureFlagsIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [showingMoreInfo, setShowingMoreInfo] = useState(false); + const { session } = store; + const { skillEntry, fetchFailed } = useSkillEntry( + FEATURE_FLAGS_STEP_SKILL_ID, + ); + + const body = showingMoreInfo ? ( + + + + The Wizard is an agent, it's here to help you set up PostHog. Its code + is open source:{' '} + https://github.com/PostHog/wizard + + + + + + Feature flags let you ship code turned off, roll it out to a + percentage of users or a chosen group, and switch it off in seconds + without a deploy. The same flags run your experiments. + + + + + The{' '} + + feature-flags + {' '} + program creates example flags in PostHog, off at 0% rollout: one for + your backend and one for your frontend, for each side your app has. It + installs and initializes the PostHog SDKs if needed, evaluates each flag + once in your code, and writes a report on how to turn them on. + + + + + + ) : ( + + + Let's add your first PostHog feature flags, so you can ship code and + turn it on when you're ready. + + + ); + + const menuOptions = showingMoreInfo + ? [{ label: 'Back', value: 'back' }] + : [ + { label: 'Continue', value: 'continue' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const menuActions: Record void> = { + cancel: () => process.exit(0), + 'more-info': () => setShowingMoreInfo(true), + back: () => setShowingMoreInfo(false), + continue: () => store.completeSetup(), + }; + + return ( + menuActions[value]?.()} + /> + ); +}; From 2820332ae5ae43211ccadba05a97d613a95c154c Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 15:22:33 -0700 Subject: [PATCH 3/7] test(feature-flags): mock the integration detector the feature-flags program imports --- src/programs/__tests__/run-agent-legacy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/programs/__tests__/run-agent-legacy.test.ts b/src/programs/__tests__/run-agent-legacy.test.ts index d47545e20..071b0326f 100644 --- a/src/programs/__tests__/run-agent-legacy.test.ts +++ b/src/programs/__tests__/run-agent-legacy.test.ts @@ -92,6 +92,7 @@ vi.mock('@utils/wizard-abort', async (original) => ({ })); vi.mock('../posthog-integration/detect', () => ({ maybeStampAiSdkDetected: vi.fn(), + detectPostHogIntegration: vi.fn(), })); vi.mock('../posthog-integration/ai-sdk-stamp', () => ({ stampAiSdkDetected: vi.fn(), From e7094d0f1b77502d797a485ab956a9bd86f2a3f1 Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 15:56:22 -0700 Subject: [PATCH 4/7] style(feature-flags): format the feature-flags program test --- src/programs/__tests__/feature-flags.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/programs/__tests__/feature-flags.test.ts b/src/programs/__tests__/feature-flags.test.ts index b82311649..a2613ad99 100644 --- a/src/programs/__tests__/feature-flags.test.ts +++ b/src/programs/__tests__/feature-flags.test.ts @@ -1,9 +1,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { - buildRegistry, - parseAgentPrompt, -} from '@agent/agent-prompt-loader'; +import { buildRegistry, parseAgentPrompt } from '@agent/agent-prompt-loader'; import { Integration } from '@shared/constants'; import { featureFlagsConfig } from '@programs/feature-flags/index'; import { FEATURE_FLAGS_PROMPTS } from '@programs/feature-flags/prompts'; From 57f84f5abe3926ad3472f8268b931289208ab52f Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 16:14:02 -0700 Subject: [PATCH 5/7] fix(feature-flags): stop at the intro when no framework is detected Give feature-flags its own OAuth scope list and share the step skill id between the evaluate prompt and the intro screen. --- .../architecture/known-violations.json | 1 + src/programs/__tests__/feature-flags.test.ts | 7 +++ src/programs/feature-flags/prompts.ts | 3 +- src/programs/oauth/program-scopes.ts | 7 ++- .../feature-flags-intro-undetected-120x40.txt | 39 +++++++++++++++ .../feature-flags-intro-undetected-80x28.txt | 27 +++++++++++ src/ui/tui/__tests__/frames.test.tsx | 27 ++++++++++- .../tui/screens/FeatureFlagsIntroScreen.tsx | 47 ++++++++++++++++++- 8 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-120x40.txt create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-80x28.txt diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 4a31ab417..aba8064e7 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -143,6 +143,7 @@ "src/ui/tui/screen-sequences.ts -> src/programs/program-step.ts", "src/ui/tui/screens/ErrorTrackingDetectScreen.tsx -> src/programs/error-tracking/detect-agentic.ts", "src/ui/tui/screens/ErrorTrackingDetectScreen.tsx -> src/programs/frameworks/registry.ts", + "src/ui/tui/screens/FeatureFlagsIntroScreen.tsx -> src/programs/feature-flags/prompts.ts", "src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx -> src/programs/frameworks/registry.ts", "src/ui/tui/screens/RevenueIntroScreen.tsx -> src/programs/revenue-analytics/index.ts", "src/ui/tui/screens/SelfDrivingGitHubScreen.tsx -> src/programs/self-driving/detect.ts", diff --git a/src/programs/__tests__/feature-flags.test.ts b/src/programs/__tests__/feature-flags.test.ts index a2613ad99..a88fd80b4 100644 --- a/src/programs/__tests__/feature-flags.test.ts +++ b/src/programs/__tests__/feature-flags.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { buildRegistry, parseAgentPrompt } from '@agent/agent-prompt-loader'; import { Integration } from '@shared/constants'; +import { getOAuthScopesForProgram } from '@programs/oauth/program-scopes'; import { featureFlagsConfig } from '@programs/feature-flags/index'; import { FEATURE_FLAGS_PROMPTS } from '@programs/feature-flags/prompts'; import { posthogIntegrationConfig } from '@programs/posthog-integration/index'; @@ -33,6 +34,12 @@ describe('feature-flags program', () => { expect(stepIds.indexOf('detect')).toBeLessThan(stepIds.indexOf('intro')); }); + test('requests both feature flag scopes', () => { + const scopes = getOAuthScopesForProgram('feature-flags'); + expect(scopes).toContain('feature_flag:read'); + expect(scopes).toContain('feature_flag:write'); + }); + test('headless pre-run sets the skill id to the detected framework', async () => { vi.spyOn( posthogIntegrationConfig as Required, diff --git a/src/programs/feature-flags/prompts.ts b/src/programs/feature-flags/prompts.ts index 6e9f07d4c..783c3e3fb 100644 --- a/src/programs/feature-flags/prompts.ts +++ b/src/programs/feature-flags/prompts.ts @@ -1,6 +1,7 @@ export const FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flags-report.md'; export const FRONTEND_FLAG_KEY = 'wizard-example-frontend-flag'; export const BACKEND_FLAG_KEY = 'wizard-example-backend-flag'; +export const FEATURE_FLAGS_STEP_SKILL_ID = 'integration-v2-feature-flags-step'; const SEED_PROMPT = `--- type: setup-feature-flags @@ -184,7 +185,7 @@ flow: feature-flags label: Evaluate the flags in the app model_pi: openai/gpt-5.6-sol effort_pi: medium -skills: [integration-v2-feature-flags-step] +skills: [${FEATURE_FLAGS_STEP_SKILL_ID}] allowedTools: [Read, Write, Edit, Glob, Grep] disallowedTools: [enqueue_task] dependsOn: [] diff --git a/src/programs/oauth/program-scopes.ts b/src/programs/oauth/program-scopes.ts index 665283261..cb6610c06 100644 --- a/src/programs/oauth/program-scopes.ts +++ b/src/programs/oauth/program-scopes.ts @@ -221,6 +221,11 @@ export const WAREHOUSE_SOURCE_SCOPE_ADDITIONS = [ */ export const CONNECT_SLACK_SCOPE_ADDITIONS = ['integration:read'] as const; +export const FEATURE_FLAGS_SCOPE_ADDITIONS = [ + 'feature_flag:read', + 'feature_flag:write', +] as const; + /** * Extra scopes the replay-vision program needs on top of `WIZARD_OAUTH_SCOPES`. * The same set self-driving's step 6c uses, narrowed to just this flow: @@ -268,7 +273,7 @@ const PROGRAM_SCOPE_ADDITIONS: Partial> = { // ever changes, this line will fail to type-check. 'mcp-tutorial': MCP_TUTORIAL_SCOPE_ADDITIONS, 'agent-skill': AGENT_SKILL_SCOPE_ADDITIONS, - 'feature-flags': AGENT_SKILL_SCOPE_ADDITIONS, + 'feature-flags': FEATURE_FLAGS_SCOPE_ADDITIONS, 'self-driving': SELF_DRIVING_SCOPE_ADDITIONS, 'warehouse-source': WAREHOUSE_SOURCE_SCOPE_ADDITIONS, // The integration run carries the Slack outro step, and — when detection diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-120x40.txt new file mode 100644 index 000000000..1d6bfd006 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Could not detect your project's framework. + + The feature-flags program needs a supported framework. Run the + wizard from your app's root directory. + + ▸ Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-80x28.txt new file mode 100644 index 000000000..8fdc3fd35 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-undetected-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Could not detect your project's framework. + + The feature-flags program needs a supported framework. Run the + wizard from your app's root directory. + + ▸ Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/frames.test.tsx b/src/ui/tui/__tests__/frames.test.tsx index 2a1e73918..fa810bb6d 100644 --- a/src/ui/tui/__tests__/frames.test.tsx +++ b/src/ui/tui/__tests__/frames.test.tsx @@ -380,7 +380,15 @@ const FIXTURES: Record = { [ScreenId.AiObservabilityIntro]: { program: Program.AiObservability }, [ScreenId.MetricsIntro]: { program: Program.Metrics }, [ScreenId.ErrorTrackingIntro]: { program: Program.ErrorTracking }, - [ScreenId.FeatureFlagsIntro]: { program: Program.FeatureFlags }, + [ScreenId.FeatureFlagsIntro]: { + program: Program.FeatureFlags, + arrange: (s) => { + s.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + s.setDetectedFramework('Next.js'); + s.setSkillId('nextjs'); + s.setDetectionComplete(); + }, + }, [ScreenId.ErrorTrackingDetect]: { program: Program.ErrorTracking, arrange: authed, @@ -626,6 +634,23 @@ describe('revenue-intro with a detect error', () => { }); }); +describe('feature-flags-intro with no detected framework', () => { + it.each(SIZES)(`at $columns x $rows`, async (size) => { + const store = makeStore(Program.FeatureFlags); + store.setDetectionComplete(); + expect(store.currentScreen).toBe(ScreenId.FeatureFlagsIntro); + const { frame } = await renderScreen( + store, + screenShell(store, makeServices(store)), + size, + ); + expect(frame).not.toContain('Continue'); + await expect(frame).toMatchFileSnapshot( + `__snapshots__/frames/feature-flags-intro-undetected-${size.columns}x${size.rows}.txt`, + ); + }); +}); + function snapshotPath(screen: ScreenName, size: TerminalSize): string { return `__snapshots__/frames/${screen}-${size.columns}x${size.rows}.txt`; } diff --git a/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx index 3348453de..4aaee843e 100644 --- a/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx +++ b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx @@ -1,6 +1,9 @@ import { Box, Text } from 'ink'; +import type { ReactNode } from 'react'; import { useState, useSyncExternalStore } from 'react'; import type { WizardStore } from '@ui/tui/store'; +import { FEATURE_FLAGS_STEP_SKILL_ID } from '@programs/feature-flags/prompts'; +import { LoadingBox } from '@ui/tui/primitives/index'; import { IntroScreenLayout } from '@ui/tui/screens/IntroScreenLayout'; import { SkillSourceInfo, @@ -11,7 +14,7 @@ interface FeatureFlagsIntroScreenProps { store: WizardStore; } -const FEATURE_FLAGS_STEP_SKILL_ID = 'integration-v2-feature-flags-step'; +const CANCEL_ONLY_MENU = [{ label: 'Cancel', value: 'cancel' }]; export const FeatureFlagsIntroScreen = ({ store, @@ -87,6 +90,28 @@ export const FeatureFlagsIntroScreen = ({ continue: () => store.completeSetup(), }; + const selectMenuAction = (value: string) => menuActions[value]?.(); + const isDetectingFramework = !session.detectionComplete; + const isFrameworkUndetected = session.integration === null; + + if (isDetectingFramework || isFrameworkUndetected) { + return ( + + ); + } + return ( menuActions[value]?.()} + onSelect={selectMenuAction} /> ); }; + +const DETECTING_FRAMEWORK_BODY: ReactNode = ( + + + +); + +const UNDETECTED_FRAMEWORK_BODY: ReactNode = ( + + Could not detect your project's framework. + + + The feature-flags program needs a supported framework. Run the wizard + from your app's root directory. + + + +); From 15287abb2a66dc1b2d296b1f6318d13290510742 Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 16:16:24 -0700 Subject: [PATCH 6/7] fix(feature-flags): stop at the intro when the framework version is unsupported --- ...flags-intro-unsupported-version-120x40.txt | 39 +++++++++++++++++++ ...-flags-intro-unsupported-version-80x28.txt | 27 +++++++++++++ src/ui/tui/__tests__/frames.test.tsx | 24 ++++++++++++ .../tui/screens/FeatureFlagsIntroScreen.tsx | 31 +++++++++++---- 4 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-120x40.txt create mode 100644 src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-80x28.txt diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-120x40.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-120x40.txt new file mode 100644 index 000000000..d469b60e8 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Version 12.3.0 is not supported by the wizard. Please upgrade to + 13.0.0 or later. + + Manual setup guide: https://posthog.com/docs/libraries/next-js + + ▸ Privacy & data + Cancel + + + + + + + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-80x28.txt b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-80x28.txt new file mode 100644 index 000000000..004dd0f12 --- /dev/null +++ b/src/ui/tui/__tests__/__snapshots__/frames/feature-flags-intro-unsupported-version-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + + + + + ███ PostHog Wizard 🦔 + + We'll use AI to analyze your project and complete work. + .env* file contents will not leave your machine. + + Version 12.3.0 is not supported by the wizard. Please upgrade to + 13.0.0 or later. + + Manual setup guide: https://posthog.com/docs/libraries/next-js + + ▸ Privacy & data + Cancel + + + + + + ↑↓ navigate enter select diff --git a/src/ui/tui/__tests__/frames.test.tsx b/src/ui/tui/__tests__/frames.test.tsx index fa810bb6d..0f25ebfd3 100644 --- a/src/ui/tui/__tests__/frames.test.tsx +++ b/src/ui/tui/__tests__/frames.test.tsx @@ -651,6 +651,30 @@ describe('feature-flags-intro with no detected framework', () => { }); }); +describe('feature-flags-intro with an unsupported framework version', () => { + it.each(SIZES)(`at $columns x $rows`, async (size) => { + const store = makeStore(Program.FeatureFlags); + store.setFrameworkConfig(Integration.nextjs, staticFrameworkConfig()); + store.setDetectedFramework('Next.js'); + store.setUnsupportedVersion({ + current: '12.3.0', + minimum: '13.0.0', + docsUrl: 'https://posthog.com/docs/libraries/next-js', + }); + store.setDetectionComplete(); + expect(store.currentScreen).toBe(ScreenId.FeatureFlagsIntro); + const { frame } = await renderScreen( + store, + screenShell(store, makeServices(store)), + size, + ); + expect(frame).not.toContain('Continue'); + await expect(frame).toMatchFileSnapshot( + `__snapshots__/frames/feature-flags-intro-unsupported-version-${size.columns}x${size.rows}.txt`, + ); + }); +}); + function snapshotPath(screen: ScreenName, size: TerminalSize): string { return `__snapshots__/frames/${screen}-${size.columns}x${size.rows}.txt`; } diff --git a/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx index 4aaee843e..76eab2995 100644 --- a/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx +++ b/src/ui/tui/screens/FeatureFlagsIntroScreen.tsx @@ -91,18 +91,13 @@ export const FeatureFlagsIntroScreen = ({ }; const selectMenuAction = (value: string) => menuActions[value]?.(); - const isDetectingFramework = !session.detectionComplete; - const isFrameworkUndetected = session.integration === null; + const blockedBody = bodyThatBlocksContinue(session); - if (isDetectingFramework || isFrameworkUndetected) { + if (blockedBody) { return ( + + Version {current} is not supported by the wizard. Please upgrade to{' '} + {minimum} or later. + + + Manual setup guide: {docsUrl} + + + ); +} + const DETECTING_FRAMEWORK_BODY: ReactNode = ( From e745ea05b2890bf1680c1eaf7412bc87ae3d8a92 Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 16:35:04 -0700 Subject: [PATCH 7/7] fix(feature-flags): count only app-owned browser code as a frontend side A Django run whose templates load no app JavaScript added a PostHog snippet to host the frontend flag. The seed now defines the frontend by JavaScript the app owns and loads, including through import maps or Vite, and names script-less server templates as backend-only. --- src/programs/feature-flags/prompts.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/programs/feature-flags/prompts.ts b/src/programs/feature-flags/prompts.ts index 783c3e3fb..4812a5767 100644 --- a/src/programs/feature-flags/prompts.ts +++ b/src/programs/feature-flags/prompts.ts @@ -28,14 +28,16 @@ First establish two facts from the repo. - \`backend\`: server code that handles requests, such as route handlers, API views, controllers, server components, or server loaders. -- \`frontend\`: code the app owns that runs in a browser or on a device, such - as client components, a single-page app, scripts in its page templates, or - mobile screens. +- \`frontend\`: JavaScript or device code the app owns that runs in a browser + or on a device, such as client components, a single-page app, inline + scripts in its page templates, script files its pages load (directly or + through asset helpers such as import maps or Vite), or mobile screens. A side counts only when the app already has code there to put an evaluation -in. Never add a side to host a flag. Full-stack frameworks usually have both; -an API has only a backend; a mobile app or a static single-page app has only a -frontend. +in. Never add a side to host a flag. Server-rendered templates that load no +JavaScript of the app's own are not a frontend: such an app has only a +backend. An API has only a backend; a mobile app or a static single-page app +has only a frontend. **2. Is PostHog already integrated on each of those sides?** Look for the PostHog SDK for that side in the dependency manifests (or, for template