From 6caf9faed480ffb6cf1833cca1b09f20cb6004fd Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:18:36 -0600 Subject: [PATCH 01/28] feat(cull-feature-flags): register the command new flat command that runs the cull-feature-flags skill. nothing clever yet, just the wiring so `wizard cull-feature-flags` resolves and the program shows up in the registry (scan + classify land in the next commits). reuses the audit-run screen so the ledger rows show up live in the tui instead of a blank spinner. scopes borrow AGENT_SKILL_SCOPE_ADDITIONS since the skill needs feature_flag:write to disable flags. skill id is the nextjs variant for now, framework resolution comes with the deferred run. --- bin.ts | 2 ++ src/__tests__/programs-cli.test.ts | 6 ++++ src/commands/cull-feature-flags.ts | 8 +++++ src/lib/oauth/program-scopes.ts | 3 ++ src/lib/programs/cull-feature-flags/index.ts | 34 ++++++++++++++++++++ src/lib/programs/program-registry.ts | 2 ++ 6 files changed, 55 insertions(+) create mode 100644 src/commands/cull-feature-flags.ts create mode 100644 src/lib/programs/cull-feature-flags/index.ts diff --git a/bin.ts b/bin.ts index 2f0b20363..87003e0fc 100644 --- a/bin.ts +++ b/bin.ts @@ -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'; @@ -105,6 +106,7 @@ Wizard.use(basicIntegrationCommand) .use(replayVisionCommand) .use(aiObservabilityCommand) .use(metricsCommand) + .use(cullFeatureFlagsCommand) .use(cliCommand) .use(auditCommand) .use(doctorCommand) diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 7b891e1ca..841951dab 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -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'; @@ -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(); diff --git a/src/commands/cull-feature-flags.ts b/src/commands/cull-feature-flags.ts new file mode 100644 index 000000000..d675eee63 --- /dev/null +++ b/src/commands/cull-feature-flags.ts @@ -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, +); diff --git a/src/lib/oauth/program-scopes.ts b/src/lib/oauth/program-scopes.ts index 69da4f59a..2b0e3369f 100644 --- a/src/lib/oauth/program-scopes.ts +++ b/src/lib/oauth/program-scopes.ts @@ -280,6 +280,9 @@ const PROGRAM_SCOPE_ADDITIONS: Partial> = { ], 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, }; /** diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts new file mode 100644 index 000000000..4f9a0dc8b --- /dev/null +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -0,0 +1,34 @@ +import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; +import { createSkillProgram } from '@lib/programs/agent-skill/index'; +import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; + +export const CULL_FEATURE_FLAGS_REPORT_FILE = + 'posthog-feature-flag-cull-report.md'; + +// The skill resolves rows through audit_resolve_checks, so the audit run +// screen renders the ledger live instead of a bare spinner. +const withAuditRunScreen = (steps: ProgramStep[]): ProgramStep[] => + steps.map((step) => + step.id === 'run' ? { ...step, screenId: 'audit-run' } : step, + ); + +export const cullFeatureFlagsConfig: ProgramConfig = { + ...createSkillProgram({ + skillId: 'cull-feature-flags-nextjs', + command: 'cull-feature-flags', + id: 'cull-feature-flags', + description: + 'Find stale PostHog feature flags in this project and remove the ones you pick', + integrationLabel: 'cull-feature-flags', + customPrompt: + 'Run the cull-feature-flags skill end-to-end: verify each seeded ledger ' + + 'row at its call site, ask once which flags to remove, apply only those, ' + + `then write ./${CULL_FEATURE_FLAGS_REPORT_FILE}.`, + successMessage: `Feature flag cull complete! View the report at ./${CULL_FEATURE_FLAGS_REPORT_FILE}`, + reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, + docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', + spinnerMessage: 'Culling stale feature flags...', + estimatedDurationMinutes: 5, + }), + steps: withAuditRunScreen(AGENT_SKILL_STEPS), +}; diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 2633c295e..fb7b321fb 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -33,6 +33,7 @@ import { mcpAnalyticsConfig } from './mcp-analytics/index.js'; import { replayVisionConfig } from './replay-vision/index.js'; import { aiObservabilityConfig } from './ai-observability/index.js'; import { metricsConfig } from './metrics/index.js'; +import { cullFeatureFlagsConfig } from './cull-feature-flags/index.js'; import { slackConnectConfig } from './slack/index.js'; // Generic skill program — runs an arbitrary context-mill skill chosen at @@ -84,6 +85,7 @@ export const PROGRAM_REGISTRY = [ replayVisionConfig, aiObservabilityConfig, metricsConfig, + cullFeatureFlagsConfig, slackConnectConfig, ] as const satisfies readonly ProgramConfig[]; From efc68fc049f20f86ca5184c921fdd34154b32c51 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:21:21 -0600 Subject: [PATCH 02/28] feat(cull-feature-flags): scan source for flag call sites deterministic scan of the target repo for posthog flag api calls. regex over a bounded glob of js/ts files, comments stripped, literal first arg captures the key and anything else gets recorded as a dynamic site (so we know when a missing reference doesn't prove anything). also records getAllFlags usage (and treats keys read out of its result as real call sites), known keys that only show up quoted in a comment, and whether each call-site file is actually reachable (next.js convention entry or imported somewhere). the llm never greps for flags in this program, this is the ground truth it gets. --- .../__tests__/cull-feature-flags-scan.test.ts | 251 ++++++++++++++++++ src/lib/programs/cull-feature-flags/scan.ts | 246 +++++++++++++++++ 2 files changed, 497 insertions(+) create mode 100644 src/lib/programs/__tests__/cull-feature-flags-scan.test.ts create mode 100644 src/lib/programs/cull-feature-flags/scan.ts diff --git a/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts new file mode 100644 index 000000000..82fb2ebb5 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts @@ -0,0 +1,251 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { scanFlagCallSites } from '@lib/programs/cull-feature-flags/scan'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'cull-scan-')); +} + +function writeFile(dir: string, relativePath: string, content: string): void { + const fullPath = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); +} + +describe('scanFlagCallSites', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('records one literal call site per flag api with file and line', async () => { + writeFile( + tmpDir, + 'src/app/dashboard/page.tsx', + [ + 'import { useFeatureFlagEnabled, useFeatureFlagPayload, useFeatureFlagVariantKey } from "@posthog/react";', + 'const a = useFeatureFlagEnabled("new-checkout");', + "const b = useFeatureFlagPayload('ai-summaries');", + 'const c = useFeatureFlagVariantKey(`signup-cta-variant`);', + 'if (!posthog.isFeatureEnabled("export-csv")) {}', + 'const d = await client.getFeatureFlag("old-pricing-test", distinctId);', + 'const e = await client.getFeatureFlagPayload("payload-flag", distinctId);', + 'const f = posthog.getFeatureFlagResult("result-flag");', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites).toEqual([ + { + key: 'new-checkout', + file: 'src/app/dashboard/page.tsx', + line: 2, + api: 'useFeatureFlagEnabled', + }, + { + key: 'ai-summaries', + file: 'src/app/dashboard/page.tsx', + line: 3, + api: 'useFeatureFlagPayload', + }, + { + key: 'signup-cta-variant', + file: 'src/app/dashboard/page.tsx', + line: 4, + api: 'useFeatureFlagVariantKey', + }, + { + key: 'export-csv', + file: 'src/app/dashboard/page.tsx', + line: 5, + api: 'isFeatureEnabled', + }, + { + key: 'old-pricing-test', + file: 'src/app/dashboard/page.tsx', + line: 6, + api: 'getFeatureFlag', + }, + { + key: 'payload-flag', + file: 'src/app/dashboard/page.tsx', + line: 7, + api: 'getFeatureFlagPayload', + }, + { + key: 'result-flag', + file: 'src/app/dashboard/page.tsx', + line: 8, + api: 'getFeatureFlagResult', + }, + ]); + expect(result.dynamicSites).toEqual([]); + expect(result.filesScanned).toBe(1); + expect(result.truncated).toBe(false); + }); + + test('records PostHogFeature components as call sites', async () => { + writeFile( + tmpDir, + 'src/app/page.tsx', + [ + '', + "", + '', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites.map((site) => [site.key, site.line])).toEqual([ + ['new-nav', 1], + ['beta-nav', 2], + ]); + expect(result.dynamicSites).toEqual([ + { file: 'src/app/page.tsx', line: 3, api: 'PostHogFeature' }, + ]); + }); + + test('records non-literal first arguments as dynamic sites', async () => { + writeFile( + tmpDir, + 'src/lib/flags.ts', + [ + 'export function isOn(key: string) { return posthog.isFeatureEnabled(key); }', + 'const variant = posthog.getFeatureFlag(FLAGS.pricing);', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites).toEqual([]); + expect(result.dynamicSites).toEqual([ + { file: 'src/lib/flags.ts', line: 1, api: 'isFeatureEnabled' }, + { file: 'src/lib/flags.ts', line: 2, api: 'getFeatureFlag' }, + ]); + }); + + test('flags bulk evaluation and counts known keys read from its result as call sites', async () => { + writeFile( + tmpDir, + 'src/app/pricing/page.tsx', + [ + 'const flags = client ? await client.getAllFlags(distinctId) : {};', + 'const showAnnualDiscount = flags["annual-discount"] === true;', + '// flags["retired-discount"] used to live here', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir, [ + 'annual-discount', + 'retired-discount', + ]); + + expect(result.usesBulkEvaluation).toBe(true); + expect(result.callSites).toEqual([ + { + key: 'annual-discount', + file: 'src/app/pricing/page.tsx', + line: 2, + api: 'getAllFlags', + }, + ]); + expect(result.mentionSites).toEqual([ + { key: 'retired-discount', file: 'src/app/pricing/page.tsx', line: 3 }, + ]); + }); + + test('ignores calls inside comments but records known keys mentioned there', async () => { + writeFile( + tmpDir, + 'src/app/page.tsx', + [ + '// const old = useFeatureFlagEnabled("legacy-banner");', + '/* posthog.isFeatureEnabled("block-flag") */', + '/*', + ' * useFeatureFlagEnabled("multi-line-flag")', + ' */', + '// Holiday promo banner removed; flag "holiday-promo" is still in PostHog.', + 'const live = useFeatureFlagEnabled("live-flag"); // was "old-flag"', + ].join('\n'), + ); + + const result = await scanFlagCallSites(tmpDir, [ + 'holiday-promo', + 'live-flag', + 'old-flag', + 'legacy-banner', + ]); + + expect(result.callSites).toEqual([ + { + key: 'live-flag', + file: 'src/app/page.tsx', + line: 7, + api: 'useFeatureFlagEnabled', + }, + ]); + expect(result.mentionSites).toEqual([ + { key: 'legacy-banner', file: 'src/app/page.tsx', line: 1 }, + { key: 'holiday-promo', file: 'src/app/page.tsx', line: 6 }, + { key: 'old-flag', file: 'src/app/page.tsx', line: 7 }, + ]); + }); + + test('marks Next.js convention entries and imported modules as reachable', async () => { + writeFile( + tmpDir, + 'src/app/layout.tsx', + 'import { useDarkMode } from "@/lib/flags";', + ); + writeFile(tmpDir, 'src/proxy.ts', 'export const proxy = 1;'); + writeFile( + tmpDir, + 'src/lib/flags.ts', + 'export const useDarkMode = () => useFeatureFlagEnabled("dark-mode");', + ); + writeFile( + tmpDir, + 'src/lib/unused/legacyTheme.ts', + 'posthog.isFeatureEnabled("legacy-theme");', + ); + writeFile(tmpDir, 'src/components/index.ts', 'export {};'); + writeFile( + tmpDir, + 'src/app/page.tsx', + 'import { Header } from "../components";', + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.reachableFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/page.tsx', + 'src/components/index.ts', + 'src/lib/flags.ts', + 'src/proxy.ts', + ]); + }); + + test('skips node_modules and build output', async () => { + writeFile( + tmpDir, + 'node_modules/posthog-js/index.js', + 'isFeatureEnabled("vendored");', + ); + writeFile(tmpDir, '.next/server/page.js', 'isFeatureEnabled("built");'); + writeFile(tmpDir, 'src/a.ts', 'isFeatureEnabled("real");'); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.callSites.map((site) => site.key)).toEqual(['real']); + expect(result.filesScanned).toBe(1); + }); +}); diff --git a/src/lib/programs/cull-feature-flags/scan.ts b/src/lib/programs/cull-feature-flags/scan.ts new file mode 100644 index 000000000..2f8176386 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/scan.ts @@ -0,0 +1,246 @@ +import * as path from 'path'; +import { + boundedGlob, + GLOB_DEADLINE_MS, + readProjectFile, +} from '@utils/bounded-fs'; + +export interface FlagCallSite { + key: string; + file: string; + line: number; + api: string; +} + +export interface FlagDynamicSite { + file: string; + line: number; + api: string; +} + +export interface FlagMentionSite { + key: string; + file: string; + line: number; +} + +export interface FlagScanResult { + callSites: FlagCallSite[]; + dynamicSites: FlagDynamicSite[]; + /** Known keys that appear as a quoted string somewhere they are not evaluated (comments, config). */ + mentionSites: FlagMentionSite[]; + /** `getAllFlags` / `getAllFlagsAndPayloads` present, so every flag may be read without a literal key. */ + usesBulkEvaluation: boolean; + /** Files that are a Next.js convention entry or imported by another scanned file. */ + reachableFiles: string[]; + filesScanned: number; + truncated: boolean; +} + +const SOURCE_GLOB = '**/*.{ts,tsx,js,jsx,mjs,cjs}'; +const SCAN_FILE_LIMIT = 2000; +const MAX_SOURCE_FILE_BYTES = 512 * 1024; + +const FLAG_APIS = [ + 'isFeatureEnabled', + 'getFeatureFlag', + 'getFeatureFlagPayload', + 'getFeatureFlagResult', + 'useFeatureFlagEnabled', + 'useFeatureFlagVariantKey', + 'useFeatureFlagPayload', +]; +const FLAG_API_ALTERNATION = FLAG_APIS.join('|'); +const LITERAL_CALL_RE = new RegExp( + `\\b(${FLAG_API_ALTERNATION})\\s*\\(\\s*(['"\`])([^'"\`]+)\\2`, + 'g', +); +const DYNAMIC_CALL_RE = new RegExp( + `\\b(${FLAG_API_ALTERNATION})\\s*\\(\\s*(?!['"\`])[^)\\s]`, + 'g', +); +const LITERAL_JSX_RE = + /]*?\bflag=(?:"([^"]+)"|'([^']+)'|\{\s*['"]([^'"]+)['"]\s*\})/g; +const DYNAMIC_JSX_RE = /]*?\bflag=\{\s*(?!['"])[^}\s]/g; +const BULK_RE = /\b(getAllFlags|getAllFlagsAndPayloads)\s*\(/; +const PREFILTER = /FeatureFlag|isFeatureEnabled|PostHogFeature|getAllFlags/; +const IMPORT_SPECIFIER_RE = + /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g; +const NEXT_ENTRY_FILE_RE = + /(?:^|\/)(?:app\/.*\/?(?:page|layout|route|loading|error|not-found|template|default|global-error)|pages\/.*|middleware|proxy|instrumentation(?:-client)?|next\.config)\.(?:tsx?|[mc]?jsx?)$/; + +function stripComments(lines: string[]): string[] { + let isInsideBlockComment = false; + return lines.map((line) => { + let code = ''; + let index = 0; + while (index < line.length) { + if (isInsideBlockComment) { + const end = line.indexOf('*/', index); + if (end === -1) return code; + isInsideBlockComment = false; + index = end + 2; + continue; + } + const blockStart = line.indexOf('/*', index); + const lineStart = line.indexOf('//', index); + const hasLineComment = + lineStart !== -1 && (blockStart === -1 || lineStart < blockStart); + if (hasLineComment) return code + line.slice(index, lineStart); + if (blockStart === -1) return code + line.slice(index); + code += line.slice(index, blockStart); + isInsideBlockComment = true; + index = blockStart + 2; + } + return code; + }); +} + +function jsxKey(match: RegExpExecArray): string { + return match[1] ?? match[2] ?? match[3]; +} + +function collectSites( + file: string, + codeLines: string[], + result: FlagScanResult, +): void { + codeLines.forEach((code, lineIndex) => { + const line = lineIndex + 1; + for (const match of code.matchAll(LITERAL_CALL_RE)) { + result.callSites.push({ key: match[3], file, line, api: match[1] }); + } + for (const match of code.matchAll(DYNAMIC_CALL_RE)) { + result.dynamicSites.push({ file, line, api: match[1] }); + } + for (const match of code.matchAll(LITERAL_JSX_RE)) { + result.callSites.push({ + key: jsxKey(match), + file, + line, + api: 'PostHogFeature', + }); + } + if (DYNAMIC_JSX_RE.test(code)) { + result.dynamicSites.push({ file, line, api: 'PostHogFeature' }); + } + DYNAMIC_JSX_RE.lastIndex = 0; + if (BULK_RE.test(code)) result.usesBulkEvaluation = true; + }); +} + +// A file that called getAllFlags reads keys out of the result object, so a +// quoted key there is an evaluation, not a stray mention. +function collectMentions( + file: string, + rawLines: string[], + codeLines: string[], + knownKeys: readonly string[], + callSitesInFile: FlagCallSite[], + result: FlagScanResult, +): void { + if (knownKeys.length === 0) return; + const hasBulkCall = codeLines.some((code) => BULK_RE.test(code)); + const evaluatedOnLine = new Set( + callSitesInFile.map((site) => `${site.line}:${site.key}`), + ); + rawLines.forEach((raw, lineIndex) => { + const line = lineIndex + 1; + for (const key of knownKeys) { + if (evaluatedOnLine.has(`${line}:${key}`)) continue; + if (!raw.includes(key)) continue; + const quoted = new RegExp(`['"\`]${escapeRegExp(key)}['"\`]`); + if (!quoted.test(raw)) continue; + const isBulkLookup = hasBulkCall && quoted.test(codeLines[lineIndex]); + if (isBulkLookup) { + result.callSites.push({ key, file, line, api: 'getAllFlags' }); + continue; + } + result.mentionSites.push({ key, file, line }); + } + }); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function importedModuleNames(source: string): string[] { + const names: string[] = []; + for (const match of source.matchAll(IMPORT_SPECIFIER_RE)) { + const specifier = match[1].replace(/\?.*$/, ''); + const segments = specifier.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (!last) continue; + names.push(last.replace(/\.(?:tsx?|[mc]?jsx?)$/, '')); + } + return names; +} + +function isReachable(file: string, importedNames: Set): boolean { + if (NEXT_ENTRY_FILE_RE.test(file)) return true; + const baseName = path.basename(file).replace(/\.(?:tsx?|[mc]?jsx?)$/, ''); + if (importedNames.has(baseName)) return true; + if (baseName !== 'index') return false; + return importedNames.has(path.basename(path.dirname(file))); +} + +/** + * Deterministic scan of a JS/TS project for PostHog feature flag evaluation + * sites. Comments are stripped before matching; `knownKeys` (the project's + * flags from PostHog) turn quoted-but-unevaluated keys into mention sites. + */ +export async function scanFlagCallSites( + installDir: string, + knownKeys: readonly string[] = [], +): Promise { + const startedAt = Date.now(); + const files = await boundedGlob(SOURCE_GLOB, { + cwd: installDir, + limit: SCAN_FILE_LIMIT, + }); + const elapsedMs = Date.now() - startedAt; + const result: FlagScanResult = { + callSites: [], + dynamicSites: [], + mentionSites: [], + usesBulkEvaluation: false, + reachableFiles: [], + filesScanned: 0, + truncated: files.length >= SCAN_FILE_LIMIT || elapsedMs >= GLOB_DEADLINE_MS, + }; + const importedNames = new Set(); + const sortedFiles = [...files].sort(); + + for (const file of sortedFiles) { + const source = readProjectFile( + path.join(installDir, file), + MAX_SOURCE_FILE_BYTES, + ); + if (source === null) continue; + result.filesScanned += 1; + for (const name of importedModuleNames(source)) importedNames.add(name); + if ( + !PREFILTER.test(source) && + !knownKeys.some((key) => source.includes(key)) + ) + continue; + const rawLines = source.split('\n'); + const codeLines = stripComments(rawLines); + const before = result.callSites.length; + collectSites(file, codeLines, result); + collectMentions( + file, + rawLines, + codeLines, + knownKeys, + result.callSites.slice(before), + result, + ); + } + + result.reachableFiles = sortedFiles.filter((file) => + isReachable(file, importedNames), + ); + return result; +} From e823f1cf93e6fddede256ec0823938373a2973aa Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:23:18 -0600 Subject: [PATCH 03/28] feat(cull-feature-flags): fetch project flags and classify them pulls the project's flags after auth (default list plus archived=true since the api hides archived flags by default, deleted ones never come back) and buckets each one against the scan. every bucket is a plain rule on rollout / active / archived / reachability / call-site count, no age, no llm. buckets follow the fixture manifest: fully-rolled-out, never-enabled, archived-still-referenced, disabled-but-referenced, unreferenced (plus a comment-only flavour), dead-code-reference, deleted-still-referenced, and a multi-callsite-no-wrapper warning. experiment, remote config and encrypted payload flags are guarded to healthy so we never propose removing something that is doing more than a bool check. --- .../cull-feature-flags-classify.test.ts | 254 ++++++++++++++++++ .../programs/cull-feature-flags/classify.ts | 168 ++++++++++++ src/lib/programs/cull-feature-flags/fetch.ts | 51 ++++ src/lib/programs/cull-feature-flags/types.ts | 64 +++++ 4 files changed, 537 insertions(+) create mode 100644 src/lib/programs/__tests__/cull-feature-flags-classify.test.ts create mode 100644 src/lib/programs/cull-feature-flags/classify.ts create mode 100644 src/lib/programs/cull-feature-flags/fetch.ts create mode 100644 src/lib/programs/cull-feature-flags/types.ts diff --git a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts new file mode 100644 index 000000000..f00a1d940 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts @@ -0,0 +1,254 @@ +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 & { 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 { + 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('never enabled flag with a call site is stale', () => { + 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', + ]); + }); + + 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: + 'remove the check, keep the true branch, then disable the flag', + reason: 'rollout 100%, posthog status ACTIVE', + flagName: 'New checkout', + callSites: [ + { file: 'src/app/page.tsx', line: 20, api: 'useFeatureFlagEnabled' }, + ], + }); + }); +}); diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts new file mode 100644 index 000000000..e6af91106 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -0,0 +1,168 @@ +import type { FlagCallSite, FlagScanResult } from './scan.js'; +import type { CullBucket, CullCandidate, FeatureFlag } from './types.js'; + +const MULTI_CALLSITE_FILE_THRESHOLD = 3; + +const PROPOSED_ACTION_BY_BUCKET: Record = { + 'dead-code-reference': 'delete the unreachable module, then disable the flag', + 'archived-still-referenced': 'remove the check, keep the false branch', + 'disabled-but-referenced': 'remove the check, keep the false branch', + 'unreferenced-comment-only': 'disable the flag, clean up the comment', + unreferenced: 'disable the flag', + 'fully-rolled-out': + 'remove the check, keep the true branch, then disable the flag', + 'never-enabled': + 'remove the check, keep the false branch, then disable the flag', + 'deleted-still-referenced': 'remove the check, keep the false branch', + 'multi-callsite-no-wrapper': 'wrap the flag in one hook or helper', + healthy: 'keep', +}; + +const VERDICT_BY_BUCKET: Record = { + 'dead-code-reference': 'stale', + 'archived-still-referenced': 'stale', + 'disabled-but-referenced': 'stale', + 'unreferenced-comment-only': 'stale', + unreferenced: 'stale', + 'fully-rolled-out': 'stale', + 'never-enabled': 'stale', + 'deleted-still-referenced': 'stale', + 'multi-callsite-no-wrapper': 'warning', + healthy: 'healthy', +}; + +function guardReason(flag: FeatureFlag): string | undefined { + if ((flag.experiment_set?.length ?? 0) > 0) return 'backs an experiment'; + if (flag.is_remote_configuration) return 'remote configuration flag'; + if (flag.has_encrypted_payloads) return 'carries encrypted payloads'; + return undefined; +} + +function isFullyRolledOut(flag: FeatureFlag): boolean { + const groups = flag.filters?.groups ?? []; + if (groups.length === 0) return false; + if ((flag.filters?.multivariate?.variants?.length ?? 0) > 0) return false; + return groups.every( + (group) => + (group.rollout_percentage ?? 100) === 100 && + (group.properties?.length ?? 0) === 0, + ); +} + +function isNeverEnabled(flag: FeatureFlag): boolean { + const groups = flag.filters?.groups ?? []; + if (groups.length === 0) return false; + return groups.every((group) => group.rollout_percentage === 0); +} + +function rolloutSummary(flag: FeatureFlag): string { + const groups = flag.filters?.groups ?? []; + const parts: string[] = []; + if (groups.length === 0) parts.push('no release conditions'); + if (groups.length > 0) { + const percentages = groups.map((group) => + String(group.rollout_percentage ?? 100), + ); + parts.push(`rollout ${percentages.join('/')}%`); + } + if (groups.some((group) => (group.properties?.length ?? 0) > 0)) + parts.push('with property filters'); + if ((flag.filters?.multivariate?.variants?.length ?? 0) > 0) + parts.push('multivariate'); + if (flag.archived) parts.push('archived'); + if (!flag.active) parts.push('inactive'); + if (flag.status) parts.push(`posthog status ${flag.status}`); + return parts.join(', '); +} + +function bucketForFlag( + flag: FeatureFlag, + sites: FlagCallSite[], + hasMentions: boolean, + reachableFiles: ReadonlySet, +): CullBucket { + if (sites.length === 0) + return hasMentions ? 'unreferenced-comment-only' : 'unreferenced'; + if (sites.every((site) => !reachableFiles.has(site.file))) + return 'dead-code-reference'; + if (flag.archived) return 'archived-still-referenced'; + if (!flag.active) return 'disabled-but-referenced'; + if (isFullyRolledOut(flag)) return 'fully-rolled-out'; + if (isNeverEnabled(flag)) return 'never-enabled'; + const distinctFiles = new Set(sites.map((site) => site.file)); + if (distinctFiles.size >= MULTI_CALLSITE_FILE_THRESHOLD) + return 'multi-callsite-no-wrapper'; + return 'healthy'; +} + +function candidate( + key: string, + bucket: CullBucket, + reason: string, + sites: FlagCallSite[], + flag?: FeatureFlag, +): CullCandidate { + return { + key, + bucket, + verdict: VERDICT_BY_BUCKET[bucket], + proposedAction: PROPOSED_ACTION_BY_BUCKET[bucket], + reason, + flagId: flag?.id, + flagName: flag?.name, + callSites: sites.map(({ file, line, api }) => ({ file, line, api })), + }; +} + +/** + * Pure classification of every PostHog flag plus every key the code evaluates + * that PostHog no longer has. Rules only look at rollout, active, archived, + * and the scan; age is never read. + */ +export function classifyFlags( + flags: readonly FeatureFlag[], + scan: FlagScanResult, +): CullCandidate[] { + const sitesByKey = new Map(); + for (const site of scan.callSites) { + sitesByKey.set(site.key, [...(sitesByKey.get(site.key) ?? []), site]); + } + const mentionedKeys = new Set(scan.mentionSites.map((site) => site.key)); + const reachableFiles = new Set(scan.reachableFiles); + const candidates: CullCandidate[] = []; + + for (const flag of flags) { + if (flag.deleted) continue; + const sites = sitesByKey.get(flag.key) ?? []; + sitesByKey.delete(flag.key); + if (flag.archived && sites.length === 0) continue; + const summary = rolloutSummary(flag); + const guard = guardReason(flag); + if (guard) { + candidates.push( + candidate(flag.key, 'healthy', `${guard}; ${summary}`, sites, flag), + ); + continue; + } + const bucket = bucketForFlag( + flag, + sites, + mentionedKeys.has(flag.key), + reachableFiles, + ); + candidates.push(candidate(flag.key, bucket, summary, sites, flag)); + } + + for (const [key, sites] of sitesByKey) { + candidates.push( + candidate( + key, + 'deleted-still-referenced', + 'no flag with this key in PostHog (deleted or never created)', + sites, + ), + ); + } + + return candidates; +} diff --git a/src/lib/programs/cull-feature-flags/fetch.ts b/src/lib/programs/cull-feature-flags/fetch.ts new file mode 100644 index 000000000..a99e1fef5 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/fetch.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; +import { handleApiError } from '@lib/api'; +import { WIZARD_USER_AGENT } from '@lib/constants'; +import { analytics } from '@utils/analytics'; +import { FeatureFlagListResponseSchema, type FeatureFlag } from './types.js'; + +const PAGE_LIMIT = 300; + +async function fetchFlagList( + accessToken: string, + apiHost: string, + projectId: number, + query: string, +): Promise { + const endpoint = `/api/projects/${projectId}/feature_flags/`; + const url = `${apiHost}${endpoint}?limit=${PAGE_LIMIT}${query}`; + try { + const response = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': WIZARD_USER_AGENT, + }, + }); + return FeatureFlagListResponseSchema.parse(response.data).results; + } catch (error) { + const apiError = handleApiError(error, 'fetch feature flags'); + analytics.captureException(apiError, { endpoint, apiHost, projectId }); + throw apiError; + } +} + +/** + * The default list hides archived flags and never returns deleted ones, so an + * archived-but-referenced flag needs the second query to show up at all. + */ +export async function fetchFeatureFlags( + accessToken: string, + apiHost: string, + projectId: number, +): Promise { + const [live, archived] = await Promise.all([ + fetchFlagList(accessToken, apiHost, projectId, ''), + fetchFlagList(accessToken, apiHost, projectId, '&archived=true'), + ]); + const seen = new Set(); + return [...live, ...archived].filter((flag) => { + if (seen.has(flag.key)) return false; + seen.add(flag.key); + return !flag.deleted; + }); +} diff --git a/src/lib/programs/cull-feature-flags/types.ts b/src/lib/programs/cull-feature-flags/types.ts new file mode 100644 index 000000000..6fbd9b99b --- /dev/null +++ b/src/lib/programs/cull-feature-flags/types.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +const FlagGroupSchema = z.object({ + rollout_percentage: z.number().nullable().optional(), + properties: z.array(z.unknown()).optional(), + variant: z.string().nullable().optional(), +}); + +// Every classification field is optional so an API drift degrades a flag to +// healthy, never to cullable. +export const FeatureFlagSchema = z.object({ + id: z.number(), + key: z.string(), + name: z.string().optional(), + active: z.boolean(), + archived: z.boolean().optional(), + deleted: z.boolean().optional(), + status: z.string().optional(), + filters: z + .object({ + groups: z.array(FlagGroupSchema).optional(), + multivariate: z + .object({ variants: z.array(z.unknown()).optional() }) + .nullable() + .optional(), + }) + .optional(), + experiment_set: z.array(z.unknown()).nullable().optional(), + is_remote_configuration: z.boolean().optional(), + has_encrypted_payloads: z.boolean().optional(), +}); +export type FeatureFlag = z.infer; + +export const FeatureFlagListResponseSchema = z.object({ + results: z.array(FeatureFlagSchema), + next: z.string().nullable().optional(), +}); + +export type CullBucket = + | 'dead-code-reference' + | 'archived-still-referenced' + | 'disabled-but-referenced' + | 'unreferenced-comment-only' + | 'unreferenced' + | 'fully-rolled-out' + | 'never-enabled' + | 'deleted-still-referenced' + | 'multi-callsite-no-wrapper' + | 'healthy'; + +export type CullVerdict = 'stale' | 'warning' | 'healthy'; + +export interface CullCandidate { + key: string; + bucket: CullBucket; + verdict: CullVerdict; + /** One line the ledger label shows next to the key. */ + proposedAction: string; + /** Why the flag landed in its bucket, plus PostHog state the skill should see. */ + reason: string; + flagId?: number; + flagName?: string; + callSites: { file: string; line: number; api: string }[]; +} From e82790e72ca1488efd70f886e803170c73af050b Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:27:06 -0600 Subject: [PATCH 04/28] feat(cull-feature-flags): seed the ledger and prompt the skill run is now deferred: dirty-tree check, framework detect (nextjs only for now, resolved to the matching context-mill variant), fetch, scan, classify, then seed .posthog-audit-checks.json with one row per flag before the agent starts. the prompt tells the skill the ledger is ground truth, one batched consent before any mutation, disable only (never delete or archive). two things that keep the undo promise honest: the run aborts on uncommitted changes (new PHW_DETECT_DIRTY_WORKING_TREE code) so "revert with git" never mixes with the user's own work, and the outro lists the touched files plus a re-enable link per disabled flag instead of dashboard links. fetch failure seeds zero rows and the skill goes report only instead of crashing the run. --- src/lib/errors/catalog.ts | 6 + src/lib/errors/codes.ts | 1 + .../__tests__/cull-feature-flags-seed.test.ts | 169 ++++++++++++++ src/lib/programs/cull-feature-flags/index.ts | 209 ++++++++++++++++-- src/lib/programs/cull-feature-flags/outro.ts | 57 +++++ src/lib/programs/cull-feature-flags/seed.ts | 88 ++++++++ .../cull-feature-flags/working-tree.ts | 19 ++ 7 files changed, 526 insertions(+), 23 deletions(-) create mode 100644 src/lib/programs/__tests__/cull-feature-flags-seed.test.ts create mode 100644 src/lib/programs/cull-feature-flags/outro.ts create mode 100644 src/lib/programs/cull-feature-flags/seed.ts create mode 100644 src/lib/programs/cull-feature-flags/working-tree.ts diff --git a/src/lib/errors/catalog.ts b/src/lib/errors/catalog.ts index 7d2c026d5..870a826e3 100644 --- a/src/lib/errors/catalog.ts +++ b/src/lib/errors/catalog.ts @@ -117,6 +117,12 @@ export const ERROR_CATALOG: Record = { 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', diff --git a/src/lib/errors/codes.ts b/src/lib/errors/codes.ts index 6342b79b0..2c73b7c45 100644 --- a/src/lib/errors/codes.ts +++ b/src/lib/errors/codes.ts @@ -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', diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts new file mode 100644 index 000000000..033c63020 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -0,0 +1,169 @@ +import { + buildCullPrompt, + candidateToCheck, +} from '@lib/programs/cull-feature-flags/seed'; +import { buildCullOutro } from '@lib/programs/cull-feature-flags/outro'; +import type { FlagScanResult } from '@lib/programs/cull-feature-flags/scan'; +import type { CullCandidate } from '@lib/programs/cull-feature-flags/types'; +import { OutroKind } from '@lib/wizard-session'; + +const STALE: CullCandidate = { + key: 'new-checkout', + bucket: 'fully-rolled-out', + verdict: 'stale', + proposedAction: + 'remove the check, keep the true branch, then disable the flag', + reason: 'rollout 100%, posthog status ACTIVE', + flagId: 42, + flagName: 'New checkout', + callSites: [ + { + file: 'src/app/dashboard/page.tsx', + line: 20, + api: 'useFeatureFlagEnabled', + }, + { file: 'src/lib/checkout.ts', line: 3, api: 'isFeatureEnabled' }, + ], +}; + +const HEALTHY: CullCandidate = { + key: 'dark-mode', + bucket: 'healthy', + verdict: 'healthy', + proposedAction: 'keep', + reason: 'rollout 30%, posthog status ACTIVE', + flagId: 7, + callSites: [ + { file: 'src/lib/flags.ts', line: 6, api: 'useFeatureFlagEnabled' }, + ], +}; + +const ORPHAN: CullCandidate = { + key: 'pricing-v2-experiment', + bucket: 'unreferenced', + verdict: 'stale', + proposedAction: 'disable the flag', + reason: 'rollout 50%, posthog status STALE', + flagId: 9, + callSites: [], +}; + +function scan(overrides: Partial = {}): FlagScanResult { + return { + callSites: [], + dynamicSites: [], + mentionSites: [], + usesBulkEvaluation: false, + reachableFiles: [], + filesScanned: 10, + truncated: false, + ...overrides, + }; +} + +describe('candidateToCheck', () => { + test('stale candidate becomes a pending row keyed by flag with bucket as area', () => { + expect(candidateToCheck(STALE)).toEqual({ + id: 'new-checkout', + area: 'fully-rolled-out', + label: + 'new-checkout: remove the check, keep the true branch, then disable the flag', + status: 'pending', + file: 'src/app/dashboard/page.tsx:20', + details: + 'rollout 100%, posthog status ACTIVE; sites: src/app/dashboard/page.tsx:20 (useFeatureFlagEnabled), src/lib/checkout.ts:3 (isFeatureEnabled)', + }); + }); + + test('healthy candidate is seeded as pass, unreferenced candidate has no file', () => { + expect(candidateToCheck(HEALTHY).status).toBe('pass'); + const orphan = candidateToCheck(ORPHAN); + expect(orphan.file).toBeUndefined(); + expect(orphan.details).toBe( + 'rollout 50%, posthog status STALE; no call sites', + ); + }); +}); + +describe('buildCullPrompt', () => { + test('names the ledger, counts per bucket, and the disable-only rule', () => { + const prompt = buildCullPrompt({ + ledgerFile: '.posthog-audit-checks.json', + candidates: [STALE, HEALTHY, ORPHAN], + scan: scan(), + postHogFetchFailed: false, + }); + expect(prompt).toContain('./.posthog-audit-checks.json'); + expect(prompt).toContain('- fully-rolled-out: 1'); + expect(prompt).toContain('- healthy: 1'); + expect(prompt).toContain('- unreferenced: 1'); + expect(prompt).toContain('never delete or archive'); + expect(prompt).not.toContain('getAllFlags'); + }); + + test('adds the bulk, dynamic, truncation and fetch-failure caveats when they apply', () => { + const prompt = buildCullPrompt({ + ledgerFile: '.posthog-audit-checks.json', + candidates: [ORPHAN], + scan: scan({ + usesBulkEvaluation: true, + dynamicSites: [ + { file: 'src/lib/flags.ts', line: 4, api: 'isFeatureEnabled' }, + ], + truncated: true, + }), + postHogFetchFailed: true, + }); + expect(prompt).toContain('calls getAllFlags'); + expect(prompt).toContain('src/lib/flags.ts:4 (isFeatureEnabled)'); + expect(prompt).toContain('hit its file limit'); + expect(prompt).toContain('flag fetch failed'); + }); +}); + +describe('buildCullOutro', () => { + const common = { + appHost: 'https://us.posthog.com', + projectId: 590630, + flagIdByKey: new Map([['new-checkout', 42]]), + reportFile: 'posthog-feature-flag-cull-report.md', + docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', + }; + + test('nothing applied means a report-only message and no undo block', () => { + const outro = buildCullOutro({ + ...common, + checks: [ + { + ...candidateToCheck(STALE), + status: 'pass', + details: 'x; declined by user', + }, + ], + touchedFiles: [], + }); + expect(outro.kind).toBe(OutroKind.Success); + expect(outro.message).toContain('Nothing was changed'); + expect(outro.changes).toEqual([]); + expect(outro.nextSteps).toBeUndefined(); + }); + + test('applied rows produce the git revert and the flag page per disabled flag', () => { + const outro = buildCullOutro({ + ...common, + checks: [ + { ...candidateToCheck(STALE), status: 'pass', details: 'x; applied' }, + ], + touchedFiles: ['src/app/dashboard/page.tsx', 'src/lib/checkout.ts'], + }); + expect(outro.message).toContain('Culled 1 feature flag.'); + expect(outro.changes).toEqual([candidateToCheck(STALE).label]); + expect(outro.nextSteps).toEqual({ + heading: 'Undo, if you want it back:', + items: [ + 'Code: git checkout -- src/app/dashboard/page.tsx src/lib/checkout.ts (or git diff to review first)', + 'Re-enable new-checkout: https://us.posthog.com/project/590630/feature_flags/42', + ], + }); + }); +}); diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 4f9a0dc8b..2787d2b9e 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -1,34 +1,197 @@ +import * as path from 'path'; +import type { ProgramRun } from '@lib/agent/agent-runner'; +import { resolveSkillVariantId } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner'; +import { getSkillsBaseUrl, Integration } from '@lib/constants'; +import { detectFramework } from '@lib/detection/index'; +import { ErrorCodes } from '@lib/errors'; import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; import { createSkillProgram } from '@lib/programs/agent-skill/index'; +import { + AUDIT_CHECKS_FILE, + AUDIT_CHECKS_KEY, + coerceAuditChecks, +} from '@lib/programs/audit/types'; import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import type { WizardSession } from '@lib/wizard-session'; +import { fetchSkillMenu } from '@lib/wizard-tools'; +import { analytics } from '@utils/analytics'; +import { readProjectFile } from '@utils/bounded-fs'; +import { logToFile } from '@utils/debug'; +import { wizardAbort } from '@utils/wizard-abort'; +import { classifyFlags } from './classify.js'; +import { fetchFeatureFlags } from './fetch.js'; +import { buildCullOutro } from './outro.js'; +import { scanFlagCallSites } from './scan.js'; +import { buildCullPrompt, seedCullLedger } from './seed.js'; +import type { FeatureFlag } from './types.js'; +import { listUncommittedPaths } from './working-tree.js'; export const CULL_FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flag-cull-report.md'; +const CULL_SKILL_GROUP = 'cull-feature-flags'; +const DOCS_URL = 'https://posthog.com/docs/feature-flags/best-practices'; -// The skill resolves rows through audit_resolve_checks, so the audit run -// screen renders the ledger live instead of a bare spinner. -const withAuditRunScreen = (steps: ProgramStep[]): ProgramStep[] => - steps.map((step) => - step.id === 'run' ? { ...step, screenId: 'audit-run' } : step, +/** Frameworks the scanner has patterns for; one context-mill variant each. */ +export const CULL_FEATURE_FLAGS_SUPPORTED: ReadonlySet = new Set([ + Integration.nextjs, +]); + +const SCREEN_BY_STEP: Record = { + run: 'audit-run', +}; + +const cullSteps: ProgramStep[] = AGENT_SKILL_STEPS.map((step) => { + const override = SCREEN_BY_STEP[step.id]; + return override ? { ...step, screenId: override } : step; +}); + +const base = createSkillProgram({ + skillId: `${CULL_SKILL_GROUP}-nextjs`, + command: 'cull-feature-flags', + id: 'cull-feature-flags', + description: + 'Find stale PostHog feature flags in this project and remove the ones you pick', + integrationLabel: 'cull-feature-flags', + successMessage: `Feature flag cull complete! View the report at ./${CULL_FEATURE_FLAGS_REPORT_FILE}`, + reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, + docsUrl: DOCS_URL, + spinnerMessage: 'Culling stale feature flags...', + estimatedDurationMinutes: 5, +}); + +async function abortDirtyWorkingTree(paths: string[]): Promise { + const shown = paths.slice(0, 10).map((p) => ` ${p}`); + const more = paths.length > 10 ? ` ...and ${paths.length - 10} more` : ''; + await wizardAbort({ + code: ErrorCodes.DetectDirtyWorkingTree, + message: + 'This project has uncommitted changes. Culling edits files, and the undo is a plain git revert, ' + + 'which only works when the tree starts clean.\n\n' + + 'Commit or stash these first, then run the command again:\n' + + [...shown, more].filter(Boolean).join('\n'), + error: new Error('cull-feature-flags: dirty working tree'), + }); +} + +async function abortUnsupportedPlatform( + integration: Integration | undefined, +): Promise { + const name = integration + ? FRAMEWORK_REGISTRY[integration]?.metadata.name ?? integration + : 'this'; + await wizardAbort({ + code: ErrorCodes.DetectUnsupportedPlatform, + message: + `Feature flag culling has no scanner for ${name} projects yet. ` + + 'Supported today: Next.js.', + error: new Error( + `cull-feature-flags unsupported platform: ${integration ?? 'unknown'}`, + ), + }); +} + +async function resolveVariantSkillId(framework: Integration): Promise { + const menu = await fetchSkillMenu(getSkillsBaseUrl()); + const entries = menu ? Object.values(menu.categories).flat() : []; + return ( + resolveSkillVariantId(entries, CULL_SKILL_GROUP, framework) ?? + `${CULL_SKILL_GROUP}-${framework}` ); +} + +async function fetchFlagsOrEmpty( + session: WizardSession, +): Promise<{ flags: FeatureFlag[]; failed: boolean }> { + const credentials = session.credentials; + if (!credentials) return { flags: [], failed: true }; + try { + const flags = await fetchFeatureFlags( + credentials.accessToken, + credentials.host.apiHost, + credentials.projectId, + ); + return { flags, failed: false }; + } catch (error) { + logToFile(`[cull-feature-flags] flag fetch failed: ${String(error)}`); + analytics.wizardCapture('cull feature flags fetch failed'); + return { flags: [], failed: true }; + } +} + +function readLedger(installDir: string) { + const raw = readProjectFile(path.join(installDir, AUDIT_CHECKS_FILE)); + if (raw === null) return []; + try { + return coerceAuditChecks(JSON.parse(raw)); + } catch { + return []; + } +} + +const cullRun = async (session: WizardSession): Promise => { + const { installDir } = session; + const uncommitted = listUncommittedPaths(installDir); + if (uncommitted.length > 0) await abortDirtyWorkingTree(uncommitted); + + const framework = await detectFramework(installDir); + if (!framework || !CULL_FEATURE_FLAGS_SUPPORTED.has(framework)) { + await abortUnsupportedPlatform(framework); + } + const skillId = await resolveVariantSkillId(framework as Integration); + + const { flags, failed } = await fetchFlagsOrEmpty(session); + const scan = await scanFlagCallSites( + installDir, + flags.map((flag) => flag.key), + ); + const candidates = classifyFlags(flags, scan); + const checks = seedCullLedger(installDir, candidates); + session.frameworkContext[AUDIT_CHECKS_KEY] = checks; + const flagIdByKey = new Map( + candidates + .filter((candidate) => candidate.flagId !== undefined) + .map((candidate) => [candidate.key, candidate.flagId as number]), + ); + analytics.wizardCapture('cull feature flags seeded', { + framework, + flags: flags.length, + candidates: candidates.length, + stale: candidates.filter((c) => c.verdict === 'stale').length, + files_scanned: scan.filesScanned, + truncated: scan.truncated, + fetch_failed: failed, + }); + + const baseRun = + typeof base.run === 'function' ? await base.run(session) : base.run; + if (!baseRun) throw new Error('cull-feature-flags has no run configuration'); + + return { + ...baseRun, + skillId, + customPrompt: () => + buildCullPrompt({ + ledgerFile: AUDIT_CHECKS_FILE, + candidates, + scan, + postHogFetchFailed: failed, + }), + buildOutroData: (sess, credentials) => + buildCullOutro({ + checks: readLedger(sess.installDir), + touchedFiles: listUncommittedPaths(sess.installDir), + appHost: credentials.host.appHost, + projectId: credentials.projectId, + flagIdByKey, + reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, + docsUrl: DOCS_URL, + }), + }; +}; export const cullFeatureFlagsConfig: ProgramConfig = { - ...createSkillProgram({ - skillId: 'cull-feature-flags-nextjs', - command: 'cull-feature-flags', - id: 'cull-feature-flags', - description: - 'Find stale PostHog feature flags in this project and remove the ones you pick', - integrationLabel: 'cull-feature-flags', - customPrompt: - 'Run the cull-feature-flags skill end-to-end: verify each seeded ledger ' + - 'row at its call site, ask once which flags to remove, apply only those, ' + - `then write ./${CULL_FEATURE_FLAGS_REPORT_FILE}.`, - successMessage: `Feature flag cull complete! View the report at ./${CULL_FEATURE_FLAGS_REPORT_FILE}`, - reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, - docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', - spinnerMessage: 'Culling stale feature flags...', - estimatedDurationMinutes: 5, - }), - steps: withAuditRunScreen(AGENT_SKILL_STEPS), + ...base, + steps: cullSteps, + run: cullRun, }; diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts new file mode 100644 index 000000000..99f4ed70d --- /dev/null +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -0,0 +1,57 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import { OutroKind, type OutroData } from '@lib/wizard-session'; +import { APPLIED_MARKER } from './seed.js'; + +export interface CullOutroInput { + checks: readonly AuditCheck[]; + touchedFiles: readonly string[]; + appHost: string; + projectId: number; + flagIdByKey: ReadonlyMap; + reportFile: string; + docsUrl: string; +} + +/** Outro that carries the undo recipe: the git revert for code, the flag page for PostHog. */ +export function buildCullOutro(input: CullOutroInput): OutroData { + const applied = input.checks.filter( + (check) => + check.status === 'pass' && (check.details ?? '').includes(APPLIED_MARKER), + ); + const undoItems: string[] = []; + if (input.touchedFiles.length > 0) { + undoItems.push( + `Code: git checkout -- ${input.touchedFiles.join( + ' ', + )} (or git diff to review first)`, + ); + } + for (const check of applied) { + const flagId = input.flagIdByKey.get(check.id); + if (flagId === undefined) continue; + undoItems.push( + `Re-enable ${check.id}: ${input.appHost}/project/${input.projectId}/feature_flags/${flagId}`, + ); + } + const message = + applied.length === 0 + ? 'Nothing was changed. The report lists what you can cull by hand.' + : `Culled ${applied.length} feature flag${ + applied.length === 1 ? '' : 's' + }. Flags were disabled, never deleted.`; + return { + kind: OutroKind.Success, + message, + reportFile: input.reportFile, + docsUrl: input.docsUrl, + changes: applied.map((check) => check.label), + ...(undoItems.length > 0 + ? { + nextSteps: { + heading: 'Undo, if you want it back:', + items: undoItems, + }, + } + : {}), + }; +} diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts new file mode 100644 index 000000000..6ede643cf --- /dev/null +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -0,0 +1,88 @@ +import { seedAuditLedger } from '@lib/programs/audit/seed'; +import type { AuditCheck } from '@lib/programs/audit/types'; +import type { FlagScanResult } from './scan.js'; +import type { CullCandidate } from './types.js'; + +export const APPLIED_MARKER = '; applied'; + +function describeSites(candidate: CullCandidate): string { + if (candidate.callSites.length === 0) return 'no call sites'; + const sites = candidate.callSites.map( + (site) => `${site.file}:${site.line} (${site.api})`, + ); + return `sites: ${sites.join(', ')}`; +} + +export function candidateToCheck(candidate: CullCandidate): AuditCheck { + const first = candidate.callSites[0]; + return { + id: candidate.key, + area: candidate.bucket, + label: `${candidate.key}: ${candidate.proposedAction}`, + status: candidate.verdict === 'healthy' ? 'pass' : 'pending', + ...(first ? { file: `${first.file}:${first.line}` } : {}), + details: `${candidate.reason}; ${describeSites(candidate)}`, + }; +} + +export function seedCullLedger( + installDir: string, + candidates: readonly CullCandidate[], +): AuditCheck[] { + const checks = candidates.map(candidateToCheck); + seedAuditLedger(installDir, checks); + return checks; +} + +export interface CullPromptInput { + ledgerFile: string; + candidates: readonly CullCandidate[]; + scan: FlagScanResult; + postHogFetchFailed: boolean; +} + +function countByBucket(candidates: readonly CullCandidate[]): string[] { + const counts = new Map(); + for (const candidate of candidates) { + counts.set(candidate.bucket, (counts.get(candidate.bucket) ?? 0) + 1); + } + return [...counts.entries()].map( + ([bucket, count]) => `- ${bucket}: ${count}`, + ); +} + +export function buildCullPrompt(input: CullPromptInput): string { + const lines = [ + 'Run the cull-feature-flags skill end-to-end. The wizard already scanned this project and fetched its PostHog flags; the ledger at', + `./${input.ledgerFile} is ground truth, one row per flag, area = bucket:`, + ...countByBucket(input.candidates), + '', + 'Never grep for flags or re-classify a row. Resolve rows only through audit_resolve_checks. Ask exactly once which rows to apply, decline option first. Disable flags only, never delete or archive. Code edits land before the PostHog disable.', + ]; + if (input.scan.usesBulkEvaluation) { + lines.push( + 'This project calls getAllFlags, so a flag with no literal call site may still be read out of that result. Verify every unreferenced row at the bulk call site before proposing it.', + ); + } + if (input.scan.dynamicSites.length > 0) { + const sites = input.scan.dynamicSites.map( + (site) => `${site.file}:${site.line} (${site.api})`, + ); + lines.push( + `Flag keys are also evaluated dynamically at ${sites.join( + ', ', + )}. Verify every unreferenced row against those sites before proposing it.`, + ); + } + if (input.scan.truncated) { + lines.push( + 'The scan hit its file limit, so "unreferenced" is not proven. Treat every unreferenced row as verify-first.', + ); + } + if (input.postHogFetchFailed) { + lines.push( + 'The PostHog flag fetch failed, so only code-side buckets are seeded. Propose nothing, write the report and say so.', + ); + } + return lines.join('\n'); +} diff --git a/src/lib/programs/cull-feature-flags/working-tree.ts b/src/lib/programs/cull-feature-flags/working-tree.ts new file mode 100644 index 000000000..df6f2d947 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/working-tree.ts @@ -0,0 +1,19 @@ +import { execSync } from 'child_process'; + +/** Paths under `installDir` with uncommitted or untracked changes, empty when clean or not a git repo. */ +export function listUncommittedPaths(installDir: string): string[] { + let status: string; + try { + status = execSync('git status --porcelain=v1 -- .', { + cwd: installDir, + stdio: ['ignore', 'pipe', 'ignore'], + }).toString(); + } catch { + return []; + } + return status + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^\S+\s+/, '')); +} From 4b2e4e5ec3c7a95db4e13a8c27000fa88d484c0a Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:28:56 -0600 Subject: [PATCH 05/28] feat(cull-feature-flags): intro screen and per-bucket slides the generic agent-skill intro says nothing about edits and the audit intro promises none happen, neither is true here. cull-intro says what will happen up front: nothing changes until you pick, flags are disabled never deleted, code edits are a git diff away from undo. one area slide per bucket on the run screen (the ledger seeds area = bucket) so the side pane explains what culling that flag does and how to undo it while the agent works through the rows. --- src/lib/programs/cull-feature-flags/index.ts | 1 + src/ui/tui/screen-registry.tsx | 2 + src/ui/tui/screen-sequences.ts | 1 + src/ui/tui/screens/CullIntroScreen.tsx | 91 +++++++++++++++++++ src/ui/tui/screens/audit/AuditRunScreen.tsx | 12 ++- src/ui/tui/screens/audit/slides/cull/index.ts | 64 +++++++++++++ 6 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/ui/tui/screens/CullIntroScreen.tsx create mode 100644 src/ui/tui/screens/audit/slides/cull/index.ts diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 2787d2b9e..404f20bcf 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -38,6 +38,7 @@ export const CULL_FEATURE_FLAGS_SUPPORTED: ReadonlySet = new Set([ ]); const SCREEN_BY_STEP: Record = { + intro: 'cull-intro', run: 'audit-run', }; diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3bc..7a8f2ff29 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -28,6 +28,7 @@ import { SourceMapsIntroScreen } from './screens/SourceMapsIntroScreen.js'; import { SourceMapsDetectScreen } from './screens/SourceMapsDetectScreen.js'; import { SourceMapsOutroScreen } from './screens/SourceMapsOutroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; +import { CullIntroScreen } from './screens/CullIntroScreen.js'; import { AiObservabilityIntroScreen } from './screens/AiObservabilityIntroScreen.js'; import { MetricsIntroScreen } from './screens/MetricsIntroScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; @@ -92,6 +93,7 @@ export function createScreens( [ScreenId.SourceMapsOutro]: , [ScreenId.MigrationIntro]: , [ScreenId.AgentSkillIntro]: , + [ScreenId.CullIntro]: , [ScreenId.AiObservabilityIntro]: ( ), diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cfb..f61156709 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -24,6 +24,7 @@ export enum ScreenId { SourceMapsOutro = 'source-maps-outro', MigrationIntro = 'migration-intro', AgentSkillIntro = 'agent-skill-intro', + CullIntro = 'cull-intro', AiObservabilityIntro = 'ai-observability-intro', MetricsIntro = 'metrics-intro', SelfDrivingIntro = 'self-driving-intro', diff --git a/src/ui/tui/screens/CullIntroScreen.tsx b/src/ui/tui/screens/CullIntroScreen.tsx new file mode 100644 index 000000000..7473195a3 --- /dev/null +++ b/src/ui/tui/screens/CullIntroScreen.tsx @@ -0,0 +1,91 @@ +import { Box, Text } from 'ink'; +import type { ReactNode } from 'react'; +import { useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { IntroScreenLayout } from './IntroScreenLayout.js'; +import { SkillSourceInfo, useSkillEntry } from './SkillSourceInfo.js'; + +interface CullIntroScreenProps { + store: WizardStore; +} + +// The generic agent-skill intro promises nothing about edits and the audit +// intro promises none happen; culling edits on consent, so it says so up front. +export const CullIntroScreen = ({ store }: CullIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const [showingMoreInfo, setShowingMoreInfo] = useState(false); + const { session } = store; + const skillId = session.skillId ?? 'cull-feature-flags'; + const { skillEntry, fetchFailed } = useSkillEntry(skillId); + + const moreInfoBody: ReactNode = ( + + + + The wizard is an agent that executes PostHog tasks. Its code is open + source: https://github.com/PostHog/wizard + + + + The wizard scans this project for feature flag calls and compares them + with the flags in your PostHog project, then shows you the ones that + look done. Nothing changes until you pick which ones to cull. Flags get + disabled in PostHog, never deleted, and code edits land as an ordinary + git diff, so either side is a one-step undo. + + + + + + ); + + const introBody: ReactNode = ( + + + We'll find feature flags that look done and show you the list. + + + Nothing changes until you pick which ones to cull. Disable only, never + delete; code edits are a git diff away from undo. + + + ); + + const body = showingMoreInfo ? moreInfoBody : introBody; + + const menuOptions = showingMoreInfo + ? [{ label: 'Back', value: 'back' }] + : [ + { label: 'Continue', value: 'continue' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + const handleSelect = (value: string) => { + if (value === 'cancel') return process.exit(0); + if (value === 'more-info') return setShowingMoreInfo(true); + if (value === 'back') return setShowingMoreInfo(false); + store.completeSetup(); + }; + + return ( + + ); +}; diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index 1f5e22b7f..aecbbbbba 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -14,6 +14,7 @@ import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js'; import { AuditAreaPane } from './AuditAreaPane.js'; import { AUDIT_AREA_SLIDES } from './slides/index.js'; import { EVENTS_AUDIT_AREA_SLIDES } from './slides/events-audit/index.js'; +import { CULL_AREA_SLIDES } from './slides/cull/index.js'; import { PendingChecksList } from './PendingChecksList.js'; import { AUDIT_CHECKS_FILE, @@ -29,6 +30,12 @@ interface AuditRunScreenProps { store: WizardStore; } +const slidesFor = (activeProgram: string, skillId: string | null) => { + if (activeProgram === 'cull-feature-flags') return CULL_AREA_SLIDES; + if (skillId === 'audit-events') return EVENTS_AUDIT_AREA_SLIDES; + return AUDIT_AREA_SLIDES; +}; + export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { useSyncExternalStore( (cb) => store.subscribe(cb), @@ -50,10 +57,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { AUDIT_REPORT_FILE; const reportPath = `./${reportFile}`; const pendingChecksList = ; - const slides = - store.session.skillId === 'audit-events' - ? EVENTS_AUDIT_AREA_SLIDES - : AUDIT_AREA_SLIDES; + const slides = slidesFor(store.router.activeProgram, store.session.skillId); const areaPane = ( ({ + area, + intro, + docsUrl: DOCS_URL, +}); + +// One slide per ledger `area`, which the wizard seeds with the bucket name. +export const CULL_AREA_SLIDES: AreaSlide[] = [ + slide('fully-rolled-out', [ + 'This flag is at 100% for everyone with no conditions. Culling keeps the on branch and removes the check.', + CONSENT, + DISABLE_ONLY, + ]), + slide('never-enabled', [ + 'This flag is at 0% for everyone. Culling keeps the off branch and removes the check.', + CONSENT, + DISABLE_ONLY, + ]), + slide('archived-still-referenced', [ + 'PostHog already archived this flag, but the code still checks it. Culling keeps the off branch and removes the check.', + CONSENT, + 'Nothing changes in PostHog for this one; the flag stays archived.', + ]), + slide('disabled-but-referenced', [ + 'This flag is switched off in PostHog, but the code still checks it. Culling keeps the off branch and removes the check.', + CONSENT, + 'Nothing changes in PostHog for this one; the flag stays off.', + ]), + slide('unreferenced', [ + 'PostHog has this flag, but nothing in this project evaluates it. Culling only disables the flag.', + 'If the project reads flags in bulk or by a computed key, the agent verifies that first.', + DISABLE_ONLY, + ]), + slide('unreferenced-comment-only', [ + 'The only place this key shows up is a comment or config string, never an evaluation. Culling disables the flag and cleans up the mention.', + CONSENT, + DISABLE_ONLY, + ]), + slide('dead-code-reference', [ + 'The only file that checks this flag is not imported anywhere and is not a Next.js route. Culling deletes that file.', + CONSENT, + DISABLE_ONLY, + ]), + slide('deleted-still-referenced', [ + 'The code checks a key PostHog no longer has, so it always resolves to off. Culling keeps the off branch and removes the check.', + 'The agent first checks the key is not a typo of a live flag.', + CONSENT, + ]), + slide('multi-callsite-no-wrapper', [ + 'Three or more files evaluate this flag directly. That is a suggestion, not a removal: the report recommends one hook or helper.', + 'Nothing is edited or disabled for this flag.', + ]), + slide('healthy', [ + 'This flag is live, partially rolled out or multivariate, and the code still needs it. Nothing to do.', + ]), +]; From bb69dc4986127251bd633318742e518869b3b677 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:33:37 -0600 Subject: [PATCH 06/28] fix(cull-feature-flags): bind the program to the switchboard and e2e registry two exhaustiveness tests caught what the scaffold commit missed: every registered program needs a switchboard binding (default linear / anthropic here) and every screen id needs an e2e action (cull-intro confirms setup like the other intros). --- e2e-harness/action-registry.ts | 1 + src/lib/agent/runner/switchboard/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdca..c50dc7f2b 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -106,6 +106,7 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.SourceMapsIntro]: [confirmSetupAction], [ScreenId.MigrationIntro]: [confirmSetupAction], [ScreenId.AgentSkillIntro]: [confirmSetupAction], + [ScreenId.CullIntro]: [confirmSetupAction], [ScreenId.AiObservabilityIntro]: [confirmSetupAction], [ScreenId.MetricsIntro]: [confirmSetupAction], [ScreenId.AuditIntro]: [confirmSetupAction], diff --git a/src/lib/agent/runner/switchboard/index.ts b/src/lib/agent/runner/switchboard/index.ts index fb4232036..4a14790f2 100644 --- a/src/lib/agent/runner/switchboard/index.ts +++ b/src/lib/agent/runner/switchboard/index.ts @@ -134,6 +134,7 @@ export const PROGRAM_BINDINGS: Partial> = { 'events-audit': DEFAULT_BINDING, 'posthog-doctor': DEFAULT_BINDING, 'web-analytics-doctor': DEFAULT_BINDING, + 'cull-feature-flags': DEFAULT_BINDING, migration: DEFAULT_BINDING, 'self-driving': DEFAULT_BINDING, 'agent-skill': DEFAULT_BINDING, From e5bf593e11cea7733f504d32c351d892e2d8f98c Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:35:14 -0600 Subject: [PATCH 07/28] fix(e2e): honor the local context-mill env flag in the tui host the host copied POSTHOG_WIZARD_LOCAL_CONTEXT_MILL onto the session but never initialised the process-wide local-dev targets that getSkillsBaseUrl reads, so a headless run always fetched the published skill menu. now it calls initLocalDev from the same env vars before starting the tui. --- scripts/tui-host.no-jest.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 7d3da90d0..3b3e92ca3 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -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, @@ -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!, From 0c52748ee4bfafdfd89fcc0637676376d8acdea9 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:40:02 -0600 Subject: [PATCH 08/28] fix(e2e): drive cull-intro and mirror capture-aio in the tui host the fixed e2e profile only knew the existing intro screens, so a headless cull run sat on cull-intro forever. it now confirms setup like the others. the host also mirrors POSTHOG_WIZARD_CAPTURE_AIO onto the session so a headless test run's llm and tool calls land in ai observability. --- e2e-harness/e2e-profile.ts | 1 + scripts/tui-host.no-jest.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 360bd3ec6..19e6acc9f 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -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: diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 3b3e92ca3..d7e18dbae 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -223,6 +223,9 @@ async function main() { localMcp: envFlag('POSTHOG_WIZARD_LOCAL_MCP'), localContextMill: envFlag('POSTHOG_WIZARD_LOCAL_CONTEXT_MILL'), localPosthog: envFlag('POSTHOG_WIZARD_LOCAL_POSTHOG'), + // Mirror the dev-only --capture-aio flag so a headless run's LLM calls + // and tool calls land in the project's AI Observability tab. + captureAio: envFlag('POSTHOG_WIZARD_CAPTURE_AIO') ?? false, // Switchboard variation overrides (see e2e.json `variations`), threaded by // the snapshot driver as one run per variation. Empty ⇒ resolved default. harness: (process.env.SNAP_HARNESS || undefined) as Harness | undefined, From 102fb0844dc9d304c77368d1975cc0d53dee7189 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 12:42:40 -0600 Subject: [PATCH 09/28] fix(cull-feature-flags): authenticate before the flag fetch first headless run showed the deferred run resolves before the runner's own auth step on the ci / headless path, so credentials were still null, the fetch was skipped and every key in code got seeded as deleted-still-referenced. run now calls the runner's authenticate first (no-op once credentials exist) and aborts with a clear scope hint when the fetch fails instead of seeding a ledger built from half the data. pins the ordering with a run test: authenticate before fetch, dirty tree and unsupported framework abort before posthog is touched, fetch failure aborts before the ledger exists. --- .../__tests__/cull-feature-flags-run.test.ts | 180 ++++++++++++++++++ .../__tests__/cull-feature-flags-seed.test.ts | 5 +- src/lib/programs/cull-feature-flags/index.ts | 38 +++- src/lib/programs/cull-feature-flags/seed.ts | 6 - 4 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 src/lib/programs/__tests__/cull-feature-flags-run.test.ts diff --git a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts new file mode 100644 index 000000000..59290d247 --- /dev/null +++ b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const { + mockAuthenticate, + mockFetchFeatureFlags, + mockDetectFramework, + mockFetchSkillMenu, + mockWizardAbort, + mockListUncommittedPaths, +} = vi.hoisted(() => ({ + mockAuthenticate: vi.fn(), + mockFetchFeatureFlags: vi.fn(), + mockDetectFramework: vi.fn(), + mockFetchSkillMenu: vi.fn(), + mockWizardAbort: vi.fn(), + mockListUncommittedPaths: vi.fn(), +})); + +vi.mock('@lib/agent/runner/shared/authenticate', () => ({ + authenticate: mockAuthenticate, +})); +vi.mock('@lib/programs/cull-feature-flags/fetch', () => ({ + fetchFeatureFlags: mockFetchFeatureFlags, +})); +vi.mock('@lib/detection/index', () => ({ + detectFramework: mockDetectFramework, +})); +vi.mock('@lib/wizard-tools', async (importOriginal) => ({ + ...(await importOriginal()), + fetchSkillMenu: mockFetchSkillMenu, +})); +vi.mock('@utils/wizard-abort', () => ({ + wizardAbort: mockWizardAbort, +})); +vi.mock('@lib/programs/cull-feature-flags/working-tree', () => ({ + listUncommittedPaths: mockListUncommittedPaths, +})); + +import { cullFeatureFlagsConfig } from '@lib/programs/cull-feature-flags/index'; +import { AUDIT_CHECKS_FILE } from '@lib/programs/audit/types'; +import { ErrorCodes } from '@lib/errors'; +import { buildSession, type WizardSession } from '@lib/wizard-session'; +import type { ProgramRun } from '@lib/agent/agent-runner'; + +class AbortSignal extends Error {} + +function sessionFor(installDir: string): WizardSession { + return buildSession({ installDir, ci: true }); +} + +function credentialsFor(session: WizardSession): void { + session.credentials = { + accessToken: 'token', + projectApiKey: 'phc_test', + projectId: 590630, + host: { + apiHost: 'https://us.posthog.com', + appHost: 'https://us.posthog.com', + }, + } as WizardSession['credentials']; +} + +async function resolveRun(session: WizardSession): Promise { + const run = cullFeatureFlagsConfig.run; + if (typeof run !== 'function') throw new Error('run must be deferred'); + return run(session); +} + +describe('cullFeatureFlagsConfig.run', () => { + let installDir: string; + + beforeEach(() => { + installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cull-run-')); + fs.mkdirSync(path.join(installDir, 'src/app'), { recursive: true }); + fs.writeFileSync( + path.join(installDir, 'src/app/page.tsx'), + 'const on = useFeatureFlagEnabled("new-checkout");', + ); + mockListUncommittedPaths.mockReturnValue([]); + mockDetectFramework.mockResolvedValue('nextjs'); + mockFetchSkillMenu.mockResolvedValue({ + categories: { + 'cull-feature-flags': [ + { + id: 'cull-feature-flags-nextjs', + group: 'cull-feature-flags', + framework: 'nextjs', + default: true, + }, + ], + }, + }); + mockAuthenticate.mockImplementation((session: WizardSession) => { + credentialsFor(session); + return Promise.resolve(); + }); + mockFetchFeatureFlags.mockResolvedValue([ + { + id: 42, + key: 'new-checkout', + active: true, + filters: { groups: [{ rollout_percentage: 100, properties: [] }] }, + }, + { id: 43, key: 'orphan', active: true, filters: { groups: [] } }, + ]); + mockWizardAbort.mockImplementation(() => { + throw new AbortSignal('abort'); + }); + }); + + afterEach(() => { + fs.rmSync(installDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + test('authenticates before fetching, seeds the ledger, resolves the framework variant', async () => { + const session = sessionFor(installDir); + + const run = await resolveRun(session); + + expect(mockAuthenticate.mock.invocationCallOrder[0]).toBeLessThan( + mockFetchFeatureFlags.mock.invocationCallOrder[0], + ); + expect(mockFetchFeatureFlags).toHaveBeenCalledWith( + 'token', + 'https://us.posthog.com', + 590630, + ); + expect(run.skillId).toBe('cull-feature-flags-nextjs'); + const ledger = JSON.parse( + fs.readFileSync(path.join(installDir, AUDIT_CHECKS_FILE), 'utf8'), + ) as { id: string; area: string; status: string }[]; + expect(ledger.map((row) => [row.id, row.area, row.status])).toEqual([ + ['new-checkout', 'fully-rolled-out', 'pending'], + ['orphan', 'unreferenced', 'pending'], + ]); + expect(run.customPrompt?.({} as never)).toContain('- fully-rolled-out: 1'); + }); + + test('aborts on a dirty working tree before touching PostHog', async () => { + mockListUncommittedPaths.mockReturnValue(['src/app/page.tsx']); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.DetectDirtyWorkingTree }), + ); + expect(mockFetchFeatureFlags).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(installDir, AUDIT_CHECKS_FILE))).toBe(false); + }); + + test('aborts on an unsupported framework', async () => { + mockDetectFramework.mockResolvedValue('django'); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.DetectUnsupportedPlatform }), + ); + }); + + test('aborts instead of seeding when the flag fetch fails', async () => { + mockFetchFeatureFlags.mockRejectedValue(new Error('403')); + + await expect(resolveRun(sessionFor(installDir))).rejects.toThrow( + AbortSignal, + ); + + expect(mockWizardAbort).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorCodes.AuthProjectFetchFailed }), + ); + expect(fs.existsSync(path.join(installDir, AUDIT_CHECKS_FILE))).toBe(false); + }); +}); diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 033c63020..86555bbb0 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -91,7 +91,6 @@ describe('buildCullPrompt', () => { ledgerFile: '.posthog-audit-checks.json', candidates: [STALE, HEALTHY, ORPHAN], scan: scan(), - postHogFetchFailed: false, }); expect(prompt).toContain('./.posthog-audit-checks.json'); expect(prompt).toContain('- fully-rolled-out: 1'); @@ -101,7 +100,7 @@ describe('buildCullPrompt', () => { expect(prompt).not.toContain('getAllFlags'); }); - test('adds the bulk, dynamic, truncation and fetch-failure caveats when they apply', () => { + test('adds the bulk, dynamic and truncation caveats when they apply', () => { const prompt = buildCullPrompt({ ledgerFile: '.posthog-audit-checks.json', candidates: [ORPHAN], @@ -112,12 +111,10 @@ describe('buildCullPrompt', () => { ], truncated: true, }), - postHogFetchFailed: true, }); expect(prompt).toContain('calls getAllFlags'); expect(prompt).toContain('src/lib/flags.ts:4 (isFeatureEnabled)'); expect(prompt).toContain('hit its file limit'); - expect(prompt).toContain('flag fetch failed'); }); }); diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 404f20bcf..2c6d158ad 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import type { ProgramRun } from '@lib/agent/agent-runner'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import { resolveSkillVariantId } from '@lib/agent/runner/sequence/orchestrator/orchestrator-runner'; import { getSkillsBaseUrl, Integration } from '@lib/constants'; import { detectFramework } from '@lib/detection/index'; @@ -12,6 +13,7 @@ import { coerceAuditChecks, } from '@lib/programs/audit/types'; import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; +import type { ProgramId } from '@lib/programs/program-registry'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import type { WizardSession } from '@lib/wizard-session'; import { fetchSkillMenu } from '@lib/wizard-tools'; @@ -30,6 +32,7 @@ import { listUncommittedPaths } from './working-tree.js'; export const CULL_FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flag-cull-report.md'; const CULL_SKILL_GROUP = 'cull-feature-flags'; +const PROGRAM_ID: ProgramId = 'cull-feature-flags'; const DOCS_URL = 'https://posthog.com/docs/feature-flags/best-practices'; /** Frameworks the scanner has patterns for; one context-mill variant each. */ @@ -50,7 +53,7 @@ const cullSteps: ProgramStep[] = AGENT_SKILL_STEPS.map((step) => { const base = createSkillProgram({ skillId: `${CULL_SKILL_GROUP}-nextjs`, command: 'cull-feature-flags', - id: 'cull-feature-flags', + id: PROGRAM_ID, description: 'Find stale PostHog feature flags in this project and remove the ones you pick', integrationLabel: 'cull-feature-flags', @@ -101,22 +104,39 @@ async function resolveVariantSkillId(framework: Integration): Promise { ); } -async function fetchFlagsOrEmpty( +async function abortFlagFetchFailed(error: unknown): Promise { + await wizardAbort({ + code: ErrorCodes.AuthProjectFetchFailed, + message: + "Could not read this project's feature flags from PostHog, so there is " + + 'nothing deterministic to propose. Check the token carries ' + + 'feature_flag:read and try again.', + error: error instanceof Error ? error : new Error(String(error)), + }); +} + +// Headless and CI paths resolve `run` before the runner's own auth step, so +// take the same path early; it is a no-op once credentials exist. +async function fetchFlagsOrAbort( session: WizardSession, -): Promise<{ flags: FeatureFlag[]; failed: boolean }> { +): Promise { + await authenticate(session, PROGRAM_ID); const credentials = session.credentials; - if (!credentials) return { flags: [], failed: true }; + if (!credentials) { + await abortFlagFetchFailed(new Error('no credentials after authenticate')); + return []; + } try { - const flags = await fetchFeatureFlags( + return await fetchFeatureFlags( credentials.accessToken, credentials.host.apiHost, credentials.projectId, ); - return { flags, failed: false }; } catch (error) { logToFile(`[cull-feature-flags] flag fetch failed: ${String(error)}`); analytics.wizardCapture('cull feature flags fetch failed'); - return { flags: [], failed: true }; + await abortFlagFetchFailed(error); + return []; } } @@ -141,7 +161,7 @@ const cullRun = async (session: WizardSession): Promise => { } const skillId = await resolveVariantSkillId(framework as Integration); - const { flags, failed } = await fetchFlagsOrEmpty(session); + const flags = await fetchFlagsOrAbort(session); const scan = await scanFlagCallSites( installDir, flags.map((flag) => flag.key), @@ -161,7 +181,6 @@ const cullRun = async (session: WizardSession): Promise => { stale: candidates.filter((c) => c.verdict === 'stale').length, files_scanned: scan.filesScanned, truncated: scan.truncated, - fetch_failed: failed, }); const baseRun = @@ -176,7 +195,6 @@ const cullRun = async (session: WizardSession): Promise => { ledgerFile: AUDIT_CHECKS_FILE, candidates, scan, - postHogFetchFailed: failed, }), buildOutroData: (sess, credentials) => buildCullOutro({ diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index 6ede643cf..67fcb91c5 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -38,7 +38,6 @@ export interface CullPromptInput { ledgerFile: string; candidates: readonly CullCandidate[]; scan: FlagScanResult; - postHogFetchFailed: boolean; } function countByBucket(candidates: readonly CullCandidate[]): string[] { @@ -79,10 +78,5 @@ export function buildCullPrompt(input: CullPromptInput): string { 'The scan hit its file limit, so "unreferenced" is not proven. Treat every unreferenced row as verify-first.', ); } - if (input.postHogFetchFailed) { - lines.push( - 'The PostHog flag fetch failed, so only code-side buckets are seeded. Propose nothing, write the report and say so.', - ); - } return lines.join('\n'); } From c3da10271a4e2982eb9e4a317cd764b8f8eeab26 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:01:43 -0600 Subject: [PATCH 10/28] fix(cull-feature-flags): make the run screen read like the setup flow first interactive run showed the audit's "we've wrapped up the review" copy the moment verification finished, while consent and apply were still ahead, and long row labels wrapping into each other on the right. - left pane now carries cull stage copy: waiting for your pick, culling n more, writing the cull report (AuditAreaPane takes an optional wrapUp) - checks rows truncate instead of wrapping - ledger areas are display names (Rolled out, Never enabled, Dead code...) so the audit plan tab and the slides read as english, and the action phrases are short enough to fit a row and the consent overlay - details drop the first call site (the row's file already has it) and the "posthog status" prefix --- .../cull-feature-flags-classify.test.ts | 5 +- .../__tests__/cull-feature-flags-run.test.ts | 6 +-- .../__tests__/cull-feature-flags-seed.test.ts | 30 +++++------ .../programs/cull-feature-flags/classify.ts | 37 ++++++++----- src/lib/programs/cull-feature-flags/seed.ts | 22 ++++---- src/lib/programs/cull-feature-flags/types.ts | 2 + src/ui/tui/screens/audit/AuditAreaPane.tsx | 29 +++++++++- src/ui/tui/screens/audit/AuditRunScreen.tsx | 6 +++ .../tui/screens/audit/PendingChecksList.tsx | 2 +- src/ui/tui/screens/audit/slides/cull/index.ts | 22 ++++---- src/ui/tui/screens/audit/slides/cull/stage.ts | 53 +++++++++++++++++++ 11 files changed, 158 insertions(+), 56 deletions(-) create mode 100644 src/ui/tui/screens/audit/slides/cull/stage.ts diff --git a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts index f00a1d940..6186fde5f 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts @@ -242,9 +242,8 @@ describe('classifyFlags', () => { const candidates = classifyFlags(flags, result); expect(candidates.map((c) => c.key)).toEqual(['new-checkout']); expect(candidates[0]).toMatchObject({ - proposedAction: - 'remove the check, keep the true branch, then disable the flag', - reason: 'rollout 100%, posthog status ACTIVE', + 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' }, diff --git a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts index 59290d247..df8772406 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts @@ -133,10 +133,10 @@ describe('cullFeatureFlagsConfig.run', () => { fs.readFileSync(path.join(installDir, AUDIT_CHECKS_FILE), 'utf8'), ) as { id: string; area: string; status: string }[]; expect(ledger.map((row) => [row.id, row.area, row.status])).toEqual([ - ['new-checkout', 'fully-rolled-out', 'pending'], - ['orphan', 'unreferenced', 'pending'], + ['new-checkout', 'Rolled out', 'pending'], + ['orphan', 'Unreferenced', 'pending'], ]); - expect(run.customPrompt?.({} as never)).toContain('- fully-rolled-out: 1'); + expect(run.customPrompt?.({} as never)).toContain('- Rolled out: 1'); }); test('aborts on a dirty working tree before touching PostHog', async () => { diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 86555bbb0..7f03e2969 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -10,10 +10,10 @@ import { OutroKind } from '@lib/wizard-session'; const STALE: CullCandidate = { key: 'new-checkout', bucket: 'fully-rolled-out', + area: 'Rolled out', verdict: 'stale', - proposedAction: - 'remove the check, keep the true branch, then disable the flag', - reason: 'rollout 100%, posthog status ACTIVE', + proposedAction: 'keep on path, drop check, disable flag', + reason: 'rollout 100%, ACTIVE', flagId: 42, flagName: 'New checkout', callSites: [ @@ -29,9 +29,10 @@ const STALE: CullCandidate = { const HEALTHY: CullCandidate = { key: 'dark-mode', bucket: 'healthy', + area: 'Healthy', verdict: 'healthy', proposedAction: 'keep', - reason: 'rollout 30%, posthog status ACTIVE', + reason: 'rollout 30%, ACTIVE', flagId: 7, callSites: [ { file: 'src/lib/flags.ts', line: 6, api: 'useFeatureFlagEnabled' }, @@ -41,9 +42,10 @@ const HEALTHY: CullCandidate = { const ORPHAN: CullCandidate = { key: 'pricing-v2-experiment', bucket: 'unreferenced', + area: 'Unreferenced', verdict: 'stale', proposedAction: 'disable the flag', - reason: 'rollout 50%, posthog status STALE', + reason: 'rollout 50%, STALE', flagId: 9, callSites: [], }; @@ -65,13 +67,11 @@ describe('candidateToCheck', () => { test('stale candidate becomes a pending row keyed by flag with bucket as area', () => { expect(candidateToCheck(STALE)).toEqual({ id: 'new-checkout', - area: 'fully-rolled-out', - label: - 'new-checkout: remove the check, keep the true branch, then disable the flag', + area: 'Rolled out', + label: 'new-checkout: keep on path, drop check, disable flag', status: 'pending', file: 'src/app/dashboard/page.tsx:20', - details: - 'rollout 100%, posthog status ACTIVE; sites: src/app/dashboard/page.tsx:20 (useFeatureFlagEnabled), src/lib/checkout.ts:3 (isFeatureEnabled)', + details: 'rollout 100%, ACTIVE; also src/lib/checkout.ts:3', }); }); @@ -79,9 +79,7 @@ describe('candidateToCheck', () => { expect(candidateToCheck(HEALTHY).status).toBe('pass'); const orphan = candidateToCheck(ORPHAN); expect(orphan.file).toBeUndefined(); - expect(orphan.details).toBe( - 'rollout 50%, posthog status STALE; no call sites', - ); + expect(orphan.details).toBe('rollout 50%, STALE; no call sites'); }); }); @@ -93,9 +91,9 @@ describe('buildCullPrompt', () => { scan: scan(), }); expect(prompt).toContain('./.posthog-audit-checks.json'); - expect(prompt).toContain('- fully-rolled-out: 1'); - expect(prompt).toContain('- healthy: 1'); - expect(prompt).toContain('- unreferenced: 1'); + expect(prompt).toContain('- Rolled out: 1'); + expect(prompt).toContain('- Healthy: 1'); + expect(prompt).toContain('- Unreferenced: 1'); expect(prompt).toContain('never delete or archive'); expect(prompt).not.toContain('getAllFlags'); }); diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts index e6af91106..b990403dd 100644 --- a/src/lib/programs/cull-feature-flags/classify.ts +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -4,20 +4,32 @@ import type { CullBucket, CullCandidate, FeatureFlag } from './types.js'; const MULTI_CALLSITE_FILE_THRESHOLD = 3; const PROPOSED_ACTION_BY_BUCKET: Record = { - 'dead-code-reference': 'delete the unreachable module, then disable the flag', - 'archived-still-referenced': 'remove the check, keep the false branch', - 'disabled-but-referenced': 'remove the check, keep the false branch', - 'unreferenced-comment-only': 'disable the flag, clean up the comment', - unreferenced: 'disable the flag', - 'fully-rolled-out': - 'remove the check, keep the true branch, then disable the flag', - 'never-enabled': - 'remove the check, keep the false branch, then disable the flag', - 'deleted-still-referenced': 'remove the check, keep the false branch', - 'multi-callsite-no-wrapper': 'wrap the flag in one hook or helper', + 'dead-code-reference': 'delete dead module, disable flag', + 'archived-still-referenced': 'keep off path, drop check', + 'disabled-but-referenced': 'keep off path, drop check', + 'unreferenced-comment-only': 'disable flag, drop comment', + unreferenced: 'disable flag', + 'fully-rolled-out': 'keep on path, drop check, disable flag', + 'never-enabled': 'keep off path, drop check, disable flag', + 'deleted-still-referenced': 'keep off path, drop check', + 'multi-callsite-no-wrapper': 'suggest one wrapper hook', healthy: 'keep', }; +/** What the ledger and the run screen show as the row's area. Keys the cull slides too. */ +export const AREA_BY_BUCKET: Record = { + 'dead-code-reference': 'Dead code', + 'archived-still-referenced': 'Archived in PostHog', + 'disabled-but-referenced': 'Disabled in PostHog', + 'unreferenced-comment-only': 'Comment only', + unreferenced: 'Unreferenced', + 'fully-rolled-out': 'Rolled out', + 'never-enabled': 'Never enabled', + 'deleted-still-referenced': 'Deleted in PostHog', + 'multi-callsite-no-wrapper': 'Many call sites', + healthy: 'Healthy', +}; + const VERDICT_BY_BUCKET: Record = { 'dead-code-reference': 'stale', 'archived-still-referenced': 'stale', @@ -71,7 +83,7 @@ function rolloutSummary(flag: FeatureFlag): string { parts.push('multivariate'); if (flag.archived) parts.push('archived'); if (!flag.active) parts.push('inactive'); - if (flag.status) parts.push(`posthog status ${flag.status}`); + if (flag.status) parts.push(flag.status); return parts.join(', '); } @@ -105,6 +117,7 @@ function candidate( return { key, bucket, + area: AREA_BY_BUCKET[bucket], verdict: VERDICT_BY_BUCKET[bucket], proposedAction: PROPOSED_ACTION_BY_BUCKET[bucket], reason, diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index 67fcb91c5..efc7977cd 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -5,23 +5,27 @@ import type { CullCandidate } from './types.js'; export const APPLIED_MARKER = '; applied'; -function describeSites(candidate: CullCandidate): string { +// The row's `file` already names the first site, so details only add the rest. +function describeSites(candidate: CullCandidate): string | undefined { if (candidate.callSites.length === 0) return 'no call sites'; - const sites = candidate.callSites.map( - (site) => `${site.file}:${site.line} (${site.api})`, - ); - return `sites: ${sites.join(', ')}`; + if (candidate.callSites.length === 1) return undefined; + const rest = candidate.callSites + .slice(1) + .map((site) => `${site.file}:${site.line}`); + return `also ${rest.join(', ')}`; } export function candidateToCheck(candidate: CullCandidate): AuditCheck { const first = candidate.callSites[0]; return { id: candidate.key, - area: candidate.bucket, + area: candidate.area, label: `${candidate.key}: ${candidate.proposedAction}`, status: candidate.verdict === 'healthy' ? 'pass' : 'pending', ...(first ? { file: `${first.file}:${first.line}` } : {}), - details: `${candidate.reason}; ${describeSites(candidate)}`, + details: [candidate.reason, describeSites(candidate)] + .filter(Boolean) + .join('; '), }; } @@ -43,7 +47,7 @@ export interface CullPromptInput { function countByBucket(candidates: readonly CullCandidate[]): string[] { const counts = new Map(); for (const candidate of candidates) { - counts.set(candidate.bucket, (counts.get(candidate.bucket) ?? 0) + 1); + counts.set(candidate.area, (counts.get(candidate.area) ?? 0) + 1); } return [...counts.entries()].map( ([bucket, count]) => `- ${bucket}: ${count}`, @@ -53,7 +57,7 @@ function countByBucket(candidates: readonly CullCandidate[]): string[] { export function buildCullPrompt(input: CullPromptInput): string { const lines = [ 'Run the cull-feature-flags skill end-to-end. The wizard already scanned this project and fetched its PostHog flags; the ledger at', - `./${input.ledgerFile} is ground truth, one row per flag, area = bucket:`, + `./${input.ledgerFile} is ground truth, one row per flag, grouped by area:`, ...countByBucket(input.candidates), '', 'Never grep for flags or re-classify a row. Resolve rows only through audit_resolve_checks. Ask exactly once which rows to apply, decline option first. Disable flags only, never delete or archive. Code edits land before the PostHog disable.', diff --git a/src/lib/programs/cull-feature-flags/types.ts b/src/lib/programs/cull-feature-flags/types.ts index 6fbd9b99b..218cd94af 100644 --- a/src/lib/programs/cull-feature-flags/types.ts +++ b/src/lib/programs/cull-feature-flags/types.ts @@ -53,6 +53,8 @@ export type CullVerdict = 'stale' | 'warning' | 'healthy'; export interface CullCandidate { key: string; bucket: CullBucket; + /** Display name of the bucket, the ledger row's `area`. */ + area: string; verdict: CullVerdict; /** One line the ledger label shows next to the key. */ proposedAction: string; diff --git a/src/ui/tui/screens/audit/AuditAreaPane.tsx b/src/ui/tui/screens/audit/AuditAreaPane.tsx index 2ceb1e512..ae7a3ae66 100644 --- a/src/ui/tui/screens/audit/AuditAreaPane.tsx +++ b/src/ui/tui/screens/audit/AuditAreaPane.tsx @@ -61,6 +61,14 @@ interface AuditAreaPaneProps { /** Notebook URL once the agent emits `[NOTEBOOK_URL]`. Same sticky-footer * treatment as the dashboard URL. */ notebookUrl?: string | null; + /** Replaces the report wrap-up once no check is pending; programs with + * stages after verification (consent, apply) pass their own copy. */ + wrapUp?: WrapUpCopy; +} + +export interface WrapUpCopy { + title: string; + paragraphs: string[]; } export const AuditAreaPane = ({ @@ -69,6 +77,7 @@ export const AuditAreaPane = ({ slides = AUDIT_AREA_SLIDES, dashboardUrl, notebookUrl, + wrapUp, }: AuditAreaPaneProps) => { const pendingChecks = checks.filter((c) => c.status === 'pending'); const activeArea = pendingChecks[0]?.area; @@ -108,12 +117,30 @@ export const AuditAreaPane = ({ // Every check is resolved and the agent is composing the report. return ( - + {wrapUp ? ( + + ) : ( + + )} {urlsFooter} ); }; +const StageCopy = ({ copy }: { copy: WrapUpCopy }) => ( + + + {copy.title} + + {copy.paragraphs.map((paragraph, i) => ( + + + {paragraph} + + ))} + +); + // ── States ─────────────────────────────────────────────────────────── const ActiveSlide = ({ diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index aecbbbbba..63f5477a5 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -15,6 +15,7 @@ import { AuditAreaPane } from './AuditAreaPane.js'; import { AUDIT_AREA_SLIDES } from './slides/index.js'; import { EVENTS_AUDIT_AREA_SLIDES } from './slides/events-audit/index.js'; import { CULL_AREA_SLIDES } from './slides/cull/index.js'; +import { cullStageCopy } from './slides/cull/stage.js'; import { PendingChecksList } from './PendingChecksList.js'; import { AUDIT_CHECKS_FILE, @@ -58,6 +59,10 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { const reportPath = `./${reportFile}`; const pendingChecksList = ; const slides = slidesFor(store.router.activeProgram, store.session.skillId); + const wrapUp = + store.router.activeProgram === 'cull-feature-flags' + ? cullStageCopy(checks, reportPath) + : undefined; const areaPane = ( { slides={slides} dashboardUrl={store.session.dashboardUrl} notebookUrl={store.session.notebookUrl} + wrapUp={wrapUp} /> ); diff --git a/src/ui/tui/screens/audit/PendingChecksList.tsx b/src/ui/tui/screens/audit/PendingChecksList.tsx index 530dacafc..2c57227e0 100644 --- a/src/ui/tui/screens/audit/PendingChecksList.tsx +++ b/src/ui/tui/screens/audit/PendingChecksList.tsx @@ -75,7 +75,7 @@ const GroupHeader = ({ const CheckRow = ({ check }: { check: AuditCheck }) => { const { glyph, color } = AUDIT_SEVERITY_STYLE[check.status]; return ( - + {glyph} {check.label} diff --git a/src/ui/tui/screens/audit/slides/cull/index.ts b/src/ui/tui/screens/audit/slides/cull/index.ts index 0668ad500..be8690121 100644 --- a/src/ui/tui/screens/audit/slides/cull/index.ts +++ b/src/ui/tui/screens/audit/slides/cull/index.ts @@ -12,53 +12,53 @@ const slide = (area: string, intro: string[]): AreaSlide => ({ docsUrl: DOCS_URL, }); -// One slide per ledger `area`, which the wizard seeds with the bucket name. +// One slide per ledger `area`; keys match AREA_BY_BUCKET in the program's classify.ts. export const CULL_AREA_SLIDES: AreaSlide[] = [ - slide('fully-rolled-out', [ + slide('Rolled out', [ 'This flag is at 100% for everyone with no conditions. Culling keeps the on branch and removes the check.', CONSENT, DISABLE_ONLY, ]), - slide('never-enabled', [ + slide('Never enabled', [ 'This flag is at 0% for everyone. Culling keeps the off branch and removes the check.', CONSENT, DISABLE_ONLY, ]), - slide('archived-still-referenced', [ + slide('Archived in PostHog', [ 'PostHog already archived this flag, but the code still checks it. Culling keeps the off branch and removes the check.', CONSENT, 'Nothing changes in PostHog for this one; the flag stays archived.', ]), - slide('disabled-but-referenced', [ + slide('Disabled in PostHog', [ 'This flag is switched off in PostHog, but the code still checks it. Culling keeps the off branch and removes the check.', CONSENT, 'Nothing changes in PostHog for this one; the flag stays off.', ]), - slide('unreferenced', [ + slide('Unreferenced', [ 'PostHog has this flag, but nothing in this project evaluates it. Culling only disables the flag.', 'If the project reads flags in bulk or by a computed key, the agent verifies that first.', DISABLE_ONLY, ]), - slide('unreferenced-comment-only', [ + slide('Comment only', [ 'The only place this key shows up is a comment or config string, never an evaluation. Culling disables the flag and cleans up the mention.', CONSENT, DISABLE_ONLY, ]), - slide('dead-code-reference', [ + slide('Dead code', [ 'The only file that checks this flag is not imported anywhere and is not a Next.js route. Culling deletes that file.', CONSENT, DISABLE_ONLY, ]), - slide('deleted-still-referenced', [ + slide('Deleted in PostHog', [ 'The code checks a key PostHog no longer has, so it always resolves to off. Culling keeps the off branch and removes the check.', 'The agent first checks the key is not a typo of a live flag.', CONSENT, ]), - slide('multi-callsite-no-wrapper', [ + slide('Many call sites', [ 'Three or more files evaluate this flag directly. That is a suggestion, not a removal: the report recommends one hook or helper.', 'Nothing is edited or disabled for this flag.', ]), - slide('healthy', [ + slide('Healthy', [ 'This flag is live, partially rolled out or multivariate, and the code still needs it. Nothing to do.', ]), ]; diff --git a/src/ui/tui/screens/audit/slides/cull/stage.ts b/src/ui/tui/screens/audit/slides/cull/stage.ts new file mode 100644 index 000000000..ff3782611 --- /dev/null +++ b/src/ui/tui/screens/audit/slides/cull/stage.ts @@ -0,0 +1,53 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import type { WrapUpCopy } from '../../AuditAreaPane.js'; + +const APPLIED_MARKER = '; applied'; +const DECLINED_MARKER = '; declined by user'; + +/** Left-pane copy for the cull stages after verification: consent, apply, report. */ +export function cullStageCopy( + checks: readonly AuditCheck[], + reportPath: string, +): WrapUpCopy | undefined { + if (checks.some((check) => check.status === 'pending')) return undefined; + const proposed = checks.filter((check) => check.status === 'warning'); + const applied = checks.filter( + (check) => + check.status === 'pass' && (check.details ?? '').includes(APPLIED_MARKER), + ); + const declined = checks.filter( + (check) => + check.status === 'pass' && + (check.details ?? '').includes(DECLINED_MARKER), + ); + const failed = checks.filter((check) => check.status === 'error'); + const isDecided = applied.length + declined.length + failed.length > 0; + + if (proposed.length > 0 && !isDecided) { + return { + title: 'Waiting for your pick', + paragraphs: [ + `Every flag is verified at its call site. ${proposed.length} look done and are up for culling; the healthy ones stay.`, + 'Nothing changes until you confirm in the prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', + ], + }; + } + if (proposed.length > 0) { + return { + title: `Culling ${proposed.length} more`, + paragraphs: [ + `${applied.length} done so far${ + failed.length > 0 ? `, ${failed.length} failed` : '' + }. Code edit first, then the PostHog disable, one flag at a time.`, + 'Every edit is an ordinary git diff; every disabled flag is one toggle from back on.', + ], + }; + } + return { + title: 'Writing the cull report', + paragraphs: [ + `${applied.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, + 'Hang tight!', + ], + }; +} From 3183ac01f8cf23592f097d0a8d9937cec261cf0a Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:06:53 -0600 Subject: [PATCH 11/28] fix(cull-feature-flags): keep the outro to one revert line and one posthog line the first live outro listed a truncated flag url per disabled flag and put untracked files (.claude/, the ledger, the report itself) in the git checkout line. the revert line now covers tracked changes only and the posthog side is one sentence; the report already carries every flag link. --- .../__tests__/cull-feature-flags-seed.test.ts | 6 ++---- src/lib/programs/cull-feature-flags/index.ts | 11 ++++++----- src/lib/programs/cull-feature-flags/outro.ts | 13 +++++++------ .../programs/cull-feature-flags/working-tree.ts | 14 +++++++++++++- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 7f03e2969..b77bde51f 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -118,8 +118,6 @@ describe('buildCullPrompt', () => { describe('buildCullOutro', () => { const common = { - appHost: 'https://us.posthog.com', - projectId: 590630, flagIdByKey: new Map([['new-checkout', 42]]), reportFile: 'posthog-feature-flag-cull-report.md', docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', @@ -143,7 +141,7 @@ describe('buildCullOutro', () => { expect(outro.nextSteps).toBeUndefined(); }); - test('applied rows produce the git revert and the flag page per disabled flag', () => { + test('applied rows produce the git revert and a one-line PostHog undo', () => { const outro = buildCullOutro({ ...common, checks: [ @@ -157,7 +155,7 @@ describe('buildCullOutro', () => { heading: 'Undo, if you want it back:', items: [ 'Code: git checkout -- src/app/dashboard/page.tsx src/lib/checkout.ts (or git diff to review first)', - 'Re-enable new-checkout: https://us.posthog.com/project/590630/feature_flags/42', + 'PostHog: 1 flag disabled, one toggle each to re-enable; the report links every flag page.', ], }); }); diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 2c6d158ad..716cc75aa 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -27,7 +27,10 @@ import { buildCullOutro } from './outro.js'; import { scanFlagCallSites } from './scan.js'; import { buildCullPrompt, seedCullLedger } from './seed.js'; import type { FeatureFlag } from './types.js'; -import { listUncommittedPaths } from './working-tree.js'; +import { + listModifiedTrackedPaths, + listUncommittedPaths, +} from './working-tree.js'; export const CULL_FEATURE_FLAGS_REPORT_FILE = 'posthog-feature-flag-cull-report.md'; @@ -196,12 +199,10 @@ const cullRun = async (session: WizardSession): Promise => { candidates, scan, }), - buildOutroData: (sess, credentials) => + buildOutroData: (sess) => buildCullOutro({ checks: readLedger(sess.installDir), - touchedFiles: listUncommittedPaths(sess.installDir), - appHost: credentials.host.appHost, - projectId: credentials.projectId, + touchedFiles: listModifiedTrackedPaths(sess.installDir), flagIdByKey, reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, docsUrl: DOCS_URL, diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts index 99f4ed70d..46348800f 100644 --- a/src/lib/programs/cull-feature-flags/outro.ts +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -5,8 +5,6 @@ import { APPLIED_MARKER } from './seed.js'; export interface CullOutroInput { checks: readonly AuditCheck[]; touchedFiles: readonly string[]; - appHost: string; - projectId: number; flagIdByKey: ReadonlyMap; reportFile: string; docsUrl: string; @@ -26,11 +24,14 @@ export function buildCullOutro(input: CullOutroInput): OutroData { )} (or git diff to review first)`, ); } - for (const check of applied) { - const flagId = input.flagIdByKey.get(check.id); - if (flagId === undefined) continue; + const disabledCount = applied.filter((check) => + input.flagIdByKey.has(check.id), + ).length; + if (disabledCount > 0) { undoItems.push( - `Re-enable ${check.id}: ${input.appHost}/project/${input.projectId}/feature_flags/${flagId}`, + `PostHog: ${disabledCount} flag${ + disabledCount === 1 ? '' : 's' + } disabled, one toggle each to re-enable; the report links every flag page.`, ); } const message = diff --git a/src/lib/programs/cull-feature-flags/working-tree.ts b/src/lib/programs/cull-feature-flags/working-tree.ts index df6f2d947..b13e7ef55 100644 --- a/src/lib/programs/cull-feature-flags/working-tree.ts +++ b/src/lib/programs/cull-feature-flags/working-tree.ts @@ -2,6 +2,18 @@ import { execSync } from 'child_process'; /** Paths under `installDir` with uncommitted or untracked changes, empty when clean or not a git repo. */ export function listUncommittedPaths(installDir: string): string[] { + return listStatusPaths(installDir, () => true); +} + +/** Tracked paths under `installDir` that are modified or deleted; untracked files never revert with `git checkout --`. */ +export function listModifiedTrackedPaths(installDir: string): string[] { + return listStatusPaths(installDir, (line) => !line.startsWith('??')); +} + +function listStatusPaths( + installDir: string, + keep: (line: string) => boolean, +): string[] { let status: string; try { status = execSync('git status --porcelain=v1 -- .', { @@ -14,6 +26,6 @@ export function listUncommittedPaths(installDir: string): string[] { return status .split('\n') .map((line) => line.trim()) - .filter((line) => line.length > 0) + .filter((line) => line.length > 0 && keep(line)) .map((line) => line.replace(/^\S+\s+/, '')); } From b6de1b594e40bed54f87862af996b186c239d494 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:07:50 -0600 Subject: [PATCH 12/28] fix(cull-feature-flags): name the report by absolute path in the outro ./posthog-feature-flag-cull-report.md is relative to --install-dir, which reads wrong when the wizard runs from another directory. the outro now prints the full path. --- src/lib/programs/__tests__/cull-feature-flags-seed.test.ts | 7 +++++++ src/lib/programs/cull-feature-flags/index.ts | 1 + src/lib/programs/cull-feature-flags/outro.ts | 7 +++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index b77bde51f..6dcae237f 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -119,6 +119,7 @@ describe('buildCullPrompt', () => { describe('buildCullOutro', () => { const common = { flagIdByKey: new Map([['new-checkout', 42]]), + installDir: '/srv/app', reportFile: 'posthog-feature-flag-cull-report.md', docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', }; @@ -137,6 +138,9 @@ describe('buildCullOutro', () => { }); expect(outro.kind).toBe(OutroKind.Success); expect(outro.message).toContain('Nothing was changed'); + expect(outro.message).toContain( + '/srv/app/posthog-feature-flag-cull-report.md', + ); expect(outro.changes).toEqual([]); expect(outro.nextSteps).toBeUndefined(); }); @@ -150,6 +154,9 @@ describe('buildCullOutro', () => { touchedFiles: ['src/app/dashboard/page.tsx', 'src/lib/checkout.ts'], }); expect(outro.message).toContain('Culled 1 feature flag.'); + expect(outro.message).toContain( + 'Report: /srv/app/posthog-feature-flag-cull-report.md', + ); expect(outro.changes).toEqual([candidateToCheck(STALE).label]); expect(outro.nextSteps).toEqual({ heading: 'Undo, if you want it back:', diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 716cc75aa..31b6576b8 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -204,6 +204,7 @@ const cullRun = async (session: WizardSession): Promise => { checks: readLedger(sess.installDir), touchedFiles: listModifiedTrackedPaths(sess.installDir), flagIdByKey, + installDir: sess.installDir, reportFile: CULL_FEATURE_FLAGS_REPORT_FILE, docsUrl: DOCS_URL, }), diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts index 46348800f..188b854f1 100644 --- a/src/lib/programs/cull-feature-flags/outro.ts +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import type { AuditCheck } from '@lib/programs/audit/types'; import { OutroKind, type OutroData } from '@lib/wizard-session'; import { APPLIED_MARKER } from './seed.js'; @@ -6,6 +7,7 @@ export interface CullOutroInput { checks: readonly AuditCheck[]; touchedFiles: readonly string[]; flagIdByKey: ReadonlyMap; + installDir: string; reportFile: string; docsUrl: string; } @@ -34,12 +36,13 @@ export function buildCullOutro(input: CullOutroInput): OutroData { } disabled, one toggle each to re-enable; the report links every flag page.`, ); } + const reportPath = path.join(input.installDir, input.reportFile); const message = applied.length === 0 - ? 'Nothing was changed. The report lists what you can cull by hand.' + ? `Nothing was changed. The report at ${reportPath} lists what you can cull by hand.` : `Culled ${applied.length} feature flag${ applied.length === 1 ? '' : 's' - }. Flags were disabled, never deleted.`; + }. Flags were disabled, never deleted. Report: ${reportPath}`; return { kind: OutroKind.Success, message, From 551731c08a9ca3d3e902f356073ad6141cc47911 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:09:39 -0600 Subject: [PATCH 13/28] fix(cull-feature-flags): say culled, not applied matches the skill's new ledger marker ("; culled") and outcome wording in the outro and the run screen's stage copy. --- .../__tests__/cull-feature-flags-seed.test.ts | 6 +++--- src/lib/programs/cull-feature-flags/outro.ts | 16 ++++++++-------- src/lib/programs/cull-feature-flags/seed.ts | 2 +- src/ui/tui/screens/audit/slides/cull/stage.ts | 12 ++++++------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 6dcae237f..63ea9d2fe 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -124,7 +124,7 @@ describe('buildCullOutro', () => { docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', }; - test('nothing applied means a report-only message and no undo block', () => { + test('nothing culled means a report-only message and no undo block', () => { const outro = buildCullOutro({ ...common, checks: [ @@ -145,11 +145,11 @@ describe('buildCullOutro', () => { expect(outro.nextSteps).toBeUndefined(); }); - test('applied rows produce the git revert and a one-line PostHog undo', () => { + test('culled rows produce the git revert and a one-line PostHog undo', () => { const outro = buildCullOutro({ ...common, checks: [ - { ...candidateToCheck(STALE), status: 'pass', details: 'x; applied' }, + { ...candidateToCheck(STALE), status: 'pass', details: 'x; culled' }, ], touchedFiles: ['src/app/dashboard/page.tsx', 'src/lib/checkout.ts'], }); diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts index 188b854f1..db003fde6 100644 --- a/src/lib/programs/cull-feature-flags/outro.ts +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import type { AuditCheck } from '@lib/programs/audit/types'; import { OutroKind, type OutroData } from '@lib/wizard-session'; -import { APPLIED_MARKER } from './seed.js'; +import { CULLED_MARKER } from './seed.js'; export interface CullOutroInput { checks: readonly AuditCheck[]; @@ -14,9 +14,9 @@ export interface CullOutroInput { /** Outro that carries the undo recipe: the git revert for code, the flag page for PostHog. */ export function buildCullOutro(input: CullOutroInput): OutroData { - const applied = input.checks.filter( + const culled = input.checks.filter( (check) => - check.status === 'pass' && (check.details ?? '').includes(APPLIED_MARKER), + check.status === 'pass' && (check.details ?? '').includes(CULLED_MARKER), ); const undoItems: string[] = []; if (input.touchedFiles.length > 0) { @@ -26,7 +26,7 @@ export function buildCullOutro(input: CullOutroInput): OutroData { )} (or git diff to review first)`, ); } - const disabledCount = applied.filter((check) => + const disabledCount = culled.filter((check) => input.flagIdByKey.has(check.id), ).length; if (disabledCount > 0) { @@ -38,17 +38,17 @@ export function buildCullOutro(input: CullOutroInput): OutroData { } const reportPath = path.join(input.installDir, input.reportFile); const message = - applied.length === 0 + culled.length === 0 ? `Nothing was changed. The report at ${reportPath} lists what you can cull by hand.` - : `Culled ${applied.length} feature flag${ - applied.length === 1 ? '' : 's' + : `Culled ${culled.length} feature flag${ + culled.length === 1 ? '' : 's' }. Flags were disabled, never deleted. Report: ${reportPath}`; return { kind: OutroKind.Success, message, reportFile: input.reportFile, docsUrl: input.docsUrl, - changes: applied.map((check) => check.label), + changes: culled.map((check) => check.label), ...(undoItems.length > 0 ? { nextSteps: { diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index efc7977cd..df0605d74 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -3,7 +3,7 @@ import type { AuditCheck } from '@lib/programs/audit/types'; import type { FlagScanResult } from './scan.js'; import type { CullCandidate } from './types.js'; -export const APPLIED_MARKER = '; applied'; +export const CULLED_MARKER = '; culled'; // The row's `file` already names the first site, so details only add the rest. function describeSites(candidate: CullCandidate): string | undefined { diff --git a/src/ui/tui/screens/audit/slides/cull/stage.ts b/src/ui/tui/screens/audit/slides/cull/stage.ts index ff3782611..4aac1df5d 100644 --- a/src/ui/tui/screens/audit/slides/cull/stage.ts +++ b/src/ui/tui/screens/audit/slides/cull/stage.ts @@ -1,7 +1,7 @@ import type { AuditCheck } from '@lib/programs/audit/types'; import type { WrapUpCopy } from '../../AuditAreaPane.js'; -const APPLIED_MARKER = '; applied'; +const CULLED_MARKER = '; culled'; const DECLINED_MARKER = '; declined by user'; /** Left-pane copy for the cull stages after verification: consent, apply, report. */ @@ -11,9 +11,9 @@ export function cullStageCopy( ): WrapUpCopy | undefined { if (checks.some((check) => check.status === 'pending')) return undefined; const proposed = checks.filter((check) => check.status === 'warning'); - const applied = checks.filter( + const culled = checks.filter( (check) => - check.status === 'pass' && (check.details ?? '').includes(APPLIED_MARKER), + check.status === 'pass' && (check.details ?? '').includes(CULLED_MARKER), ); const declined = checks.filter( (check) => @@ -21,7 +21,7 @@ export function cullStageCopy( (check.details ?? '').includes(DECLINED_MARKER), ); const failed = checks.filter((check) => check.status === 'error'); - const isDecided = applied.length + declined.length + failed.length > 0; + const isDecided = culled.length + declined.length + failed.length > 0; if (proposed.length > 0 && !isDecided) { return { @@ -36,7 +36,7 @@ export function cullStageCopy( return { title: `Culling ${proposed.length} more`, paragraphs: [ - `${applied.length} done so far${ + `${culled.length} done so far${ failed.length > 0 ? `, ${failed.length} failed` : '' }. Code edit first, then the PostHog disable, one flag at a time.`, 'Every edit is an ordinary git diff; every disabled flag is one toggle from back on.', @@ -46,7 +46,7 @@ export function cullStageCopy( return { title: 'Writing the cull report', paragraphs: [ - `${applied.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, + `${culled.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, 'Hang tight!', ], }; From 011febb4b3b4facd9316bc699125cfa8c2b2d3c4 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:59:11 -0600 Subject: [PATCH 14/28] fix(e2e): drop the capture-aio mirror from the tui host it rode along with the cull-intro e2e fix but has nothing to do with the program. observability for headless runs can come in on its own. --- scripts/tui-host.no-jest.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index d7e18dbae..3b3e92ca3 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -223,9 +223,6 @@ async function main() { localMcp: envFlag('POSTHOG_WIZARD_LOCAL_MCP'), localContextMill: envFlag('POSTHOG_WIZARD_LOCAL_CONTEXT_MILL'), localPosthog: envFlag('POSTHOG_WIZARD_LOCAL_POSTHOG'), - // Mirror the dev-only --capture-aio flag so a headless run's LLM calls - // and tool calls land in the project's AI Observability tab. - captureAio: envFlag('POSTHOG_WIZARD_CAPTURE_AIO') ?? false, // Switchboard variation overrides (see e2e.json `variations`), threaded by // the snapshot driver as one run per variation. Empty ⇒ resolved default. harness: (process.env.SNAP_HARNESS || undefined) as Harness | undefined, From 0232e01ca6979959068eaa1cc311b89a00636055 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 13:59:13 -0600 Subject: [PATCH 15/28] test(cull-feature-flags): pin shape, not copy, in the seed test every wording tweak on the prompt and the outro had to edit this file (five of the last six commits). the test now checks what has to hold: the ledger path and each area show up in the prompt, the caveats only appear when the scan hit them, and a cull produces one undo step for code (naming the touched files) and one for posthog. --- .../__tests__/cull-feature-flags-seed.test.ts | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 63ea9d2fe..982da38f6 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -84,23 +84,28 @@ describe('candidateToCheck', () => { }); describe('buildCullPrompt', () => { - test('names the ledger, counts per bucket, and the disable-only rule', () => { + const ledgerFile = '.posthog-audit-checks.json'; + + test('names the ledger and every area the candidates fall in', () => { const prompt = buildCullPrompt({ - ledgerFile: '.posthog-audit-checks.json', + ledgerFile, candidates: [STALE, HEALTHY, ORPHAN], scan: scan(), }); - expect(prompt).toContain('./.posthog-audit-checks.json'); - expect(prompt).toContain('- Rolled out: 1'); - expect(prompt).toContain('- Healthy: 1'); - expect(prompt).toContain('- Unreferenced: 1'); - expect(prompt).toContain('never delete or archive'); - expect(prompt).not.toContain('getAllFlags'); + expect(prompt).toContain(ledgerFile); + expect(prompt).toContain('Rolled out'); + expect(prompt).toContain('Healthy'); + expect(prompt).toContain('Unreferenced'); }); - test('adds the bulk, dynamic and truncation caveats when they apply', () => { - const prompt = buildCullPrompt({ - ledgerFile: '.posthog-audit-checks.json', + test('bulk, dynamic and truncation caveats only appear when the scan hit them', () => { + const plain = buildCullPrompt({ + ledgerFile, + candidates: [ORPHAN], + scan: scan(), + }); + const withCaveats = buildCullPrompt({ + ledgerFile, candidates: [ORPHAN], scan: scan({ usesBulkEvaluation: true, @@ -110,9 +115,11 @@ describe('buildCullPrompt', () => { truncated: true, }), }); - expect(prompt).toContain('calls getAllFlags'); - expect(prompt).toContain('src/lib/flags.ts:4 (isFeatureEnabled)'); - expect(prompt).toContain('hit its file limit'); + expect(plain).not.toContain('getAllFlags'); + expect(plain).not.toContain('src/lib/flags.ts:4'); + expect(withCaveats).toContain('getAllFlags'); + expect(withCaveats).toContain('src/lib/flags.ts:4'); + expect(withCaveats.length).toBeGreaterThan(plain.length); }); }); @@ -124,7 +131,7 @@ describe('buildCullOutro', () => { docsUrl: 'https://posthog.com/docs/feature-flags/best-practices', }; - test('nothing culled means a report-only message and no undo block', () => { + test('nothing culled means no changes and no undo block', () => { const outro = buildCullOutro({ ...common, checks: [ @@ -137,7 +144,6 @@ describe('buildCullOutro', () => { touchedFiles: [], }); expect(outro.kind).toBe(OutroKind.Success); - expect(outro.message).toContain('Nothing was changed'); expect(outro.message).toContain( '/srv/app/posthog-feature-flag-cull-report.md', ); @@ -145,7 +151,7 @@ describe('buildCullOutro', () => { expect(outro.nextSteps).toBeUndefined(); }); - test('culled rows produce the git revert and a one-line PostHog undo', () => { + test('culled rows list the change and an undo step for code and for PostHog', () => { const outro = buildCullOutro({ ...common, checks: [ @@ -153,17 +159,14 @@ describe('buildCullOutro', () => { ], touchedFiles: ['src/app/dashboard/page.tsx', 'src/lib/checkout.ts'], }); - expect(outro.message).toContain('Culled 1 feature flag.'); expect(outro.message).toContain( - 'Report: /srv/app/posthog-feature-flag-cull-report.md', + '/srv/app/posthog-feature-flag-cull-report.md', ); expect(outro.changes).toEqual([candidateToCheck(STALE).label]); - expect(outro.nextSteps).toEqual({ - heading: 'Undo, if you want it back:', - items: [ - 'Code: git checkout -- src/app/dashboard/page.tsx src/lib/checkout.ts (or git diff to review first)', - 'PostHog: 1 flag disabled, one toggle each to re-enable; the report links every flag page.', - ], - }); + const [codeUndo, posthogUndo] = outro.nextSteps?.items ?? []; + expect(codeUndo).toContain('src/app/dashboard/page.tsx'); + expect(codeUndo).toContain('src/lib/checkout.ts'); + expect(posthogUndo).toBeDefined(); + expect(outro.nextSteps?.items).toHaveLength(2); }); }); From 9fddf3b9af53c1e30bdfaf329a15b68370385244 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 14:59:10 -0600 Subject: [PATCH 16/28] refactor(cull-feature-flags): prompt carries facts, the skill carries rules The seed prompt repeated five rules the skill already states, so the two copies would drift on their own release cycles. The prompt now sends only what the host knows: the ledger, per-area counts, and a Scan facts block that always prints bulk evaluation, dynamic key sites, and truncation as yes/no. The skill decides what those mean. --- .../__tests__/cull-feature-flags-seed.test.ts | 5 +-- src/lib/programs/cull-feature-flags/seed.ts | 39 +++++++------------ 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index 982da38f6..f9e57f209 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -98,7 +98,7 @@ describe('buildCullPrompt', () => { expect(prompt).toContain('Unreferenced'); }); - test('bulk, dynamic and truncation caveats only appear when the scan hit them', () => { + test('dynamic key sites appear only when the scan found them', () => { const plain = buildCullPrompt({ ledgerFile, candidates: [ORPHAN], @@ -115,11 +115,8 @@ describe('buildCullPrompt', () => { truncated: true, }), }); - expect(plain).not.toContain('getAllFlags'); expect(plain).not.toContain('src/lib/flags.ts:4'); - expect(withCaveats).toContain('getAllFlags'); expect(withCaveats).toContain('src/lib/flags.ts:4'); - expect(withCaveats.length).toBeGreaterThan(plain.length); }); }); diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index df0605d74..c7c5dcb1f 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -55,32 +55,19 @@ function countByBucket(candidates: readonly CullCandidate[]): string[] { } export function buildCullPrompt(input: CullPromptInput): string { - const lines = [ - 'Run the cull-feature-flags skill end-to-end. The wizard already scanned this project and fetched its PostHog flags; the ledger at', - `./${input.ledgerFile} is ground truth, one row per flag, grouped by area:`, + const yesNo = (value: boolean): string => (value ? 'yes' : 'no'); + const dynamicSites = input.scan.dynamicSites.map( + (site) => `${site.file}:${site.line} (${site.api})`, + ); + return [ + `Run the cull-feature-flags skill end-to-end. The ledger at ./${input.ledgerFile} is ground truth, one row per flag, grouped by area:`, ...countByBucket(input.candidates), '', - 'Never grep for flags or re-classify a row. Resolve rows only through audit_resolve_checks. Ask exactly once which rows to apply, decline option first. Disable flags only, never delete or archive. Code edits land before the PostHog disable.', - ]; - if (input.scan.usesBulkEvaluation) { - lines.push( - 'This project calls getAllFlags, so a flag with no literal call site may still be read out of that result. Verify every unreferenced row at the bulk call site before proposing it.', - ); - } - if (input.scan.dynamicSites.length > 0) { - const sites = input.scan.dynamicSites.map( - (site) => `${site.file}:${site.line} (${site.api})`, - ); - lines.push( - `Flag keys are also evaluated dynamically at ${sites.join( - ', ', - )}. Verify every unreferenced row against those sites before proposing it.`, - ); - } - if (input.scan.truncated) { - lines.push( - 'The scan hit its file limit, so "unreferenced" is not proven. Treat every unreferenced row as verify-first.', - ); - } - return lines.join('\n'); + "Scan facts (deterministic, from the wizard's scan of this project):", + `- Bulk evaluation (getAllFlags): ${yesNo(input.scan.usesBulkEvaluation)}`, + `- Dynamic flag keys: ${ + dynamicSites.length > 0 ? dynamicSites.join(', ') : 'none' + }`, + `- Scan truncated at the file limit: ${yesNo(input.scan.truncated)}`, + ].join('\n'); } From cbd65f97a6b0fc1afb7b2b985dc0466d8f52487e Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 15:57:39 -0600 Subject: [PATCH 17/28] fix(cull-feature-flags): honest 0% bucket, every next.js convention is a root review caught two things. "never enabled" claimed history the api does not give us: a flag rolled back to 0% after an incident looks identical to one that never shipped. the area is now "off for everyone" and the row's details say it may be a rollback, so the skill verifies before it proposes. splitting the two for real needs the activity endpoint, later. reachability missed the metadata conventions (sitemap, robots, manifest, opengraph-image, twitter-image, icon, apple-icon) and the bare side effect import form (`import './register'`), so a flag checked only from those files landed in dead code. both are one regex each. require() and dynamic import() were already matched. the ledger is now seeded in bucket order so the grouped run list reads top to bottom as the agent moves through it. Confidence: high Scope-risk: narrow --- .../cull-feature-flags-classify.test.ts | 5 ++- .../__tests__/cull-feature-flags-run.test.ts | 2 +- .../__tests__/cull-feature-flags-scan.test.ts | 34 +++++++++++++++++++ .../programs/cull-feature-flags/classify.ts | 23 +++++++++++-- src/lib/programs/cull-feature-flags/scan.ts | 4 +-- src/lib/programs/cull-feature-flags/seed.ts | 8 ++++- src/ui/tui/screens/audit/slides/cull/index.ts | 3 +- 7 files changed, 71 insertions(+), 8 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts index 6186fde5f..5ced60b4f 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-classify.test.ts @@ -67,7 +67,7 @@ describe('classifyFlags', () => { expect(bucketOf(flags, result, 'k')).toEqual(['fully-rolled-out', 'stale']); }); - test('never enabled flag with a call site is 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')], @@ -76,6 +76,9 @@ describe('classifyFlags', () => { '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', () => { diff --git a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts index df8772406..30011a321 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts @@ -133,8 +133,8 @@ describe('cullFeatureFlagsConfig.run', () => { fs.readFileSync(path.join(installDir, AUDIT_CHECKS_FILE), 'utf8'), ) as { id: string; area: string; status: string }[]; expect(ledger.map((row) => [row.id, row.area, row.status])).toEqual([ - ['new-checkout', 'Rolled out', 'pending'], ['orphan', 'Unreferenced', 'pending'], + ['new-checkout', 'Rolled out', 'pending'], ]); expect(run.customPrompt?.({} as never)).toContain('- Rolled out: 1'); }); diff --git a/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts index 82fb2ebb5..133341db9 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-scan.test.ts @@ -234,6 +234,40 @@ describe('scanFlagCallSites', () => { ]); }); + test('metadata routes and side-effect imports remain reachable', async () => { + writeFile( + tmpDir, + 'src/app/sitemap.ts', + 'export default function sitemap() {}', + ); + writeFile( + tmpDir, + 'src/app/robots.ts', + 'export default function robots() {}', + ); + writeFile( + tmpDir, + 'src/app/opengraph-image.tsx', + 'export default function Image() {}', + ); + writeFile(tmpDir, 'src/app/layout.tsx', 'import "../lib/register";'); + writeFile( + tmpDir, + 'src/lib/register.ts', + 'posthog.isFeatureEnabled("boot-flag");', + ); + + const result = await scanFlagCallSites(tmpDir); + + expect(result.reachableFiles).toEqual([ + 'src/app/layout.tsx', + 'src/app/opengraph-image.tsx', + 'src/app/robots.ts', + 'src/app/sitemap.ts', + 'src/lib/register.ts', + ]); + }); + test('skips node_modules and build output', async () => { writeFile( tmpDir, diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts index b990403dd..17684afad 100644 --- a/src/lib/programs/cull-feature-flags/classify.ts +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -24,12 +24,25 @@ export const AREA_BY_BUCKET: Record = { 'unreferenced-comment-only': 'Comment only', unreferenced: 'Unreferenced', 'fully-rolled-out': 'Rolled out', - 'never-enabled': 'Never enabled', + 'never-enabled': 'Off for everyone', 'deleted-still-referenced': 'Deleted in PostHog', 'multi-callsite-no-wrapper': 'Many call sites', healthy: 'Healthy', }; +export const BUCKET_ORDER: readonly CullBucket[] = [ + 'unreferenced', + 'unreferenced-comment-only', + 'dead-code-reference', + 'archived-still-referenced', + 'disabled-but-referenced', + 'fully-rolled-out', + 'never-enabled', + 'deleted-still-referenced', + 'multi-callsite-no-wrapper', + 'healthy', +]; + const VERDICT_BY_BUCKET: Record = { 'dead-code-reference': 'stale', 'archived-still-referenced': 'stale', @@ -87,6 +100,11 @@ function rolloutSummary(flag: FeatureFlag): string { return parts.join(', '); } +function reasonForBucket(bucket: CullBucket, summary: string): string { + if (bucket !== 'never-enabled') return summary; + return `${summary}; may be a rollback, verify before culling`; +} + function bucketForFlag( flag: FeatureFlag, sites: FlagCallSite[], @@ -163,7 +181,8 @@ export function classifyFlags( mentionedKeys.has(flag.key), reachableFiles, ); - candidates.push(candidate(flag.key, bucket, summary, sites, flag)); + const reason = reasonForBucket(bucket, summary); + candidates.push(candidate(flag.key, bucket, reason, sites, flag)); } for (const [key, sites] of sitesByKey) { diff --git a/src/lib/programs/cull-feature-flags/scan.ts b/src/lib/programs/cull-feature-flags/scan.ts index 2f8176386..0f0c36ff8 100644 --- a/src/lib/programs/cull-feature-flags/scan.ts +++ b/src/lib/programs/cull-feature-flags/scan.ts @@ -65,9 +65,9 @@ const DYNAMIC_JSX_RE = /]*?\bflag=\{\s*(?!['"])[^}\s]/g; const BULK_RE = /\b(getAllFlags|getAllFlagsAndPayloads)\s*\(/; const PREFILTER = /FeatureFlag|isFeatureEnabled|PostHogFeature|getAllFlags/; const IMPORT_SPECIFIER_RE = - /(?:from\s*|import\s*\(\s*|require\s*\(\s*)['"]([^'"]+)['"]/g; + /(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s+)['"]([^'"]+)['"]/g; const NEXT_ENTRY_FILE_RE = - /(?:^|\/)(?:app\/.*\/?(?:page|layout|route|loading|error|not-found|template|default|global-error)|pages\/.*|middleware|proxy|instrumentation(?:-client)?|next\.config)\.(?:tsx?|[mc]?jsx?)$/; + /(?:^|\/)(?:app\/.*\/?(?:page|layout|route|loading|error|not-found|template|default|global-error|sitemap|robots|manifest|opengraph-image|twitter-image|icon|apple-icon)|pages\/.*|middleware|proxy|instrumentation(?:-client)?|next\.config)\.(?:tsx?|[mc]?jsx?)$/; function stripComments(lines: string[]): string[] { let isInsideBlockComment = false; diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index c7c5dcb1f..b0df199cd 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -2,6 +2,7 @@ import { seedAuditLedger } from '@lib/programs/audit/seed'; import type { AuditCheck } from '@lib/programs/audit/types'; import type { FlagScanResult } from './scan.js'; import type { CullCandidate } from './types.js'; +import { BUCKET_ORDER } from './classify.js'; export const CULLED_MARKER = '; culled'; @@ -33,7 +34,12 @@ export function seedCullLedger( installDir: string, candidates: readonly CullCandidate[], ): AuditCheck[] { - const checks = candidates.map(candidateToCheck); + const checks = [...candidates] + .sort( + (left, right) => + BUCKET_ORDER.indexOf(left.bucket) - BUCKET_ORDER.indexOf(right.bucket), + ) + .map(candidateToCheck); seedAuditLedger(installDir, checks); return checks; } diff --git a/src/ui/tui/screens/audit/slides/cull/index.ts b/src/ui/tui/screens/audit/slides/cull/index.ts index be8690121..04ef891bd 100644 --- a/src/ui/tui/screens/audit/slides/cull/index.ts +++ b/src/ui/tui/screens/audit/slides/cull/index.ts @@ -19,8 +19,9 @@ export const CULL_AREA_SLIDES: AreaSlide[] = [ CONSENT, DISABLE_ONLY, ]), - slide('Never enabled', [ + slide('Off for everyone', [ 'This flag is at 0% for everyone. Culling keeps the off branch and removes the check.', + 'A flag rolled back after an incident looks the same; if it reads like a kill switch, keep it.', CONSENT, DISABLE_ONLY, ]), From 444077d359f2fb7ed32fae6b653697560ae5d639 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 15:57:43 -0600 Subject: [PATCH 18/28] feat(cull-feature-flags): learn deck until the area pane takes over reviewers said the run screen gives you nothing to read while the agent works and the area slides alone do not explain what is happening. the default program plays a content deck through LearnCard, but the audit run screen never read getContentBlocks, so skill programs on that screen had no deck at all. cull now ships its own deck (what a stale flag is, the four ways one goes stale, why the wizard scanned instead of asking the agent, what consent covers, how undo works) and the audit run screen plays it in the left pane for this program until it completes, then hands over to the area slides as before. other programs on the screen are untouched. Confidence: high Scope-risk: narrow --- .../cull-feature-flags/content/index.tsx | 90 +++++++++++++++++++ src/lib/programs/cull-feature-flags/index.ts | 2 + src/ui/tui/screens/audit/AuditRunScreen.tsx | 25 ++++-- 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 src/lib/programs/cull-feature-flags/content/index.tsx diff --git a/src/lib/programs/cull-feature-flags/content/index.tsx b/src/lib/programs/cull-feature-flags/content/index.tsx new file mode 100644 index 000000000..ba1b99414 --- /dev/null +++ b/src/lib/programs/cull-feature-flags/content/index.tsx @@ -0,0 +1,90 @@ +import { Text } from 'ink'; +import { Colors } from '@ui/tui/styles'; +import { TextRevealMode } from '@ui/tui/primitives/TextBlock'; +import type { ContentBlock } from '@ui/tui/primitives/content-types'; + +const STALE_WAYS: ContentBlock = { + type: 'lines', + interval: 500, + pause: 7000, + lines: [ + + {'100%'} + {' on for everyone, check is dead weight'} + , + + {' 0%'} + {' off for everyone, feature never ships'} + , + + {' off'} + {' disabled or archived, code still asks'} + , + + {' ?'} + {' in PostHog, never evaluated in code'} + , + ], +}; + +export const getContentBlocks = (): ContentBlock[] => [ + { + content: 'Welcome.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + { + content: "I'm looking for feature flags this project no longer needs.", + pause: 5000, + }, + + { type: 'clear', pause: 1500 }, + + { + content: + 'A flag is a switch. Once everyone has the feature, the switch is a dead branch in your code.', + pause: 6000, + }, + { content: 'Four ways a flag goes stale:', pause: 2000 }, + STALE_WAYS, + + { type: 'clear', pause: 1500 }, + + { + content: + "I don't guess. The wizard scanned every source file and pulled your flags from PostHog before I started.", + pause: 6500, + }, + { + content: + 'Fixed rules put each flag in a bucket. My job is to read the call site and confirm it, or keep the flag.', + pause: 6500, + }, + + { type: 'clear', pause: 1500 }, + + { + content: + 'Nothing changes until you pick. One prompt: report only, or cull the flags you choose.', + pause: 6000, + }, + { + content: + 'Culling keeps the winning branch, drops the check, and disables the flag in PostHog. Never deleted.', + pause: 6500, + }, + { + content: + 'Undo is one step each: git checkout for the code, one toggle on the flag page for PostHog.', + pause: 6500, + }, + + { type: 'clear', pause: 1500 }, + + { + content: 'Verifying call sites now. The list on the right moves as I go.', + pause: 60000, + persist: true, + }, +]; diff --git a/src/lib/programs/cull-feature-flags/index.ts b/src/lib/programs/cull-feature-flags/index.ts index 31b6576b8..6f38ed55c 100644 --- a/src/lib/programs/cull-feature-flags/index.ts +++ b/src/lib/programs/cull-feature-flags/index.ts @@ -22,6 +22,7 @@ import { readProjectFile } from '@utils/bounded-fs'; import { logToFile } from '@utils/debug'; import { wizardAbort } from '@utils/wizard-abort'; import { classifyFlags } from './classify.js'; +import { getContentBlocks } from './content/index.js'; import { fetchFeatureFlags } from './fetch.js'; import { buildCullOutro } from './outro.js'; import { scanFlagCallSites } from './scan.js'; @@ -215,4 +216,5 @@ export const cullFeatureFlagsConfig: ProgramConfig = { ...base, steps: cullSteps, run: cullRun, + getContentBlocks, }; diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index 63f5477a5..e3b293a14 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -10,6 +10,7 @@ import { } from '@ui/tui/primitives/index'; import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; import { useFileWatcher } from '@ui/tui/hooks/file-watcher'; +import { LearnCard } from '@ui/tui/components/LearnCard'; import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js'; import { AuditAreaPane } from './AuditAreaPane.js'; import { AUDIT_AREA_SLIDES } from './slides/index.js'; @@ -58,12 +59,13 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { AUDIT_REPORT_FILE; const reportPath = `./${reportFile}`; const pendingChecksList = ; - const slides = slidesFor(store.router.activeProgram, store.session.skillId); - const wrapUp = - store.router.activeProgram === 'cull-feature-flags' - ? cullStageCopy(checks, reportPath) - : undefined; - const areaPane = ( + const activeProgram = store.router.activeProgram; + const isCull = activeProgram === 'cull-feature-flags'; + const slides = slidesFor(activeProgram, store.session.skillId); + const wrapUp = isCull ? cullStageCopy(checks, reportPath) : undefined; + const learnBlocks = getProgramConfig(activeProgram).getContentBlocks; + const showLearnDeck = isCull && !store.learnCardComplete && !!learnBlocks; + let leftPane = ( { wrapUp={wrapUp} /> ); + if (showLearnDeck && learnBlocks) { + leftPane = ( + store.setLearnCardComplete()} + /> + ); + } // Narrow terminals: drop the area pane. const statusComponent = @@ -81,7 +92,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { {pendingChecksList} ) : ( - + ); const tabs = [ From 0e72d26f236e6e0acdd553433a8102a74eaac490 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 16:54:22 -0600 Subject: [PATCH 19/28] fix(cull-feature-flags): count only flags that were disabled the outro counted every culled row with a flag id as "disabled in posthog", but the skill makes no posthog call for archived, disabled or deleted rows, so the undo line told people to re-enable flags nothing had touched. disabled now means culled in an area the skill disables. "left for you" counted every row that was not culled or failed, which swept the healthy flags in. it now counts declined rows only, and the zero counts stay out of the message. the undo copy says the three things reviewers asked for: each flag was its own unit, the tree was clean so git diff is all ours, and disabling kept the flag's rollout conditions. Confidence: high Scope-risk: narrow --- .../__tests__/cull-feature-flags-seed.test.ts | 46 +++++++++++++++++++ src/lib/programs/cull-feature-flags/outro.ts | 28 ++++++++--- src/lib/programs/cull-feature-flags/seed.ts | 1 + 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts index f9e57f209..8a250c872 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-seed.test.ts @@ -166,4 +166,50 @@ describe('buildCullOutro', () => { expect(posthogUndo).toBeDefined(); expect(outro.nextSteps?.items).toHaveLength(2); }); + + test('counts only culls that disabled a PostHog flag', () => { + const outro = buildCullOutro({ + ...common, + flagIdByKey: new Map([ + ['new-checkout', 42], + ['archived-flag', 43], + ]), + checks: [ + { ...candidateToCheck(STALE), status: 'pass', details: 'x; culled' }, + { + ...candidateToCheck(STALE), + id: 'archived-flag', + area: 'Archived in PostHog', + status: 'pass', + details: 'x; culled', + }, + { + ...candidateToCheck(STALE), + id: 'deleted-flag', + area: 'Deleted in PostHog', + status: 'pass', + details: 'x; culled', + }, + { + ...candidateToCheck(STALE), + id: 'failed-flag', + status: 'error', + details: 'x; failed', + }, + { + ...candidateToCheck(STALE), + id: 'declined-flag', + status: 'pass', + details: 'x; declined by user', + }, + ], + touchedFiles: ['src/app/dashboard/page.tsx'], + }); + + expect(outro.message).toContain('Culled 3 feature flags'); + expect(outro.message).toContain('1 failed'); + expect(outro.message).toContain('1 left for you'); + expect(outro.nextSteps?.items).toHaveLength(2); + expect(outro.nextSteps?.items[1]).toContain('1 flag'); + }); }); diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts index db003fde6..c50af780f 100644 --- a/src/lib/programs/cull-feature-flags/outro.ts +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -1,7 +1,16 @@ import * as path from 'path'; import type { AuditCheck } from '@lib/programs/audit/types'; import { OutroKind, type OutroData } from '@lib/wizard-session'; -import { CULLED_MARKER } from './seed.js'; +import { AREA_BY_BUCKET } from './classify.js'; +import { CULLED_MARKER, DECLINED_MARKER } from './seed.js'; + +const DISABLING_AREAS = new Set([ + AREA_BY_BUCKET['dead-code-reference'], + AREA_BY_BUCKET['unreferenced-comment-only'], + AREA_BY_BUCKET.unreferenced, + AREA_BY_BUCKET['fully-rolled-out'], + AREA_BY_BUCKET['never-enabled'], +]); export interface CullOutroInput { checks: readonly AuditCheck[]; @@ -18,22 +27,27 @@ export function buildCullOutro(input: CullOutroInput): OutroData { (check) => check.status === 'pass' && (check.details ?? '').includes(CULLED_MARKER), ); + const failed = input.checks.filter((check) => check.status === 'error'); + const leftForYou = input.checks.filter((check) => + (check.details ?? '').includes(DECLINED_MARKER), + ).length; const undoItems: string[] = []; if (input.touchedFiles.length > 0) { undoItems.push( `Code: git checkout -- ${input.touchedFiles.join( ' ', - )} (or git diff to review first)`, + )} (or git diff to review first). The tree was clean when this run started, so every change git diff shows is the wizard's. Each flag was its own unit: a failed row left its flag untouched, and earlier culls stand.`, ); } - const disabledCount = culled.filter((check) => - input.flagIdByKey.has(check.id), + const disabledCount = culled.filter( + (check) => + DISABLING_AREAS.has(check.area) && input.flagIdByKey.has(check.id), ).length; if (disabledCount > 0) { undoItems.push( `PostHog: ${disabledCount} flag${ disabledCount === 1 ? '' : 's' - } disabled, one toggle each to re-enable; the report links every flag page.`, + } disabled, never deleted; one toggle each to re-enable. Disabling kept the flag's rollout conditions, variants, and payloads, so re-enabling restores exactly what was there. The report links every flag page.`, ); } const reportPath = path.join(input.installDir, input.reportFile); @@ -42,7 +56,9 @@ export function buildCullOutro(input: CullOutroInput): OutroData { ? `Nothing was changed. The report at ${reportPath} lists what you can cull by hand.` : `Culled ${culled.length} feature flag${ culled.length === 1 ? '' : 's' - }. Flags were disabled, never deleted. Report: ${reportPath}`; + }.${failed.length > 0 ? ` ${failed.length} failed.` : ''}${ + leftForYou > 0 ? ` ${leftForYou} left for you.` : '' + } Flags were disabled, never deleted. Report: ${reportPath}`; return { kind: OutroKind.Success, message, diff --git a/src/lib/programs/cull-feature-flags/seed.ts b/src/lib/programs/cull-feature-flags/seed.ts index b0df199cd..98028232e 100644 --- a/src/lib/programs/cull-feature-flags/seed.ts +++ b/src/lib/programs/cull-feature-flags/seed.ts @@ -5,6 +5,7 @@ import type { CullCandidate } from './types.js'; import { BUCKET_ORDER } from './classify.js'; export const CULLED_MARKER = '; culled'; +export const DECLINED_MARKER = '; declined by user'; // The row's `file` already names the first site, so details only add the rest. function describeSites(candidate: CullCandidate): string | undefined { From 7a6881bde4322790fb9f2b6d8b15b0e616d33587 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 16:54:25 -0600 Subject: [PATCH 20/28] feat(cull-feature-flags): group buckets into lanes ten buckets is the right granularity for the classifier and the report, and the wrong one for a run screen. every bucket falls into one of four lanes by what drifted: posthog decided for everyone, posthog is off but the code still asks, posthog has it but nothing asks, nothing to cull. the lane map is keyed by the ledger row's area because that is all the ui ever holds. the seed order now follows the lanes so the list reads top to bottom as the agent moves. Confidence: high Scope-risk: narrow --- .../__tests__/cull-feature-flags-run.test.ts | 2 +- .../programs/cull-feature-flags/classify.ts | 50 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts index 30011a321..df8772406 100644 --- a/src/lib/programs/__tests__/cull-feature-flags-run.test.ts +++ b/src/lib/programs/__tests__/cull-feature-flags-run.test.ts @@ -133,8 +133,8 @@ describe('cullFeatureFlagsConfig.run', () => { fs.readFileSync(path.join(installDir, AUDIT_CHECKS_FILE), 'utf8'), ) as { id: string; area: string; status: string }[]; expect(ledger.map((row) => [row.id, row.area, row.status])).toEqual([ - ['orphan', 'Unreferenced', 'pending'], ['new-checkout', 'Rolled out', 'pending'], + ['orphan', 'Unreferenced', 'pending'], ]); expect(run.customPrompt?.({} as never)).toContain('- Rolled out: 1'); }); diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts index 17684afad..890b994c2 100644 --- a/src/lib/programs/cull-feature-flags/classify.ts +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -30,15 +30,55 @@ export const AREA_BY_BUCKET: Record = { healthy: 'Healthy', }; +export type CullLane = + | 'decided' + | 'off-in-posthog' + | 'not-in-code' + | 'nothing-to-cull'; + +export const LANE_ORDER: readonly CullLane[] = [ + 'decided', + 'off-in-posthog', + 'not-in-code', + 'nothing-to-cull', +]; + +export const LANE_LABEL: Record = { + decided: 'Decided in PostHog', + 'off-in-posthog': 'Off in PostHog, still in code', + 'not-in-code': 'In PostHog, not in code', + 'nothing-to-cull': 'Nothing to cull', +}; + +const LANE_BY_BUCKET: Record = { + 'dead-code-reference': 'not-in-code', + 'archived-still-referenced': 'off-in-posthog', + 'disabled-but-referenced': 'off-in-posthog', + 'unreferenced-comment-only': 'not-in-code', + unreferenced: 'not-in-code', + 'fully-rolled-out': 'decided', + 'never-enabled': 'decided', + 'deleted-still-referenced': 'off-in-posthog', + 'multi-callsite-no-wrapper': 'nothing-to-cull', + healthy: 'nothing-to-cull', +}; + +export const LANE_BY_AREA: Record = Object.fromEntries( + (Object.keys(AREA_BY_BUCKET) as CullBucket[]).map((bucket) => [ + AREA_BY_BUCKET[bucket], + LANE_BY_BUCKET[bucket], + ]), +); + export const BUCKET_ORDER: readonly CullBucket[] = [ - 'unreferenced', - 'unreferenced-comment-only', - 'dead-code-reference', - 'archived-still-referenced', - 'disabled-but-referenced', 'fully-rolled-out', 'never-enabled', + 'archived-still-referenced', + 'disabled-but-referenced', 'deleted-still-referenced', + 'unreferenced', + 'unreferenced-comment-only', + 'dead-code-reference', 'multi-callsite-no-wrapper', 'healthy', ]; From 38ce3c644e17f67f905d303559bec1e29731dc4d Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 16:54:28 -0600 Subject: [PATCH 21/28] feat(cull-feature-flags): phase from the ledger and the status feed the left pane guessed the run's stage from ledger statuses alone. since the skill edits every approved row, type checks once and only then disables and resolves, rows stay proposed through the whole edit pass and the screen said "waiting for your pick" while files were changing. the store now reduces the skill's mandated status lines (culling, editing, type checking, disabling) into a small progress record as they arrive, because the status list itself keeps ten lines. cullPhase reads the ledger and that record and returns one of verify, pick, cull, report plus the copy for the pane. the cull copy is a pass card: which pass, which flag and file, and why the order is code, check, posthog. it only names a flag that exists in the ledger. esc gets its own outcome copy. Confidence: high Scope-risk: narrow --- src/lib/programs/cull-feature-flags/phase.ts | 60 +++++ .../audit/__tests__/cull-phase.test.ts | 236 ++++++++++++++++++ src/ui/tui/screens/audit/slides/cull/phase.ts | 117 +++++++++ src/ui/tui/screens/audit/slides/cull/stage.ts | 53 ---- src/ui/tui/store.ts | 16 ++ 5 files changed, 429 insertions(+), 53 deletions(-) create mode 100644 src/lib/programs/cull-feature-flags/phase.ts create mode 100644 src/ui/tui/screens/audit/__tests__/cull-phase.test.ts create mode 100644 src/ui/tui/screens/audit/slides/cull/phase.ts delete mode 100644 src/ui/tui/screens/audit/slides/cull/stage.ts diff --git a/src/lib/programs/cull-feature-flags/phase.ts b/src/lib/programs/cull-feature-flags/phase.ts new file mode 100644 index 000000000..35437d6df --- /dev/null +++ b/src/lib/programs/cull-feature-flags/phase.ts @@ -0,0 +1,60 @@ +export interface CullProgress { + pass: 'idle' | 'edit' | 'verify' | 'disable'; + activeKey: string | null; + activeFile: string | null; + edited: string[]; +} + +export const INITIAL_CULL_PROGRESS: CullProgress = { + pass: 'idle', + activeKey: null, + activeFile: null, + edited: [], +}; + +export function reduceCullProgress( + state: CullProgress, + message: string, +): CullProgress { + const cullingMatch = message.match(/^Culling (.+)$/); + if (cullingMatch) { + const edited = state.activeKey + ? [...state.edited, state.activeKey] + : state.edited; + return { + pass: 'edit', + activeKey: cullingMatch[1], + activeFile: null, + edited, + }; + } + + const editingMatch = message.match(/^Editing (.+)$/); + if (editingMatch) { + return { ...state, activeFile: editingMatch[1] }; + } + + if (/^Type checking \d+ files$/.test(message)) { + const edited = state.activeKey + ? [...state.edited, state.activeKey] + : state.edited; + return { + pass: 'verify', + activeKey: null, + activeFile: null, + edited, + }; + } + + const disablingMatch = message.match(/^Disabling (.+) in PostHog$/); + if (disablingMatch) { + return { + ...state, + pass: 'disable', + activeKey: disablingMatch[1], + activeFile: null, + }; + } + + return state; +} diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts new file mode 100644 index 000000000..9de0c95f3 --- /dev/null +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -0,0 +1,236 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + cullPhase, + reduceCullProgress, + type CullProgress, +} from '../slides/cull/phase'; + +function buildCheck(overrides: Partial = {}): AuditCheck { + return { + id: 'checkout-redesign', + area: 'Rolled out', + label: 'Keeps the on path', + status: 'warning', + file: 'src/checkout.ts', + details: 'winning branch: true', + ...overrides, + }; +} + +function progress(overrides: Partial = {}): CullProgress { + return { ...INITIAL_CULL_PROGRESS, ...overrides }; +} + +const REPORT_PATH = './posthog-feature-flag-cull-report.md'; + +describe('reduceCullProgress', () => { + it('reduces the complete cull status script as messages arrive', () => { + const waiting = reduceCullProgress( + INITIAL_CULL_PROGRESS, + 'Waiting for confirmation', + ); + expect(waiting).toBe(INITIAL_CULL_PROGRESS); + + const firstFlag = reduceCullProgress(waiting, 'Culling checkout-redesign'); + expect(firstFlag).toEqual({ + pass: 'edit', + activeKey: 'checkout-redesign', + activeFile: null, + edited: [], + }); + + const firstFile = reduceCullProgress(firstFlag, 'Editing src/checkout.ts'); + expect(firstFile).toEqual({ + ...firstFlag, + activeFile: 'src/checkout.ts', + }); + + const secondFile = reduceCullProgress( + firstFile, + 'Editing src/checkout.test.ts', + ); + expect(secondFile).toEqual({ + ...firstFile, + activeFile: 'src/checkout.test.ts', + }); + + const secondFlag = reduceCullProgress(secondFile, 'Culling search-v2'); + expect(secondFlag).toEqual({ + pass: 'edit', + activeKey: 'search-v2', + activeFile: null, + edited: ['checkout-redesign'], + }); + + const verifying = reduceCullProgress(secondFlag, 'Type checking 2 files'); + expect(verifying).toEqual({ + pass: 'verify', + activeKey: null, + activeFile: null, + edited: ['checkout-redesign', 'search-v2'], + }); + + const disabling = reduceCullProgress( + verifying, + 'Disabling checkout-redesign in PostHog', + ); + expect(disabling).toEqual({ + pass: 'disable', + activeKey: 'checkout-redesign', + activeFile: null, + edited: ['checkout-redesign', 'search-v2'], + }); + + const completed = reduceCullProgress( + disabling, + 'Culled 2 flags, 0 failed, 0 left for you', + ); + expect(completed).toBe(disabling); + }); + + it('returns the same object for an unknown status line', () => { + const currentProgress = progress({ pass: 'verify' }); + + expect(reduceCullProgress(currentProgress, 'Writing report')).toBe( + currentProgress, + ); + }); +}); + +describe('cullPhase', () => { + it('stays in verify while any ledger row is pending', () => { + expect( + cullPhase( + [buildCheck({ status: 'pending' })], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ), + ).toEqual({ phase: 'verify', copy: undefined }); + }); + + it('waits for a pick when proposals are undecided and progress is idle', () => { + const phase = cullPhase([buildCheck()], INITIAL_CULL_PROGRESS, REPORT_PATH); + + expect(phase.phase).toBe('pick'); + expect(phase.copy).toEqual({ + title: 'Waiting for your pick', + paragraphs: expect.any(Array), + }); + }); + + it('enters cull when every approved proposal is still warning', () => { + const checks = [ + buildCheck(), + buildCheck({ id: 'search-v2', file: 'src/search.ts' }), + ]; + const activeProgress = reduceCullProgress( + INITIAL_CULL_PROGRESS, + 'Culling checkout-redesign', + ); + const phase = cullPhase(checks, activeProgress, REPORT_PATH); + + expect(phase.phase).toBe('cull'); + expect(phase.copy).toMatchObject({ + title: 'Editing code', + paragraphs: expect.any(Array), + }); + expect(phase.copy?.paragraphs.join(' ')).toContain('checkout-redesign'); + }); + + it.each([ + ['edit', 'Editing code'], + ['verify', 'Checking the edits'], + ['disable', 'Disabling flags in PostHog'], + ] as const)('shows the %s pass card', (pass, expectedTitle) => { + const phase = cullPhase([buildCheck()], progress({ pass }), REPORT_PATH); + + expect(phase).toMatchObject({ + phase: 'cull', + copy: { title: expectedTitle, paragraphs: expect.any(Array) }, + }); + }); + + it('enters cull while progress is idle after any row is decided', () => { + const phase = cullPhase( + [ + buildCheck(), + buildCheck({ + id: 'old-search', + status: 'pass', + details: 'winning branch: false; declined by user', + }), + ], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase).toMatchObject({ + phase: 'cull', + copy: { title: 'Culling', paragraphs: expect.any(Array) }, + }); + }); + + it('uses the approved count when the active key is absent from the ledger', () => { + const phase = cullPhase( + [buildCheck(), buildCheck({ id: 'search-v2' })], + progress({ pass: 'edit', activeKey: 'not-in-the-ledger' }), + REPORT_PATH, + ); + const paragraphs = phase.copy?.paragraphs.join(' ') ?? ''; + + expect(phase.phase).toBe('cull'); + expect(paragraphs).toContain('Culling 2 flags'); + expect(paragraphs).not.toContain('not-in-the-ledger'); + }); + + it('reports after proposals are resolved', () => { + const phase = cullPhase( + [buildCheck({ status: 'pass', details: 'winning branch: true; culled' })], + progress({ pass: 'disable' }), + REPORT_PATH, + ); + + expect(phase).toMatchObject({ + phase: 'report', + copy: { paragraphs: expect.any(Array) }, + }); + }); + + it('uses the report-only outcome after every proposal is declined', () => { + const phase = cullPhase( + [ + buildCheck({ + status: 'pass', + details: 'winning branch: true; declined by user', + }), + ], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase).toEqual({ + phase: 'report', + copy: { + title: 'Report only. Nothing changed.', + paragraphs: [ + expect.stringContaining('./posthog-feature-flag-cull-report.md'), + ], + }, + }); + }); + + it('excludes report-only many-call-site warnings from proposals', () => { + const phase = cullPhase( + [buildCheck({ area: 'Many call sites' })], + INITIAL_CULL_PROGRESS, + REPORT_PATH, + ); + + expect(phase.phase).toBe('report'); + expect(phase.copy).toEqual({ + title: expect.any(String), + paragraphs: expect.any(Array), + }); + }); +}); diff --git a/src/ui/tui/screens/audit/slides/cull/phase.ts b/src/ui/tui/screens/audit/slides/cull/phase.ts new file mode 100644 index 000000000..5a14d5660 --- /dev/null +++ b/src/ui/tui/screens/audit/slides/cull/phase.ts @@ -0,0 +1,117 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import type { CullProgress } from '@lib/programs/cull-feature-flags/phase'; +import { + CULLED_MARKER, + DECLINED_MARKER, +} from '@lib/programs/cull-feature-flags/seed'; +import type { WrapUpCopy } from '../../AuditAreaPane.js'; + +export { + INITIAL_CULL_PROGRESS, + reduceCullProgress, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; + +const CULL_SAFETY_COPY = + 'Code first, then a type check, then PostHog: a failed edit never leaves a disabled flag behind live code.'; + +export type CullPhase = 'verify' | 'pick' | 'cull' | 'report'; + +function hasMarker(check: AuditCheck, marker: string): boolean { + return (check.details ?? '').includes(marker); +} + +function buildCullCopy( + checks: readonly AuditCheck[], + progress: CullProgress, + proposals: readonly AuditCheck[], + culled: readonly AuditCheck[], + failed: readonly AuditCheck[], +): WrapUpCopy { + const titleByPass: Record = { + idle: 'Culling', + edit: 'Editing code', + verify: 'Checking the edits', + disable: 'Disabling flags in PostHog', + }; + const activeCheck = checks.find((check) => check.id === progress.activeKey); + const approvedCount = proposals.length + culled.length + failed.length; + const activeFile = progress.activeFile ?? activeCheck?.file; + const firstParagraph = activeCheck + ? `Culling ${activeCheck.id}${activeFile ? ` in ${activeFile}` : ''}.` + : `Culling ${approvedCount} ${approvedCount === 1 ? 'flag' : 'flags'}.`; + const completedCounts = [ + culled.length > 0 ? `${culled.length} culled` : null, + failed.length > 0 ? `${failed.length} failed` : null, + ].filter((count): count is string => count !== null); + const paragraphs = [firstParagraph]; + if (completedCounts.length > 0) { + paragraphs.push(`${completedCounts.join(', ')} so far.`); + } + paragraphs.push(CULL_SAFETY_COPY); + + return { title: titleByPass[progress.pass], paragraphs }; +} + +export function cullPhase( + checks: readonly AuditCheck[], + progress: CullProgress, + reportPath: string, +): { phase: CullPhase; copy: WrapUpCopy | undefined } { + if (checks.some((check) => check.status === 'pending')) { + return { phase: 'verify', copy: undefined }; + } + + const proposals = checks.filter( + (check) => check.status === 'warning' && check.area !== 'Many call sites', + ); + const culled = checks.filter((check) => hasMarker(check, CULLED_MARKER)); + const declined = checks.filter((check) => hasMarker(check, DECLINED_MARKER)); + const failed = checks.filter((check) => check.status === 'error'); + const hasDecisions = culled.length + declined.length + failed.length > 0; + + if (proposals.length > 0 && progress.pass === 'idle' && !hasDecisions) { + return { + phase: 'pick', + copy: { + title: 'Waiting for your pick', + paragraphs: [ + `Every flag is verified at its call site. ${proposals.length} look done and are up for culling; the healthy ones stay.`, + 'Nothing changes until you confirm in the prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', + ], + }, + }; + } + + if (proposals.length > 0) { + return { + phase: 'cull', + copy: buildCullCopy(checks, progress, proposals, culled, failed), + }; + } + + if (culled.length === 0 && declined.length > 0) { + return { + phase: 'report', + copy: { + title: 'Report only. Nothing changed.', + paragraphs: [ + `The report at ${reportPath} lists the ${declined.length} ${ + declined.length === 1 ? 'flag' : 'flags' + } left for you.`, + ], + }, + }; + } + + return { + phase: 'report', + copy: { + title: 'Writing the cull report', + paragraphs: [ + `${culled.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, + 'Hang tight!', + ], + }, + }; +} diff --git a/src/ui/tui/screens/audit/slides/cull/stage.ts b/src/ui/tui/screens/audit/slides/cull/stage.ts deleted file mode 100644 index 4aac1df5d..000000000 --- a/src/ui/tui/screens/audit/slides/cull/stage.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AuditCheck } from '@lib/programs/audit/types'; -import type { WrapUpCopy } from '../../AuditAreaPane.js'; - -const CULLED_MARKER = '; culled'; -const DECLINED_MARKER = '; declined by user'; - -/** Left-pane copy for the cull stages after verification: consent, apply, report. */ -export function cullStageCopy( - checks: readonly AuditCheck[], - reportPath: string, -): WrapUpCopy | undefined { - if (checks.some((check) => check.status === 'pending')) return undefined; - const proposed = checks.filter((check) => check.status === 'warning'); - const culled = checks.filter( - (check) => - check.status === 'pass' && (check.details ?? '').includes(CULLED_MARKER), - ); - const declined = checks.filter( - (check) => - check.status === 'pass' && - (check.details ?? '').includes(DECLINED_MARKER), - ); - const failed = checks.filter((check) => check.status === 'error'); - const isDecided = culled.length + declined.length + failed.length > 0; - - if (proposed.length > 0 && !isDecided) { - return { - title: 'Waiting for your pick', - paragraphs: [ - `Every flag is verified at its call site. ${proposed.length} look done and are up for culling; the healthy ones stay.`, - 'Nothing changes until you confirm in the prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', - ], - }; - } - if (proposed.length > 0) { - return { - title: `Culling ${proposed.length} more`, - paragraphs: [ - `${culled.length} done so far${ - failed.length > 0 ? `, ${failed.length} failed` : '' - }. Code edit first, then the PostHog disable, one flag at a time.`, - 'Every edit is an ordinary git diff; every disabled flag is one toggle from back on.', - ], - }; - } - return { - title: 'Writing the cull report', - paragraphs: [ - `${culled.length} culled, ${declined.length} left for you, ${failed.length} failed. The report at ${reportPath} lists all of them with the undo recipe.`, - 'Hang tight!', - ], - }; -} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b8507ca0d..ea20665be 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -61,6 +61,11 @@ import { reportWarehouseSourcesDetected } from '@lib/programs/posthog-integratio import { EXPANDED_COUNT } from '@ui/tui/constants'; import { IS_DEV } from '@lib/constants'; import { computeTokenCostUsd } from '@lib/agent/token-pricing'; +import { + INITIAL_CULL_PROGRESS, + reduceCullProgress, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; export { TaskStatus, ScreenId, Overlay, Program, RunPhase, McpOutcome }; export type { ScreenName, OutroData, WizardSession, ProgramId }; @@ -188,6 +193,7 @@ export class WizardStore { private $statusMessages = atom([]); private $statusExpanded = atom(false); private $tasks = atom([]); + private $cullProgress = atom(INITIAL_CULL_PROGRESS); private $eventPlan = atom([]); private $handoffText = atom(null); private $learnCardBlockIdx = atom(0); @@ -389,6 +395,10 @@ export class WizardStore { return this.$tasks.get(); } + get cullProgress(): CullProgress { + return this.$cullProgress.get(); + } + get eventPlan(): PlannedEvent[] { return this.$eventPlan.get(); } @@ -905,6 +915,7 @@ export class WizardStore { this.$session.setKey('completedRuns', [...done, stepId]); } this.$tasks.set([]); + this.$cullProgress.set(INITIAL_CULL_PROGRESS); this.setRunPhase(RunPhase.Idle); } @@ -1048,6 +1059,11 @@ export class WizardStore { const msgs = this.$statusMessages.get(); // Skip consecutive duplicate messages (no allocation on the hot path) if (msgs.length > 0 && msgs[msgs.length - 1] === message) return; + const cullProgress = this.$cullProgress.get(); + const nextCullProgress = reduceCullProgress(cullProgress, message); + if (nextCullProgress !== cullProgress) { + this.$cullProgress.set(nextCullProgress); + } // Nanostore detects change by reference equality, so a new array is // required. At the cap, allocate exactly once at the final size (dropping // the oldest entry) rather than push-then-truncate. From a97f316f73054ccc422eebde76dab106078be20c Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 16:54:32 -0600 Subject: [PATCH 22/28] feat(cull-feature-flags): stepper and lane list on the cull run screen the right pane listed every ledger row under ten headings: forty lines on the fixture, never fit, and ten of the rows were healthy flags that never change. reviewers called the layout busy and they were right. cull now gets its own right pane grouped by lane, with healthy and suggestion rows folded into one footer line, five rows per lane before "+n more", and the rows carrying their reason (kept why, failed why). during the cull it shows which flag is being edited or disabled. a one-line stepper above the panes names the phase. the deck only plays while verifying, so a phase change preempts it. other programs keep the shared list untouched. a playground demo cycles the phases against a 19-row and a 400-row ledger. Confidence: medium Scope-risk: narrow --- src/ui/tui/playground/PlaygroundApp.tsx | 6 + src/ui/tui/playground/demos/CullRunDemo.tsx | 254 ++++++++++ src/ui/tui/screens/audit/AuditRunScreen.tsx | 45 +- .../audit/__tests__/cull-flag-list.test.ts | 175 +++++++ .../tui/screens/audit/cull/CullFlagList.tsx | 447 ++++++++++++++++++ 5 files changed, 915 insertions(+), 12 deletions(-) create mode 100644 src/ui/tui/playground/demos/CullRunDemo.tsx create mode 100644 src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts create mode 100644 src/ui/tui/screens/audit/cull/CullFlagList.tsx diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 3c06e130e..87f1b373c 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -25,6 +25,7 @@ import { EndScreensDemo } from './demos/EndScreensDemo.js'; import { AiOptInDemo } from './demos/AiOptInDemo.js'; import { AskModalDemo } from './demos/AskModalDemo.js'; import { ViewportGuardDemo } from './demos/ViewportGuardDemo.js'; +import { CullRunDemo } from './demos/CullRunDemo.js'; interface PlaygroundAppProps { store: WizardStore; @@ -77,6 +78,11 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { label: 'Audit checks', component: , }, + { + id: 'cull-run', + label: 'Cull run', + component: , + }, { id: 'learn-deck', label: 'Learn deck', diff --git a/src/ui/tui/playground/demos/CullRunDemo.tsx b/src/ui/tui/playground/demos/CullRunDemo.tsx new file mode 100644 index 000000000..88e4a717b --- /dev/null +++ b/src/ui/tui/playground/demos/CullRunDemo.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + reduceCullProgress, +} from '@lib/programs/cull-feature-flags/phase'; +import { SplitView } from '@ui/tui/primitives/index'; +import { Colors } from '@ui/tui/styles'; +import { AuditAreaPane } from '@ui/tui/screens/audit/AuditAreaPane'; +import { + CullFlagList, + PhaseStepper, +} from '@ui/tui/screens/audit/cull/CullFlagList'; +import { CULL_AREA_SLIDES } from '@ui/tui/screens/audit/slides/cull'; +import { + cullPhase, + type CullPhase, +} from '@ui/tui/screens/audit/slides/cull/phase'; + +const REPORT_PATH = './posthog-feature-flag-cull-report.md'; +const PHASES: CullPhase[] = ['verify', 'pick', 'cull', 'report']; +const MOCK_STATUS_SCRIPT = [ + 'Culling legacy-banner', + 'Editing src/dashboard.tsx', + 'Culling old-pricing-test', + 'Type checking 2 files', + 'Disabling legacy-banner in PostHog', +]; + +const STALE_FLAGS: AuditCheck[] = [ + { + id: 'new-checkout', + area: 'Rolled out', + label: 'new-checkout: keep on path, drop check, disable flag', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'beta-dashboard', + area: 'Off for everyone', + label: 'beta-dashboard: keep off path, drop check, disable flag', + status: 'pending', + details: 'rollout 0%', + }, + { + id: 'legacy-banner', + area: 'Archived in PostHog', + label: 'legacy-banner: keep off path, drop check', + status: 'pending', + details: 'rollout 100%, archived', + }, + { + id: 'old-pricing-test', + area: 'Disabled in PostHog', + label: 'old-pricing-test: keep off path, drop check', + status: 'pending', + details: 'rollout 50%, inactive', + }, + { + id: 'pricing-v2-experiment', + area: 'Unreferenced', + label: 'pricing-v2-experiment: disable flag', + status: 'pending', + details: 'rollout 30%', + }, + { + id: 'holiday-promo', + area: 'Comment only', + label: 'holiday-promo: disable flag, drop comment', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'legacy-theme', + area: 'Dead code', + label: 'legacy-theme: delete dead module, disable flag', + status: 'pending', + details: 'rollout 100%', + }, + { + id: 'server-rate-limit', + area: 'Deleted in PostHog', + label: 'server-rate-limit: keep off path, drop check', + status: 'pending', + details: 'no flag with this key in PostHog', + }, +]; + +const SMALL_LEDGER: AuditCheck[] = [ + ...STALE_FLAGS, + { + id: 'ai-assistant', + area: 'Many call sites', + label: 'ai-assistant: suggest one wrapper hook', + status: 'suggestion', + details: 'rollout 40%; also src/chat.ts:12, src/nav.ts:8', + }, + ...Array.from( + { length: 10 }, + (_, index): AuditCheck => ({ + id: `healthy-flag-${String(index + 1).padStart(2, '0')}`, + area: 'Healthy', + label: `healthy-flag-${index + 1}: keep`, + status: 'pass', + details: `rollout ${20 + index * 5}%`, + }), + ), +]; + +function generatedLedger(): AuditCheck[] { + return Array.from({ length: 400 }, (_, index) => { + const template = SMALL_LEDGER[index % SMALL_LEDGER.length]; + return { + ...template, + id: `${template.id}-${String(index + 1).padStart(3, '0')}`, + label: `${template.label} ${index + 1}`, + }; + }); +} + +function appendDetail(details: string | undefined, detail: string): string { + if (!details) return detail; + return `${details}; ${detail}`; +} + +function ledgerForPhase( + sourceChecks: readonly AuditCheck[], + phase: CullPhase, +): AuditCheck[] { + let staleIndex = 0; + return sourceChecks.map((check) => { + if (check.area === 'Healthy' || check.area === 'Many call sites') { + return { ...check }; + } + const currentStaleIndex = staleIndex; + staleIndex += 1; + if (phase === 'verify') return { ...check, status: 'pending' }; + if (phase === 'pick') { + if (currentStaleIndex % 8 === 0) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'kept: rollback switch'), + }; + } + return { ...check, status: 'warning' }; + } + if (phase === 'cull') { + if (currentStaleIndex % 8 === 0) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'culled'), + }; + } + if (currentStaleIndex % 8 === 1) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'declined by user'), + }; + } + return { ...check, status: 'warning' }; + } + if (currentStaleIndex % 8 < 5) { + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'culled'), + }; + } + if (currentStaleIndex % 8 === 6) { + return { + ...check, + status: 'error', + details: appendDetail(check.details, 'failed: type check failed'), + }; + } + return { + ...check, + status: 'pass', + details: appendDetail(check.details, 'declined by user'), + }; + }); +} + +export const CullRunDemo = () => { + const [phaseIndex, setPhaseIndex] = useState(0); + const [progress, setProgress] = useState(INITIAL_CULL_PROGRESS); + const [statusIndex, setStatusIndex] = useState(0); + const [isLargeLedger, setIsLargeLedger] = useState(false); + const selectedPhase = PHASES[phaseIndex]; + const checks = useMemo(() => { + const sourceChecks = isLargeLedger ? generatedLedger() : SMALL_LEDGER; + return ledgerForPhase(sourceChecks, selectedPhase); + }, [isLargeLedger, selectedPhase]); + const { phase, copy } = cullPhase(checks, progress, REPORT_PATH); + + const resetProgress = () => { + setProgress(INITIAL_CULL_PROGRESS); + setStatusIndex(0); + }; + + useInput((input) => { + if (input === 'n') { + setPhaseIndex((current) => Math.min(PHASES.length - 1, current + 1)); + resetProgress(); + return; + } + if (input === 'p') { + setPhaseIndex((current) => Math.max(0, current - 1)); + resetProgress(); + return; + } + if (input === 's') { + const status = MOCK_STATUS_SCRIPT[statusIndex]; + setProgress((current) => reduceCullProgress(current, status)); + setStatusIndex((current) => (current + 1) % MOCK_STATUS_SCRIPT.length); + return; + } + if (input === 'l') setIsLargeLedger((current) => !current); + }); + + const leftPane = ( + + ); + + return ( + + + + + } + /> + + + + n/p phase ·{' '} + s status ·{' '} + l ledger ({checks.length}) + + + + ); +}; diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index e3b293a14..7bf26012b 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { join } from 'node:path'; import { Box } from 'ink'; import type { WizardStore } from '@ui/tui/store'; @@ -16,8 +16,9 @@ import { AuditAreaPane } from './AuditAreaPane.js'; import { AUDIT_AREA_SLIDES } from './slides/index.js'; import { EVENTS_AUDIT_AREA_SLIDES } from './slides/events-audit/index.js'; import { CULL_AREA_SLIDES } from './slides/cull/index.js'; -import { cullStageCopy } from './slides/cull/stage.js'; +import { cullPhase } from './slides/cull/phase.js'; import { PendingChecksList } from './PendingChecksList.js'; +import { CullFlagList, PhaseStepper } from './cull/CullFlagList.js'; import { AUDIT_CHECKS_FILE, AUDIT_CHECKS_KEY, @@ -58,13 +59,18 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { getProgramConfig(store.router.activeProgram).reportFile ?? AUDIT_REPORT_FILE; const reportPath = `./${reportFile}`; - const pendingChecksList = ; const activeProgram = store.router.activeProgram; const isCull = activeProgram === 'cull-feature-flags'; const slides = slidesFor(activeProgram, store.session.skillId); - const wrapUp = isCull ? cullStageCopy(checks, reportPath) : undefined; - const learnBlocks = getProgramConfig(activeProgram).getContentBlocks; - const showLearnDeck = isCull && !store.learnCardComplete && !!learnBlocks; + const { phase, copy } = isCull + ? cullPhase(checks, store.cullProgress, reportPath) + : { phase: undefined, copy: undefined }; + const learnBlocks = useMemo(() => { + if (activeProgram !== 'cull-feature-flags') return undefined; + return getProgramConfig(activeProgram).getContentBlocks?.(store); + }, [activeProgram, store]); + const showLearnDeck = + isCull && phase === 'verify' && !store.learnCardComplete && !!learnBlocks; let leftPane = ( { slides={slides} dashboardUrl={store.session.dashboardUrl} notebookUrl={store.session.notebookUrl} - wrapUp={wrapUp} + wrapUp={copy} /> ); - if (showLearnDeck && learnBlocks) { + if (showLearnDeck) { leftPane = ( store.setLearnCardComplete()} /> ); } + const pendingChecksList = ; + const cullFlagList = phase ? ( + + ) : null; + const rightPane = isCull ? cullFlagList : pendingChecksList; + // Narrow terminals: drop the area pane. - const statusComponent = + const statusLayout = columns < 80 ? ( - {pendingChecksList} + {rightPane} + + ) : ( + + ); + const statusComponent = + isCull && phase ? ( + + + {statusLayout} ) : ( - + statusLayout ); const tabs = [ diff --git a/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts new file mode 100644 index 000000000..049c16fd9 --- /dev/null +++ b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts @@ -0,0 +1,175 @@ +import type { AuditCheck } from '@lib/programs/audit/types'; +import { + INITIAL_CULL_PROGRESS, + type CullProgress, +} from '@lib/programs/cull-feature-flags/phase'; +import { toLaneGroups } from '../cull/CullFlagList.js'; + +function check( + id: string, + area: string, + status: AuditCheck['status'] = 'pending', + details = 'rollout 100%', +): AuditCheck { + return { id, area, status, details, label: id }; +} + +function progress(overrides: Partial): CullProgress { + return { ...INITIAL_CULL_PROGRESS, ...overrides }; +} + +describe('toLaneGroups', () => { + it('groups rows in the configured lane order and sends unknown areas to nothing to cull', () => { + const groups = toLaneGroups( + [ + check('unknown', 'Future classifier area'), + check('missing', 'Unreferenced'), + check('off', 'Archived in PostHog'), + check('decided', 'Rolled out'), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(groups.map((group) => group.lane)).toEqual([ + 'decided', + 'off-in-posthog', + 'not-in-code', + 'nothing-to-cull', + ]); + expect(groups[3].rows.map((row) => row.id)).toEqual(['unknown']); + }); + + it('folds healthy and many-call-site rows into one verify footer', () => { + const groups = toLaneGroups( + [ + check('healthy-one', 'Healthy', 'pass', 'rollout 25%'), + check('healthy-two', 'Healthy', 'pass', 'multivariate'), + check('wrapper', 'Many call sites', 'suggestion', 'rollout 50%'), + check('candidate', 'Rolled out'), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + const nothingToCull = groups.find( + (group) => group.lane === 'nothing-to-cull', + ); + + expect(nothingToCull).toMatchObject({ + rows: [], + footer: '2 healthy, 1 suggestion, nothing to do', + }); + }); + + it('folds declined rows into their lane footer during cull', () => { + const groups = toLaneGroups( + [ + check( + 'declined-one', + 'Rolled out', + 'pass', + 'rollout 100%; declined by user', + ), + check( + 'declined-two', + 'Off for everyone', + 'pass', + 'rollout 0%; declined by user', + ), + check( + 'declined-three', + 'Disabled in PostHog', + 'pass', + 'inactive; declined by user', + ), + ], + INITIAL_CULL_PROGRESS, + 'cull', + ); + + expect(groups).toEqual([ + expect.objectContaining({ + lane: 'decided', + rows: [], + footer: '2 left for you', + complete: 2, + total: 2, + }), + expect.objectContaining({ + lane: 'off-in-posthog', + rows: [], + footer: '1 left for you', + complete: 1, + total: 1, + }), + ]); + }); + + it('keeps kept rows visible in verify with the kept reason', () => { + const [group] = toLaneGroups( + [ + check( + 'kill-switch', + 'Off for everyone', + 'pass', + 'rollout 0%; kept: protects emergency rollback; reviewed', + ), + ], + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(group.rows[0]).toMatchObject({ + id: 'kill-switch', + state: 'kept', + text: 'kill-switch protects emergency rollback', + }); + }); + + it('caps each lane at five rows and reports the hidden count', () => { + const [group] = toLaneGroups( + Array.from({ length: 7 }, (_, index) => + check(`flag-${index + 1}`, 'Rolled out'), + ), + INITIAL_CULL_PROGRESS, + 'verify', + ); + + expect(group.rows).toHaveLength(5); + expect(group.hiddenCount).toBe(2); + expect(group.total).toBe(7); + }); + + it.each([ + ['edit', 'active', [], 'editing'], + ['disable', 'active', [], 'disabling'], + ['verify', null, ['active'], 'edited'], + ] as const)( + 'maps %s progress to the %s row state', + (pass, activeKey, edited, expectedState) => { + const [group] = toLaneGroups( + [check('active', 'Rolled out', 'warning')], + progress({ pass, activeKey, edited: [...edited] }), + 'cull', + ); + + expect(group.rows[0]).toMatchObject({ + state: expectedState, + text: `active ${expectedState}`, + }); + }, + ); + + it('does not mark any row active when the active key is unknown', () => { + const states = toLaneGroups( + [ + check('first', 'Rolled out', 'warning'), + check('second', 'Off for everyone', 'warning'), + ], + progress({ pass: 'edit', activeKey: 'not-in-ledger' }), + 'cull', + ).flatMap((lane) => lane.rows.map((row) => row.state)); + + expect(states).toEqual(['proposed', 'proposed']); + }); +}); diff --git a/src/ui/tui/screens/audit/cull/CullFlagList.tsx b/src/ui/tui/screens/audit/cull/CullFlagList.tsx new file mode 100644 index 000000000..2edd0fb39 --- /dev/null +++ b/src/ui/tui/screens/audit/cull/CullFlagList.tsx @@ -0,0 +1,447 @@ +import { Fragment } from 'react'; +import { Box, Text } from 'ink'; +import { Spinner } from '@inkjs/ui'; +import { + AUDIT_SEVERITY_STYLE, + type AuditCheck, +} from '@lib/programs/audit/types'; +import { + LANE_BY_AREA, + LANE_LABEL, + LANE_ORDER, + type CullLane, +} from '@lib/programs/cull-feature-flags/classify'; +import type { CullProgress } from '@lib/programs/cull-feature-flags/phase'; +import { + CULLED_MARKER, + DECLINED_MARKER, +} from '@lib/programs/cull-feature-flags/seed'; +import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; +import { LoadingBox } from '@ui/tui/primitives/index'; +import { Colors, Icons } from '@ui/tui/styles'; +import type { CullPhase } from '../slides/cull/phase.js'; + +export type LaneRowState = + | 'pending' + | 'proposed' + | 'kept' + | 'editing' + | 'edited' + | 'disabling' + | 'culled' + | 'failed' + | 'declined'; + +export interface LaneRow { + id: string; + glyph: string; + color: string; + text: string; + state: LaneRowState; +} + +export interface LaneGroup { + lane: CullLane; + label: string; + rows: LaneRow[]; + hiddenCount: number; + footer?: string; + complete: number; + total: number; +} + +interface MutableLaneGroup { + rows: LaneRow[]; + complete: number; + total: number; + declinedCount: number; +} + +interface FoldedCounts { + healthy: number; + kept: number; + suggestions: number; +} + +interface CullFlagListProps { + checks: readonly AuditCheck[]; + progress: CullProgress; + phase: CullPhase; +} + +const MAX_VISIBLE_ROWS = 5; +const COLLAPSE_BELOW_ROWS = 30; +const KEPT_MARKER = '; kept:'; +const FAILED_MARKER = '; failed:'; + +const PHASE_STEPS: ReadonlyArray<{ phase: CullPhase; label: string }> = [ + { phase: 'verify', label: 'Verify' }, + { phase: 'pick', label: 'Pick' }, + { phase: 'cull', label: 'Cull' }, + { phase: 'report', label: 'Report' }, +]; + +function hasMarker(check: AuditCheck, marker: string): boolean { + return (check.details ?? '').includes(marker); +} + +function markerReason(details: string, marker: string): string { + const markerIndex = details.indexOf(marker); + if (markerIndex < 0) return ''; + return details + .slice(markerIndex + marker.length) + .split(';')[0] + .trim(); +} + +function rowReason(check: AuditCheck): string { + const details = check.details ?? ''; + if (hasMarker(check, KEPT_MARKER)) { + return markerReason(details, KEPT_MARKER); + } + if (hasMarker(check, FAILED_MARKER)) { + return markerReason(details, FAILED_MARKER); + } + return details.split(';')[0].trim(); +} + +function baseRowState(check: AuditCheck): LaneRowState { + if (hasMarker(check, CULLED_MARKER)) return 'culled'; + if (check.status === 'error' || hasMarker(check, FAILED_MARKER)) { + return 'failed'; + } + if (hasMarker(check, DECLINED_MARKER)) return 'declined'; + if (hasMarker(check, KEPT_MARKER)) return 'kept'; + if (check.status === 'warning') return 'proposed'; + return 'pending'; +} + +function rowState( + check: AuditCheck, + progress: CullProgress, + phase: CullPhase, +): LaneRowState { + const state = baseRowState(check); + if (state === 'culled' || state === 'failed' || state === 'declined') { + return state; + } + if (phase !== 'cull') return state; + if (check.id === progress.activeKey && progress.pass === 'edit') { + return 'editing'; + } + if (check.id === progress.activeKey && progress.pass === 'disable') { + return 'disabling'; + } + if (progress.edited.includes(check.id)) return 'edited'; + return state; +} + +function stateReason(state: LaneRowState, check: AuditCheck): string { + if (state === 'editing') return 'editing'; + if (state === 'edited') return 'edited'; + if (state === 'disabling') return 'disabling'; + if (state === 'culled') return 'culled'; + return rowReason(check); +} + +function toLaneRow( + check: AuditCheck, + progress: CullProgress, + phase: CullPhase, +): LaneRow { + const state = rowState(check, progress, phase); + const { glyph, color } = AUDIT_SEVERITY_STYLE[check.status]; + return { + id: check.id, + glyph, + color, + text: `${check.id} ${stateReason(state, check)}`, + state, + }; +} + +function isCompleteState(state: LaneRowState): boolean { + return ['kept', 'edited', 'culled', 'failed', 'declined'].includes(state); +} + +function isHealthy(check: AuditCheck): boolean { + if (check.status !== 'pass') return false; + return ![KEPT_MARKER, CULLED_MARKER, DECLINED_MARKER].some((marker) => + hasMarker(check, marker), + ); +} + +function shouldShowInVerify(check: AuditCheck): boolean { + if (hasMarker(check, KEPT_MARKER)) return true; + return check.status === 'pending' || check.status === 'warning'; +} + +function shouldShowInCull(check: AuditCheck): boolean { + if (hasMarker(check, CULLED_MARKER)) return true; + if (check.status === 'error') return true; + return check.status === 'warning'; +} + +function foldedFooter(counts: FoldedCounts): string | undefined { + const parts = [ + counts.healthy > 0 ? `${counts.healthy} healthy` : null, + counts.kept > 0 ? `${counts.kept} kept` : null, + counts.suggestions > 0 + ? `${counts.suggestions} suggestion${counts.suggestions === 1 ? '' : 's'}` + : null, + ].filter((part): part is string => part !== null); + if (parts.length === 0) return undefined; + return `${parts.join(', ')}, nothing to do`; +} + +export function toLaneGroups( + checks: readonly AuditCheck[], + progress: CullProgress, + phase: CullPhase, +): LaneGroup[] { + const groupsByLane: Record = { + decided: { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'off-in-posthog': { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'not-in-code': { rows: [], complete: 0, total: 0, declinedCount: 0 }, + 'nothing-to-cull': { + rows: [], + complete: 0, + total: 0, + declinedCount: 0, + }, + }; + const foldedCounts: FoldedCounts = { + healthy: 0, + kept: 0, + suggestions: 0, + }; + const isVerifyOrPick = phase === 'verify' || phase === 'pick'; + + for (const check of checks) { + const sourceLane = LANE_BY_AREA[check.area] ?? 'nothing-to-cull'; + const isSuggestion = check.area === 'Many call sites'; + const isApprovedSuggestion = + !isVerifyOrPick && + (hasMarker(check, CULLED_MARKER) || check.status === 'error'); + if (isSuggestion && !isApprovedSuggestion) { + foldedCounts.suggestions += 1; + continue; + } + if (isVerifyOrPick && isHealthy(check)) { + foldedCounts.healthy += 1; + continue; + } + if (!isVerifyOrPick && hasMarker(check, DECLINED_MARKER)) { + const group = groupsByLane[sourceLane]; + group.declinedCount += 1; + group.complete += 1; + group.total += 1; + continue; + } + if (!isVerifyOrPick && hasMarker(check, KEPT_MARKER)) { + foldedCounts.kept += 1; + continue; + } + if (!isVerifyOrPick && isHealthy(check)) { + foldedCounts.healthy += 1; + continue; + } + + const shouldShow = isVerifyOrPick + ? shouldShowInVerify(check) + : shouldShowInCull(check); + if (!shouldShow) continue; + const group = groupsByLane[sourceLane]; + const row = toLaneRow(check, progress, phase); + group.rows.push(row); + group.total += 1; + if (isCompleteState(row.state)) group.complete += 1; + } + + const nothingToCullFooter = foldedFooter(foldedCounts); + return LANE_ORDER.map((lane): LaneGroup | null => { + const group = groupsByLane[lane]; + const hiddenCount = Math.max(0, group.rows.length - MAX_VISIBLE_ROWS); + const declinedFooter = + group.declinedCount > 0 + ? `${group.declinedCount} left for you` + : undefined; + const footer = + lane === 'nothing-to-cull' ? nothingToCullFooter : declinedFooter; + if (group.rows.length === 0 && !footer) return null; + return { + lane, + label: LANE_LABEL[lane], + rows: group.rows.slice(0, MAX_VISIBLE_ROWS), + hiddenCount, + footer, + complete: group.complete, + total: group.total, + }; + }).filter((group): group is LaneGroup => group !== null); +} + +function groupIcon(group: LaneGroup): { icon: string; color: string } { + if (group.complete === 0) { + return { icon: Icons.squareOpen, color: Colors.muted }; + } + if (group.complete === group.total) { + return { icon: Icons.squareFilled, color: Colors.success }; + } + return { icon: Icons.triangleRight, color: Colors.primary }; +} + +const LaneHeader = ({ + group, + isActive, +}: { + group: LaneGroup; + isActive: boolean; +}) => { + const { icon, color } = groupIcon(group); + return ( + + {isActive ? ( + + + + ) : ( + + {icon}{' '} + + )} + + {group.label}{' '} + {group.lane !== 'nothing-to-cull' && ( + + ({group.complete}/{group.total}) + + )} + + + ); +}; + +const FlagRow = ({ row }: { row: LaneRow }) => { + if (row.state === 'editing' || row.state === 'disabling') { + return ( + + + + + {row.text} + + ); + } + return ( + + {row.glyph} {row.text} + + ); +}; + +function activeLaneFor( + checks: readonly AuditCheck[], + progress: CullProgress, + phase: CullPhase, +): CullLane | undefined { + if (phase === 'verify') { + const firstPending = checks.find( + (check) => check.status === 'pending' && check.area !== 'Many call sites', + ); + if (!firstPending) return undefined; + return LANE_BY_AREA[firstPending.area] ?? 'nothing-to-cull'; + } + if (phase !== 'cull' || !progress.activeKey) return undefined; + const activeCheck = checks.find((check) => check.id === progress.activeKey); + if (!activeCheck) return undefined; + return LANE_BY_AREA[activeCheck.area] ?? 'nothing-to-cull'; +} + +export const PhaseStepper = ({ phase }: { phase: CullPhase }) => { + const currentIndex = PHASE_STEPS.findIndex((step) => step.phase === phase); + return ( + + {PHASE_STEPS.map((step, index) => ( + + {index > 0 && } + {index < currentIndex ? ( + + {Icons.check} {step.label} + + ) : null} + {index === currentIndex ? ( + + {step.label} + + ) : null} + {index > currentIndex ? ( + {step.label} + ) : null} + + ))} + + ); +}; + +export const CullFlagList = ({ + checks, + progress, + phase, +}: CullFlagListProps) => { + const [, terminalRows] = useStdoutDimensions(); + if (checks.length === 0) { + return ( + + Flags + + + + ); + } + + const groups = toLaneGroups(checks, progress, phase); + const activeLane = activeLaneFor(checks, progress, phase); + const shouldCollapse = terminalRows < COLLAPSE_BELOW_ROWS; + + return ( + + Flags + + {groups.map((group, index) => { + const isActive = group.lane === activeLane; + const isExpanded = !shouldCollapse || isActive; + const showsOnlyFooter = + group.lane === 'nothing-to-cull' && group.rows.length === 0; + if (showsOnlyFooter) { + return ( + + {group.footer} + + ); + } + return ( + + + {isExpanded && + group.rows.map((row) => )} + {isExpanded && group.hiddenCount > 0 && ( + + +{group.hiddenCount} more + + )} + {isExpanded && group.footer && ( + + {group.footer} + + )} + + ); + })} + + ); +}; From dff43651303e018d15e1f451cdba9698b5c2da3b Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 16:54:35 -0600 Subject: [PATCH 23/28] fix(cull-feature-flags): the deck teaches the lanes in plain words the first deck listed four ways a flag goes stale and used "winning branch" like the skill does. a junior reviewer did not know what a call site or a winning branch was, and nothing on screen told them. the deck now teaches one model in the same words the list uses: a flag lives in two places, posthog decides, the code asks, and the three lanes are the three ways that drifts. it defines call site, borrows the status peek and "press s" from the integration deck so people find the live narration, and says what culling and undo do. about 97 seconds. slides drop the consent and undo lines the deck now owns and say that only this repository was scanned. the deck test loops every program deck so both get the width guards. Confidence: high Scope-risk: narrow --- .../__tests__/self-driving-deck.test.ts | 119 +++++++++++++----- .../cull-feature-flags/content/index.tsx | 86 +++++++------ src/ui/tui/screens/audit/slides/cull/index.ts | 52 ++++---- 3 files changed, 159 insertions(+), 98 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-deck.test.ts b/src/lib/programs/__tests__/self-driving-deck.test.ts index d4c552a23..bb7163fa9 100644 --- a/src/lib/programs/__tests__/self-driving-deck.test.ts +++ b/src/lib/programs/__tests__/self-driving-deck.test.ts @@ -1,41 +1,80 @@ -/** - * Layout guards for the self-driving 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/self-driving/content/index'; - -/** paneWidth in LearnCard at 80 cols: (min(120, 80) - 2) / 2 - 2 */ +import type { ReactElement, ReactNode } from 'react'; +import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; +import { WizardStore } from '@ui/tui/store'; + const PANE_WIDTH_80COL = 37; +const LEGACY_FIXED_LINE_WIDTH_BY_PROGRAM = new Map([ + ['posthog-integration', 45], + ['migration', 53], +]); +const LEGACY_PROSE_ROWS_BY_PROGRAM = new Map([ + ['error-tracking-upload-source-maps', 6], +]); 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); + const element = node as ReactElement<{ children?: ReactNode }>; + return textOf(element.props?.children); } -describe('self-driving learn deck', () => { - const blocks = getContentBlocks(); +const baseStore = new WizardStore(); +const decks = PROGRAM_REGISTRY.flatMap((program) => { + if (!program.getContentBlocks) return []; + const store = program.skillId + ? withSessionOverride(baseStore, { skillId: program.skillId }) + : baseStore; + return [ + { + id: program.id, + blocks: program.getContentBlocks(store), + }, + ]; +}); + +function withSessionOverride( + store: WizardStore, + patch: Partial, +): WizardStore { + const stub = Object.create(Object.getPrototypeOf(store)) as WizardStore; + Object.assign(stub, store); + Object.defineProperty(stub, 'session', { + value: { ...store.session, ...patch }, + writable: false, + configurable: true, + }); + return stub; +} - it('has blocks', () => { - expect(blocks.length).toBeGreaterThan(0); +describe('program learn decks', () => { + it('has blocks in every registered deck', () => { + const emptyDecks = decks + .filter((deck) => deck.blocks.length === 0) + .map((deck) => deck.id); + expect(emptyDecks).toEqual([]); }); - it('keeps every fixed-layout line within the 80-col pane', () => { + it('keeps every fixed-layout line within its width ceiling', () => { 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); + for (const deck of decks) { + const maxLineWidth = + LEGACY_FIXED_LINE_WIDTH_BY_PROGRAM.get(deck.id) ?? PANE_WIDTH_80COL; + for (const block of deck.blocks) { + if ( + typeof block !== 'object' || + !('type' in block) || + block.type !== 'lines' + ) { + continue; + } + for (const line of block.lines) { + for (const physicalLine of textOf(line).split('\n')) { + if ([...physicalLine].length > maxLineWidth) { + wide.push(`${deck.id}: ${physicalLine}`); + } + } + } } } expect(wide).toEqual([]); @@ -43,13 +82,31 @@ describe('self-driving learn deck', () => { 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); + for (const deck of decks) { + const maxProseRows = LEGACY_PROSE_ROWS_BY_PROGRAM.get(deck.id) ?? 4; + for (const block of deck.blocks) { + if (typeof block !== 'object' || !('content' in block)) continue; + if (typeof block.content !== 'string') continue; + if (Math.ceil(block.content.length / PANE_WIDTH_80COL) > maxProseRows) { + long.push(`${deck.id}: ${block.content}`); + } } } expect(long).toEqual([]); }); + + it('keeps implementation jargon out of the cull deck', () => { + const cullDeck = decks.find((deck) => deck.id === 'cull-feature-flags'); + expect(cullDeck).toBeDefined(); + const forbiddenContent = cullDeck?.blocks.filter((block) => { + if (typeof block === 'string') { + return /winning branch|grep|bucket/i.test(block); + } + if (!('content' in block) || typeof block.content !== 'string') { + return false; + } + return /winning branch|grep|bucket/i.test(block.content); + }); + expect(forbiddenContent).toEqual([]); + }); }); diff --git a/src/lib/programs/cull-feature-flags/content/index.tsx b/src/lib/programs/cull-feature-flags/content/index.tsx index ba1b99414..d4df44612 100644 --- a/src/lib/programs/cull-feature-flags/content/index.tsx +++ b/src/lib/programs/cull-feature-flags/content/index.tsx @@ -1,35 +1,35 @@ 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 STALE_WAYS: ContentBlock = { +const CULL_LANES: ContentBlock = { type: 'lines', interval: 500, - pause: 7000, + pause: 8000, lines: [ - {'100%'} - {' on for everyone, check is dead weight'} + Decided in PostHog + {'\nPostHog is at 100% or 0%'} , - {' 0%'} - {' off for everyone, feature never ships'} + Off in PostHog, still in code + {'\nPostHog says off, code still asks'} , - {' off'} - {' disabled or archived, code still asks'} - , - - {' ?'} - {' in PostHog, never evaluated in code'} + In PostHog, not in code + {'\nPostHog has it, nobody asks'} , ], }; -export const getContentBlocks = (): ContentBlock[] => [ +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => [ { - content: 'Welcome.', + content: store?.session.apiUser?.first_name + ? `Welcome, ${store.session.apiUser.first_name}.` + : 'Welcome.', pause: 3000, mode: TextRevealMode.Typewriter, animationInterval: 160, @@ -41,39 +41,53 @@ export const getContentBlocks = (): ContentBlock[] => [ { type: 'clear', pause: 1500 }, - { - content: - 'A flag is a switch. Once everyone has the feature, the switch is a dead branch in your code.', - pause: 6000, - }, - { content: 'Four ways a flag goes stale:', pause: 2000 }, - STALE_WAYS, + { content: 'A flag lives in two places.', pause: 3000 }, + { content: 'PostHog decides. Your code asks.', pause: 3500 }, + { content: 'Stale is when the two drift apart.', pause: 4000 }, + CULL_LANES, + { content: 'Everything else is “Nothing to cull”.', pause: 3000 }, { type: 'clear', pause: 1500 }, { - content: - "I don't guess. The wizard scanned every source file and pulled your flags from PostHog before I started.", - pause: 6500, + content: 'The wizard found every call site before I started.', + pause: 4000, }, { - content: - 'Fixed rules put each flag in a bucket. My job is to read the call site and confirm it, or keep the flag.', - pause: 6500, + content: 'A call site is the line of code that asks PostHog about a flag.', + pause: 5500, + }, + { + content: 'I read each one and confirm the flag is done, or keep it.', + pause: 5000, + }, + { + pause: 5000, + persist: true, + content: , }, - - { type: 'clear', pause: 1500 }, - { - content: - 'Nothing changes until you pick. One prompt: report only, or cull the flags you choose.', pause: 6000, + content: ( + + Press{' '} + + S + {' '} + to expand or collapse the status. + + ), }, + + { type: 'clear', pause: 1500 }, + + { content: 'Nothing changes until you pick.', pause: 3000 }, { content: - 'Culling keeps the winning branch, drops the check, and disables the flag in PostHog. Never deleted.', - pause: 6500, + 'Culling keeps the code that runs today, drops the check, and disables the flag in PostHog.', + pause: 5000, }, + { content: 'Never deleted.', pause: 1500 }, { content: 'Undo is one step each: git checkout for the code, one toggle on the flag page for PostHog.', @@ -83,8 +97,8 @@ export const getContentBlocks = (): ContentBlock[] => [ { type: 'clear', pause: 1500 }, { - content: 'Verifying call sites now. The list on the right moves as I go.', - pause: 60000, + content: 'Verifying now. The list on the right moves as I go.', + pause: 20000, persist: true, }, ]; diff --git a/src/ui/tui/screens/audit/slides/cull/index.ts b/src/ui/tui/screens/audit/slides/cull/index.ts index 04ef891bd..435963345 100644 --- a/src/ui/tui/screens/audit/slides/cull/index.ts +++ b/src/ui/tui/screens/audit/slides/cull/index.ts @@ -1,10 +1,6 @@ import type { AreaSlide } from '../shared.js'; const DOCS_URL = 'https://posthog.com/docs/feature-flags/best-practices'; -const CONSENT = - "You'll confirm before any edit, and git diff shows exactly what moved."; -const DISABLE_ONLY = - 'The flag gets disabled in PostHog, never deleted, so re-enabling is one toggle on the flag page.'; const slide = (area: string, intro: string[]): AreaSlide => ({ area, @@ -12,54 +8,48 @@ const slide = (area: string, intro: string[]): AreaSlide => ({ docsUrl: DOCS_URL, }); -// One slide per ledger `area`; keys match AREA_BY_BUCKET in the program's classify.ts. export const CULL_AREA_SLIDES: AreaSlide[] = [ slide('Rolled out', [ - 'This flag is at 100% for everyone with no conditions. Culling keeps the on branch and removes the check.', - CONSENT, - DISABLE_ONLY, + 'This flag is at 100% for everyone, so culling preserves the on path and removes the check.', + 'The wizard checked that no rollout conditions remain; the agent confirms the call site is a plain on/off check.', ]), slide('Off for everyone', [ - 'This flag is at 0% for everyone. Culling keeps the off branch and removes the check.', + 'This flag is at 0% for everyone, so culling preserves the off path and removes the check.', 'A flag rolled back after an incident looks the same; if it reads like a kill switch, keep it.', - CONSENT, - DISABLE_ONLY, ]), slide('Archived in PostHog', [ - 'PostHog already archived this flag, but the code still checks it. Culling keeps the off branch and removes the check.', - CONSENT, - 'Nothing changes in PostHog for this one; the flag stays archived.', + 'PostHog archived this flag, but the code still checks it, so culling preserves the off path and removes the check.', + 'The flag stays archived.', ]), slide('Disabled in PostHog', [ - 'This flag is switched off in PostHog, but the code still checks it. Culling keeps the off branch and removes the check.', - CONSENT, - 'Nothing changes in PostHog for this one; the flag stays off.', + 'PostHog switched this flag off, but the code still checks it, so culling preserves the off path and removes the check.', + 'The flag stays off in PostHog.', ]), slide('Unreferenced', [ - 'PostHog has this flag, but nothing in this project evaluates it. Culling only disables the flag.', - 'If the project reads flags in bulk or by a computed key, the agent verifies that first.', - DISABLE_ONLY, + 'PostHog has this flag, but nothing in this repository evaluates it.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent checks bulk and computed-key reads before suggesting it.', ]), slide('Comment only', [ - 'The only place this key shows up is a comment or config string, never an evaluation. Culling disables the flag and cleans up the mention.', - CONSENT, - DISABLE_ONLY, + 'This key appears only in a comment or config string, not an evaluation.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent confirms that no executable evaluation exists before removing the mention.', ]), slide('Dead code', [ - 'The only file that checks this flag is not imported anywhere and is not a Next.js route. Culling deletes that file.', - CONSENT, - DISABLE_ONLY, + 'The only file checking this flag is not imported and is not a Next.js route.', + 'Only this repository was scanned, so a flag read by another service or app can look unreferenced here.', + 'The agent verifies that the file is unreachable before deleting it.', ]), slide('Deleted in PostHog', [ - 'The code checks a key PostHog no longer has, so it always resolves to off. Culling keeps the off branch and removes the check.', - 'The agent first checks the key is not a typo of a live flag.', - CONSENT, + 'The code checks a key PostHog no longer has, so culling preserves the off path and removes the check.', + 'The agent confirms that the key is not a typo of a live flag.', ]), slide('Many call sites', [ - 'Three or more files evaluate this flag directly. That is a suggestion, not a removal: the report recommends one hook or helper.', + 'Three or more files evaluate this flag directly, so the report suggests one hook or helper.', 'Nothing is edited or disabled for this flag.', ]), slide('Healthy', [ - 'This flag is live, partially rolled out or multivariate, and the code still needs it. Nothing to do.', + 'This flag is live, partially rolled out or multivariate, and the code still needs it.', + 'It is kept unchanged.', ]), ]; From ff7ddd26af06894f79fbf95da47bf863ca4b44b4 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 17:05:41 -0600 Subject: [PATCH 24/28] feat(cull-feature-flags): say why each flag is being culled the pass card named the flag and the file but not the reason, so during the cull you saw "culling server-rate-limit" with no idea what put it on the list. the card now adds one paragraph from the ledger row: the bucket and its rollout summary, which branch stays, and whether posthog is touched. the set of areas that disable a flag moves to classify so the outro and the card cannot disagree. Confidence: high Scope-risk: narrow --- .../programs/cull-feature-flags/classify.ts | 13 ++++++++ src/lib/programs/cull-feature-flags/outro.ts | 10 +----- .../audit/__tests__/cull-phase.test.ts | 6 ++++ src/ui/tui/screens/audit/slides/cull/phase.ts | 32 +++++++++++++++++++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/lib/programs/cull-feature-flags/classify.ts b/src/lib/programs/cull-feature-flags/classify.ts index 890b994c2..db4b6fc5c 100644 --- a/src/lib/programs/cull-feature-flags/classify.ts +++ b/src/lib/programs/cull-feature-flags/classify.ts @@ -70,6 +70,19 @@ export const LANE_BY_AREA: Record = Object.fromEntries( ]), ); +/** Areas whose cull disables the flag in PostHog; the other areas leave PostHog untouched. */ +export const DISABLING_AREAS: ReadonlySet = new Set( + ( + [ + 'fully-rolled-out', + 'never-enabled', + 'unreferenced', + 'unreferenced-comment-only', + 'dead-code-reference', + ] as CullBucket[] + ).map((bucket) => AREA_BY_BUCKET[bucket]), +); + export const BUCKET_ORDER: readonly CullBucket[] = [ 'fully-rolled-out', 'never-enabled', diff --git a/src/lib/programs/cull-feature-flags/outro.ts b/src/lib/programs/cull-feature-flags/outro.ts index c50af780f..23c788b24 100644 --- a/src/lib/programs/cull-feature-flags/outro.ts +++ b/src/lib/programs/cull-feature-flags/outro.ts @@ -1,17 +1,9 @@ import * as path from 'path'; import type { AuditCheck } from '@lib/programs/audit/types'; import { OutroKind, type OutroData } from '@lib/wizard-session'; -import { AREA_BY_BUCKET } from './classify.js'; +import { DISABLING_AREAS } from './classify.js'; import { CULLED_MARKER, DECLINED_MARKER } from './seed.js'; -const DISABLING_AREAS = new Set([ - AREA_BY_BUCKET['dead-code-reference'], - AREA_BY_BUCKET['unreferenced-comment-only'], - AREA_BY_BUCKET.unreferenced, - AREA_BY_BUCKET['fully-rolled-out'], - AREA_BY_BUCKET['never-enabled'], -]); - export interface CullOutroInput { checks: readonly AuditCheck[]; touchedFiles: readonly string[]; diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts index 9de0c95f3..99ccf76d0 100644 --- a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -136,6 +136,12 @@ describe('cullPhase', () => { paragraphs: expect.any(Array), }); expect(phase.copy?.paragraphs.join(' ')).toContain('checkout-redesign'); + const why = phase.copy?.paragraphs.find((paragraph) => + paragraph.startsWith('Why: '), + ); + expect(why).toContain('Rolled out'); + expect(why).toContain('Keeps the code that runs today'); + expect(why).toContain('disabled in PostHog'); }); it.each([ diff --git a/src/ui/tui/screens/audit/slides/cull/phase.ts b/src/ui/tui/screens/audit/slides/cull/phase.ts index 5a14d5660..d28682a6f 100644 --- a/src/ui/tui/screens/audit/slides/cull/phase.ts +++ b/src/ui/tui/screens/audit/slides/cull/phase.ts @@ -1,5 +1,6 @@ import type { AuditCheck } from '@lib/programs/audit/types'; import type { CullProgress } from '@lib/programs/cull-feature-flags/phase'; +import { DISABLING_AREAS } from '@lib/programs/cull-feature-flags/classify'; import { CULLED_MARKER, DECLINED_MARKER, @@ -21,6 +22,36 @@ function hasMarker(check: AuditCheck, marker: string): boolean { return (check.details ?? '').includes(marker); } +const MARKER_PREFIXES = [ + 'also ', + 'winning branch:', + 'kept:', + 'culled', + 'failed:', + 'declined by user', +]; + +function whyCulled(check: AuditCheck): string { + const clauses = (check.details ?? '') + .split(';') + .map((clause) => clause.trim()) + .filter((clause) => clause.length > 0) + .filter( + (clause) => !MARKER_PREFIXES.some((prefix) => clause.startsWith(prefix)), + ); + const branch = (check.details ?? '').includes('winning branch: false') + ? 'Keeps the off branch and drops the check.' + : (check.details ?? '').includes('winning branch: true') + ? 'Keeps the code that runs today and drops the check.' + : ''; + const posthog = DISABLING_AREAS.has(check.area) + ? 'Then the flag is disabled in PostHog, never deleted.' + : 'PostHog is left untouched for this one.'; + return [`Why: ${check.area}, ${clauses.join(', ')}.`, branch, posthog] + .filter((sentence) => sentence.length > 0) + .join(' '); +} + function buildCullCopy( checks: readonly AuditCheck[], progress: CullProgress, @@ -45,6 +76,7 @@ function buildCullCopy( failed.length > 0 ? `${failed.length} failed` : null, ].filter((count): count is string => count !== null); const paragraphs = [firstParagraph]; + if (activeCheck) paragraphs.push(whyCulled(activeCheck)); if (completedCounts.length > 0) { paragraphs.push(`${completedCounts.join(', ')} so far.`); } From 9432f489f013cc0c65615433c89d4f57404492f4 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 17:06:54 -0600 Subject: [PATCH 25/28] fix(cull-feature-flags): kept flags and suggestions are not "nothing to do" the folded footer said "10 healthy, 2 kept, 1 suggestion, nothing to do". the kept flags were kept for a reason and the suggestion asks for a wrapper, both of which are in the report. the footer now ends with "details in the report" whenever either count is nonzero, and keeps "nothing to do" for a footer that is healthy flags only. Confidence: high Scope-risk: narrow --- src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts | 2 +- src/ui/tui/screens/audit/cull/CullFlagList.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts index 049c16fd9..48648ff4a 100644 --- a/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts +++ b/src/ui/tui/screens/audit/__tests__/cull-flag-list.test.ts @@ -57,7 +57,7 @@ describe('toLaneGroups', () => { expect(nothingToCull).toMatchObject({ rows: [], - footer: '2 healthy, 1 suggestion, nothing to do', + footer: '2 healthy, 1 suggestion, details in the report', }); }); diff --git a/src/ui/tui/screens/audit/cull/CullFlagList.tsx b/src/ui/tui/screens/audit/cull/CullFlagList.tsx index 2edd0fb39..fc3229997 100644 --- a/src/ui/tui/screens/audit/cull/CullFlagList.tsx +++ b/src/ui/tui/screens/audit/cull/CullFlagList.tsx @@ -191,7 +191,9 @@ function foldedFooter(counts: FoldedCounts): string | undefined { : null, ].filter((part): part is string => part !== null); if (parts.length === 0) return undefined; - return `${parts.join(', ')}, nothing to do`; + const hasReportItems = counts.kept > 0 || counts.suggestions > 0; + const tail = hasReportItems ? 'details in the report' : 'nothing to do'; + return `${parts.join(', ')}, ${tail}`; } export function toLaneGroups( From bc09d02ba4a4efac0b32304656ccf44e2233f476 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 17:42:58 -0600 Subject: [PATCH 26/28] fix(cull-feature-flags): the spinner line is not a flag being culled the wizard's own "Culling stale feature flags..." spinner message matched the reducer's `^Culling (.+)$`, so the run screen sat in the edit pass from the first second and jumped straight from Verify to Cull once every row was verified, skipping the Pick screen while the ask overlay was still coming. flag keys never contain whitespace, so the reducer now only accepts a single token as the culled key. --- src/lib/programs/cull-feature-flags/phase.ts | 2 +- src/ui/tui/screens/audit/__tests__/cull-phase.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/programs/cull-feature-flags/phase.ts b/src/lib/programs/cull-feature-flags/phase.ts index 35437d6df..9891f1dea 100644 --- a/src/lib/programs/cull-feature-flags/phase.ts +++ b/src/lib/programs/cull-feature-flags/phase.ts @@ -16,7 +16,7 @@ export function reduceCullProgress( state: CullProgress, message: string, ): CullProgress { - const cullingMatch = message.match(/^Culling (.+)$/); + const cullingMatch = message.match(/^Culling (\S+)$/); if (cullingMatch) { const edited = state.activeKey ? [...state.edited, state.activeKey] diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts index 99ccf76d0..d37b44f64 100644 --- a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -32,7 +32,13 @@ describe('reduceCullProgress', () => { ); expect(waiting).toBe(INITIAL_CULL_PROGRESS); - const firstFlag = reduceCullProgress(waiting, 'Culling checkout-redesign'); + const spinner = reduceCullProgress( + waiting, + 'Culling stale feature flags...', + ); + expect(spinner).toBe(INITIAL_CULL_PROGRESS); + + const firstFlag = reduceCullProgress(spinner, 'Culling checkout-redesign'); expect(firstFlag).toEqual({ pass: 'edit', activeKey: 'checkout-redesign', From d91a3b9f8f4181ee27a31e83ad70fa92895ab67b Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 17:48:29 -0600 Subject: [PATCH 27/28] fix(cull-feature-flags): say the prompt is coming, not that we are waiting "Waiting for your pick" showed while the agent was still composing the wizard_ask call, so the screen read as stuck when nothing had opened yet. the pick card now says the list is being written and will open here, then repeats the nothing-changes-until-you-confirm promise. --- src/ui/tui/screens/audit/__tests__/cull-phase.test.ts | 2 +- src/ui/tui/screens/audit/slides/cull/phase.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts index d37b44f64..3f10e4006 100644 --- a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -120,7 +120,7 @@ describe('cullPhase', () => { expect(phase.phase).toBe('pick'); expect(phase.copy).toEqual({ - title: 'Waiting for your pick', + title: 'Your pick list is on its way', paragraphs: expect.any(Array), }); }); diff --git a/src/ui/tui/screens/audit/slides/cull/phase.ts b/src/ui/tui/screens/audit/slides/cull/phase.ts index d28682a6f..755c91597 100644 --- a/src/ui/tui/screens/audit/slides/cull/phase.ts +++ b/src/ui/tui/screens/audit/slides/cull/phase.ts @@ -106,10 +106,11 @@ export function cullPhase( return { phase: 'pick', copy: { - title: 'Waiting for your pick', + title: 'Your pick list is on its way', paragraphs: [ `Every flag is verified at its call site. ${proposals.length} look done and are up for culling; the healthy ones stay.`, - 'Nothing changes until you confirm in the prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', + 'The agent is writing the prompt now, one question per group with the plan for each flag. It opens here in a moment.', + 'Nothing changes until you confirm in that prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', ], }, }; From d96d5111689569ccb9f29dfaf1f7ecfb11f1fcf6 Mon Sep 17 00:00:00 2001 From: johncwaters Date: Fri, 4 Sep 2026 17:49:36 -0600 Subject: [PATCH 28/28] fix(cull-feature-flags): spin while the agent is mid-turn on the run screen the pick and cull cards sat still for a minute or more while the agent composed the ask or edited files, and a still card reads as a hang. the stage copy now carries an isWorking flag and the pane draws a spinner before the title while it is set; the pick card also says the wait is usually a minute or two. --- src/ui/tui/screens/audit/AuditAreaPane.tsx | 16 +++++++++++++--- .../screens/audit/__tests__/cull-phase.test.ts | 1 + src/ui/tui/screens/audit/slides/cull/phase.ts | 5 +++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/ui/tui/screens/audit/AuditAreaPane.tsx b/src/ui/tui/screens/audit/AuditAreaPane.tsx index ae7a3ae66..257700147 100644 --- a/src/ui/tui/screens/audit/AuditAreaPane.tsx +++ b/src/ui/tui/screens/audit/AuditAreaPane.tsx @@ -15,6 +15,7 @@ import { Fragment } from 'react'; import { Box, Text, useInput } from 'ink'; +import { Spinner } from '@inkjs/ui'; import { spawn } from 'node:child_process'; import { Colors } from '@ui/tui/styles'; import { type AuditCheck } from '@lib/programs/audit/types'; @@ -69,6 +70,8 @@ interface AuditAreaPaneProps { export interface WrapUpCopy { title: string; paragraphs: string[]; + /** The agent is mid-turn behind this copy; show a spinner so a long wait reads as work, not a hang. */ + isWorking?: boolean; } export const AuditAreaPane = ({ @@ -129,9 +132,16 @@ export const AuditAreaPane = ({ const StageCopy = ({ copy }: { copy: WrapUpCopy }) => ( - - {copy.title} - + + {copy.isWorking ? ( + + + + ) : null} + + {copy.title} + + {copy.paragraphs.map((paragraph, i) => ( diff --git a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts index 3f10e4006..5c8ed5669 100644 --- a/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts +++ b/src/ui/tui/screens/audit/__tests__/cull-phase.test.ts @@ -122,6 +122,7 @@ describe('cullPhase', () => { expect(phase.copy).toEqual({ title: 'Your pick list is on its way', paragraphs: expect.any(Array), + isWorking: true, }); }); diff --git a/src/ui/tui/screens/audit/slides/cull/phase.ts b/src/ui/tui/screens/audit/slides/cull/phase.ts index 755c91597..0d44a9475 100644 --- a/src/ui/tui/screens/audit/slides/cull/phase.ts +++ b/src/ui/tui/screens/audit/slides/cull/phase.ts @@ -82,7 +82,7 @@ function buildCullCopy( } paragraphs.push(CULL_SAFETY_COPY); - return { title: titleByPass[progress.pass], paragraphs }; + return { title: titleByPass[progress.pass], paragraphs, isWorking: true }; } export function cullPhase( @@ -109,9 +109,10 @@ export function cullPhase( title: 'Your pick list is on its way', paragraphs: [ `Every flag is verified at its call site. ${proposals.length} look done and are up for culling; the healthy ones stay.`, - 'The agent is writing the prompt now, one question per group with the plan for each flag. It opens here in a moment.', + 'The agent is writing the prompt now, one question per group with the plan for each flag. It opens here when ready, usually within a minute or two.', 'Nothing changes until you confirm in that prompt. Each pick gets its check removed from code and the flag disabled in PostHog, never deleted.', ], + isWorking: true, }, }; }