diff --git a/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/.openspec.yaml b/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/.openspec.yaml new file mode 100644 index 00000000..a8821c74 --- /dev/null +++ b/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/notes.md b/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/notes.md new file mode 100644 index 00000000..46448ec9 --- /dev/null +++ b/openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/notes.md @@ -0,0 +1,16 @@ +# agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21 (minimal / T1) + +Branch: `agent//` + +Describe the change in a sentence or two. Commit message is the spec of record. + +## Handoff + +- Handoff: change=`agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/commands/launch.test.ts b/src/commands/launch.test.ts index 132cfb30..3bb0dfed 100644 --- a/src/commands/launch.test.ts +++ b/src/commands/launch.test.ts @@ -443,6 +443,14 @@ describe("buildPickerSections", () => { const out = buildPickerSections(opt("__default"), all, [], 3, now, suggested); expect(out.some((o) => o.value === `${DIVIDER_PREFIX}suggested`)).toBe(false); }); + + test("AI suggestions get a distinct advisor heading", () => { + const out = buildPickerSections(opt("__default"), [opt("google-ads")], [], 3, now, [ + { name: "google-ads", confidence: 0.9, reasons: ["GAQL scripts"], source: "ai" }, + ]); + expect(out[0]?.label).toContain("AI profile advisor"); + expect(out.find((o) => o.value === "google-ads")?.hint).toBe("90% match — GAQL scripts"); + }); }); describe("getDefaultSelector", () => { diff --git a/src/commands/launch.ts b/src/commands/launch.ts index bade1eb1..5bda6ced 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -796,6 +796,8 @@ export interface SuggestedEntry { confidence: number; /** Files / signals that drove the match — surfaced in the hint. */ reasons: string[]; + /** AI advice is visually distinguished from deterministic detection. */ + source?: "ai" | "deterministic"; } /** @@ -1004,7 +1006,9 @@ export function buildPickerSections( if (eligibleSuggestions.length > 0) { result.push({ value: `${DIVIDER_PREFIX}suggested`, - label: " ── 🔍 Suggested for this cwd ──", + label: eligibleSuggestions.some((s) => s.source === "ai") + ? " ── ✨ AI profile advisor ──" + : " ── 🔍 Suggested for this cwd ──", hint: "", divider: true, }); @@ -1160,7 +1164,10 @@ interface ProfileOptionSet { defaultSelector?: string; } -async function listProfileOptions(pinnedProfile?: string): Promise { +async function listProfileOptions( + pinnedProfile?: string, + preferredAgent: "claude" | "codex" = "claude", +): Promise { const names = await listProfiles(); const knownNames = new Set(names); const opts: PickerOption[] = []; @@ -1218,6 +1225,13 @@ async function listProfileOptions(pinnedProfile?: string): Promise o.value === pinnedProfile); + if (current) current.hint = `current profile (.cue.profile)${current.hint ? ` — ${current.hint}` : ""}`; + } + // Build the Default entry (composite of core + user-added profiles). // Pressing Enter on the picker selects it (it's first in the section order). // The loaded parts are baked into the label so the user always sees what @@ -1263,13 +1277,33 @@ async function listProfileOptions(pinnedProfile?: string): Promise + const deterministic = detectProfileV2(cwd).filter((d: DetectionResultV2) => knownProfileNames.has(d.profile), ); + let detections = deterministic; + let suggestionSource: SuggestedEntry["source"] = "deterministic"; + try { + const { adviseProfiles } = await import("../lib/ai-profile-advisor"); + const advice = await adviseProfiles({ + cwd, + knownProfiles: names, + currentProfile: pinnedProfile, + preferredAgent, + }); + if (advice) { + detections = advice.suggestions; + suggestionSource = "ai"; + } + } catch (err) { + // Timeout, unavailable agents, invalid JSON, and cache errors all fall + // through to the existing deterministic detector. + debug("launch:profile-advisor", err); + } const suggested: SuggestedEntry[] = detections.map((d) => ({ name: d.profile, confidence: d.confidence, reasons: d.reasons, + source: suggestionSource, })); // Tag any option that the cwd autodetect strongly endorses so the combine @@ -1863,7 +1897,7 @@ export async function run(args: string[]): Promise { } } catch { /* never block launch on onboarding failure */ } - const optionSet = await listProfileOptions(existingProfile); + const optionSet = await listProfileOptions(existingProfile, parsed.agent); const options = optionSet.options; // Mine local session history for "you usually pair X with Y" suggestions. // The picker pre-checks empirical partners in the combine multiselect. diff --git a/src/lib/ai-profile-advisor.test.ts b/src/lib/ai-profile-advisor.test.ts new file mode 100644 index 00000000..0c4247c9 --- /dev/null +++ b/src/lib/ai-profile-advisor.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { adviseProfiles, advisorCacheKey, parseProfileAdvice } from "./ai-profile-advisor"; + +const dirs: string[] = []; +const temp = () => { + const dir = mkdtempSync(join(tmpdir(), "cue-advisor-")); + dirs.push(dir); + return dir; +}; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +describe("AI profile advisor", () => { + test("accepts JSON only when every suggestion names an installed profile", () => { + const known = new Set(["google-ads", "coolify"]); + expect(parseProfileAdvice('{"summary":"ads repo","suggestions":[{"profile":"google-ads","confidence":0.92,"reasons":["GAQL scripts"]}]}', known)?.suggestions[0]?.profile).toBe("google-ads"); + expect(parseProfileAdvice('{"summary":"bad","suggestions":[{"profile":"invented","confidence":0.9,"reasons":["guess"]}]}', known)).toBeNull(); + }); + + test("caches by resolved repository path and git HEAD", async () => { + const cwd = temp(); + const cacheRoot = temp(); + writeFileSync(join(cwd, "README.md"), "Google Ads campaign manager"); + let calls = 0; + const runner = () => { + calls += 1; + return '{"summary":"ads","suggestions":[{"profile":"google-ads","confidence":0.9,"reasons":["README"]}]}'; + }; + const options = { cwd, cacheRoot, head: "abc", knownProfiles: ["google-ads", "coolify"], runner }; + expect((await adviseProfiles(options))?.suggestions[0]?.profile).toBe("google-ads"); + expect((await adviseProfiles(options))?.suggestions[0]?.profile).toBe("google-ads"); + expect(calls).toBe(1); + expect(advisorCacheKey(cwd, "abc")).not.toBe(advisorCacheKey(cwd, "def")); + }); + + test("tries the other agent, then returns null for deterministic fallback", async () => { + const cwd = temp(); + const attempted: string[] = []; + const result = await adviseProfiles({ + cwd, + cacheRoot: temp(), + head: "abc", + knownProfiles: ["core"], + preferredAgent: "codex", + runner: (agent) => { attempted.push(agent); return agent === "codex" ? "not json" : null; }, + }); + expect(result).toBeNull(); + expect(attempted).toEqual(["codex", "claude"]); + }); +}); diff --git a/src/lib/ai-profile-advisor.ts b/src/lib/ai-profile-advisor.ts new file mode 100644 index 00000000..65541739 --- /dev/null +++ b/src/lib/ai-profile-advisor.ts @@ -0,0 +1,140 @@ +/** Best-effort AI profile advice for the interactive launch picker. */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { basename, join, resolve } from "node:path"; +import { cacheDir } from "./config-paths"; +import type { DetectionResultV2 } from "./auto-detect"; + +export const ADVISOR_CACHE_VERSION = 1; +export const ADVISOR_TIMEOUT_MS = 12_000; + +export interface ProfileAdvice { + suggestions: DetectionResultV2[]; + summary: string; +} + +export type AdvisorRunner = (agent: "claude" | "codex", prompt: string) => string | null | Promise; + +function gitHead(cwd: string): string { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + cwd, + encoding: "utf8", + timeout: 2_000, + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : "no-git-head"; +} + +export function advisorCacheKey(cwd: string, head: string): string { + return createHash("sha256").update(`${resolve(cwd)}\0${head}`).digest("hex"); +} + +function repoEvidence(cwd: string): string { + const files = readdirSync(cwd, { withFileTypes: true }) + .filter((entry) => ![".git", "node_modules", "dist", "build", ".next"].includes(entry.name)) + .slice(0, 80) + .map((entry) => `${entry.isDirectory() ? "dir" : "file"}:${entry.name}`); + const excerpts: string[] = []; + for (const file of ["package.json", "README.md", "AGENTS.md", "CLAUDE.md", "pyproject.toml", "docker-compose.yml"] as const) { + try { + excerpts.push(`--- ${file} ---\n${readFileSync(join(cwd, file), "utf8").slice(0, 4_000)}`); + } catch { /* optional evidence */ } + } + return `repository: ${basename(resolve(cwd))}\nentries:\n${files.join("\n")}\n${excerpts.join("\n")}`.slice(0, 16_000); +} + +function promptFor(cwd: string, knownProfiles: string[], currentProfile?: string): string { + return `You select Cue profiles for a repository. Analyze its PRIMARY work domain, not merely deployment files. +Advertising/domain signals (google-ads, claude-ads, marketing, ads-manager) outrank generic Docker/Coolify signals unless infrastructure is the repository's primary product. +Return JSON only, with this exact shape: +{"summary":"one short sentence","suggestions":[{"profile":"existing-name","confidence":0.0,"reasons":["short evidence"]}]} +Return at most 3 suggestions. Profiles MUST be selected only from this list: +${JSON.stringify(knownProfiles)} +Current profile (context only; never assume it is correct): ${currentProfile ?? "none"} +Repository evidence: +${repoEvidence(cwd)}`; +} + +async function defaultRunner(agent: "claude" | "codex", prompt: string): Promise { + // Reuse cue's isolated, bounded classifier instead of spawning the bare + // agent name (which may be cue's own shim and recurse into launch). + if (agent === "claude") { + const { runClassifier } = await import("./claude-classifier"); + const result = await runClassifier(prompt, ADVISOR_TIMEOUT_MS); + return result.ok ? result.output : null; + } + const { findRealAgentBin } = await import("./claude-binary"); + const bin = findRealAgentBin("codex"); + if (!bin) return null; + const result = spawnSync(bin, ["exec", "--skip-git-repo-check", prompt], { + encoding: "utf8", + timeout: ADVISOR_TIMEOUT_MS, + maxBuffer: 256 * 1024, + stdio: ["ignore", "pipe", "ignore"], + env: { ...process.env, CUE_BYPASS: "1" }, + }); + return result.status === 0 ? result.stdout : null; +} + +export function parseProfileAdvice(raw: string, knownProfiles: ReadonlySet): ProfileAdvice | null { + try { + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start < 0 || end < start) return null; + const value = JSON.parse(raw.slice(start, end + 1)) as Record; + if (typeof value.summary !== "string" || !Array.isArray(value.suggestions)) return null; + const suggestions: DetectionResultV2[] = []; + const seen = new Set(); + for (const item of value.suggestions.slice(0, 3)) { + if (!item || typeof item !== "object") return null; + const row = item as Record; + if (typeof row.profile !== "string" || !knownProfiles.has(row.profile) || seen.has(row.profile)) return null; + if (typeof row.confidence !== "number" || row.confidence < 0 || row.confidence > 1) return null; + if (!Array.isArray(row.reasons) || row.reasons.length === 0 || !row.reasons.every((r) => typeof r === "string")) return null; + seen.add(row.profile); + suggestions.push({ profile: row.profile, confidence: row.confidence, reasons: row.reasons.slice(0, 3) as string[] }); + } + if (suggestions.length === 0) return null; + return { summary: value.summary.slice(0, 240), suggestions }; + } catch { + return null; + } +} + +export async function adviseProfiles(opts: { + cwd: string; + knownProfiles: string[]; + currentProfile?: string; + preferredAgent?: "claude" | "codex"; + cacheRoot?: string; + runner?: AdvisorRunner; + head?: string; +}): Promise { + const head = opts.head ?? gitHead(opts.cwd); + const dir = join(opts.cacheRoot ?? cacheDir(), "profile-advisor"); + const path = join(dir, `${advisorCacheKey(opts.cwd, head)}.json`); + const known = new Set(opts.knownProfiles); + try { + const cached = JSON.parse(readFileSync(path, "utf8")) as { version?: number; advice?: unknown }; + if (cached.version === ADVISOR_CACHE_VERSION) { + const parsed = parseProfileAdvice(JSON.stringify(cached.advice), known); + if (parsed) return parsed; + } + } catch { /* cache miss/corruption */ } + + const runner = opts.runner ?? defaultRunner; + const order: Array<"claude" | "codex"> = opts.preferredAgent === "codex" ? ["codex", "claude"] : ["claude", "codex"]; + const prompt = promptFor(opts.cwd, opts.knownProfiles, opts.currentProfile); + let advice: ProfileAdvice | null = null; + for (const agent of order) { + const raw = await runner(agent, prompt); + if (raw && (advice = parseProfileAdvice(raw, known))) break; + } + if (!advice) return null; + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify({ version: ADVISOR_CACHE_VERSION, cwd: resolve(opts.cwd), head, advice }) + "\n"); + } catch { /* cache failure must not block launch */ } + return advice; +} diff --git a/src/lib/auto-detect.test.ts b/src/lib/auto-detect.test.ts index de3585da..41a204a4 100644 --- a/src/lib/auto-detect.test.ts +++ b/src/lib/auto-detect.test.ts @@ -15,6 +15,11 @@ afterEach(() => { }); describe("detectProfileV2", () => { + test("prioritizes the Ads domain from the repository name", () => { + const out = detectProfileV2("/tmp/google-ads-manager"); + expect(out[0]?.profile).toBe("google-ads"); + expect(out.map((x) => x.profile)).toContain("ads-manager"); + }); test("Cargo.toml → rust with 0.9 confidence", () => { writeFileSync(join(tmp, "Cargo.toml"), "[package]"); const results = detectProfileV2(tmp); diff --git a/src/lib/auto-detect.ts b/src/lib/auto-detect.ts index 5a2cb80a..fb668e75 100644 --- a/src/lib/auto-detect.ts +++ b/src/lib/auto-detect.ts @@ -4,7 +4,7 @@ */ import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; /** * V2 detection result with 0-1 confidence and reasons array. @@ -222,6 +222,18 @@ export function detectProfileV2(cwd: string): DetectionResultV2[] { results.set(profile, entry); } + // Domain-first signals: advertising repos are often infrastructure-shaped + // (Docker/Coolify) but their primary job is campaign management. Prefer the + // domain profile over generic ops when the repo name/docs/scripts say Ads. + const repoText = `${cwd} ${basename(cwd)}`.toLowerCase(); + const adsSignal = /(^|[^a-z])(ads?|google[-_ ]?ads|campaigns?|ppc|roas|gaql|ad[-_ ]?copy)([^a-z]|$)/.test(repoText); + if (adsSignal) { + add("google-ads", 0.86, "repository name suggests advertising"); + add("claude-ads", 0.78, "repository name suggests advertising"); + add("marketing", 0.72, "repository name suggests advertising"); + add("ads-manager", 0.68, "repository name suggests advertising"); + } + // ── Rust ── if (ex(cwd, "Cargo.toml")) { add("rust", 0.9, "Cargo.toml");