Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fc73457
feat(picker): match every profile against the repo, not just the 19 w…
Imdeadpool1 Jul 26, 2026
cce1777
fix(test): make cue handoff tests e2e, drop the global mock.module leak
Imdeadpool1 Jul 26, 2026
36e5075
chore: delete 733 lines of verified dead code
Imdeadpool1 Jul 26, 2026
7e337c7
docs(dead-code): mark the report superseded, record the false-positiv…
Imdeadpool1 Jul 26, 2026
7d34aeb
feat(brief): hand the agent verified facts about the directory it lau…
Imdeadpool1 Jul 27, 2026
7973afe
feat(picker): let the model rerank profile matches, without ever wait…
Imdeadpool1 Jul 27, 2026
7c8494a
feat(picker): scope remembered stacks to the repo you launch in
Imdeadpool1 Jul 27, 2026
b029942
feat(picker): scope pair affinity to the repo you launch in
Imdeadpool1 Jul 27, 2026
c157281
feat(picker): scope Recent by repository, not by path prefix
Imdeadpool1 Jul 27, 2026
5eb0ccd
fix(picker): rank suggested stacks by what you actually launch here
Imdeadpool1 Jul 27, 2026
fdd083b
fix(suggest): score skills on what the user actually said
Imdeadpool1 Jul 27, 2026
ae59ebf
fix(auth): keep concurrent sessions from revoking each other's tokens
Imdeadpool1 Jul 27, 2026
cbeeb6b
fix(auth): read the default account's identity where Claude Code keep…
Imdeadpool1 Jul 28, 2026
6385de5
fix(resolver): follow symlinked skill directories
Imdeadpool1 Aug 5, 2026
120e327
feat(core): keep ego-browser loaded in every project
Imdeadpool1 Aug 7, 2026
788d72d
feat(security): gate freshly-fetched skills through NVIDIA SkillSpector
Imdeadpool1 Aug 7, 2026
8112f7b
fix(materializer): stop unresolving the live runtime path mid-swap
Imdeadpool1 Aug 7, 2026
ceebde6
refactor(picker): pull the shared visual primitives out of card and p…
Imdeadpool1 Aug 7, 2026
062b40a
chore: integrity-protocol wording, tag hooks, two new profiles
Imdeadpool1 Aug 7, 2026
0c0f25b
Merge origin/main into fix/oauth-identity-desync
Imdeadpool1 Aug 7, 2026
c495340
fix(liedetector): one ~N% raster, a drift guard, and hook test covera…
NagyVikt Aug 7, 2026
b0c3400
Merge origin/main into fix/oauth-identity-desync
Imdeadpool1 Aug 7, 2026
a5dc3a1
Merge remote-tracking branch 'origin/main' into fix/oauth-identity-de…
Imdeadpool1 Aug 7, 2026
80a6b47
fix(codex): share AuthMux login with Cue runtimes (#141)
NagyVikt Aug 10, 2026
7990594
feat: advise launch profiles from repository context
Imdeadpool1 Aug 11, 2026
5a3b987
Merge remote-tracking branch 'origin/main' into agent/ai-profile-advi…
Imdeadpool1 Aug 11, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# agent-ai-profile-advisor-ai-profile-advisor-for-launch-picker-2026-08-12-01-21 (minimal / T1)

Branch: `agent/<your-name>/<branch-slug>`

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/<your-name>/<branch-slug>`; 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/<your-name>/<branch-slug>`. 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/<your-name>/<branch-slug> --base dev --via-pr --wait-for-merge --cleanup`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/<your-name>/<branch-slug> --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`).
8 changes: 8 additions & 0 deletions src/commands/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
42 changes: 38 additions & 4 deletions src/commands/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -1160,7 +1164,10 @@ interface ProfileOptionSet {
defaultSelector?: string;
}

async function listProfileOptions(pinnedProfile?: string): Promise<ProfileOptionSet> {
async function listProfileOptions(
pinnedProfile?: string,
preferredAgent: "claude" | "codex" = "claude",
): Promise<ProfileOptionSet> {
const names = await listProfiles();
const knownNames = new Set(names);
const opts: PickerOption[] = [];
Expand Down Expand Up @@ -1218,6 +1225,13 @@ async function listProfileOptions(pinnedProfile?: string): Promise<ProfileOption
}
}

// Keep the resolved .cue.profile visible as context, but never silently
// select it or let it suppress advice. The user remains in control.
if (pinnedProfile) {
const current = opts.find((o) => 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
Expand Down Expand Up @@ -1263,13 +1277,33 @@ async function listProfileOptions(pinnedProfile?: string): Promise<ProfileOption
// Cwd-detected suggestions (medusa-config.js, Cargo.toml, etc.). Only
// surfaced when a profile of the same name actually exists in this install.
const knownProfileNames = new Set(names);
const detections = detectProfileV2(cwd).filter((d: DetectionResultV2) =>
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
Expand Down Expand Up @@ -1863,7 +1897,7 @@ export async function run(args: string[]): Promise<number> {
}
} 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.
Expand Down
52 changes: 52 additions & 0 deletions src/lib/ai-profile-advisor.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
140 changes: 140 additions & 0 deletions src/lib/ai-profile-advisor.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;

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<string | null> {
// 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<string>): 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<string, unknown>;
if (typeof value.summary !== "string" || !Array.isArray(value.suggestions)) return null;
const suggestions: DetectionResultV2[] = [];
const seen = new Set<string>();
for (const item of value.suggestions.slice(0, 3)) {
if (!item || typeof item !== "object") return null;
const row = item as Record<string, unknown>;
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<ProfileAdvice | null> {
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;
}
5 changes: 5 additions & 0 deletions src/lib/auto-detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion src/lib/auto-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Loading