Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1d25cc7
chore: add logs
sortafreel Jun 11, 2026
ae021a1
chore: logs
sortafreel Jun 11, 2026
33c8e5c
fix: safer stringify
sortafreel Jun 11, 2026
9b3f1b3
chore: more logs on the auth
sortafreel Jun 11, 2026
2a597ca
chore: Simplify comments.
sortafreel Jun 11, 2026
56a7d42
chore: Logs.
sortafreel Jun 11, 2026
350fa16
fix: Ensure check MCP status properly (both locally and in prod).
sortafreel Jun 11, 2026
6f1c211
fix: if require is unavailable - use replacement
sortafreel Jun 11, 2026
100749c
fix(wizard-tools): make the ask adjacency nudge one-time so maxQuesti…
sortafreel Jun 10, 2026
ee82b2c
feat(programs): add product-autonomy program (wizard autonomy)
sortafreel Jun 10, 2026
5781653
chore: autonomy plan
sortafreel Jun 11, 2026
af15908
chore: temp handoff
sortafreel Jun 11, 2026
423f0f9
chore: collect project data into the context
sortafreel Jun 12, 2026
ffc3201
feat: allow checking external sources connection.
sortafreel Jun 12, 2026
8ce44ab
feat: Allow creating custom scouts per product
sortafreel Jun 15, 2026
2a00d83
feat: Fix clicking/copying long URLs from terminal.
sortafreel Jun 15, 2026
99c7c29
feat: Docs.
sortafreel Jun 18, 2026
b2e857f
Merge branch 'main' into feat/product-autonomy
sortafreel Jun 18, 2026
cf185b4
fix: Simplify step 3.
sortafreel Jun 18, 2026
3bc6cfa
chore: Docs.
sortafreel Jun 18, 2026
9f57212
feat: Show custom tips/description for autonomy.,
sortafreel Jun 18, 2026
66f61c3
chore: Docs.
sortafreel Jun 18, 2026
e709008
chore: Docs.
sortafreel Jun 18, 2026
472f60e
chore: Docs.
sortafreel Jun 19, 2026
847d2a4
chore: Docs
sortafreel Jun 19, 2026
10a48ab
feat: Allow custom subtitles.
sortafreel Jun 19, 2026
7d11f86
feat: Better communicate what's inbox and what to do next
sortafreel Jun 19, 2026
2e258e8
feat: Simplify sources connection
sortafreel Jun 19, 2026
aae1ce6
chore: Rename to self-driving.
sortafreel Jun 19, 2026
f69d8d3
chore: Rename to self-driving.
sortafreel Jun 19, 2026
ff21a2d
feat: Add optional description to the multi-selection.
sortafreel Jun 19, 2026
1f965ac
chore: Docs.
sortafreel Jun 19, 2026
67c2826
fix: Remove excessive step.
sortafreel Jun 19, 2026
2767064
fix: Remove temp files.
sortafreel Jun 19, 2026
6889e1c
fix: no self-driving in signup-ci form.
sortafreel Jun 19, 2026
c117557
fix: Docs.
sortafreel Jun 19, 2026
2284b55
fix: Do no require setup artefact.
sortafreel Jun 19, 2026
5ba5c3c
fix: Guardrail.
sortafreel Jun 19, 2026
cff95f3
feat: Show outro.
sortafreel Jun 19, 2026
83c97a0
fix: Increase timeout.
sortafreel Jun 19, 2026
77b035e
fix: Skills.
sortafreel Jun 19, 2026
04c1741
fix: Decrease self-driving sidebar tips waiting time.
sortafreel Jun 19, 2026
273dcb6
Merge branch 'main' into feat/product-autonomy
sortafreel Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ plugins
e2e-tests/fixtures/.tracking/*

# Generated at build time by scripts/generate-version.js
src/lib/version.ts
src/lib/version.ts
# Re-include src/lib explicitly: a user-global ignore pattern like `lib/`
# would otherwise silently drop new source files under src/lib from commits.
!src/lib/
2 changes: 2 additions & 0 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { auditCommand } from './src/commands/audit';
import { doctorCommand } from './src/commands/doctor';
import { migrateCommand } from './src/commands/migrate';
import { revenueCommand } from './src/commands/revenue';
import { selfDrivingCommand } from './src/commands/self-driving';
import { slackCommand } from './src/commands/slack';
import { uploadSourcemapsCommand } from './src/commands/upload-sourcemaps';
import { skillCommand } from './src/commands/skill';
Expand Down Expand Up @@ -64,6 +65,7 @@ Wizard.use(basicIntegrationCommand)
.use(doctorCommand)
.use(migrateCommand)
.use(revenueCommand)
.use(selfDrivingCommand)
.use(slackCommand)
.use(uploadSourcemapsCommand)
.use(skillCommand)
Expand Down
75 changes: 75 additions & 0 deletions src/__tests__/link-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
osc8Hyperlink,
extractUrls,
splitPromptIntoSegments,
} from '@ui/tui/primitives/link-helpers';

const ESC = String.fromCharCode(0x1b);
const BEL = String.fromCharCode(0x07);
const LINEAR_URL =
'http://localhost:8010/api/environments/1/integrations/authorize?kind=linear';

describe('osc8Hyperlink', () => {
it('wraps the url in an OSC 8 escape, url as the default label', () => {
expect(osc8Hyperlink(LINEAR_URL)).toBe(
`${ESC}]8;;${LINEAR_URL}${BEL}${LINEAR_URL}${ESC}]8;;${BEL}`,
);
});

it('supports a custom label', () => {
expect(osc8Hyperlink('https://x.test', 'open')).toBe(
`${ESC}]8;;https://x.test${BEL}open${ESC}]8;;${BEL}`,
);
});
});

describe('extractUrls', () => {
it('finds a single url', () => {
expect(extractUrls(`open ${LINEAR_URL} now`)).toEqual([LINEAR_URL]);
});

it('returns [] when there is no url', () => {
expect(extractUrls('no links here')).toEqual([]);
});

it('finds multiple urls', () => {
expect(extractUrls('a https://one.test and https://two.test')).toEqual([
'https://one.test',
'https://two.test',
]);
});

it('strips trailing sentence punctuation', () => {
expect(extractUrls('see https://x.test.')).toEqual(['https://x.test']);
expect(extractUrls('(https://x.test)')).toEqual(['https://x.test']);
});
});

describe('splitPromptIntoSegments', () => {
it('breaks a standalone url line into its own segment, preserving spacing', () => {
const prompt = `One click connects Linear: open this link —\n\n${LINEAR_URL}\n\nThen come back here.`;
expect(splitPromptIntoSegments(prompt)).toEqual([
{ type: 'text', value: 'One click connects Linear: open this link —\n' },
{ type: 'url', value: LINEAR_URL },
{ type: 'text', value: '\nThen come back here.' },
]);
});

it('returns a single text segment when there is no standalone url', () => {
expect(splitPromptIntoSegments('just some prose')).toEqual([
{ type: 'text', value: 'just some prose' },
]);
});

it('keeps inline urls inside the text segment', () => {
expect(splitPromptIntoSegments('visit https://x.test for details')).toEqual(
[{ type: 'text', value: 'visit https://x.test for details' }],
);
});

it('handles a url-only prompt', () => {
expect(splitPromptIntoSegments(LINEAR_URL)).toEqual([
{ type: 'url', value: LINEAR_URL },
]);
});
});
23 changes: 23 additions & 0 deletions src/__tests__/programs-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { migrateCommand } from '../commands/migrate';
import { revenueCommand } from '../commands/revenue';
import { uploadSourcemapsCommand } from '../commands/upload-sourcemaps';
import { selfDrivingCommand } from '../commands/self-driving';
import {
dispatchFamily,
pickerChildrenToShow,
Expand Down Expand Up @@ -147,7 +148,7 @@
});

test('migrate dispatches with migrate-statsig skillId', () => {
migrateCommand.handler!(makeArgv({ installDir: '/tmp/some-app' }));

Check warning on line 151 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
const [config, opts] = mockRunWizard.mock.calls[0] as [
{ skillId?: string },
Record<string, unknown>,
Expand All @@ -157,7 +158,7 @@
});

test('revenue-analytics dispatches with revenue-analytics-setup skillId', () => {
revenueCommand.handler!(makeArgv({ debug: true }));

Check warning on line 161 in src/__tests__/programs-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
const [config] = mockRunWizard.mock.calls[0] as [{ skillId?: string }];
expect(config.skillId).toBe('revenue-analytics-setup');
});
Expand Down Expand Up @@ -219,3 +220,25 @@
expect(shown.map((c) => c.name)).toEqual(['events', 'all']);
});
});

describe('self-driving rejects unsupported modes', () => {
// The guard lives in selfDrivingCommand.check, so it runs at the yargs layer
// before the handler — parseCommand exercises that real path.
test('rejects --signup', async () => {
await expect(
parseCommand(selfDrivingCommand, 'self-driving --signup'),
).rejects.toThrow(/--signup/i);
});

test('rejects --ci', async () => {
await expect(
parseCommand(selfDrivingCommand, 'self-driving --ci'),
).rejects.toThrow(/CI mode/i);
});

test('accepts a plain run', async () => {
await expect(
parseCommand(selfDrivingCommand, 'self-driving --install-dir /tmp/app'),
).resolves.toBeDefined();
});
});
43 changes: 43 additions & 0 deletions src/commands/self-driving.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { runWizard, runWizardCI } from '@lib/runners';
import { selfDrivingConfig } from '@lib/programs/self-driving/index';
import { skillProgramOptions } from './skill-program-options';
import type { Command } from './command';

export const selfDrivingCommand: Command = {
name: 'self-driving',
description: selfDrivingConfig.description,
options: {
...skillProgramOptions,
...(selfDrivingConfig.cliOptions ?? {}),
},
check: (argv) => {
// self-driving builds on an existing integration and is fully interactive,
// so the modes that break it are rejected before the TUI/agent loop spins
// up rather than failing late (a 403 on the first MCP probe under --signup,
// or a stalled `wizard_ask` with no bridge under --ci).
if (argv.signup) {
throw new Error(
'`self-driving` cannot run with --signup. It builds on an existing ' +
'PostHog integration — run the base `wizard` to create your account ' +
'and set up PostHog first, then run `wizard self-driving`.',
);
}
if (argv.ci) {
throw new Error(
'`self-driving` cannot run in CI mode — it requires interactive steps ' +
'(GitHub connect, issue-tracker selection, custom-scout approval).',
);
}
return true;
},
handler: (argv) => {
Comment thread
sortafreel marked this conversation as resolved.
const extras =
selfDrivingConfig.mapCliOptions?.(argv as Record<string, unknown>) ?? {};
const options = { ...argv, ...extras };
if (options.ci) {
runWizardCI(selfDrivingConfig, options);
} else {
runWizard(selfDrivingConfig, options);
}
},
};
13 changes: 13 additions & 0 deletions src/lib/__tests__/wizard-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,19 @@ describe('evaluateAskCap', () => {
});
});

it('fires the adjacency nudge only once — later calls proceed up to the cap', () => {
// After the nudge is recorded, calls between the threshold and the cap
// go through; otherwise caps above the threshold would be unreachable.
for (let i = ASK_BATCH_THRESHOLD; i < MAX; i++) {
expect(evaluateAskCap(i, MAX, true)).toEqual({ kind: 'ok' });
}
expect(evaluateAskCap(MAX, MAX, true)).toEqual({
kind: 'capped',
reason: 'max_questions',
message: expect.stringMatching(/cap reached/i),
});
});

it('escalates to the max_questions reason once the cap is reached', () => {
expect(evaluateAskCap(MAX, MAX)).toEqual({
kind: 'capped',
Expand Down
18 changes: 18 additions & 0 deletions src/lib/agent/agent-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ export interface PromptContext {
host: string;
/** Set when skillId was provided and the skill was installed successfully. */
skillPath?: string;
/**
* Org-level AI consent (`is_ai_data_processing_approved`) read from the
* `/api/users/@me/` payload at auth time. `null` = unknown (older orgs,
* or the user fetch failed). Lets prompts pre-resolve consent state so
* agents only ask the user when it is actually off or unknown.
*/
orgAiDataProcessingApproved?: boolean | null;
/**
* Team product opt-ins from the `/api/projects/:id/` payload at auth
* time. Project-level truth for "is this product enabled" — products
* can be instrumented from other repos or the snippet, so repo-local
* evidence must never rule them out. `null` field = unknown.
*/
teamProductOptIns?: {
sessionReplay?: boolean | null;
exceptionAutocapture?: boolean | null;
surveys?: boolean | null;
} | null;
}

