Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6caf9fa
feat(cull-feature-flags): register the command
johncwaters Sep 4, 2026
efc68fc
feat(cull-feature-flags): scan source for flag call sites
johncwaters Sep 4, 2026
e823f1c
feat(cull-feature-flags): fetch project flags and classify them
johncwaters Sep 4, 2026
e82790e
feat(cull-feature-flags): seed the ledger and prompt the skill
johncwaters Sep 4, 2026
4b2e4e5
feat(cull-feature-flags): intro screen and per-bucket slides
johncwaters Sep 4, 2026
bb69dc4
fix(cull-feature-flags): bind the program to the switchboard and e2e …
johncwaters Sep 4, 2026
e5bf593
fix(e2e): honor the local context-mill env flag in the tui host
johncwaters Sep 4, 2026
0c52748
fix(e2e): drive cull-intro and mirror capture-aio in the tui host
johncwaters Sep 4, 2026
102fb08
fix(cull-feature-flags): authenticate before the flag fetch
johncwaters Sep 4, 2026
c3da102
fix(cull-feature-flags): make the run screen read like the setup flow
johncwaters Sep 4, 2026
3183ac0
fix(cull-feature-flags): keep the outro to one revert line and one po…
johncwaters Sep 4, 2026
b6de1b5
fix(cull-feature-flags): name the report by absolute path in the outro
johncwaters Sep 4, 2026
551731c
fix(cull-feature-flags): say culled, not applied
johncwaters Sep 4, 2026
011febb
fix(e2e): drop the capture-aio mirror from the tui host
johncwaters Sep 4, 2026
0232e01
test(cull-feature-flags): pin shape, not copy, in the seed test
johncwaters Sep 4, 2026
9fddf3b
refactor(cull-feature-flags): prompt carries facts, the skill carries…
johncwaters Sep 4, 2026
cbd65f9
fix(cull-feature-flags): honest 0% bucket, every next.js convention i…
johncwaters Sep 4, 2026
444077d
feat(cull-feature-flags): learn deck until the area pane takes over
johncwaters Sep 4, 2026
0e72d26
fix(cull-feature-flags): count only flags that were disabled
johncwaters Sep 4, 2026
7a6881b
feat(cull-feature-flags): group buckets into lanes
johncwaters Sep 4, 2026
38ce3c6
feat(cull-feature-flags): phase from the ledger and the status feed
johncwaters Sep 4, 2026
a97f316
feat(cull-feature-flags): stepper and lane list on the cull run screen
johncwaters Sep 4, 2026
dff4365
fix(cull-feature-flags): the deck teaches the lanes in plain words
johncwaters Sep 4, 2026
ff7ddd2
feat(cull-feature-flags): say why each flag is being culled
johncwaters Sep 4, 2026
9432f48
fix(cull-feature-flags): kept flags and suggestions are not "nothing …
johncwaters Sep 4, 2026
bc09d02
fix(cull-feature-flags): the spinner line is not a flag being culled
johncwaters Sep 4, 2026
d91a3b9
fix(cull-feature-flags): say the prompt is coming, not that we are wa…
johncwaters Sep 4, 2026
d96d511
fix(cull-feature-flags): spin while the agent is mid-turn on the run …
johncwaters Sep 4, 2026
36746f6
Merge remote-tracking branch 'upstream/main' into feat/cull-feature-f…
johncwaters Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { mcpAnalyticsCommand } from './src/commands/mcp-analytics';
import { replayVisionCommand } from './src/commands/replay-vision';
import { aiObservabilityCommand } from './src/commands/ai-observability';
import { metricsCommand } from './src/commands/metrics';
import { cullFeatureFlagsCommand } from './src/commands/cull-feature-flags';
import { auditCommand } from './src/commands/audit';
import { doctorCommand } from './src/commands/doctor';
import { migrateCommand } from './src/commands/migrate';
Expand Down Expand Up @@ -105,6 +106,7 @@ Wizard.use(basicIntegrationCommand)
.use(replayVisionCommand)
.use(aiObservabilityCommand)
.use(metricsCommand)
.use(cullFeatureFlagsCommand)
.use(cliCommand)
.use(auditCommand)
.use(doctorCommand)
Expand Down
1 change: 1 addition & 0 deletions e2e-harness/action-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const ACTION_REGISTRY: Partial<Record<ScreenName, DriverAction[]>> = {
[ScreenId.SourceMapsIntro]: [confirmSetupAction],
[ScreenId.MigrationIntro]: [confirmSetupAction],
[ScreenId.AgentSkillIntro]: [confirmSetupAction],
[ScreenId.CullIntro]: [confirmSetupAction],
[ScreenId.AiObservabilityIntro]: [confirmSetupAction],
[ScreenId.MetricsIntro]: [confirmSetupAction],
[ScreenId.AuditIntro]: [confirmSetupAction],
Expand Down
1 change: 1 addition & 0 deletions e2e-harness/e2e-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export function decideE2eAction(
case ScreenId.RevenueIntro:
case ScreenId.MigrationIntro:
case ScreenId.AgentSkillIntro:
case ScreenId.CullIntro:
case ScreenId.AiObservabilityIntro:
case ScreenId.MetricsIntro:
case ScreenId.AuditIntro:
Expand Down
10 changes: 10 additions & 0 deletions scripts/tui-host.no-jest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import fs from 'fs';
import net from 'net';
import { spawnSync } from 'child_process';
import { startTUI } from '@ui/tui/start-tui';
import { initLocalDev } from '@lib/local-dev';
import { VERSION } from '@lib/version';
import {
Program,
Expand Down Expand Up @@ -195,6 +196,15 @@ async function main() {
// requires-interactive-mode the moment they need to ask a question.
process.env.WIZARD_ASK_AUTODRIVE = '1';

// The session flags below only describe the run; `getSkillsBaseUrl` reads
// the process-wide targets that the CLI middleware normally sets from argv.
initLocalDev({
localDev: process.env.POSTHOG_WIZARD_LOCAL_DEV === 'true',
localMcp: envFlag('POSTHOG_WIZARD_LOCAL_MCP'),
localContextMill: envFlag('POSTHOG_WIZARD_LOCAL_CONTEXT_MILL'),
localPosthog: envFlag('POSTHOG_WIZARD_LOCAL_POSTHOG'),
});

const { store } = startTUI(VERSION, programId);
store.session = buildSession({
installDir: process.env.APP_DIR!,
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/programs-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { auditCommand } from '../commands/audit';
import { migrateCommand } from '../commands/migrate';
import { mcpAnalyticsCommand } from '../commands/mcp-analytics';
import { replayVisionCommand } from '../commands/replay-vision';
import { cullFeatureFlagsCommand } from '../commands/cull-feature-flags';
import { revenueCommand } from '../commands/revenue';
import { warehouseCommand } from '../commands/warehouse';
import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps';
Expand Down Expand Up @@ -91,6 +92,11 @@ describe('top-level command shapes', () => {
expect(replayVisionCommand.children).toBeUndefined();
});

test('cull-feature-flags is a flat skill command', () => {
expect(cullFeatureFlagsCommand.name).toBe('cull-feature-flags');
expect(cullFeatureFlagsCommand.children).toBeUndefined();
});

test('warehouse is a flat skill command', () => {
expect(warehouseCommand.name).toBe('warehouse');
expect(warehouseCommand.children).toBeUndefined();
Expand Down
8 changes: 8 additions & 0 deletions src/commands/cull-feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { cullFeatureFlagsConfig } from '@lib/programs/cull-feature-flags/index';

import type { Command } from './command';
import { nativeCommandFactory } from './factories/native-command-factory';

export const cullFeatureFlagsCommand: Command = nativeCommandFactory(
cullFeatureFlagsConfig,
);
1 change: 1 addition & 0 deletions src/lib/agent/runner/switchboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export const PROGRAM_BINDINGS: Partial<Record<ProgramId, ProgramBinding>> = {
'events-audit': DEFAULT_BINDING,
'posthog-doctor': DEFAULT_BINDING,
'web-analytics-doctor': DEFAULT_BINDING,
'cull-feature-flags': DEFAULT_BINDING,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the program is a bit slow, understandable since it scans and analyzes all feature flags. any thoughts on what model or harness you might use instead of the defaults?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default binding this pass. Cut the reads and the per-flag tool discovery instead, and added a learn deck to cover the wait. Harness or model swap is worth a measured run later.

migration: DEFAULT_BINDING,
'self-driving': DEFAULT_BINDING,
'agent-skill': DEFAULT_BINDING,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/errors/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export const ERROR_CATALOG: Record<ErrorCode, ErrorCatalogEntry> = {
retry: 'no',
description: 'The project platform has no matching skill variant.',
},
[ErrorCodes.DetectDirtyWorkingTree]: {
group: 'detect',
retry: 'yes',
description:
'The program edits project files and needs a clean git working tree so the edits stay revertable on their own.',
},
[ErrorCodes.DetectNoPosthogSdk]: {
group: 'detect',
retry: 'no',
Expand Down
1 change: 1 addition & 0 deletions src/lib/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const ErrorCodes = {
DetectNoFramework: 'PHW_DETECT_NO_FRAMEWORK',
DetectUnsupportedVersion: 'PHW_DETECT_UNSUPPORTED_VERSION',
DetectUnsupportedPlatform: 'PHW_DETECT_UNSUPPORTED_PLATFORM',
DetectDirtyWorkingTree: 'PHW_DETECT_DIRTY_WORKING_TREE',
DetectNoPosthogSdk: 'PHW_DETECT_NO_POSTHOG_SDK',
DetectNoProjectFiles: 'PHW_DETECT_NO_PROJECT_FILES',
DetectNoSources: 'PHW_DETECT_NO_SOURCES',
Expand Down
3 changes: 3 additions & 0 deletions src/lib/oauth/program-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ const PROGRAM_SCOPE_ADDITIONS: Partial<Record<ProgramId, readonly string[]>> = {
],
slack: CONNECT_SLACK_SCOPE_ADDITIONS,
'replay-vision': REPLAY_VISION_SCOPE_ADDITIONS,
// Disabling a flag needs feature_flag:write; the agent-skill set already
// carries the read/write pair.
'cull-feature-flags': AGENT_SKILL_SCOPE_ADDITIONS,
};

/**
Expand Down
256 changes: 256 additions & 0 deletions src/lib/programs/__tests__/cull-feature-flags-classify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { classifyFlags } from '@lib/programs/cull-feature-flags/classify';
import type { FlagScanResult } from '@lib/programs/cull-feature-flags/scan';
import type { FeatureFlag } from '@lib/programs/cull-feature-flags/types';

let nextFlagId = 1;

function flag(overrides: Partial<FeatureFlag> & { key: string }): FeatureFlag {
return {
id: nextFlagId++,
active: true,
archived: false,
deleted: false,
status: 'ACTIVE',
filters: { groups: [{ rollout_percentage: 50, properties: [] }] },
experiment_set: [],
is_remote_configuration: false,
has_encrypted_payloads: false,
...overrides,
};
}

function rollout(percentage: number | null): FeatureFlag['filters'] {
return { groups: [{ rollout_percentage: percentage, properties: [] }] };
}

function site(key: string, file: string, line = 1) {
return { key, file, line, api: 'useFeatureFlagEnabled' };
}

function scan(overrides: Partial<FlagScanResult> = {}): FlagScanResult {
return {
callSites: [],
dynamicSites: [],
mentionSites: [],
usesBulkEvaluation: false,
reachableFiles: ['src/app/page.tsx', 'src/lib/flags.ts'],
filesScanned: 2,
truncated: false,
...overrides,
};
}

function bucketOf(
flags: FeatureFlag[],
scanResult: FlagScanResult,
key: string,
) {
const match = classifyFlags(flags, scanResult).find((c) => c.key === key);
return match ? [match.bucket, match.verdict] : undefined;
}

describe('classifyFlags', () => {
test('fully rolled out flag with a call site is stale', () => {
const flags = [flag({ key: 'new-checkout', filters: rollout(100) })];
const result = scan({
callSites: [site('new-checkout', 'src/app/page.tsx')],
});
expect(bucketOf(flags, result, 'new-checkout')).toEqual([
'fully-rolled-out',
'stale',
]);
});

test('rollout null counts as 100 percent', () => {
const flags = [flag({ key: 'k', filters: rollout(null) })];
const result = scan({ callSites: [site('k', 'src/app/page.tsx')] });
expect(bucketOf(flags, result, 'k')).toEqual(['fully-rolled-out', 'stale']);
});

test('flag at 0% everywhere is stale but flagged as a possible rollback', () => {
const flags = [flag({ key: 'beta-dashboard', filters: rollout(0) })];
const result = scan({
callSites: [site('beta-dashboard', 'src/app/page.tsx')],
});
expect(bucketOf(flags, result, 'beta-dashboard')).toEqual([
'never-enabled',
'stale',
]);
const [candidate] = classifyFlags(flags, result);
expect(candidate.area).toBe('Off for everyone');
expect(candidate.reason).toContain('may be a rollback');
});

test('archived flag still referenced is stale, archived and unreferenced is skipped', () => {
const flags = [
flag({
key: 'legacy-banner',
active: false,
archived: true,
filters: rollout(100),
}),
flag({ key: 'gone', active: false, archived: true }),
];
const result = scan({
callSites: [site('legacy-banner', 'src/app/page.tsx')],
});
expect(bucketOf(flags, result, 'legacy-banner')).toEqual([
'archived-still-referenced',
'stale',
]);
expect(bucketOf(flags, result, 'gone')).toBeUndefined();
});

test('disabled flag still referenced is stale', () => {
const flags = [flag({ key: 'off', active: false })];
const result = scan({ callSites: [site('off', 'src/app/page.tsx')] });
expect(bucketOf(flags, result, 'off')).toEqual([
'disabled-but-referenced',
'stale',
]);
});

test('unreferenced flag is stale, comment-only mention is its own bucket', () => {
const flags = [
flag({ key: 'pricing-v2-experiment' }),
flag({ key: 'holiday-promo' }),
];
const result = scan({
mentionSites: [
{ key: 'holiday-promo', file: 'src/app/page.tsx', line: 17 },
],
});
expect(bucketOf(flags, result, 'pricing-v2-experiment')).toEqual([
'unreferenced',
'stale',
]);
expect(bucketOf(flags, result, 'holiday-promo')).toEqual([
'unreferenced-comment-only',
'stale',
]);
});

test('call site only in an unreachable file is dead code, even when fully rolled out', () => {
const flags = [flag({ key: 'legacy-theme', filters: rollout(100) })];
const result = scan({
callSites: [site('legacy-theme', 'src/lib/unused/legacyTheme.ts')],
});
expect(bucketOf(flags, result, 'legacy-theme')).toEqual([
'dead-code-reference',
'stale',
]);
});

test('key evaluated in code with no PostHog flag is stale', () => {
const result = scan({
callSites: [site('old-pricing-test', 'src/app/page.tsx')],
});
const [only] = classifyFlags([], result);
expect([only.bucket, only.verdict, only.flagId]).toEqual([
'deleted-still-referenced',
'stale',
undefined,
]);
});

test('three or more files evaluating the same key directly is a warning', () => {
const flags = [flag({ key: 'ai-assistant' })];
const result = scan({
callSites: [
site('ai-assistant', 'src/app/page.tsx'),
site('ai-assistant', 'src/components/A.tsx'),
site('ai-assistant', 'src/components/B.tsx'),
],
reachableFiles: [
'src/app/page.tsx',
'src/components/A.tsx',
'src/components/B.tsx',
],
});
expect(bucketOf(flags, result, 'ai-assistant')).toEqual([
'multi-callsite-no-wrapper',
'warning',
]);
});

test('partial rollout and multivariate flags with call sites are healthy', () => {
const flags = [
flag({ key: 'dark-mode', filters: rollout(30) }),
flag({
key: 'signup-cta-variant',
filters: {
groups: [{ rollout_percentage: 100, properties: [] }],
multivariate: { variants: [{ key: 'a' }, { key: 'b' }] },
},
}),
flag({
key: 'gated',
filters: {
groups: [{ rollout_percentage: 100, properties: [{ key: 'email' }] }],
},
}),
];
const result = scan({
callSites: [
site('dark-mode', 'src/lib/flags.ts'),
site('signup-cta-variant', 'src/app/page.tsx'),
site('gated', 'src/app/page.tsx'),
],
});
expect(bucketOf(flags, result, 'dark-mode')).toEqual([
'healthy',
'healthy',
]);
expect(bucketOf(flags, result, 'signup-cta-variant')).toEqual([
'healthy',
'healthy',
]);
expect(bucketOf(flags, result, 'gated')).toEqual(['healthy', 'healthy']);
});

test('experiment, remote config and encrypted payload flags are guarded to healthy', () => {
const flags = [
flag({ key: 'exp', filters: rollout(100), experiment_set: [1] }),
flag({ key: 'rc', filters: rollout(100), is_remote_configuration: true }),
flag({ key: 'enc', filters: rollout(100), has_encrypted_payloads: true }),
];
const result = scan({
callSites: [
site('exp', 'src/app/page.tsx'),
site('rc', 'src/app/page.tsx'),
],
});
const byKey = Object.fromEntries(
classifyFlags(flags, result).map((c) => [c.key, c]),
);
expect(byKey.exp.bucket).toBe('healthy');
expect(byKey.exp.reason).toContain('backs an experiment');
expect(byKey.rc.bucket).toBe('healthy');
expect(byKey.enc.bucket).toBe('healthy');
});

test('deleted flags are ignored and candidates carry ledger-ready fields', () => {
const flags = [
flag({ key: 'zombie', deleted: true }),
flag({
key: 'new-checkout',
name: 'New checkout',
filters: rollout(100),
status: 'ACTIVE',
}),
];
const result = scan({
callSites: [site('new-checkout', 'src/app/page.tsx', 20)],
});
const candidates = classifyFlags(flags, result);
expect(candidates.map((c) => c.key)).toEqual(['new-checkout']);
expect(candidates[0]).toMatchObject({
proposedAction: 'keep on path, drop check, disable flag',
reason: 'rollout 100%, ACTIVE',
flagName: 'New checkout',
callSites: [
{ file: 'src/app/page.tsx', line: 20, api: 'useFeatureFlagEnabled' },
],
});
});
});
Loading