Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
/src/lib/programs/ai-observability/ @PostHog/team-ai-observability
/src/lib/programs/error-tracking-upload-source-maps/ @PostHog/team-error-tracking
/src/lib/programs/mcp-analytics/ @PostHog/team-mcp-analytics
/src/lib/programs/metrics/ @PostHog/apm
/src/lib/programs/posthog-integration/ @PostHog/team-wizard-docs
/src/lib/programs/replay-vision/ @PostHog/team-replay
/src/lib/programs/revenue-analytics/ @PostHog/team-web-analytics
Expand Down
2 changes: 2 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { mcpCommand } from './src/commands/mcp';
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 { auditCommand } from './src/commands/audit';
import { doctorCommand } from './src/commands/doctor';
import { migrateCommand } from './src/commands/migrate';
Expand Down Expand Up @@ -83,6 +84,7 @@ Wizard.use(basicIntegrationCommand)
.use(mcpAnalyticsCommand)
.use(replayVisionCommand)
.use(aiObservabilityCommand)
.use(metricsCommand)
.use(cliCommand)
.use(auditCommand)
.use(doctorCommand)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,46 @@ exports[`e2e flow snapshot — ai-observability > walks intro → health → aut
}
`;

exports[`e2e flow snapshot — metrics > walks intro → health → auth → run → outro → skills 1`] = `
{
"profile": {
"ask": "first",
"healthCheck": "dismiss",
"mcp": "skip",
"setup": "first",
"skills": "delete",
"slack": "skip",
},
"program": "metrics",
"trace": [
{
"action": "confirm_setup",
"screen": "metrics-intro",
},
{
"action": "dismiss_outage",
"screen": "health-check",
},
{
"action": "(external)",
"screen": "auth",
},
{
"action": "(external)",
"screen": "run",
},
{
"action": "dismiss_outro",
"screen": "outro",
},
{
"action": "keep_skills",
"screen": "keep-skills",
},
],
}
`;

exports[`e2e flow snapshot — posthog-integration > Next.js (with a setup question) walks a stable path 1`] = `
{
"profile": {
Expand Down
23 changes: 23 additions & 0 deletions e2e-harness/__tests__/e2e-flow-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,26 @@ describe('e2e flow snapshot — ai-observability', () => {
}).toMatchSnapshot();
});
});

describe('e2e flow snapshot — metrics', () => {
it('walks intro → health → auth → run → outro → skills', () => {
expect({
program: 'metrics',
profile: profileFor(Program.Metrics),
trace: traceFlow(Integration.javascriptNode, Program.Metrics),
}).toMatchSnapshot();
});

it('reaches a terminal decision instead of stalling on the intro', () => {
const trace = traceFlow(Integration.javascriptNode, Program.Metrics);
// A screen with no `decideE2eAction` case yields `(external)` forever, so
// the guard loop runs its full 40 iterations on one screen. The metrics
// intro must be drivable.
expect(trace[0]).toEqual({
screen: 'metrics-intro',
action: 'confirm_setup',
});
expect(trace.at(-1)?.action).toBe('keep_skills');
expect(trace.length).toBeLessThan(40);
});
});
1 change: 1 addition & 0 deletions e2e-harness/action-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const ACTION_REGISTRY: Partial<Record<ScreenName, DriverAction[]>> = {
[ScreenId.MigrationIntro]: [confirmSetupAction],
[ScreenId.AgentSkillIntro]: [confirmSetupAction],
[ScreenId.AiObservabilityIntro]: [confirmSetupAction],
[ScreenId.MetricsIntro]: [confirmSetupAction],
[ScreenId.AuditIntro]: [confirmSetupAction],
[ScreenId.DoctorIntro]: [confirmSetupAction],
[ScreenId.WarehouseIntro]: [confirmSetupAction],
Expand Down
1 change: 1 addition & 0 deletions e2e-harness/e2e-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function decideE2eAction(
case ScreenId.MigrationIntro:
case ScreenId.AgentSkillIntro:
case ScreenId.AiObservabilityIntro:
case ScreenId.MetricsIntro:
case ScreenId.AuditIntro:
case ScreenId.SourceMapsIntro:
case ScreenId.DoctorIntro:
Expand Down
3 changes: 3 additions & 0 deletions e2e-harness/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import {
} from './e2e-profile.js';
import posthogIntegrationE2e from '@lib/programs/posthog-integration/test/e2e.json';
import aiObservabilityE2e from '@lib/programs/ai-observability/test/e2e.json';
import metricsE2e from '@lib/programs/metrics/test/e2e.json';
import replayVisionE2e from '@lib/programs/replay-vision/test/e2e.json';

const PROFILES: Partial<Record<ProgramId, WizardE2eProfile>> = {
[Program.PostHogIntegration]:
posthogIntegrationE2e.profile as WizardE2eProfile,
[Program.AiObservability]: aiObservabilityE2e.profile as WizardE2eProfile,
[Program.Metrics]: metricsE2e.profile as WizardE2eProfile,
[Program.ReplayVision]: replayVisionE2e.profile as WizardE2eProfile,
};

Expand All @@ -31,6 +33,7 @@ const VARIATIONS: Partial<Record<ProgramId, WizardE2eVariation[]>> = {
posthogIntegrationE2e.variations as WizardE2eVariation[],
[Program.AiObservability]:
aiObservabilityE2e.variations as WizardE2eVariation[],
[Program.Metrics]: metricsE2e.variations as WizardE2eVariation[],
[Program.ReplayVision]: replayVisionE2e.variations as WizardE2eVariation[],
};

Expand Down
16 changes: 16 additions & 0 deletions src/commands/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { metricsConfig } from '@lib/programs/metrics/index';

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

/**
* `wizard metrics` — flat skill command, wire PostHog application metrics
* (counters, gauges, histograms via \`posthog.metrics\`) into a project.
*
* The `metrics` context-mill skill has one variant per platform (python,
* nodejs, javascript, kubernetes, other/OTLP); the agent picks the right one
* at run time by scanning the project's manifest and (when ambiguous) asking
* the user via `wizard_ask`. Stays flat while a single "add metrics to a
* project" flow is the only action.
*/
export const metricsCommand: Command = nativeCommandFactory(metricsConfig);
12 changes: 12 additions & 0 deletions src/lib/agent/__tests__/agent-prompt-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ describe('agentRunTools', () => {
'mcp__wizard-tools__wizard_ask',
]);
});

it('qualifies the skill-menu tools against the wizard-tools server', () => {
const p = parseAgentPrompt(
'---\nallowedTools: [Read, load_skill_menu, install_skill]\n---\nx',
't',
);
expect(agentRunTools(p).allowedTools).toEqual([
'Read',
'mcp__wizard-tools__load_skill_menu',
'mcp__wizard-tools__install_skill',
]);
});
});

describe('buildRegistry', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/agent/agent-prompt-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ const ORCHESTRATOR_TOOLS = new Set([
/** The one tool that stops a task until a person answers. Named short in frontmatter. */
export const ASK_TOOL = 'wizard_ask';

/** The skill-menu tools, as agents ask for them in frontmatter. */
const SKILL_MENU_TOOL = 'load_skill_menu';
const INSTALL_SKILL_TOOL = 'install_skill';

/**
* The PostHog MCP, as an agent asks for it in frontmatter. Every tool a task
* gets is granted by its own prompt, this one included: a task that never names
Expand Down Expand Up @@ -309,6 +313,8 @@ interface AgentMenu {
/** A native tool passes through; an MCP tool gets its fully-qualified name. */
function expandToolName(name: string): string {
if (name === ASK_TOOL) return WIZARD_TOOL_NAMES.wizardAsk;
if (name === SKILL_MENU_TOOL) return WIZARD_TOOL_NAMES.loadSkillMenu;
if (name === INSTALL_SKILL_TOOL) return WIZARD_TOOL_NAMES.installSkill;
if (name === POSTHOG_MCP_TOOL) return POSTHOG_MCP_SDK_TOOL;
return ORCHESTRATOR_TOOLS.has(name)
? `${ORCHESTRATOR_TOOL_PREFIX}${name}`
Expand Down
18 changes: 16 additions & 2 deletions src/lib/agent/runner/__tests__/switchboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe('switchboard PROGRAM_BINDINGS', () => {
it('resolves every program, unflagged, to the same default binding', () => {
for (const program of PROGRAM_IDS) {
if (program === 'ai-observability') continue; // pinned below
if (program === 'metrics') continue; // pinned below
if (program === 'replay-vision') continue; // pinned below
expect(resolveBinding({ program, flags: {} })).toEqual(DEFAULT_RESOLVED);
}
Expand All @@ -78,6 +79,17 @@ describe('switchboard PROGRAM_BINDINGS', () => {
},
trace: { harness: 'binding', model: 'binding', sequence: 'binding' },
},
{
name: 'binds metrics to the orchestrator on pi; stage models come from the flow frontmatter',
ctx: { program: 'metrics', flags: {} },
binding: {
sequence: Sequence.orchestrator,
harness: Harness.pi,
model: DEFAULT_AGENT_MODEL,
thinkingLevel: undefined,
},
trace: { harness: 'binding', model: 'binding', sequence: 'binding' },
},
{
name: 'binds replay-vision to the orchestrator sequence',
ctx: { program: 'replay-vision', flags: {} },
Expand Down Expand Up @@ -194,13 +206,15 @@ describe('switchboard composed clamp', () => {
trace: {},
};
// The flag routes posthog-integration's harness to pi; the composed
// clamp holds every sequence at linear; other programs keep their
// bindings (sonnet 5 for ai-observability, the default elsewhere).
// clamp holds every sequence at linear — the orchestrator bindings
// (metrics, replay-vision) included; other axes keep their bindings.
expect(resolveBinding(ctx)).toEqual(
program === 'posthog-integration'
? { ...DEFAULT_RESOLVED, harness: Harness.pi }
: program === 'ai-observability'
? { ...DEFAULT_RESOLVED, model: SONNET_5_MODEL }
: program === 'metrics'
? { ...DEFAULT_RESOLVED, harness: Harness.pi }
: DEFAULT_RESOLVED,
);
expect(ctx.trace?.sequence).toBe('composed');
Expand Down
14 changes: 14 additions & 0 deletions src/lib/agent/runner/harness/pi/__tests__/task-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ describe('allowedPiWizardTools', () => {
it('withholds wizard_ask when a task states no tools at all', () => {
expect(allowedPiWizardTools(undefined).has('wizard_ask')).toBe(false);
});

it('grants the skill-menu pair only to a task whose prompt allows them', () => {
const granted = allowedPiWizardTools([
'Read',
'load_skill_menu',
'install_skill',
]);
expect(granted.has('load_skill_menu')).toBe(true);
expect(granted.has('install_skill')).toBe(true);

const withheld = allowedPiWizardTools(['Read', 'Edit']);
expect(withheld.has('load_skill_menu')).toBe(false);
expect(withheld.has('install_skill')).toBe(false);
});
});

describe('allowedPiCodingTools', () => {
Expand Down
18 changes: 10 additions & 8 deletions src/lib/agent/runner/harness/pi/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ export function allowedOrchestratorTools(

/**
* The wizard tools a task gets. Four are always on — their handlers are fenced
* and the coding tasks depend on them. `wizard_ask` is opt-in per task: it
* stops the run until a person answers, so only a task whose prompt asks for it
* may open that overlay.
* and the coding tasks depend on them. The rest are opt-in per task through
* its frontmatter: `wizard_ask` stops the run until a person answers, and the
* skill-menu pair (`load_skill_menu`, `install_skill`) lets a task pull its
* own skill variant.
*/
const ALWAYS_ON_WIZARD_TOOLS = [
'check_env_keys',
Expand All @@ -100,15 +101,16 @@ const ALWAYS_ON_WIZARD_TOOLS = [
'publish_handoff',
];

const OPT_IN_WIZARD_TOOLS = ['wizard_ask', 'load_skill_menu', 'install_skill'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: Skill installation can escape the workspace

Registering install_skill here makes the existing unsafe destination calculation reachable from remote task prompts. The installer passes the registry-controlled skillEntry.id to path.join(installDir, '.claude', 'skills', skillEntry.id) without verifying that the result remains under .claude/skills; an attacker controlling the skill registry can use an ID such as ../../../../target to write the downloaded bundle outside the project, and a rejected scan subsequently calls rmSync on that escaped directory. Validate skill IDs against a strict format and resolve the destination before any directory creation, download extraction, scanning, or cleanup, rejecting paths that are not descendants of the intended skills root.


export function allowedPiWizardTools(
allowedTools: readonly string[] | undefined,
): Set<string> {
const allowed = (allowedTools ?? []).map(shortToolName);
return new Set(
allowed.includes('wizard_ask')
? [...ALWAYS_ON_WIZARD_TOOLS, 'wizard_ask']
: ALWAYS_ON_WIZARD_TOOLS,
);
return new Set([
...ALWAYS_ON_WIZARD_TOOLS,
...OPT_IN_WIZARD_TOOLS.filter((tool) => allowed.includes(tool)),
]);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ describe('isolation — everything on at once', () => {
...LINEAR_ANTHROPIC_DEFAULT,
model: SONNET_5_MODEL,
});
} else if (program === 'metrics') {
// Orchestrator + pi from its OWN binding, not the flag; stage models
// are pinned context-mill side in the flow frontmatter.
expect(resolved).toEqual({
...ORCHESTRATOR_PI_DEFAULT,
});
} else if (program === 'replay-vision') {
// Orchestrator from its OWN binding, not the flag — the
// wizard-orchestrator experiment does not cover this program, so it
Expand Down
8 changes: 8 additions & 0 deletions src/lib/agent/runner/switchboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ export const PROGRAM_BINDINGS: Partial<Record<ProgramId, ProgramBinding>> = {
'mcp-remove': DEFAULT_BINDING,
'mcp-tutorial': DEFAULT_BINDING,
'mcp-analytics': DEFAULT_BINDING,
// Orchestrator on pi. The binding routes only; every stage's model and
// effort are pinned context-mill side in the flow's frontmatter
// (`model_pi`/`effort_pi`: terra seed, sol tasks, luna report).
metrics: {
sequence: Sequence.orchestrator,
harness: Harness.pi,
model: DEFAULT_AGENT_MODEL,
},
'replay-vision': {
sequence: Sequence.orchestrator,
harness: Harness.anthropic,
Expand Down
68 changes: 68 additions & 0 deletions src/lib/programs/__tests__/metrics-program.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index';
import { getProgramConfig, Program } from '@lib/programs/program-registry';
import { metricsConfig } from '@lib/programs/metrics/index';
import type { ProgramRun } from '@lib/agent/agent-runner';
import type { WizardSession } from '@lib/wizard-session';

import { metricsCommand } from '../../../commands/metrics';

function staticRun(config: typeof metricsConfig): ProgramRun {
if (typeof config.run === 'function') {
throw new Error('expected a static ProgramRun, got a function');
}
if (!config.run) throw new Error('expected a ProgramRun');
return config.run;
}

describe('metrics program', () => {
it('is registered as a flat top-level `metrics` command', () => {
const config = getProgramConfig('metrics');
expect(config).toBe(metricsConfig);
expect(config.command).toBe('metrics');
expect(config.parentCommand).toBeUndefined();
expect(Program.Metrics).toBe('metrics');
});

it('uses the agent-skill steps with a metrics-specific intro', () => {
const [intro, ...rest] = metricsConfig.steps;
expect(intro.id).toBe('intro');
expect(intro.screenId).toBe('metrics-intro');
expect(rest).toEqual(AGENT_SKILL_STEPS.slice(1));
});

it('runs the metrics agent flow on the orchestrator', () => {
expect(metricsConfig.agentFlow).toBe('metrics');
});

it('has no fixed skillId — the agent picks the variant from the menu', () => {
const run = staticRun(metricsConfig);
expect(run.skillId).toBeUndefined();

const prompt = run.customPrompt?.({} as WizardSession);
expect(prompt).toContain('load_skill_menu');
expect(prompt).toContain('"metrics"');
// Every published variant the prompt teaches the agent to choose from.
for (const variant of [
'metrics-python',
'metrics-nodejs',
'metrics-javascript',
'metrics-kubernetes',
'metrics-other',
]) {
expect(prompt).toContain(variant);
}
});

it('points the outro at the metrics docs and report file', () => {
const run = staticRun(metricsConfig);
expect(run.docsUrl).toBe('https://posthog.com/docs/metrics');
expect(run.reportFile).toBe('posthog-metrics-report.md');
expect(metricsConfig.reportFile).toBe(run.reportFile);
});

it('is exposed as a yargs command via nativeCommandFactory', () => {
expect(metricsCommand.name).toBe('metrics');
expect(metricsCommand.description).toBe(metricsConfig.description);
expect(typeof metricsCommand.handler).toBe('function');
});
});
Loading
Loading