function defaultProjectPrompt(ctx: PromptContext): string {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/agent/runner/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function runLinearProgram(
mcpUrl,
wizardFlags,
wizardMetadata,
project,
} = boot;

// 5. Skill install (if skillId provided)
Expand Down Expand Up @@ -96,6 +97,7 @@ export async function runLinearProgram(
: createWizardAskBridge({
getSource: () => session.skillId ?? config.integrationLabel,
showQuestion: (q) => getUI().requestQuestion(q),
richLinks: config.richLinks ?? false,
timeoutMs: config.askTimeoutMs,
});

Expand Down Expand Up @@ -136,6 +138,15 @@ export async function runLinearProgram(
projectApiKey,
host,
skillPath,
orgAiDataProcessingApproved:
session.apiUser?.organization?.is_ai_data_processing_approved ?? null,
teamProductOptIns: project
? {
sessionReplay: project.session_recording_opt_in ?? null,
exceptionAutocapture: project.autocapture_exceptions_opt_in ?? null,
surveys: project.surveys_opt_in ?? null,
}
: null,
});
logToFile(`[agent-runner] prompt assembled (${prompt.length} chars)`);

Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/runner/shared/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export async function bootstrapProgram(
cloudRegion,
roleAtOrganization,
user,
project,
} = await getOrAskForProjectData({
signup: session.signup,
ci: session.ci,
Expand Down Expand Up @@ -234,5 +235,6 @@ export async function bootstrapProgram(
mcpUrl,
wizardFlags,
wizardMetadata,
project,
};
}
10 changes: 10 additions & 0 deletions src/lib/agent/runner/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
import type { PromptContext } from '@lib/agent/agent-prompt';
import type { PackageManagerDetector } from '@lib/detection/package-manager';
import type { CloudRegion } from '@utils/types';
import type { ApiProject } from '@lib/api';

export type { PromptContext, Credentials };

Expand Down Expand Up @@ -64,6 +65,13 @@ export interface ProgramRun {
* always returns a "batch your questions" error regardless of the cap.
*/
maxQuestions?: number;
/**
* Opt this program's `wizard_ask` overlays into rich link rendering:
* standalone URLs in prompt text become OSC 8 hyperlinks and a lone URL is
* copied to the clipboard, so a long URL can't be broken by the overlay's
* line wrapping. Defaults to false — leave off for flows we don't own.
*/
richLinks?: boolean;
/**
* Per-question `wizard_ask` timeout in milliseconds. Defaults to
* DEFAULT_ASK_TIMEOUT_MS (5 minutes). Raise it for programs whose
Expand All @@ -88,4 +96,6 @@ export interface BootstrapResult {
mcpUrl: string;
wizardFlags: Record<string, string>;
wizardMetadata: Record<string, string>;
/** Full project payload, for project-level prompt context (opt-ins). */
project: ApiProject | null;
}
13 changes: 13 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export const ApiUserSchema = z
slug: z.string().nullish(),
membership_level: z.number().nullish(),
customer_id: z.string().nullish(),
// Org-level AI consent gate. Signals drops all findings while
// this is not true. Null on older orgs (pre-2026-05 default
// flip) — treat null as "unknown", not "off".
is_ai_data_processing_approved: z.boolean().nullish(),
})
.passthrough(),
organizations: z.array(
Expand Down Expand Up @@ -109,6 +113,15 @@ export const ApiProjectSchema = z.object({
organization: z.string().uuid(),
api_token: z.string(),
name: z.string(),
// Product opt-ins (TeamSerializer-compat fields on /api/projects/:id).
// Project-level truth for "is this product enabled" — a product can be
// instrumented from another repo or the snippet, so these settings
// override repo-local evidence. Null/absent = unknown. Only the
// opt-ins a signals decision consumes: replay + exception autocapture
// feed signal-source choices; surveys feeds the surveys-scout tuning.
session_recording_opt_in: z.boolean().nullish(),
autocapture_exceptions_opt_in: z.boolean().nullish(),
surveys_opt_in: z.boolean().nullish(),
});

export type ApiUser = z.infer<typeof ApiUserSchema>;
Expand Down
49 changes: 49 additions & 0 deletions src/lib/oauth/program-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,54 @@ export const AGENT_SKILL_SCOPE_ADDITIONS = [
'property_definition:read',
] as const;

/**
* Extra scopes the self-driving program needs on top of
* `WIZARD_OAUTH_SCOPES`. All consumed by the PostHog MCP tools the
* agent drives during the run:
* • task:read / task:write — the signal source config API
* (`inbox-source-configs-*`) is permissioned under the generic
* `task` scope object, NOT a signals-specific one. Unrelated to
* the Tasks product.
* • integration:read — `integrations-list`, to check whether the
* team already has a GitHub integration and to verify the connect
* flow completed.
* • signal_scout:read / signal_scout:write — list, sync, and tune
* the Signals scout fleet (`signals-scout-config-*`).
* • session_recording:read / survey:read / error_tracking:read —
* server-side product-usage probes (`query-session-recordings-list`,
* `survey-list`, `error-issue-list`). Product usage is a
* project-level fact (often instrumented in another repo or via
* the snippet), so the agent asks the server instead of inferring
* only from the local setup report. All three are read-only and
* already in the wizard OAuth app's production scope ceiling (the
* mcp-tutorial program requests them).
* • external_data_source:read / external_data_source:write — the
* connected-tools step creates the GitHub Issues / Linear warehouse
* sources directly (`external-data-sources-create`) and verifies
* what's actually connected (`external-data-sources-list`) instead
* of taking the user's word for it.
* • llm_skill:read / llm_skill:write — the custom-scouts step
* (skill step 7b): read the seeded `authoring-signals-scouts`
* guide and canonical scout bodies (`llma-skill-get` /
* `llma-skill-file-get`) and author the user-approved custom
* `signals-scout-*` skills (`llma-skill-create`). Canonical scout
* bodies are never edited.
*/
export const SELF_DRIVING_SCOPE_ADDITIONS = [
'task:read',
'task:write',
Comment thread
sortafreel marked this conversation as resolved.
'integration:read',
'signal_scout:read',
'signal_scout:write',
'session_recording:read',
'survey:read',
'error_tracking:read',
'external_data_source:read',
'external_data_source:write',
'llm_skill:read',
'llm_skill:write',
] as const;

/**
* Extra scope the Connect-Slack step needs on top of `WIZARD_OAUTH_SCOPES`.
*
Expand Down Expand Up @@ -150,6 +198,7 @@ const PROGRAM_SCOPE_ADDITIONS: Partial<Record<ProgramId, readonly string[]>> = {
// ever changes, this line will fail to type-check.
'mcp-tutorial': MCP_TUTORIAL_SCOPE_ADDITIONS,
'agent-skill': AGENT_SKILL_SCOPE_ADDITIONS,
'self-driving': SELF_DRIVING_SCOPE_ADDITIONS,
Comment thread
sortafreel marked this conversation as resolved.
'posthog-integration': CONNECT_SLACK_SCOPE_ADDITIONS,
slack: CONNECT_SLACK_SCOPE_ADDITIONS,
};
Expand Down
Loading
Loading