From 295ebf96e8d3fd9db5c916dcfb6ff1d260620915 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 29 Jul 2026 12:06:54 +0100 Subject: [PATCH 1/4] feat(self-driving): prioritise codebase-detected tools in the connected-tools ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-driving connected-tools multi-select (STEP 5) grew from 5 to 35 sources and is heading toward the full ~500-source warehouse catalog, so it overflows the terminal. The fix agreed in the thread: surface the tools we can detect in the user's codebase first, then the SaaS basics, then an "others" bucket — rather than dumping the whole list. The wizard already has the detection for this: the same deterministic `detectWarehouseSources` scanner that powers the warehouse program already covers ~16 of the self-driving inbox tools (GitHub, GitLab, Gitea, Linear, Jira, Sentry, Rollbar, Bugsnag, Zendesk, Freshdesk, Front, Gorgias, Intercom, and more). This wires that detection into the self-driving flow and feeds it to the agent. - The self-driving detect step now runs the scan and stashes the result under the shared `DETECTED_WAREHOUSE_SOURCES_KEY` (best-effort; a scan failure or an empty result never blocks the flow). - `buildSelfDrivingPrompt` renders a "Tools detected in this codebase" block and points STEP 4/STEP 5 at it. Empty scan states so explicitly, so the agent falls back to the skill's default ordering instead of inventing its own scan. The wizard only provides the deterministic facts; which tools are inbox-relevant and how the ask is ordered stays in the context-mill skill (companion change), keeping product knowledge out of wizard code. --- .../__tests__/self-driving-detect.test.ts | 30 +++++ .../__tests__/self-driving-prompt.test.ts | 56 ++++++++ src/lib/programs/self-driving/detect.ts | 42 ++++++ src/lib/programs/self-driving/index.ts | 120 +++++++++--------- src/lib/programs/self-driving/prompt.ts | 44 ++++++- 5 files changed, 233 insertions(+), 59 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index ba4b992cf..305ab9c70 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -10,6 +10,10 @@ import { detectPostHogPresent, POSTHOG_MANIFESTS, } from '@lib/programs/self-driving/detect'; +import { + DETECTED_WAREHOUSE_SOURCES_KEY, + getDetectedWarehouseSources, +} from '@lib/programs/warehouse-source/detect'; import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic'; import { PROJECT_MANIFESTS, @@ -59,6 +63,32 @@ describe('detectSelfDrivingPrerequisites', () => { expect(ctx.detectError).toBeUndefined(); }); + + it('stashes tools detected in the codebase for the connected-tools ask', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ dependencies: { '@sentry/node': '^7.0.0' } }), + ); + const session = buildSession({ installDir: tmpDir }); + detectSelfDrivingPrerequisites(session, setCtx); + + // Mirror the value onto the session the way the store setter would, so the + // shared accessor the run closure uses reads it back. + session.frameworkContext[DETECTED_WAREHOUSE_SOURCES_KEY] = + ctx[DETECTED_WAREHOUSE_SOURCES_KEY]; + const detected = getDetectedWarehouseSources(session); + expect(detected.map((s) => s.kind)).toContain('Sentry'); + }); + + it('writes nothing when the codebase has no detectable tools', () => { + // Bare dir: valid (no detectError) but no tools to prioritise, so the key + // stays unset and STEP 5 falls back to the skill default. + const session = buildSession({ installDir: tmpDir }); + detectSelfDrivingPrerequisites(session, setCtx); + + expect(ctx.detectError).toBeUndefined(); + expect(ctx[DETECTED_WAREHOUSE_SOURCES_KEY]).toBeUndefined(); + }); }); describe('SELF_DRIVING_ABORT_CASES', () => { diff --git a/src/lib/programs/__tests__/self-driving-prompt.test.ts b/src/lib/programs/__tests__/self-driving-prompt.test.ts index ede157e25..52b7ed616 100644 --- a/src/lib/programs/__tests__/self-driving-prompt.test.ts +++ b/src/lib/programs/__tests__/self-driving-prompt.test.ts @@ -1,6 +1,7 @@ import { buildSelfDrivingPrompt } from '@lib/programs/self-driving/prompt'; import type { PromptContext } from '@lib/agent/agent-runner'; import { HostResolution } from '@lib/host-resolution'; +import type { DetectedSource } from '@lib/warehouse-sources/types'; const ctx: PromptContext = { projectId: 123, @@ -8,6 +9,19 @@ const ctx: PromptContext = { host: HostResolution.fromApiHost('https://us.posthog.com'), }; +const SENTRY: DetectedSource = { + kind: 'Sentry', + label: 'Sentry', + mode: 'in-cli', + matchedSignal: 'found `@sentry/node` in package.json', +}; +const LINEAR: DetectedSource = { + kind: 'Linear', + label: 'Linear', + mode: 'deep-link', + matchedSignal: 'found `LINEAR_API_KEY` in .env', +}; + describe('buildSelfDrivingPrompt', () => { it('covers only the Self-driving steps — integration is a separate phase', () => { const prompt = buildSelfDrivingPrompt(ctx); @@ -34,3 +48,45 @@ describe('buildSelfDrivingPrompt', () => { expect(prompt).toContain('STEP 7 — Write the report and hand off'); }); }); + +describe('detected-tools block', () => { + it('lists each detected tool with its source_type and matched signal', () => { + const prompt = buildSelfDrivingPrompt(ctx, [SENTRY, LINEAR]); + expect(prompt).toContain('Tools detected in this codebase'); + expect(prompt).toContain('Sentry (source_type: Sentry)'); + expect(prompt).toContain('found `@sentry/node` in package.json'); + expect(prompt).toContain('Linear (source_type: Linear)'); + }); + + it('appears before STEP 5 so the connected-tools ask can read it', () => { + const prompt = buildSelfDrivingPrompt(ctx, [SENTRY]); + expect(prompt.indexOf('Tools detected in this codebase')).toBeLessThan( + prompt.indexOf('STEP 5 — Offer issue-tracker integrations'), + ); + }); + + it('points STEP 5 at the detected list, basics, then others', () => { + const prompt = buildSelfDrivingPrompt(ctx, [SENTRY]); + // Scope to STEP 5's text (up to STEP 6) and ignore incidental wrapping. + const step5 = prompt + .slice(prompt.indexOf('STEP 5 —'), prompt.indexOf('STEP 6 —')) + .replace(/\s+/g, ' '); + expect(step5).toContain('Tools detected in this codebase'); + expect(step5).toContain('SaaS basics'); + expect(step5).toContain('others'); + }); + + it('states nothing was found when the scan is empty (no invented scan)', () => { + const prompt = buildSelfDrivingPrompt(ctx, []); + expect(prompt).toContain('none found by the dependency + env scan'); + expect(prompt).not.toContain('source_type:'); + }); + + it('defaults to an empty scan when no sources are passed', () => { + // The single-arg call site (older tests, and the type default) must not throw. + expect(() => buildSelfDrivingPrompt(ctx)).not.toThrow(); + expect(buildSelfDrivingPrompt(ctx)).toContain( + 'none found by the dependency + env scan', + ); + }); +}); diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index e2d6a811e..6e21d2d33 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -27,8 +27,11 @@ import { type Dirent, } from 'fs'; import { join } from 'path'; +import { analytics } from '@utils/analytics'; import type { WizardSession } from '@lib/wizard-session'; import type { AbortCase } from '@lib/agent/agent-runner'; +import { detectWarehouseSources } from '@lib/warehouse-sources/detect'; +import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect'; /** frameworkContext key holding the deterministic PostHog-presence result. */ export const POSTHOG_PRESENT_KEY = 'postHogPresent'; @@ -291,4 +294,43 @@ export function detectSelfDrivingPrerequisites( // screen: found → skip straight to self-driving; not found → ask to set up // PostHog first. setFrameworkContext(POSTHOG_PRESENT_KEY, detectPostHogPresent(installDir)); + + detectConnectedTools(installDir, setFrameworkContext); +} + +/** + * Scan the codebase for the tools it uses (Sentry, Linear, GitHub, Stripe, …) + * so STEP 5's connected-tools ask can surface detected tools first instead of + * dumping the full ~500-source catalog on the user. Same deterministic scanner + * the warehouse program uses, so the result lands under the shared + * `DETECTED_WAREHOUSE_SOURCES_KEY` and is read back with + * `getDetectedWarehouseSources`. + * + * Best-effort: the connected-tools ask degrades to the skill's default + * ordering when nothing is detected, so a scan failure must never break the + * surrounding prerequisite check. + */ +function detectConnectedTools( + installDir: string, + setFrameworkContext: (key: string, value: unknown) => void, +): void { + try { + const sources = detectWarehouseSources(installDir); + if (sources.length === 0) return; + + // Tag the run so the connected-tools funnel can slice on what the project + // had available. Deliberately NOT the `warehouse sources detected` event + // the integration flow emits — that metric's denominator is integration + // runs, and firing it here would fold self-driving runs into it. + analytics.setTag( + 'connected_tools_detected', + sources.map((s) => s.kind).join(','), + ); + setFrameworkContext(DETECTED_WAREHOUSE_SOURCES_KEY, sources); + } catch (error) { + analytics.captureException( + error instanceof Error ? error : new Error(String(error)), + { step: 'detectConnectedTools' }, + ); + } } diff --git a/src/lib/programs/self-driving/index.ts b/src/lib/programs/self-driving/index.ts index 9ea3cbf6c..7474e58fc 100644 --- a/src/lib/programs/self-driving/index.ts +++ b/src/lib/programs/self-driving/index.ts @@ -2,8 +2,9 @@ import { join } from 'path'; import { access, rm } from 'node:fs/promises'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { ProgramRun } from '@lib/agent/agent-runner'; -import { OutroKind } from '@lib/wizard-session'; +import { OutroKind, type WizardSession } from '@lib/wizard-session'; import { createSkillProgram } from '../agent-skill/index.js'; +import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; import { SELF_DRIVING_PROGRAM } from './steps.js'; import { SELF_DRIVING_ABORT_CASES } from './detect.js'; import { buildSelfDrivingPrompt } from './prompt.js'; @@ -35,64 +36,69 @@ async function removeInstalledSkill(installDir: string): Promise { await rm(skillDir, { recursive: true, force: true }).catch(() => undefined); } -const run: ProgramRun = { - skillId: SELF_DRIVING_SKILL_ID, - integrationLabel: SELF_DRIVING_SKILL_ID, - customPrompt: buildSelfDrivingPrompt, - successMessage: SUCCESS_MESSAGE, - reportFile: REPORT_FILE, - docsUrl: DOCS_URL, - spinnerMessage: 'Setting up PostHog Self-driving...', - estimatedDurationMinutes: 10, - abortCases: SELF_DRIVING_ABORT_CASES, - // The flow legitimately needs several interactions (GitHub connect + - // verify, issue-tracker picks, the scout-tailoring proposal), so raise - // the wizard_ask budget a little above the default 10. - maxQuestions: 13, - // This flow hands the user long OAuth/authorize URLs (Linear, GitHub - // fallback, Zendesk) in wizard_ask prompts. Render them as OSC 8 - // hyperlinks + clipboard copy so the overlay's line wrapping can't break - // the click target. Scoped to this program only. - richLinks: true, - // STEP 3 (GitHub App install) and STEP 5 (Linear OAuth) park on wizard_ask - // while the user does slow browser work; a first-time GitHub App install - // routinely exceeds the 5-min default, and a timeout is indistinguishable - // from a decline (both resolve to __cancelled__). Match upload-source-maps. - askTimeoutMs: 30 * 60 * 1000, +// A session closure (not a static object) so `customPrompt` can read the +// tools detected in the codebase — written to frameworkContext by the detect +// step — and hand them to the prompt for STEP 4/STEP 5 prioritisation. +const buildRun = (session: WizardSession): Promise => + Promise.resolve({ + skillId: SELF_DRIVING_SKILL_ID, + integrationLabel: SELF_DRIVING_SKILL_ID, + customPrompt: (ctx) => + buildSelfDrivingPrompt(ctx, getDetectedWarehouseSources(session)), + successMessage: SUCCESS_MESSAGE, + reportFile: REPORT_FILE, + docsUrl: DOCS_URL, + spinnerMessage: 'Setting up PostHog Self-driving...', + estimatedDurationMinutes: 10, + abortCases: SELF_DRIVING_ABORT_CASES, + // The flow legitimately needs several interactions (GitHub connect + + // verify, issue-tracker picks, the scout-tailoring proposal), so raise + // the wizard_ask budget a little above the default 10. + maxQuestions: 13, + // This flow hands the user long OAuth/authorize URLs (Linear, GitHub + // fallback, Zendesk) in wizard_ask prompts. Render them as OSC 8 + // hyperlinks + clipboard copy so the overlay's line wrapping can't break + // the click target. Scoped to this program only. + richLinks: true, + // STEP 3 (GitHub App install) and STEP 5 (Linear OAuth) park on wizard_ask + // while the user does slow browser work; a first-time GitHub App install + // routinely exceeds the 5-min default, and a timeout is indistinguishable + // from a decline (both resolve to __cancelled__). Match upload-source-maps. + askTimeoutMs: 30 * 60 * 1000, - // Emit a `wizard: step` analytics event on each agent task transition so we - // can build a step-level drop-off funnel (where a run stops — GitHub connect, - // scout enable, etc.), including silent steps with no wizard_ask. Opt-in, so - // only self-driving runs emit these; every other program is unchanged. - trackStepProgress: true, + // Emit a `wizard: step` analytics event on each agent task transition so we + // can build a step-level drop-off funnel (where a run stops — GitHub connect, + // scout enable, etc.), including silent steps with no wizard_ask. Opt-in, so + // only self-driving runs emit these; every other program is unchanged. + trackStepProgress: true, - postRun: async (session) => { - await removeInstalledSkill(session.installDir); - }, + postRun: async (session) => { + await removeInstalledSkill(session.installDir); + }, - buildOutroData: (_session, credentials) => { - const uiHost = credentials.host.appHost.replace(/\/$/, ''); - const inboxUrl = `${uiHost}/project/${credentials.projectId}/inbox`; - return { - kind: OutroKind.Success as const, - message: SUCCESS_MESSAGE, - primaryLink: { label: 'Your Self-driving inbox', url: inboxUrl }, - nextSteps: { - heading: 'In your inbox you can:', - items: [ - 'Investigate reports with the agent', - 'Tag teammates to loop them in', - 'Kick off a PR when you like the proposed fix ($15 flat)', - 'Or work from Slack (tag @PostHog) and MCP', - ], - }, - body: - 'Pricing: scouts, signals, and reports are free. You pay a flat ' + - '$15 only when a report ships a PR.', - reportFile: REPORT_FILE, - }; - }, -}; + buildOutroData: (_session, credentials) => { + const uiHost = credentials.host.appHost.replace(/\/$/, ''); + const inboxUrl = `${uiHost}/project/${credentials.projectId}/inbox`; + return { + kind: OutroKind.Success as const, + message: SUCCESS_MESSAGE, + primaryLink: { label: 'Your Self-driving inbox', url: inboxUrl }, + nextSteps: { + heading: 'In your inbox you can:', + items: [ + 'Investigate reports with the agent', + 'Tag teammates to loop them in', + 'Kick off a PR when you like the proposed fix ($15 flat)', + 'Or work from Slack (tag @PostHog) and MCP', + ], + }, + body: + 'Pricing: scouts, signals, and reports are free. You pay a flat ' + + '$15 only when a report ships a PR.', + reportFile: REPORT_FILE, + }; + }, + }); export const selfDrivingConfig: ProgramConfig = { ...createSkillProgram({ @@ -110,7 +116,7 @@ export const selfDrivingConfig: ProgramConfig = { abortCases: SELF_DRIVING_ABORT_CASES, }), steps: SELF_DRIVING_PROGRAM, - run, + run: buildRun, getTips, getContentBlocks, }; diff --git a/src/lib/programs/self-driving/prompt.ts b/src/lib/programs/self-driving/prompt.ts index 2934f94a0..cc90343ab 100644 --- a/src/lib/programs/self-driving/prompt.ts +++ b/src/lib/programs/self-driving/prompt.ts @@ -1,5 +1,33 @@ import { AgentSignals } from '@lib/agent/agent-interface'; import type { PromptContext } from '@lib/agent/agent-runner'; +import type { DetectedSource } from '@lib/warehouse-sources/types'; + +/** + * Render the deterministic codebase-tool scan for the prompt. STEP 4 and + * STEP 5 read this instead of the agent doing its own flaky "light scan": + * enable the matching signal sources, and surface detected tools first in the + * connected-tools ask rather than dumping the full ~500-source catalog. When + * nothing is detected we still say so explicitly, so the agent falls back to + * the skill's default ordering instead of inventing a scan. + */ +function renderDetectedTools(sources: DetectedSource[]): string { + if (sources.length === 0) { + return `Tools detected in this codebase: none found by the dependency + env scan. Use the skill's default ordering for STEP 5.`; + } + + const lines = sources.map( + (s) => `- ${s.label} (source_type: ${s.kind}) — ${s.matchedSignal}`, + ); + return [ + 'Tools detected in this codebase (deterministic dependency + env-key scan — this is evidence, not a guess):', + ...lines, + '', + 'Use this list in STEP 4 (enable the matching signal sources) and STEP 5 ' + + '(surface these detected tools first in the connected-tools ask, ahead ' + + 'of the SaaS basics and the "others" option), exactly as the skill ' + + 'describes. Never enable or pre-select a tool the user has not confirmed.', + ].join('\n'); +} /** * Build the self-driving run prompt. The installed @@ -8,11 +36,18 @@ import type { PromptContext } from '@lib/agent/agent-runner'; * to verify); this prompt carries the order, the wizard-specific * mechanics (wizard_ask, abort signals), and the project URLs. * + * `detectedSources` is the deterministic codebase scan (from the detect step); + * it drives STEP 4/STEP 5 tool prioritisation. Empty is fine — the block then + * tells the agent to use the skill's default ordering. + * * Integration (when the project has no PostHog yet) runs as a separate phase * before this — the real integration program, with its own screens and task * list — so this prompt only covers the Self-driving steps. */ -export function buildSelfDrivingPrompt(ctx: PromptContext): string { +export function buildSelfDrivingPrompt( + ctx: PromptContext, + detectedSources: DetectedSource[] = [], +): string { const uiHost = ctx.host.appHost.replace(/\/$/, ''); const projectBase = `${uiHost}/project/${ctx.projectId}`; const integrationsSettingsUrl = `${projectBase}/settings/environment-integrations`; @@ -39,6 +74,8 @@ snippet, so repo evidence may rule a product IN but never OUT): - Exception autocapture (error tracking): ${optIn(optIns?.exceptionAutocapture)} - Surveys: ${optIn(optIns?.surveys)} +${renderDetectedTools(detectedSources)} + The installed skill is the source of truth for the HOW of every step: which MCP tools to call, which sources and scouts apply to this product, and how to verify each change. The STEPS below give the order and the @@ -124,7 +161,10 @@ STEP 4 — Enable signal sources. (skill: "Enable sources") STEP 5 — Offer issue-tracker integrations. (skill: "Connected tools") One batched multi-select wizard_ask for the external tools the skill - lists. The run auto-connects the ones it can (GitHub Issues, and + lists. Order it per the skill: the tools from the "Tools detected in + this codebase" list above come first, then the SaaS basics, then an + "others" option for the long tail — never dump the whole catalog. + The run auto-connects the ones it can (GitHub Issues, and Linear via a one-click OAuth link), verifying each with a single silent check — never nudge. For GitHub Issues: when the GitHub integration has exactly one repository connected, use that repo by From 9ac798153d0308754dcd2ee6f3f59fb9df73708b Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:25:45 -0400 Subject: [PATCH 2/4] fix(self-driving): scope the detected-tools scan to self-driving and filter it (#1028) --- .../__tests__/self-driving-detect.test.ts | 54 +++++++--- src/lib/programs/self-driving/detect.ts | 101 +++++++++++++++--- src/lib/programs/self-driving/index.ts | 8 +- src/lib/runners/run-wizard.ts | 9 +- 4 files changed, 139 insertions(+), 33 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index 305ab9c70..27c4f6626 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -9,11 +9,11 @@ import { import { detectPostHogPresent, POSTHOG_MANIFESTS, + SELF_DRIVING_DETECTED_TOOLS_KEY, + SELF_DRIVING_TOOL_KINDS, } from '@lib/programs/self-driving/detect'; -import { - DETECTED_WAREHOUSE_SOURCES_KEY, - getDetectedWarehouseSources, -} from '@lib/programs/warehouse-source/detect'; +import { SOURCE_DETECTORS } from '@lib/warehouse-sources/registry'; +import type { DetectedSource } from '@lib/warehouse-sources/types'; import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic'; import { PROJECT_MANIFESTS, @@ -64,20 +64,33 @@ describe('detectSelfDrivingPrerequisites', () => { expect(ctx.detectError).toBeUndefined(); }); - it('stashes tools detected in the codebase for the connected-tools ask', () => { + /** Kinds stashed for the connected-tools ask after a scan of `tmpDir`. */ + const detectedKinds = (deps: Record): string[] => { fs.writeFileSync( path.join(tmpDir, 'package.json'), - JSON.stringify({ dependencies: { '@sentry/node': '^7.0.0' } }), + JSON.stringify({ dependencies: deps }), ); - const session = buildSession({ installDir: tmpDir }); - detectSelfDrivingPrerequisites(session, setCtx); + detectSelfDrivingPrerequisites( + buildSession({ installDir: tmpDir }), + setCtx, + ); + const tools = ctx[SELF_DRIVING_DETECTED_TOOLS_KEY] as + | DetectedSource[] + | undefined; + return (tools ?? []).map((s) => s.kind); + }; + + it('stashes tools detected in the codebase for the connected-tools ask', () => { + expect(detectedKinds({ '@sentry/node': '^7.0.0' })).toContain('Sentry'); + }); - // Mirror the value onto the session the way the store setter would, so the - // shared accessor the run closure uses reads it back. - session.frameworkContext[DETECTED_WAREHOUSE_SOURCES_KEY] = - ctx[DETECTED_WAREHOUSE_SOURCES_KEY]; - const detected = getDetectedWarehouseSources(session); - expect(detected.map((s) => s.kind)).toContain('Sentry'); + it('keeps only the tools the inbox can connect', () => { + // `pg` and `stripe` are warehouse sources, not connected tools — surfacing + // them would lead STEP 5 with a database and a payment processor. + expect(detectedKinds({ pg: '^8.0.0', stripe: '^14.0.0' })).toEqual([]); + expect(detectedKinds({ pg: '^8.0.0', '@sentry/node': '^7.0.0' })).toEqual([ + 'Sentry', + ]); }); it('writes nothing when the codebase has no detectable tools', () => { @@ -87,7 +100,18 @@ describe('detectSelfDrivingPrerequisites', () => { detectSelfDrivingPrerequisites(session, setCtx); expect(ctx.detectError).toBeUndefined(); - expect(ctx[DETECTED_WAREHOUSE_SOURCES_KEY]).toBeUndefined(); + expect(ctx[SELF_DRIVING_DETECTED_TOOLS_KEY]).toBeUndefined(); + }); +}); + +describe('SELF_DRIVING_TOOL_KINDS', () => { + it('names only kinds the source registry can actually detect', () => { + // The filter is a plain string set, so a registry rename would silently + // drop a tool from the ask. Fail here instead. + const known = new Set(SOURCE_DETECTORS.map((d) => d.kind)); + expect([...SELF_DRIVING_TOOL_KINDS].filter((k) => !known.has(k))).toEqual( + [], + ); }); }); diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index 6e21d2d33..93f2a5de8 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -31,7 +31,7 @@ import { analytics } from '@utils/analytics'; import type { WizardSession } from '@lib/wizard-session'; import type { AbortCase } from '@lib/agent/agent-runner'; import { detectWarehouseSources } from '@lib/warehouse-sources/detect'; -import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect'; +import type { DetectedSource } from '@lib/warehouse-sources/types'; /** frameworkContext key holding the deterministic PostHog-presence result. */ export const POSTHOG_PRESENT_KEY = 'postHogPresent'; @@ -44,6 +44,31 @@ export const POSTHOG_PRESENT_KEY = 'postHogPresent'; */ export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath'; +/** + * frameworkContext key holding the tools this codebase uses that are worth + * promoting in the connected-tools ask. Self-driving's own key, not the + * warehouse program's `DETECTED_WAREHOUSE_SOURCES_KEY`: the integration program + * reads that one to build its prompt, and the integrate-run phase inherits a + * copy of this frameworkContext — so writing there would silently rewrite the + * integration agent's prompt on every self-driving run that installs PostHog + * first. (Its outro is safe: a composed run returns before `buildOutroData`.) + */ +export const SELF_DRIVING_DETECTED_TOOLS_KEY = 'selfDrivingDetectedTools'; + +/** + * Read the detected tools out of frameworkContext. Single accessor shared by + * the detect step and the prompt builder so the key + cast live in one place. + */ +export function getSelfDrivingDetectedTools( + session: WizardSession, +): DetectedSource[] { + return ( + (session.frameworkContext[SELF_DRIVING_DETECTED_TOOLS_KEY] as + | DetectedSource[] + | undefined) ?? [] + ); +} + // Matches `posthog` at a dependency boundary (line start, or after "'/=:.@ or // whitespace): catches `com.posthog:posthog-android` and `@posthog/ai`, skips // substrings inside other words. @@ -299,12 +324,50 @@ export function detectSelfDrivingPrerequisites( } /** - * Scan the codebase for the tools it uses (Sentry, Linear, GitHub, Stripe, …) - * so STEP 5's connected-tools ask can surface detected tools first instead of - * dumping the full ~500-source catalog on the user. Same deterministic scanner - * the warehouse program uses, so the result lands under the shared - * `DETECTED_WAREHOUSE_SOURCES_KEY` and is read back with - * `getDetectedWarehouseSources`. + * Source kinds worth promoting in the connected-tools ask. The shared scanner + * matches the whole warehouse catalog (databases, payments, LLM vendors, ad + * platforms) and most of that has nothing to do with STEP 5: unfiltered, a + * routine repo leads the issue-tracker ask with Postgres (matched on + * `DATABASE_URL`), Stripe and OpenAI — the wall of irrelevant options this is + * supposed to remove. + * + * Exactly the kinds enumerated as inbox tools in #1022, which were sourced from + * the context-mill `self-driving` skill's connected-tools list. Deliberately no + * guesses beyond it: this list only decides what gets PROMOTED, so leaving a + * kind out costs a nudge (the skill still offers the tool), while putting a kind + * in that the inbox can't connect sends the agent after a source it can't + * create. When the skill's catalog grows, reconcile here. + * + * A shadow list of the registry, so it drifts in one direction the guard test + * can't catch: a new inbox-connectable kind added to `SOURCE_DETECTORS` has to + * be added here too or it never gets promoted. If that bites, the fix is a + * field on `SourceDetector` rather than a third copy of this list. + */ +export const SELF_DRIVING_TOOL_KINDS: ReadonlySet = new Set([ + // Issue trackers / code hosts + 'Github', + 'GitLab', + 'Gitea', + 'Linear', + 'Jira', + // Error trackers + 'Sentry', + 'Rollbar', + 'Bugsnag', + // Support desks + 'Zendesk', + 'Freshdesk', + 'Front', + 'Gorgias', + 'Intercom', +]); + +/** + * Scan the codebase for the tools it uses that the inbox can connect (Sentry, + * Linear, GitHub, Zendesk, …) so STEP 5's connected-tools ask can surface those + * first instead of dumping the full source catalog on the user. Stashed under + * self-driving's own `SELF_DRIVING_DETECTED_TOOLS_KEY` and read back with + * `getSelfDrivingDetectedTools`. * * Best-effort: the connected-tools ask degrades to the skill's default * ordering when nothing is detected, so a scan failure must never break the @@ -315,19 +378,29 @@ function detectConnectedTools( setFrameworkContext: (key: string, value: unknown) => void, ): void { try { - const sources = detectWarehouseSources(installDir); - if (sources.length === 0) return; + const tools = detectWarehouseSources(installDir).filter((s) => + SELF_DRIVING_TOOL_KINDS.has(s.kind), + ); - // Tag the run so the connected-tools funnel can slice on what the project - // had available. Deliberately NOT the `warehouse sources detected` event - // the integration flow emits — that metric's denominator is integration + // Tagged on every run that scans, including the empty case — without the + // zero rows there is no way to tell "the scan found nothing" from "this code + // path never ran", which is the first thing to check when the ask looks + // unprioritised. Deliberately NOT the `warehouse sources detected` event + // the integration flow emits: that metric's denominator is integration // runs, and firing it here would fold self-driving runs into it. + analytics.setTag('connected_tools_detected_count', tools.length); + if (tools.length === 0) return; + analytics.setTag( 'connected_tools_detected', - sources.map((s) => s.kind).join(','), + tools.map((s) => s.kind).join(','), ); - setFrameworkContext(DETECTED_WAREHOUSE_SOURCES_KEY, sources); + setFrameworkContext(SELF_DRIVING_DETECTED_TOOLS_KEY, tools); } catch (error) { + // -1 rather than nothing: an absent tag would be indistinguishable from a + // build that never ran this scan, which is what the count is here to rule + // out. The captured exception carries the why. + analytics.setTag('connected_tools_detected_count', -1); analytics.captureException( error instanceof Error ? error : new Error(String(error)), { step: 'detectConnectedTools' }, diff --git a/src/lib/programs/self-driving/index.ts b/src/lib/programs/self-driving/index.ts index 7474e58fc..b4b19fad1 100644 --- a/src/lib/programs/self-driving/index.ts +++ b/src/lib/programs/self-driving/index.ts @@ -4,9 +4,11 @@ import type { ProgramConfig } from '@lib/programs/program-step'; import type { ProgramRun } from '@lib/agent/agent-runner'; import { OutroKind, type WizardSession } from '@lib/wizard-session'; import { createSkillProgram } from '../agent-skill/index.js'; -import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; import { SELF_DRIVING_PROGRAM } from './steps.js'; -import { SELF_DRIVING_ABORT_CASES } from './detect.js'; +import { + SELF_DRIVING_ABORT_CASES, + getSelfDrivingDetectedTools, +} from './detect.js'; import { buildSelfDrivingPrompt } from './prompt.js'; import { getTips } from './content/tips.js'; import { getContentBlocks } from './content/index.js'; @@ -44,7 +46,7 @@ const buildRun = (session: WizardSession): Promise => skillId: SELF_DRIVING_SKILL_ID, integrationLabel: SELF_DRIVING_SKILL_ID, customPrompt: (ctx) => - buildSelfDrivingPrompt(ctx, getDetectedWarehouseSources(session)), + buildSelfDrivingPrompt(ctx, getSelfDrivingDetectedTools(session)), successMessage: SUCCESS_MESSAGE, reportFile: REPORT_FILE, docsUrl: DOCS_URL, diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index bf99bc9c8..436e7ce58 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -18,7 +18,14 @@ type Step = ProgramConfig['steps'][number]; /** The session a run step's agent runs in: scoped to the step's target dir * (e.g. a monorepo sub-app) with its own framework context, after any prep. - * A step without `targetDir` runs in the live session, unchanged. */ + * A step without `targetDir` runs in the live session, unchanged. + * + * The copy is shallow and unfiltered, so a composed sub-run inherits every + * frameworkContext key the host program wrote — including ones the sub-run's + * own program reads for its prompt. Name keys for the program that owns them + * (`selfDrivingDetectedTools`, not `detectedWarehouseSources`) so a host can't + * silently rewrite a spliced-in program's behaviour. If that collision shows up + * a second time, scope the inheritance here instead of renaming again. */ async function prepareRunSession( step: Step, live: WizardSession, From 85701c4853cf16d812ae7d12adb33b10caca1760 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:10:44 -0400 Subject: [PATCH 3/4] fix(self-driving): reconcile promoted tools with the real ask, restore the leak test (#1031) --- .../__tests__/self-driving-detect.test.ts | 45 +++++++++++++++++++ src/lib/programs/self-driving/detect.ts | 42 ++++++++++++----- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index 27c4f6626..cdc5132b9 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -11,7 +11,10 @@ import { POSTHOG_MANIFESTS, SELF_DRIVING_DETECTED_TOOLS_KEY, SELF_DRIVING_TOOL_KINDS, + getSelfDrivingDetectedTools, } from '@lib/programs/self-driving/detect'; +import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; +import { WizardStore } from '@ui/tui/store'; import { SOURCE_DETECTORS } from '@lib/warehouse-sources/registry'; import type { DetectedSource } from '@lib/warehouse-sources/types'; import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic'; @@ -115,6 +118,48 @@ describe('SELF_DRIVING_TOOL_KINDS', () => { }); }); +describe('the detect step does not leak into the composed integration run', () => { + // Driven through the REAL store, the way run-wizard does it + // (`await store.runReadyHooks()`), because the leak lived in the plumbing + // rather than in `detectConnectedTools`: writing the warehouse program's key + // here put the scan into the integration agent's prompt, since the + // integrate-run phase inherits a copy of this frameworkContext and + // `posthog-integration` reads that key to build its prompt. Asserting on the + // setter's argument alone would not have caught it. + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + dependencies: { '@sentry/node': '^7.0.0', pg: '^8.0.0' }, + }), + ); + }); + afterEach(() => cleanup(tmpDir)); + + it('stashes under its own key and leaves the warehouse key untouched', async () => { + const store = new WizardStore('self-driving'); + store.session = buildSession({ installDir: tmpDir }); + await store.runReadyHooks(); + + // Self-driving sees its tools... + expect( + getSelfDrivingDetectedTools(store.session).map((s) => s.kind), + ).toContain('Sentry'); + // ...and the integration program, reading its own key off the session it + // inherits, sees nothing — so its prompt is byte-identical to a run without + // self-driving in front of it. + expect(getDetectedWarehouseSources(store.session)).toEqual([]); + const inherited = { + ...store.session, + frameworkContext: { ...store.session.frameworkContext }, + }; + expect(getDetectedWarehouseSources(inherited)).toEqual([]); + }); +}); + describe('SELF_DRIVING_ABORT_CASES', () => { const reasons = [ 'self-driving is not available for this project', diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index 93f2a5de8..87ffa660e 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -331,17 +331,24 @@ export function detectSelfDrivingPrerequisites( * `DATABASE_URL`), Stripe and OpenAI — the wall of irrelevant options this is * supposed to remove. * - * Exactly the kinds enumerated as inbox tools in #1022, which were sourced from - * the context-mill `self-driving` skill's connected-tools list. Deliberately no - * guesses beyond it: this list only decides what gets PROMOTED, so leaving a - * kind out costs a nudge (the skill still offers the tool), while putting a kind - * in that the inbox can't connect sends the agent after a source it can't - * create. When the skill's catalog grows, reconcile here. + * The intersection of two lists, computed rather than guessed: the tools the + * connected-tools ask actually offers (the `options` array in context-mill's + * `self-driving/references/5-connected-tools.md`) and the kinds this repo can + * detect (`SOURCE_DETECTORS`). Promoting anything outside that intersection is + * wasted at best and misleading at worst — a tool the ask never lists can't be + * picked, so pointing the agent at it sends it after a source it can't create. * - * A shadow list of the registry, so it drifts in one direction the guard test - * can't catch: a new inbox-connectable kind added to `SOURCE_DETECTORS` has to - * be added here too or it never gets promoted. If that bites, the fix is a - * field on `SourceDetector` rather than a third copy of this list. + * The ask's remaining options are deliberately absent because no detector + * matches them (Freshservice, Dixa, pganalyze, SonarQube, Semgrep, Rapid7 + * InsightVM, Featurebase, Frill, Aha, UserVoice, AskNicely, Retently, + * Appfigures, AppFollow, Judge.me). They stay offered by the skill; they just + * never get promoted, which is the safe direction. + * + * A shadow list of both sources, and the guard test only covers one of them — + * it catches a kind that leaves `SOURCE_DETECTORS`, but nothing here can see the + * skill's catalog change in another repo. So when step 5's option list grows, + * reconcile against it. If that becomes a habit, the fix is a field on + * `SourceDetector` rather than a third copy of this list. */ export const SELF_DRIVING_TOOL_KINDS: ReadonlySet = new Set([ // Issue trackers / code hosts @@ -350,16 +357,27 @@ export const SELF_DRIVING_TOOL_KINDS: ReadonlySet = new Set([ 'Gitea', 'Linear', 'Jira', - // Error trackers + 'Shortcut', + // Error tracking 'Sentry', 'Rollbar', 'Bugsnag', + 'Honeybadger', + 'Raygun', // Support desks 'Zendesk', 'Freshdesk', 'Front', 'Gorgias', - 'Intercom', + 'Kustomer', + 'Plain', + // Security scanners + 'Snyk', + // Product feedback + 'Canny', + 'Productboard', + // Search analytics + 'GoogleSearchConsole', ]); /** From 04b1770a48ffd2b2a05e8f601a1dfd3b5b77c738 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:31:47 -0400 Subject: [PATCH 4/4] chore(self-driving): cut the comments I added down to one line each (#1032) --- .../__tests__/self-driving-detect.test.ts | 21 ++---- src/lib/programs/self-driving/detect.ts | 66 ++----------------- src/lib/runners/run-wizard.ts | 8 +-- 3 files changed, 12 insertions(+), 83 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index cdc5132b9..209d3572d 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -88,8 +88,7 @@ describe('detectSelfDrivingPrerequisites', () => { }); it('keeps only the tools the inbox can connect', () => { - // `pg` and `stripe` are warehouse sources, not connected tools — surfacing - // them would lead STEP 5 with a database and a payment processor. + // `pg` and `stripe` are warehouse sources, not connected tools. expect(detectedKinds({ pg: '^8.0.0', stripe: '^14.0.0' })).toEqual([]); expect(detectedKinds({ pg: '^8.0.0', '@sentry/node': '^7.0.0' })).toEqual([ 'Sentry', @@ -97,8 +96,7 @@ describe('detectSelfDrivingPrerequisites', () => { }); it('writes nothing when the codebase has no detectable tools', () => { - // Bare dir: valid (no detectError) but no tools to prioritise, so the key - // stays unset and STEP 5 falls back to the skill default. + // Bare dir: valid, but no tools to prioritise, so the key stays unset. const session = buildSession({ installDir: tmpDir }); detectSelfDrivingPrerequisites(session, setCtx); @@ -109,8 +107,7 @@ describe('detectSelfDrivingPrerequisites', () => { describe('SELF_DRIVING_TOOL_KINDS', () => { it('names only kinds the source registry can actually detect', () => { - // The filter is a plain string set, so a registry rename would silently - // drop a tool from the ask. Fail here instead. + // A plain string set, so a registry rename would otherwise drop a tool silently. const known = new Set(SOURCE_DETECTORS.map((d) => d.kind)); expect([...SELF_DRIVING_TOOL_KINDS].filter((k) => !known.has(k))).toEqual( [], @@ -119,13 +116,7 @@ describe('SELF_DRIVING_TOOL_KINDS', () => { }); describe('the detect step does not leak into the composed integration run', () => { - // Driven through the REAL store, the way run-wizard does it - // (`await store.runReadyHooks()`), because the leak lived in the plumbing - // rather than in `detectConnectedTools`: writing the warehouse program's key - // here put the scan into the integration agent's prompt, since the - // integrate-run phase inherits a copy of this frameworkContext and - // `posthog-integration` reads that key to build its prompt. Asserting on the - // setter's argument alone would not have caught it. + // Through the real store — the leak lived in the plumbing, not in detectConnectedTools. let tmpDir: string; beforeEach(() => { @@ -148,9 +139,7 @@ describe('the detect step does not leak into the composed integration run', () = expect( getSelfDrivingDetectedTools(store.session).map((s) => s.kind), ).toContain('Sentry'); - // ...and the integration program, reading its own key off the session it - // inherits, sees nothing — so its prompt is byte-identical to a run without - // self-driving in front of it. + // ...and the integration program, on the session it inherits, sees nothing. expect(getDetectedWarehouseSources(store.session)).toEqual([]); const inherited = { ...store.session, diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index 87ffa660e..377e5a5fa 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -44,21 +44,10 @@ export const POSTHOG_PRESENT_KEY = 'postHogPresent'; */ export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath'; -/** - * frameworkContext key holding the tools this codebase uses that are worth - * promoting in the connected-tools ask. Self-driving's own key, not the - * warehouse program's `DETECTED_WAREHOUSE_SOURCES_KEY`: the integration program - * reads that one to build its prompt, and the integrate-run phase inherits a - * copy of this frameworkContext — so writing there would silently rewrite the - * integration agent's prompt on every self-driving run that installs PostHog - * first. (Its outro is safe: a composed run returns before `buildOutroData`.) - */ +/** Self-driving's own detected-tools key — not the warehouse one, which the integration program reads. */ export const SELF_DRIVING_DETECTED_TOOLS_KEY = 'selfDrivingDetectedTools'; -/** - * Read the detected tools out of frameworkContext. Single accessor shared by - * the detect step and the prompt builder so the key + cast live in one place. - */ +/** Read the detected tools out of frameworkContext. */ export function getSelfDrivingDetectedTools( session: WizardSession, ): DetectedSource[] { @@ -323,33 +312,7 @@ export function detectSelfDrivingPrerequisites( detectConnectedTools(installDir, setFrameworkContext); } -/** - * Source kinds worth promoting in the connected-tools ask. The shared scanner - * matches the whole warehouse catalog (databases, payments, LLM vendors, ad - * platforms) and most of that has nothing to do with STEP 5: unfiltered, a - * routine repo leads the issue-tracker ask with Postgres (matched on - * `DATABASE_URL`), Stripe and OpenAI — the wall of irrelevant options this is - * supposed to remove. - * - * The intersection of two lists, computed rather than guessed: the tools the - * connected-tools ask actually offers (the `options` array in context-mill's - * `self-driving/references/5-connected-tools.md`) and the kinds this repo can - * detect (`SOURCE_DETECTORS`). Promoting anything outside that intersection is - * wasted at best and misleading at worst — a tool the ask never lists can't be - * picked, so pointing the agent at it sends it after a source it can't create. - * - * The ask's remaining options are deliberately absent because no detector - * matches them (Freshservice, Dixa, pganalyze, SonarQube, Semgrep, Rapid7 - * InsightVM, Featurebase, Frill, Aha, UserVoice, AskNicely, Retently, - * Appfigures, AppFollow, Judge.me). They stay offered by the skill; they just - * never get promoted, which is the safe direction. - * - * A shadow list of both sources, and the guard test only covers one of them — - * it catches a kind that leaves `SOURCE_DETECTORS`, but nothing here can see the - * skill's catalog change in another repo. So when step 5's option list grows, - * reconcile against it. If that becomes a habit, the fix is a field on - * `SourceDetector` rather than a third copy of this list. - */ +/** Step 5's ask options intersected with `SOURCE_DETECTORS` — reconcile when the skill's catalog grows. */ export const SELF_DRIVING_TOOL_KINDS: ReadonlySet = new Set([ // Issue trackers / code hosts 'Github', @@ -380,17 +343,7 @@ export const SELF_DRIVING_TOOL_KINDS: ReadonlySet = new Set([ 'GoogleSearchConsole', ]); -/** - * Scan the codebase for the tools it uses that the inbox can connect (Sentry, - * Linear, GitHub, Zendesk, …) so STEP 5's connected-tools ask can surface those - * first instead of dumping the full source catalog on the user. Stashed under - * self-driving's own `SELF_DRIVING_DETECTED_TOOLS_KEY` and read back with - * `getSelfDrivingDetectedTools`. - * - * Best-effort: the connected-tools ask degrades to the skill's default - * ordering when nothing is detected, so a scan failure must never break the - * surrounding prerequisite check. - */ +/** Scan for inbox-connectable tools so STEP 5 can surface them first. Best-effort — never blocks detection. */ function detectConnectedTools( installDir: string, setFrameworkContext: (key: string, value: unknown) => void, @@ -400,12 +353,7 @@ function detectConnectedTools( SELF_DRIVING_TOOL_KINDS.has(s.kind), ); - // Tagged on every run that scans, including the empty case — without the - // zero rows there is no way to tell "the scan found nothing" from "this code - // path never ran", which is the first thing to check when the ask looks - // unprioritised. Deliberately NOT the `warehouse sources detected` event - // the integration flow emits: that metric's denominator is integration - // runs, and firing it here would fold self-driving runs into it. + // Tagged even at zero, so "found nothing" is distinguishable from "never ran". analytics.setTag('connected_tools_detected_count', tools.length); if (tools.length === 0) return; @@ -415,9 +363,7 @@ function detectConnectedTools( ); setFrameworkContext(SELF_DRIVING_DETECTED_TOOLS_KEY, tools); } catch (error) { - // -1 rather than nothing: an absent tag would be indistinguishable from a - // build that never ran this scan, which is what the count is here to rule - // out. The captured exception carries the why. + // -1, not absent, so a failed scan stays distinguishable from one that never ran. analytics.setTag('connected_tools_detected_count', -1); analytics.captureException( error instanceof Error ? error : new Error(String(error)), diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 436e7ce58..c9beb886f 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -19,13 +19,7 @@ type Step = ProgramConfig['steps'][number]; /** The session a run step's agent runs in: scoped to the step's target dir * (e.g. a monorepo sub-app) with its own framework context, after any prep. * A step without `targetDir` runs in the live session, unchanged. - * - * The copy is shallow and unfiltered, so a composed sub-run inherits every - * frameworkContext key the host program wrote — including ones the sub-run's - * own program reads for its prompt. Name keys for the program that owns them - * (`selfDrivingDetectedTools`, not `detectedWarehouseSources`) so a host can't - * silently rewrite a spliced-in program's behaviour. If that collision shows up - * a second time, scope the inheritance here instead of renaming again. */ + * The frameworkContext copy is shallow and unfiltered — name keys per owning program. */ async function prepareRunSession( step: Step, live: WizardSession,