From 7a36bdd2ef81d7a5a3862e0fa1ce5b6ee61658c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Tue, 1 Sep 2026 22:44:55 +0200 Subject: [PATCH 1/5] feat(feature-flags): add wizard feature-flags as a top-level install program --- AGENTS.md | 10 ++- README.md | 20 ++++- bin.ts | 2 + src/__tests__/programs-cli.test.ts | 12 +++ src/commands/feature-flags.ts | 14 +++ src/lib/agent/runner/switchboard/index.ts | 1 + .../oauth/__tests__/program-scopes.test.ts | 27 ++++++ src/lib/oauth/program-scopes.ts | 20 ++++- .../programs/__tests__/feature-flags.test.ts | 43 ++++++++++ src/lib/programs/feature-flags/index.ts | 85 +++++++++++++++++++ src/lib/programs/program-registry.ts | 3 + 11 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 src/commands/feature-flags.ts create mode 100644 src/lib/programs/__tests__/feature-flags.test.ts create mode 100644 src/lib/programs/feature-flags/index.ts diff --git a/AGENTS.md b/AGENTS.md index 9538219a4..67279c901 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,14 @@ command names.** Old names mostly no longer exist — only some are kept as alia | `wizard audit session-replay` | session replay setup | | `wizard audit web-analytics` | web analytics setup (**wizard-native**, not a skill) | +### Feature flags + +`wizard feature-flags` is a **flat skill command** (same shape as +`revenue-analytics` / `mcp-analytics`). It runs the `feature-flags-setup` +context-mill skill: Next.js App Router only, server `evaluateFlags()` + client +bootstrap, skip-first, optional 0% boolean flag + additive UI path after +confirm. Distinct from `wizard audit feature-flags`, which is read-only. + ### Commands vs. skills (the `audit [skill]` gotcha) A skill and a command are the **same machinery** — a context-mill skill becomes a @@ -86,7 +94,7 @@ confuse it with the top-level `wizard skill` command. - **Registration:** [`bin.ts`](bin.ts) — the `.use()` chain wires each command. - **Command shape:** [`src/commands/command.ts`](src/commands/command.ts) — the `Command` interface every command implements. -- **Flat native commands** (e.g. `revenue-analytics`, `upload-source-maps`) are +- **Flat native commands** (e.g. `feature-flags`, `revenue-analytics`, `upload-source-maps`) are built with `nativeCommandFactory` ([`src/commands/factories/native-command-factory.ts`](src/commands/factories/native-command-factory.ts)). - **Family commands** (e.g. `audit`) resolve subcommands at runtime against the diff --git a/README.md b/README.md index fdf2efcef..f01e9af1f 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,22 @@ new audits appear without a wizard release (`web-analytics` is wizard-native). > (`wizard audit --help` still labels the positional `[skill]` — read it as "pick > a subcommand.") +### Feature flags + +Add PostHog feature flags to a Next.js App Router app: evaluate once per request +on the server, bootstrap those values into the client, and disable `/flags` +polling in CI. After one confirm, optionally create one boolean flag at **0% +rollout** and gate one additive UI path. Skip is the default — no new flag and +no UI change. Production users keep current behavior until someone raises +rollout in PostHog. + +```bash +npx @posthog/wizard feature-flags +``` + +Next.js App Router only. Distinct from `wizard audit feature-flags`, which +audits existing flag usage and cost and does not install anything. + ### Revenue Analytics Wire up an existing PostHog + Stripe project for revenue analytics: @@ -165,8 +181,8 @@ route review to their owning team instead. | `src/lib/programs/web-analytics-doctor/` | `@PostHog/team-web-analytics` | Ownership is by directory. Programs not listed above -(`agent-skill`, `audit`, `events-audit`, `mcp`, `migration`, `posthog-doctor`, -`shared`, `slack`) fall through the default and are owned by +(`agent-skill`, `audit`, `events-audit`, `feature-flags`, `mcp`, `migration`, +`posthog-doctor`, `shared`, `slack`) fall through the default and are owned by `team-wizard-docs`. Today CODEOWNERS only auto-requests review — approval is not a merge gate. diff --git a/bin.ts b/bin.ts index f4bb1bd58..11d97109c 100644 --- a/bin.ts +++ b/bin.ts @@ -55,6 +55,7 @@ import { Wizard } from './src/wizard'; import { basicIntegrationCommand } from './src/commands/basic-integration'; import { mcpCommand } from './src/commands/mcp'; import { mcpAnalyticsCommand } from './src/commands/mcp-analytics'; +import { featureFlagsCommand } from './src/commands/feature-flags'; import { replayVisionCommand } from './src/commands/replay-vision'; import { aiObservabilityCommand } from './src/commands/ai-observability'; import { metricsCommand } from './src/commands/metrics'; @@ -88,6 +89,7 @@ function resolveInstallDir(): string { Wizard.use(basicIntegrationCommand) .use(mcpCommand) .use(mcpAnalyticsCommand) + .use(featureFlagsCommand) .use(replayVisionCommand) .use(aiObservabilityCommand) .use(metricsCommand) diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 70c9bd9b9..c6c62af4c 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -21,6 +21,7 @@ import type { MockedFunction } from 'vitest'; import { auditCommand } from '../commands/audit'; import { migrateCommand } from '../commands/migrate'; import { mcpAnalyticsCommand } from '../commands/mcp-analytics'; +import { featureFlagsCommand } from '../commands/feature-flags'; import { replayVisionCommand } from '../commands/replay-vision'; import { revenueCommand } from '../commands/revenue'; import { warehouseCommand } from '../commands/warehouse'; @@ -86,6 +87,11 @@ describe('top-level command shapes', () => { expect(mcpAnalyticsCommand.children).toBeUndefined(); }); + test('feature-flags is a flat skill command', () => { + expect(featureFlagsCommand.name).toBe('feature-flags'); + expect(featureFlagsCommand.children).toBeUndefined(); + }); + test('replay-vision is a flat skill command', () => { expect(replayVisionCommand.name).toBe('replay-vision'); expect(replayVisionCommand.children).toBeUndefined(); @@ -190,6 +196,12 @@ describe('flat skill commands', () => { expect(config.skillId).toBe('mcp-analytics'); }); + test('feature-flags dispatches with feature-flags-setup skillId', () => { + featureFlagsCommand.handler!(makeArgv({ debug: true })); + const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; + expect(config.skillId).toBe('feature-flags-setup'); + }); + test('replay-vision dispatches with replay-vision-setup skillId', () => { replayVisionCommand.handler!(makeArgv({ debug: true })); const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }]; diff --git a/src/commands/feature-flags.ts b/src/commands/feature-flags.ts new file mode 100644 index 000000000..0192531ba --- /dev/null +++ b/src/commands/feature-flags.ts @@ -0,0 +1,14 @@ +import { featureFlagsConfig } from '@lib/programs/feature-flags/index'; + +import type { Command } from './command'; +import { nativeCommandFactory } from './factories/native-command-factory'; + +/** + * `wizard feature-flags` — flat skill command. + * + * Distinct from `wizard audit feature-flags` (read-only cost/correctness + * audit) and from `wizard migrate` (come from another vendor). Stays flat + * while install-and-instrument is the only action. + */ +export const featureFlagsCommand: Command = + nativeCommandFactory(featureFlagsConfig); diff --git a/src/lib/agent/runner/switchboard/index.ts b/src/lib/agent/runner/switchboard/index.ts index fb4232036..de721acaf 100644 --- a/src/lib/agent/runner/switchboard/index.ts +++ b/src/lib/agent/runner/switchboard/index.ts @@ -141,6 +141,7 @@ export const PROGRAM_BINDINGS: Partial> = { 'mcp-remove': DEFAULT_BINDING, 'mcp-tutorial': DEFAULT_BINDING, 'mcp-analytics': DEFAULT_BINDING, + 'feature-flags': DEFAULT_BINDING, // Orchestrator on pi. The binding routes only; every stage's model and // effort are pinned context-mill side in the flow's frontmatter // (`model_pi`/`effort_pi`: terra seed, sol tasks, luna report). diff --git a/src/lib/oauth/__tests__/program-scopes.test.ts b/src/lib/oauth/__tests__/program-scopes.test.ts index f6ef4453f..7bae895dc 100644 --- a/src/lib/oauth/__tests__/program-scopes.test.ts +++ b/src/lib/oauth/__tests__/program-scopes.test.ts @@ -22,6 +22,26 @@ describe('posthog-integration scopes', () => { }); }); +describe('feature-flags scopes', () => { + it('can list and create flags after the user confirms a gate target', () => { + const scopes = getOAuthScopesForProgram('feature-flags'); + expect(scopes).toContain('feature_flag:read'); + expect(scopes).toContain('feature_flag:write'); + }); + + it('does not request person-property targeting scopes', () => { + expect(getOAuthScopesForProgram('feature-flags')).not.toContain( + 'property_definition:read', + ); + }); + + it('does not strip the base completion scopes', () => { + expect(getOAuthScopesForProgram('feature-flags')).toEqual( + expect.arrayContaining([...getOAuthScopesForProgram(null)]), + ); + }); +}); + /** * Run 69afc6f8 requested only the base set, so the PostHog MCP served a * catalog without the scanner tools: every scanner task took its "tool @@ -60,6 +80,13 @@ describe('provisioning scopes', () => { expect(scopes).toContain('project:read'); }); + it('layers feature-flags write scopes on the provisioning base', () => { + const scopes = getProvisioningScopesForProgram('feature-flags'); + expect(scopes).toContain('feature_flag:write'); + expect(scopes).toContain('feature_flag:read'); + expect(scopes).not.toContain('property_definition:read'); + }); + it('keeps other programs on the unmodified base', () => { expect(getProvisioningScopesForProgram(null)).not.toContain( 'replay_scanner:write', diff --git a/src/lib/oauth/program-scopes.ts b/src/lib/oauth/program-scopes.ts index 69da4f59a..8f86ec2cf 100644 --- a/src/lib/oauth/program-scopes.ts +++ b/src/lib/oauth/program-scopes.ts @@ -13,7 +13,8 @@ * Current additions: `McpTutorial` layers read-only on every product * surface (feature flags, experiments, surveys, replays, errors, web * analytics, LLM analytics, cohorts, persons) plus read/write on - * annotations; `AgentSkill` adds feature-flag read/write; the default + * annotations; `AgentSkill` adds feature-flag read/write plus + * `property_definition:read`; `feature-flags` adds only flag read/write; the default * `PostHogIntegration` run and the standalone `slack` flow add * `integration:read` for the Connect-Slack step. Persistence writes (dashboard:write, * insight:write, notebook:write, query:read) come for free from the @@ -122,6 +123,22 @@ export const AGENT_SKILL_SCOPE_ADDITIONS = [ 'property_definition:read', ] as const; +/** + * Extra scopes `wizard feature-flags` needs on top of `WIZARD_OAUTH_SCOPES`. + * + * After the user confirms a gate target the skill creates one boolean flag + * at 0% rollout. Without `feature_flag:write` the MCP catalog hides + * create-feature-flag and the run cannot finish. `:write` does not imply + * `:read`, so listing existing keys still needs `feature_flag:read`. + * + * Narrower than `AGENT_SKILL_SCOPE_ADDITIONS`: this install does not build + * person-property targeting, so it does not request `property_definition:read`. + */ +export const FEATURE_FLAGS_SCOPE_ADDITIONS = [ + 'feature_flag:read', + 'feature_flag:write', +] as const; + /** * Extra scopes the self-driving program needs on top of * `WIZARD_OAUTH_SCOPES`. All consumed by the PostHog MCP tools the @@ -268,6 +285,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': 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/lib/programs/__tests__/feature-flags.test.ts b/src/lib/programs/__tests__/feature-flags.test.ts new file mode 100644 index 000000000..6eb57ffec --- /dev/null +++ b/src/lib/programs/__tests__/feature-flags.test.ts @@ -0,0 +1,43 @@ +import { + FEATURE_FLAGS_ABORT_CASES, + featureFlagsConfig, +} from '@lib/programs/feature-flags/index'; + +describe('FEATURE_FLAGS_ABORT_CASES', () => { + // Exact `[ABORT] ` strings the feature-flags-setup skill emits + // (context-mill `context/skills/feature-flags-setup/description.md`), with + // the `[ABORT] ` prefix already stripped — matching what the runner passes + // to `AbortCase.match`. + const reasons = [ + 'unsupported stack for feature flags', + 'could not locate a UI surface to gate', + 'no posthog project credentials', + 'could not create the feature flag', + ]; + + it.each(reasons)('matches the "%s" abort reason exactly once', (reason) => { + const matched = FEATURE_FLAGS_ABORT_CASES.filter((c) => + c.match.test(reason), + ); + expect(matched).toHaveLength(1); + expect(matched[0].message).toBeTruthy(); + expect(matched[0].body).toBeTruthy(); + }); +}); + +describe('featureFlagsConfig', () => { + it('wires a flat command to the feature-flags-setup skill', () => { + expect(featureFlagsConfig.command).toBe('feature-flags'); + expect(featureFlagsConfig.id).toBe('feature-flags'); + expect(featureFlagsConfig.skillId).toBe('feature-flags-setup'); + const run = featureFlagsConfig.run; + if (!run || typeof run === 'function') { + throw new Error('expected a static run object'); + } + expect(run.abortCases).toBe(FEATURE_FLAGS_ABORT_CASES); + }); + + it('does not require the default integration — the skill installs packages itself', () => { + expect(featureFlagsConfig.requires).toBeUndefined(); + }); +}); diff --git a/src/lib/programs/feature-flags/index.ts b/src/lib/programs/feature-flags/index.ts new file mode 100644 index 000000000..397643b58 --- /dev/null +++ b/src/lib/programs/feature-flags/index.ts @@ -0,0 +1,85 @@ +import type { AbortCase } from '@lib/agent/agent-runner'; +import { ErrorCodes } from '@lib/errors'; +import { createSkillProgram } from '@lib/programs/agent-skill/index'; + +const FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flags-report.md'; + +/** + * `[ABORT]` reasons the feature-flags-setup skill emits when the project + * can't be instrumented. Kept in sync with the stop conditions in the + * skill's `description.md` (context-mill `context/skills/feature-flags-setup`). + */ +export const FEATURE_FLAGS_ABORT_CASES: AbortCase[] = [ + { + match: /^unsupported stack for feature flags$/i, + errorCode: ErrorCodes.DetectUnsupportedPlatform, + message: 'Unsupported stack for wizard feature-flags', + body: + 'This program instruments Next.js App Router apps only (`app/` directory + ' + + 'the `next` package). Other frameworks, Pages Router, and backend-only ' + + 'packages are out of scope — stack detection is deliberately narrow. ' + + 'See https://posthog.com/docs/libraries/next-js and ' + + 'https://posthog.com/docs/feature-flags/bootstrapping for the pattern, ' + + 'or run `npx @posthog/wizard` for a general PostHog install.', + }, + { + match: /^could not locate a UI surface to gate$/i, + message: 'No UI surface to gate', + body: + 'The agent could not find a page or component that is safe to add an ' + + 'additive, flag-gated element to. Wrapping auth, checkout, or ' + + 'data-mutation paths is out of scope. Point the wizard at an app with ' + + 'a visible UI, or skip gating and keep the SDK install.', + }, + { + match: /^no posthog project credentials$/i, + message: 'No PostHog project credentials', + body: + 'A project API key (phc_…) is required to evaluate flags and to create ' + + 'one in your project. Re-run after authenticating, or set ' + + 'NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN in the app env.', + }, + { + match: /^could not create the feature flag$/i, + message: 'Could not create the feature flag', + body: + 'The install needs feature_flag:write to create a 0% boolean flag after ' + + 'you confirm a UI path. Re-run after granting that scope, or skip gating ' + + 'and keep the SDK install. The wizard does not leave a half-created flag ' + + 'or ask you to create one by hand.', + }, +]; + +/** + * `wizard feature-flags` — flat skill command. + * + * Next.js App Router flags install: server-side `evaluateFlags()` + * bootstrapped into the client. Optional 0% boolean flag + additive + * UI path after one confirm. Skip is first: no new flag, no UI change. + * Distinct from `wizard audit feature-flags` (read-only, post-hoc) and + * from the default `wizard` install (product analytics). + * + * Flat while install-and-instrument is the only action. A second leaf + * (e.g. local-eval, experiments) would restructure into a family later — + * not pre-emptively. + * + * The mill skill is the source of truth for steps. This prompt only points + * at it — do not restate the playbook here. + */ +export const featureFlagsConfig = createSkillProgram({ + skillId: 'feature-flags-setup', + command: 'feature-flags', + id: 'feature-flags', + description: + 'Add PostHog feature flags (Next.js App Router: server eval + client bootstrap)', + integrationLabel: 'feature-flags', + customPrompt: + 'Run the `feature-flags-setup` skill end-to-end. Do not contradict it. ' + + `The final report is written to ./${FEATURE_FLAGS_REPORT_FILE}.`, + successMessage: `Feature flags configured! View the report at ./${FEATURE_FLAGS_REPORT_FILE}`, + reportFile: FEATURE_FLAGS_REPORT_FILE, + docsUrl: 'https://posthog.com/docs/feature-flags/start-here', + spinnerMessage: 'Setting up feature flags...', + estimatedDurationMinutes: 6, + abortCases: FEATURE_FLAGS_ABORT_CASES, +}); diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 2633c295e..6cc2e8ea5 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -30,6 +30,7 @@ import { mcpTutorialConfig, } from './mcp/index.js'; import { mcpAnalyticsConfig } from './mcp-analytics/index.js'; +import { featureFlagsConfig } from './feature-flags/index.js'; import { replayVisionConfig } from './replay-vision/index.js'; import { aiObservabilityConfig } from './ai-observability/index.js'; import { metricsConfig } from './metrics/index.js'; @@ -81,6 +82,7 @@ export const PROGRAM_REGISTRY = [ mcpRemoveConfig, mcpTutorialConfig, mcpAnalyticsConfig, + featureFlagsConfig, replayVisionConfig, aiObservabilityConfig, metricsConfig, @@ -108,6 +110,7 @@ export const Program = { McpRemove: mcpRemoveConfig.id, McpTutorial: mcpTutorialConfig.id, McpAnalytics: mcpAnalyticsConfig.id, + FeatureFlags: featureFlagsConfig.id, ReplayVision: replayVisionConfig.id, AiObservability: aiObservabilityConfig.id, Metrics: metricsConfig.id, From 418ac16a9670355dd214f6276247d74100de68d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Tue, 1 Sep 2026 22:50:26 +0200 Subject: [PATCH 2/5] feat(feature-flags): addapt custom binding for feature-flags --- .../runner/__tests__/switchboard.test.ts | 19 +++++++++++++++++++ .../switchboard/flags/__tests__/flags.test.ts | 8 ++++++++ src/lib/agent/runner/switchboard/index.ts | 8 +++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/agent/runner/__tests__/switchboard.test.ts b/src/lib/agent/runner/__tests__/switchboard.test.ts index aa925c6dc..b4b560474 100644 --- a/src/lib/agent/runner/__tests__/switchboard.test.ts +++ b/src/lib/agent/runner/__tests__/switchboard.test.ts @@ -64,6 +64,7 @@ describe('switchboard PROGRAM_BINDINGS', () => { if (program === 'error-tracking-upload-source-maps') continue; // pinned below if (program === 'metrics') continue; // pinned below if (program === 'replay-vision') continue; // pinned below + if (program === 'feature-flags') continue; // pinned below expect(resolveBinding({ program, flags: {} })).toEqual(DEFAULT_RESOLVED); } }); @@ -91,6 +92,17 @@ describe('switchboard PROGRAM_BINDINGS', () => { }, trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, }, + { + name: 'binds feature-flags to pi + terra medium', + ctx: { program: 'feature-flags', flags: {} }, + binding: { + sequence: Sequence.linear, + harness: Harness.pi, + model: GPT5_6_TERRA_MODEL, + thinkingLevel: 'medium', + }, + trace: { harness: 'binding', model: 'binding', sequence: 'binding' }, + }, { name: 'binds metrics to the orchestrator on pi; stage models come from the flow frontmatter', ctx: { program: 'metrics', flags: {} }, @@ -232,6 +244,13 @@ describe('switchboard composed clamp', () => { model: GPT5_6_SOL_MODEL, thinkingLevel: 'medium', } + : program === 'feature-flags' + ? { + ...DEFAULT_RESOLVED, + harness: Harness.pi, + model: GPT5_6_TERRA_MODEL, + thinkingLevel: 'medium', + } : program === 'metrics' ? { ...DEFAULT_RESOLVED, harness: Harness.pi } : DEFAULT_RESOLVED, diff --git a/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts b/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts index 16383d72a..be20f769e 100644 --- a/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts +++ b/src/lib/agent/runner/switchboard/flags/__tests__/flags.test.ts @@ -292,6 +292,14 @@ describe('isolation — everything on at once', () => { model: GPT5_6_SOL_MODEL, thinkingLevel: 'medium', }); + } else if (program === 'feature-flags') { + // Pi + terra medium from its OWN binding, not the flag. + expect(resolved).toEqual({ + sequence: Sequence.linear, + harness: Harness.pi, + model: GPT5_6_TERRA_MODEL, + thinkingLevel: 'medium', + }); } else if (program === 'replay-vision') { // Orchestrator from its OWN binding, not the flag — the // wizard-orchestrator experiment does not cover this program, so it diff --git a/src/lib/agent/runner/switchboard/index.ts b/src/lib/agent/runner/switchboard/index.ts index de721acaf..794702fb9 100644 --- a/src/lib/agent/runner/switchboard/index.ts +++ b/src/lib/agent/runner/switchboard/index.ts @@ -10,6 +10,7 @@ import { DEFAULT_AGENT_MODEL, GPT5_6_SOL_MODEL, + GPT5_6_TERRA_MODEL, SONNET_5_MODEL, Harness, Sequence, @@ -141,7 +142,12 @@ export const PROGRAM_BINDINGS: Partial> = { 'mcp-remove': DEFAULT_BINDING, 'mcp-tutorial': DEFAULT_BINDING, 'mcp-analytics': DEFAULT_BINDING, - 'feature-flags': DEFAULT_BINDING, + 'feature-flags': { + sequence: Sequence.linear, + harness: Harness.pi, + model: GPT5_6_TERRA_MODEL, + thinkingLevel: 'medium', + }, // Orchestrator on pi. The binding routes only; every stage's model and // effort are pinned context-mill side in the flow's frontmatter // (`model_pi`/`effort_pi`: terra seed, sol tasks, luna report). From dcc182c6716058998df4991388c6abbf174b416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 17:08:15 +0200 Subject: [PATCH 3/5] feat(feature-flags): stop killing the run if there is no demo widget --- src/lib/programs/__tests__/feature-flags.test.ts | 8 +++++++- src/lib/programs/feature-flags/index.ts | 9 --------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/programs/__tests__/feature-flags.test.ts b/src/lib/programs/__tests__/feature-flags.test.ts index 6eb57ffec..e46312cba 100644 --- a/src/lib/programs/__tests__/feature-flags.test.ts +++ b/src/lib/programs/__tests__/feature-flags.test.ts @@ -10,7 +10,6 @@ describe('FEATURE_FLAGS_ABORT_CASES', () => { // to `AbortCase.match`. const reasons = [ 'unsupported stack for feature flags', - 'could not locate a UI surface to gate', 'no posthog project credentials', 'could not create the feature flag', ]; @@ -23,6 +22,13 @@ describe('FEATURE_FLAGS_ABORT_CASES', () => { expect(matched[0].message).toBeTruthy(); expect(matched[0].body).toBeTruthy(); }); + + it('does not abort when no UI surface is available to gate', () => { + const matched = FEATURE_FLAGS_ABORT_CASES.filter((c) => + c.match.test('could not locate a UI surface to gate'), + ); + expect(matched).toHaveLength(0); + }); }); describe('featureFlagsConfig', () => { diff --git a/src/lib/programs/feature-flags/index.ts b/src/lib/programs/feature-flags/index.ts index 397643b58..ef2dce9fb 100644 --- a/src/lib/programs/feature-flags/index.ts +++ b/src/lib/programs/feature-flags/index.ts @@ -22,15 +22,6 @@ export const FEATURE_FLAGS_ABORT_CASES: AbortCase[] = [ 'https://posthog.com/docs/feature-flags/bootstrapping for the pattern, ' + 'or run `npx @posthog/wizard` for a general PostHog install.', }, - { - match: /^could not locate a UI surface to gate$/i, - message: 'No UI surface to gate', - body: - 'The agent could not find a page or component that is safe to add an ' + - 'additive, flag-gated element to. Wrapping auth, checkout, or ' + - 'data-mutation paths is out of scope. Point the wizard at an app with ' + - 'a visible UI, or skip gating and keep the SDK install.', - }, { match: /^no posthog project credentials$/i, message: 'No PostHog project credentials', From 3271d4b5d963201123073ac932c210a0634d9ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 18:12:24 +0200 Subject: [PATCH 4/5] feat(feature-flags): layer on the default wizard instead of reinstalling PostHog --- AGENTS.md | 3 +- README.md | 17 +++++----- .../programs/__tests__/feature-flags.test.ts | 5 +-- src/lib/programs/feature-flags/index.ts | 34 ++++++++++++------- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67279c901..ab364d22b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,8 @@ command names.** Old names mostly no longer exist — only some are kept as alia `wizard feature-flags` is a **flat skill command** (same shape as `revenue-analytics` / `mcp-analytics`). It runs the `feature-flags-setup` -context-mill skill: Next.js App Router only, server `evaluateFlags()` + client +context-mill skill: Next.js App Router 15.3+, extends an existing PostHog +install (aborts if not initialized), server `evaluateFlags()` + client bootstrap, skip-first, optional 0% boolean flag + additive UI path after confirm. Distinct from `wizard audit feature-flags`, which is read-only. diff --git a/README.md b/README.md index f01e9af1f..ca209f959 100644 --- a/README.md +++ b/README.md @@ -105,19 +105,20 @@ new audits appear without a wizard release (`web-analytics` is wizard-native). ### Feature flags -Add PostHog feature flags to a Next.js App Router app: evaluate once per request -on the server, bootstrap those values into the client, and disable `/flags` -polling in CI. After one confirm, optionally create one boolean flag at **0% -rollout** and gate one additive UI path. Skip is the default — no new flag and -no UI change. Production users keep current behavior until someone raises -rollout in PostHog. +Add the cheap feature-flags path to an existing PostHog install on Next.js App +Router 15.3+: evaluate once per request on the server, bootstrap those values +into the client, and disable `/flags` polling in CI. After one confirm, +optionally create one boolean flag at **0% rollout** and gate one additive UI +path. Skip is the default — no new flag and no UI change. Production users keep +current behavior until someone raises rollout in PostHog. ```bash npx @posthog/wizard feature-flags ``` -Next.js App Router only. Distinct from `wizard audit feature-flags`, which -audits existing flag usage and cost and does not install anything. +Requires an existing PostHog install (`npx @posthog/wizard`). Next.js App Router +15.3+ only. Distinct from `wizard audit feature-flags`, which audits existing +flag usage and cost and does not install anything. ### Revenue Analytics diff --git a/src/lib/programs/__tests__/feature-flags.test.ts b/src/lib/programs/__tests__/feature-flags.test.ts index e46312cba..82fb73360 100644 --- a/src/lib/programs/__tests__/feature-flags.test.ts +++ b/src/lib/programs/__tests__/feature-flags.test.ts @@ -10,6 +10,7 @@ describe('FEATURE_FLAGS_ABORT_CASES', () => { // to `AbortCase.match`. const reasons = [ 'unsupported stack for feature flags', + 'posthog not initialized', 'no posthog project credentials', 'could not create the feature flag', ]; @@ -43,7 +44,7 @@ describe('featureFlagsConfig', () => { expect(run.abortCases).toBe(FEATURE_FLAGS_ABORT_CASES); }); - it('does not require the default integration — the skill installs packages itself', () => { - expect(featureFlagsConfig.requires).toBeUndefined(); + it('requires the default integration — this skill extends it, it does not reinstall it', () => { + expect(featureFlagsConfig.requires).toEqual(['posthog-integration']); }); }); diff --git a/src/lib/programs/feature-flags/index.ts b/src/lib/programs/feature-flags/index.ts index ef2dce9fb..878d0f7bb 100644 --- a/src/lib/programs/feature-flags/index.ts +++ b/src/lib/programs/feature-flags/index.ts @@ -15,12 +15,21 @@ export const FEATURE_FLAGS_ABORT_CASES: AbortCase[] = [ errorCode: ErrorCodes.DetectUnsupportedPlatform, message: 'Unsupported stack for wizard feature-flags', body: - 'This program instruments Next.js App Router apps only (`app/` directory + ' + - 'the `next` package). Other frameworks, Pages Router, and backend-only ' + - 'packages are out of scope — stack detection is deliberately narrow. ' + - 'See https://posthog.com/docs/libraries/next-js and ' + - 'https://posthog.com/docs/feature-flags/bootstrapping for the pattern, ' + - 'or run `npx @posthog/wizard` for a general PostHog install.', + 'This program instruments Next.js App Router 15.3+ only (`app/` directory + ' + + 'the `next` package at 15.3.0 or newer). Pages Router, older Next, other ' + + 'frameworks, and backend-only packages are out of scope — stack detection ' + + 'is deliberately narrow. See https://posthog.com/docs/libraries/next-js and ' + + 'https://posthog.com/docs/feature-flags/bootstrapping. ' + + 'For a general PostHog install, run `npx @posthog/wizard`.', + }, + { + match: /^posthog not initialized$/i, + message: 'PostHog is not initialized', + body: + 'This program adds the cheap flags pattern (server evaluateFlags + ' + + 'client bootstrap) to an existing PostHog install. It does not replace ' + + 'the default wizard. Run `npx @posthog/wizard` first, then re-run ' + + '`npx @posthog/wizard feature-flags`.', }, { match: /^no posthog project credentials$/i, @@ -44,11 +53,11 @@ export const FEATURE_FLAGS_ABORT_CASES: AbortCase[] = [ /** * `wizard feature-flags` — flat skill command. * - * Next.js App Router flags install: server-side `evaluateFlags()` - * bootstrapped into the client. Optional 0% boolean flag + additive - * UI path after one confirm. Skip is first: no new flag, no UI change. - * Distinct from `wizard audit feature-flags` (read-only, post-hoc) and - * from the default `wizard` install (product analytics). + * Next.js App Router 15.3+ flags layer on an existing PostHog install: + * server-side `evaluateFlags()` bootstrapped into the client. Optional 0% + * boolean flag + additive UI path after one confirm. Skip is first: no new + * flag, no UI change. Distinct from `wizard audit feature-flags` (read-only, + * post-hoc) and from the default `wizard` install (product analytics). * * Flat while install-and-instrument is the only action. A second leaf * (e.g. local-eval, experiments) would restructure into a family later — @@ -62,7 +71,7 @@ export const featureFlagsConfig = createSkillProgram({ command: 'feature-flags', id: 'feature-flags', description: - 'Add PostHog feature flags (Next.js App Router: server eval + client bootstrap)', + 'Add PostHog feature flags (Next.js App Router 15.3+: server eval + client bootstrap)', integrationLabel: 'feature-flags', customPrompt: 'Run the `feature-flags-setup` skill end-to-end. Do not contradict it. ' + @@ -72,5 +81,6 @@ export const featureFlagsConfig = createSkillProgram({ docsUrl: 'https://posthog.com/docs/feature-flags/start-here', spinnerMessage: 'Setting up feature flags...', estimatedDurationMinutes: 6, + requires: ['posthog-integration'], abortCases: FEATURE_FLAGS_ABORT_CASES, }); From 9772646ff4b8d2b9ab3dca7f5b26a0f260057662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ristovi=C4=87?= Date: Wed, 2 Sep 2026 19:32:05 +0200 Subject: [PATCH 5/5] feat(feature-flags): add learn cards --- .../__tests__/feature-flags-deck.test.ts | 132 +++++++++ .../programs/feature-flags/content/index.tsx | 205 ++++++++++++++ .../feature-flags/content/set-pieces.tsx | 254 ++++++++++++++++++ .../programs/feature-flags/content/tips.ts | 43 +++ src/lib/programs/feature-flags/index.ts | 43 +-- 5 files changed, 659 insertions(+), 18 deletions(-) create mode 100644 src/lib/programs/__tests__/feature-flags-deck.test.ts create mode 100644 src/lib/programs/feature-flags/content/index.tsx create mode 100644 src/lib/programs/feature-flags/content/set-pieces.tsx create mode 100644 src/lib/programs/feature-flags/content/tips.ts diff --git a/src/lib/programs/__tests__/feature-flags-deck.test.ts b/src/lib/programs/__tests__/feature-flags-deck.test.ts new file mode 100644 index 000000000..5eae34d3a --- /dev/null +++ b/src/lib/programs/__tests__/feature-flags-deck.test.ts @@ -0,0 +1,132 @@ +/** + * Layout guards for the feature-flags learn deck. The LearnCard pane is + * ~37 chars wide at an 80-column terminal (the narrowest split view; below + * 80 cols the pane is dropped entirely). Prose blocks word-wrap fine, but + * fixed-layout `lines` blocks (diagrams, lists) must fit unwrapped, and no + * scene should stack more prose than the pane can show at once. + */ + +import type { ReactNode, ReactElement } from 'react'; +import { getContentBlocks } from '@lib/programs/feature-flags/content/index'; +import { FEATURE_FLAGS_TIPS } from '@lib/programs/feature-flags/content/tips'; +import { FLAG_SPIKE } from '@lib/programs/feature-flags/content/set-pieces'; +import { featureFlagsConfig } from '@lib/programs/feature-flags/index'; + +/** paneWidth in LearnCard at 80 cols: (min(120, 80) - 2) / 2 - 2 */ +const PANE_WIDTH_80COL = 37; + +function textOf(node: ReactNode): string { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textOf).join(''); + const el = node as ReactElement<{ children?: ReactNode }>; + return textOf(el.props?.children); +} + +function allDeckText(blocks: ReturnType): string[] { + const out: string[] = []; + for (const b of blocks) { + if (typeof b === 'string') { + out.push(b); + continue; + } + if (typeof b !== 'object') continue; + if ('type' in b && b.type === 'lines') { + for (const line of b.lines) out.push(textOf(line)); + continue; + } + if ('content' in b && typeof b.content === 'string') { + out.push(b.content); + } + } + return out; +} + +describe('feature-flags learn deck', () => { + const blocks = getContentBlocks(); + + it('has more than the generic three-block skill deck', () => { + expect(blocks.length).toBeGreaterThan(10); + }); + + it('is wired onto the program config, not the generic skill deck', () => { + expect(featureFlagsConfig.getContentBlocks).toBe(getContentBlocks); + expect(featureFlagsConfig.getTips?.()).toEqual(FEATURE_FLAGS_TIPS); + }); + + it('greets by first name when the session has one', () => { + const named = getContentBlocks({ + session: { apiUser: { first_name: 'Filip' } }, + } as Parameters[0]); + const first = named[0]; + expect( + typeof first === 'object' && 'content' in first ? first.content : '', + ).toBe('Welcome, Filip.'); + }); + + it('keeps the /flags spike chart as one connected line', () => { + const curves = FLAG_SPIKE.lines + .map(textOf) + .filter((t) => t.includes('╭') || t.includes('╯')); + expect(curves.length).toBeGreaterThan(2); + for (let i = 1; i < curves.length; i++) { + const prevStart = [...curves[i - 1]].indexOf('╭'); + const thisEnd = [...curves[i]].indexOf('╯'); + expect(thisEnd).toBe(prevStart); + } + }); + + it('keeps every fixed-layout line within the 80-col pane', () => { + const wide: string[] = []; + for (const b of blocks) { + if (typeof b !== 'object' || !('type' in b) || b.type !== 'lines') { + continue; + } + for (const line of b.lines) { + const text = textOf(line); + if ([...text].length > PANE_WIDTH_80COL) wide.push(text); + } + } + expect(wide).toEqual([]); + }); + + it('keeps every prose beat short enough to never fill the pane', () => { + const long: string[] = []; + for (const b of blocks) { + if (typeof b !== 'object' || !('content' in b)) continue; + if (typeof b.content !== 'string') continue; + if (Math.ceil(b.content.length / PANE_WIDTH_80COL) > 4) { + long.push(b.content); + } + } + expect(long).toEqual([]); + }); + + it('tells the kill-switch story, not a syllabus', () => { + const text = allDeckText(blocks).join('\n').toLowerCase(); + expect(text).toContain("i'm putting a kill switch on this app"); + expect(text).toContain('but...'); + expect(text).toContain('this request inflates the bill'); + expect(text).toContain('/flags'); + expect(text).toContain('evaluateflags'); + expect(text).toContain('bootstrap'); + expect(text).toContain('boolean'); + expect(text).toContain('multivariate'); + expect(text).toContain('skip is first'); + expect(text).toContain('0%'); + expect(text).toContain('until you raise it, nobody sees a thing'); + expect(text).toContain('welcome back'); + expect(text).toContain('[ save ]'); + expect(text).not.toContain('this is not the default wizard'); + }); + + it('does not use em-dashes or en-dashes in string copy', () => { + const hits = allDeckText(blocks).filter((s) => /[\u2013\u2014]/.test(s)); + expect(hits).toEqual([]); + const tipHits = FEATURE_FLAGS_TIPS.flatMap((t) => [ + t.title, + t.description, + ]).filter((s) => /[\u2013\u2014]/.test(s)); + expect(tipHits).toEqual([]); + }); +}); diff --git a/src/lib/programs/feature-flags/content/index.tsx b/src/lib/programs/feature-flags/content/index.tsx new file mode 100644 index 000000000..028361463 --- /dev/null +++ b/src/lib/programs/feature-flags/content/index.tsx @@ -0,0 +1,205 @@ +/** + * Feature-flags learn-deck. Played in the run screen's left pane while + * the agent layers a kill switch onto an existing PostHog install. + * + * Story, not a syllabus: named welcome, a /flags spike, a 0% boolean, + * the page flipping on, then a closer you can quote. Set pieces live in + * ./set-pieces.tsx. Pane is ~37 chars at 80 columns. + * + * Program-owned; wired onto featureFlagsConfig.getContentBlocks. + */ + +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 { + isClearBlock, + type ContentBlock, +} from '@ui/tui/primitives/content-types'; +import { StatusPeekTrigger } from '@ui/tui/components/StatusPeekTrigger'; +import { + FLAG_SPIKE, + FIRST_PAINT, + INVOICE_CHEAP, + PAGE_OFF, + PAGE_ON, + ROLLOUT_SLIDER, +} from './set-pieces.js'; + +/** + * Per-slide dwell multiplier. Each block stays on screen for `pause * SLIDE_PACE` + * ms after it finishes animating. Clear (page-break) blocks are left + * untouched so the blank gap between slides stays snappy. + */ +const SLIDE_PACE = 1.5; + +const withPace = (block: ContentBlock): ContentBlock => { + if (typeof block === 'string' || isClearBlock(block) || block.pause == null) { + return block; + } + return { ...block, pause: Math.round(block.pause * SLIDE_PACE) }; +}; + +const pace = (blocks: ContentBlock[]): ContentBlock[] => blocks.map(withPace); + +const CLEAR: ContentBlock = { type: 'clear', pause: 1500 }; + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => + pace([ + { + content: store?.session.apiUser?.first_name + ? `Welcome, ${store.session.apiUser.first_name}.` + : 'Welcome.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + + { content: 'The Wizard is an agent.', pause: 4000 }, + + { + content: "I'm putting a kill switch on this app.", + pause: 2800, + dimWhenComplete: false, + }, + + { + content: 'But...', + pause: 1800, + mode: TextRevealMode.Typewriter, + animationInterval: 90, + sentenceInterval: 400, + dimWhenComplete: false, + }, + + { + content: 'Nothing turns on until you say so.', + pause: 5000, + }, + + { + content: + 'I would make you a coffee, but I am a terminal, so that part is on you.', + pause: 5500, + }, + + CLEAR, + + { + pause: 5000, + persist: true, + content: , + }, + + { + pause: 6000, + content: ( + + Press{' '} + + S + {' '} + to expand or collapse the status. + + ), + }, + + CLEAR, + + { + content: + 'Flags are usually quiet, until CI wakes up and never goes back to sleep.', + pause: 5500, + }, + + FLAG_SPIKE, + + { + content: 'This request inflates the bill and every /flags call is on it.', + pause: 6000, + }, + + CLEAR, + + { + content: + 'So we call evaluateFlags once on the server, then bootstrap the client.', + pause: 5500, + }, + + FIRST_PAINT, + + { + content: + 'The Save button is already there at t=0, with no flicker and no second trip.', + pause: 5500, + }, + + CLEAR, + + { + content: 'The receipt, if you were wondering.', + pause: 3500, + }, + + INVOICE_CHEAP, + + CLEAR, + + { + content: + 'Confirm, and we create one boolean at 0%. Which is a polite way of saying nothing happens yet.', + pause: 5500, + }, + + ROLLOUT_SLIDER, + + { + content: + 'Need green vs blue later? That is multivariate, and we are absolutely not doing that today.', + pause: 6500, + }, + + { + content: + 'Skip is first, if you only wanted the wiring and none of the drama.', + pause: 5000, + }, + + CLEAR, + + { content: 'Raise it, and the page gains one new thing.', pause: 4500 }, + + PAGE_OFF, + + { content: 'Then this.', pause: 2000 }, + + PAGE_ON, + + { + content: + 'Set it back to 0%, and the banner is gone like it was never invited.', + pause: 5500, + }, + + CLEAR, + + { + content: 'Until you raise it, nobody sees a thing.', + pause: 8000, + }, + + { + pause: 90000, + content: ( + + Press{' '} + + S + {' '} + to follow along. Or sit tight, I'll let you know when it's done. + + ), + }, + ]); diff --git a/src/lib/programs/feature-flags/content/set-pieces.tsx b/src/lib/programs/feature-flags/content/set-pieces.tsx new file mode 100644 index 000000000..a8044417b --- /dev/null +++ b/src/lib/programs/feature-flags/content/set-pieces.tsx @@ -0,0 +1,254 @@ +/** + * Shareable set pieces for the feature-flags learn-deck. Sized for the + * LearnCard pane at 80 columns (~37 chars). Trailing padding is part of + * the box, so line length is the full rendered row. + * + * Helpers return trees (not custom components) so the sequencer + * and the deck tests see the same copy. Same shape as the integration + * funnel and data-flow blocks. + */ + +import { Text } from 'ink'; +import { Colors } from '@ui/tui/styles'; +import type { ContentBlock } from '@ui/tui/primitives/content-types'; +import type { ReactNode } from 'react'; + +/** Inner width of the invoice box (dashes between the corners). */ +const INVOICE_INNER = 28; +/** Characters after ` │ ` and before the closing `│`. */ +const INVOICE_BODY = INVOICE_INNER - 1; + +const invoiceRule = '─'.repeat(INVOICE_INNER); + +function invoicePad(left: string, right = ''): string { + const gap = Math.max(0, INVOICE_BODY - left.length - right.length); + return `${left}${' '.repeat(gap)}${right}`; +} + +function invoiceRow( + left: string, + right = '', + accent = false, + key?: string, +): ReactNode { + const body = invoicePad(left, right); + return ( + + {' │ '} + {accent ? ( + + {body} + + ) : ( + {body} + )} + + + ); +} + +function invoiceBox( + title: string, + rows: { left: string; right?: string; accent?: boolean }[], +): ReactNode[] { + return [ + {` ┌${invoiceRule}┐`}, + invoiceRow(title, '', false, 'title'), + ...rows.map((row, i) => + invoiceRow(row.left, row.right ?? '', row.accent, `r-${i}`), + ), + {` └${invoiceRule}┘`}, + ]; +} + +/** What this install does instead. */ +export const INVOICE_CHEAP: ContentBlock = { + type: 'lines', + interval: 280, + pause: 7500, + lines: invoiceBox('This install', [ + { left: 'evaluateFlags', right: 'once' }, + { left: 'client', right: 'already knows' }, + { left: '─'.repeat(INVOICE_BODY) }, + { left: 'first paint', right: '$0 extra', accent: true }, + ]), +}; + +const PAGE_INNER = 20; +const PAGE_BODY = PAGE_INNER - 1; +const pageRule = '─'.repeat(PAGE_INNER); + +function pagePad(s: string): string { + return s.padEnd(PAGE_BODY); +} + +function pageRow(text: string, color?: string, key?: string): ReactNode { + return ( + + {' │ '} + {pagePad(text)} + + + ); +} + +function pageBox(rows: { text: string; color?: string }[]): ReactNode[] { + return [ + {` ┌${pageRule}┐`}, + ...rows.map((row, i) => pageRow(row.text, row.color, `p-${i}`)), + {` └${pageRule}┘`}, + ]; +} + +/** The app with the flag off: today's UI, nothing extra. */ +export const PAGE_OFF: ContentBlock = { + type: 'lines', + interval: 220, + pause: 6500, + lines: [ + + {' flag off'} + , + ...pageBox([ + { text: 'Your todos' }, + { text: '[ ] buy milk' }, + { text: '[ ] stretch' }, + ]), + ], +}; + +/** The same app with the flag on: one additive banner. */ +export const PAGE_ON: ContentBlock = { + type: 'lines', + interval: 220, + pause: 7500, + lines: [ + + {' flag on · '} + + 100% + + , + ...pageBox([ + { text: 'Welcome back', color: 'cyan' }, + { text: 'Your todos' }, + { text: '[ ] buy milk' }, + { text: '[ ] stretch' }, + ]), + ], +}; + +/** A boolean sitting at 0%. The control, not a glossary. */ +export const ROLLOUT_SLIDER: ContentBlock = { + type: 'lines', + interval: 350, + pause: 7000, + lines: [ + + + {' show-home-banner'} + + , + + {' ['} + · + {' ] '} + + 0% + + , + + {' boolean · on or off'} + , + ], +}; + +/** + * Y-axis prefix for the /flags spike chart. Every row uses the same + * box glyph (`┤`) so the curve columns line up. Mixing `┤` and `│` + * leaves holes: those glyphs are not the same width in every font. + * Pad the label to 5 characters so the prefix is always 7 (`' 80k ┤'`). + */ +const spikeY = (label: string): string => `${label.padStart(5, ' ')} ┤`; + +/** + * The expensive default, as a trends chart: quiet, then CI starts + * polling /flags overnight. Same craft as the integration signup chart. + * + * Each ╯ sits in the same column as the ╭ on the row above. Counted + * from the 7-char prefix: ╭ at 25 / 24 / 19 / 14, ╯ at 25 / 24 / 19 / 14. + */ +export const FLAG_SPIKE: ContentBlock = { + type: 'lines', + interval: 260, + pause: 8000, + lines: [ + + {' Trends · /flags calls'} + , + , + + {spikeY('80k')} + {' '.repeat(18)} + {'╭─'} + {' CI'} + , + + {spikeY('')} + {' '.repeat(17)} + {'╭╯'} + , + + {spikeY('40k')} + {' '.repeat(12)} + {'╭────╯'} + , + + {spikeY('')} + {' '.repeat(7)} + {'╭────╯'} + , + + {spikeY('0')} + {'───────╯'} + , + + {' └┬────┬────┬────┬──'} + , + + {' Mon Wed Fri Sun'} + , + ], +}; + +/** + * Why bootstrap exists: the Save button that pops in late vs the one + * that was already there. A comic, not another box. + */ +export const FIRST_PAINT: ContentBlock = { + type: 'lines', + interval: 350, + pause: 8000, + lines: [ + + {' client fetch'} + , + + {' t=0 · · ·'} + , + + {' t=200ms '} + {'[ Save ]'} + {' pop'} + , + , + + {' bootstrap'} + , + + {' t=0 '} + {'[ Save ]'} + {' already'} + , + ], +}; diff --git a/src/lib/programs/feature-flags/content/tips.ts b/src/lib/programs/feature-flags/content/tips.ts new file mode 100644 index 000000000..8d689c687 --- /dev/null +++ b/src/lib/programs/feature-flags/content/tips.ts @@ -0,0 +1,43 @@ +/** + * Sidebar tips after the feature-flags Learn deck finishes. Same story + * as the deck, as footnotes, so the pane never overflows. Wired onto + * the program's getTips; unset would fall back to generic onboarding + * (persons, Stripe), which is the wrong lesson for this run. + */ + +import type { Tip } from '@ui/tui/components/TipsCard'; + +export const FEATURE_FLAGS_TIPS: Tip[] = [ + { + id: 'the-bill', + title: 'This request inflates the bill', + description: + 'Flags are billed per /flags call, not per person. Client init, identify, and reload each fetch. One server evaluateFlags plus bootstrap is the cheap path.', + }, + { + id: 'zero-percent', + title: 'Until you raise it', + description: + 'Skip is first: wiring only, no new flag. Confirm creates one boolean at 0%, so nothing happens yet. Raise rollout to 100% to see the UI, then set it back to kill it.', + }, + { + id: 'boolean-vs-multi', + title: 'Boolean vs multivariate', + description: + 'A boolean is on or off. That is today. Multivariate is green vs blue on the same key, when you have a real experiment.', + }, + { + id: 'bootstrap', + title: 'Server, then the client', + description: + 'evaluateFlags() once per request, then bootstrap those values into the client. First paint has no flicker and no second fetch. CI does not poll overnight.', + }, + { + id: 'not-audit', + title: 'A kill switch, not an audit', + description: + 'wizard feature-flags installs this path. wizard audit feature-flags is read-only, after flags already exist. The default wizard is product analytics.', + }, +]; + +export const getTips = (): Tip[] => FEATURE_FLAGS_TIPS; diff --git a/src/lib/programs/feature-flags/index.ts b/src/lib/programs/feature-flags/index.ts index 878d0f7bb..e415b7721 100644 --- a/src/lib/programs/feature-flags/index.ts +++ b/src/lib/programs/feature-flags/index.ts @@ -1,6 +1,9 @@ import type { AbortCase } from '@lib/agent/agent-runner'; import { ErrorCodes } from '@lib/errors'; import { createSkillProgram } from '@lib/programs/agent-skill/index'; +import type { ProgramConfig } from '@lib/programs/program-step'; +import { getContentBlocks } from './content/index.js'; +import { getTips } from './content/tips.js'; const FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flags-report.md'; @@ -66,21 +69,25 @@ export const FEATURE_FLAGS_ABORT_CASES: AbortCase[] = [ * The mill skill is the source of truth for steps. This prompt only points * at it — do not restate the playbook here. */ -export const featureFlagsConfig = createSkillProgram({ - skillId: 'feature-flags-setup', - command: 'feature-flags', - id: 'feature-flags', - description: - 'Add PostHog feature flags (Next.js App Router 15.3+: server eval + client bootstrap)', - integrationLabel: 'feature-flags', - customPrompt: - 'Run the `feature-flags-setup` skill end-to-end. Do not contradict it. ' + - `The final report is written to ./${FEATURE_FLAGS_REPORT_FILE}.`, - successMessage: `Feature flags configured! View the report at ./${FEATURE_FLAGS_REPORT_FILE}`, - reportFile: FEATURE_FLAGS_REPORT_FILE, - docsUrl: 'https://posthog.com/docs/feature-flags/start-here', - spinnerMessage: 'Setting up feature flags...', - estimatedDurationMinutes: 6, - requires: ['posthog-integration'], - abortCases: FEATURE_FLAGS_ABORT_CASES, -}); +export const featureFlagsConfig: ProgramConfig = { + ...createSkillProgram({ + skillId: 'feature-flags-setup', + command: 'feature-flags', + id: 'feature-flags', + description: + 'Add PostHog feature flags (Next.js App Router 15.3+: server eval + client bootstrap)', + integrationLabel: 'feature-flags', + customPrompt: + 'Run the `feature-flags-setup` skill end-to-end. Do not contradict it. ' + + `The final report is written to ./${FEATURE_FLAGS_REPORT_FILE}.`, + successMessage: `Feature flags configured! View the report at ./${FEATURE_FLAGS_REPORT_FILE}`, + reportFile: FEATURE_FLAGS_REPORT_FILE, + docsUrl: 'https://posthog.com/docs/feature-flags/installation/ai-wizard', + spinnerMessage: 'Setting up feature flags...', + estimatedDurationMinutes: 6, + requires: ['posthog-integration'], + abortCases: FEATURE_FLAGS_ABORT_CASES, + }), + getContentBlocks, + getTips, +};