diff --git a/packages/extension/scores/overture/SCORE.md b/packages/extension/scores/overture/SCORE.md index a3695f5b..8cf36678 100644 --- a/packages/extension/scores/overture/SCORE.md +++ b/packages/extension/scores/overture/SCORE.md @@ -41,6 +41,35 @@ stages: prompt: "Want me to show you the full workflow end-to-end? (requires Julia)" choices: ["Yes, show me", "Skip the demo"] default: "Yes, show me" + - id: environment + questions: + - id: environment + prompt: "How will pulses eventually reach hardware — what are we patching into?" + choices: + [ + "QICK lab (on-prem control code)", + "Cloud system with emulator (e.g. Pasqal)", + "Simulation only for now", + "Something else", + ] + default: "Simulation only for now" + - id: devices + optional: true + questions: + - id: devices + prompt: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" + default: "skip for now" + - id: goals + questions: + - id: goals + prompt: "What are you hoping to accomplish with Amico?" + kind: text + - id: handoff + questions: + - id: handoff + prompt: "Ready to get started?" + choices: ["Walk me through designing a pulse", "Open a normal session", "Show me around first"] + default: "Walk me through designing a pulse" --- You are running the **overture** — Amico's onboarding interview (session zero). @@ -164,7 +193,50 @@ Per-stage guidance and the `amicode_profile` mapping: - The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry). - If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer. - After the demo (or skipping), advance to the next stage. **Stages 5–8 are - defined in subsequent slices** — for now, after Stage 4 completes, record - the completion marker: `amicode_profile {entity:"onboarding_completed"}` - and hand off to a normal session. + After the demo (or skipping), advance to Stage 5. + +5. **environment** — ask how pulses will reach hardware. **Pre-fill from + seeds:** call `amicode_profile {entity:"status"}` and check if an + environment is already recorded from the context-seed (Stage 3). If so, + present it as a confirmation: "I found you use {archetype} — confirm, or + change?" via the `question` tool. If no seed, ask the standard choice + question with the options above. + + Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`. + Follow up on details per archetype if confirmed (QICK: tProc version, + repo pointer; cloud-Pasqal: which provider, emulator access; etc.). + +6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded, + confirm it. Otherwise ask: "Any specific device(s) you want me to remember?" + This stage is ALWAYS skippable — "none" or "skip" is a valid answer. + + Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`. + If skipped, move on without recording. + +7. **goals** — free-text question via `question` tool with `kind: "text"`: + "What are you hoping to accomplish with Amico?" No pre-fill (goals are + personal, not inferrable from configs). + + Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. + +8. **handoff** — the terminal stage. FIRST, record the completion marker: + `amicode_profile {entity:"onboarding_completed"}` (exactly once — this is + what lets Amico remember them next time and triggers the distiller to + materialize the vault). + + Then route by the user's intent selections (from Stage 2 — read from the + events stream, do NOT re-ask): + + - **Research** selected (alone or combined) → "Let's design your first + pulse" → continue straight into the **pulse-designer interview** in this + same session. Use everything learned (platform, environment, device) to + skip pulse-design questions already answered. + - **Research + General coding** → same as above, but acknowledge: "I'm also + your general coding companion — you can switch modes any time." + - **General coding only** (no Research) → open a normal session: "You're all + set — I'll remember your context across sessions. Ask me anything." + Highlight memory + vault features briefly. + - **Exploring only** → "Welcome aboard — want a quick tour of what I can do, + or just dive in?" Offer a brief orientation tour. + + The handoff does NOT re-ask intent — it reads what was recorded and routes. diff --git a/packages/extension/src/handoff_routing.ts b/packages/extension/src/handoff_routing.ts new file mode 100644 index 00000000..274adff5 --- /dev/null +++ b/packages/extension/src/handoff_routing.ts @@ -0,0 +1,88 @@ +// Handoff routing — Stage 8 intent-based routing (#438) +// +// Pure routing logic for the onboarding handoff: given the user's intent +// selections from Stage 2, determines the correct next experience. + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type IntentSlug = "research" | "general_coding" | "exploring"; + +export type HandoffAction = + | "pulse-designer" // Research selected → guided pulse-designer interview + | "normal-session" // General coding only → open session, highlight memory + vault + | "tour-session" // Exploring only → open session with brief tour offer + | "pulse-designer-plus"; // Research + General coding → pulse-designer with broader studio mention + +// ─── Routing table (pure, testable) ────────────────────────────────────────── + +/** Given the user's intent selections, determine the handoff action. + * Research takes priority when combined with other selections. */ +export function resolveHandoffAction(intents: IntentSlug[]): HandoffAction { + const hasResearch = intents.includes("research"); + const hasGeneral = intents.includes("general_coding"); + const hasExploring = intents.includes("exploring"); + + // Research always routes to pulse-designer (possibly with broader mention) + if (hasResearch && hasGeneral) return "pulse-designer-plus"; + if (hasResearch && hasExploring) return "pulse-designer"; + if (hasResearch) return "pulse-designer"; + + // General coding only + if (hasGeneral && hasExploring) return "normal-session"; + if (hasGeneral) return "normal-session"; + + // Exploring only + if (hasExploring) return "tour-session"; + + // Fallback (empty or unknown) — safe default + return "normal-session"; +} + +// ─── Pre-fill resolution ───────────────────────────────────────────────────── + +export interface SeedState { + environment?: { slug?: string; archetype?: string }; + device?: { name?: string; platform?: string }; +} + +export interface PreFillResult { + /** If non-null, show confirmation prompt with this value. If null, ask fresh. */ + environmentSeed: string | null; + /** If non-null, show confirmation prompt. If null, ask fresh or skip. */ + deviceSeed: string | null; +} + +/** Given the current onboarding state (from readOnboardingState), determine + * what pre-fill values to offer for Stages 5-6. */ +export function resolvePreFills(state: SeedState): PreFillResult { + let environmentSeed: string | null = null; + let deviceSeed: string | null = null; + + if (state.environment?.archetype || state.environment?.slug) { + environmentSeed = state.environment.archetype ?? state.environment.slug ?? null; + } + + if (state.device?.name) { + deviceSeed = state.device.name; + if (state.device.platform) { + deviceSeed += ` (${state.device.platform})`; + } + } + + return { environmentSeed, deviceSeed }; +} + +// ─── Intent reading from events stream ─────────────────────────────────────── + +/** Read the intent selections from the onboarding state's profile entity. + * Returns the intent array or an empty array if not yet recorded. */ +export function readIntentFromState( + profileState: Record | undefined, +): IntentSlug[] { + if (!profileState) return []; + const intent = profileState.intent; + if (!Array.isArray(intent)) return []; + return intent.filter((i): i is IntentSlug => + typeof i === "string" && ["research", "general_coding", "exploring"].includes(i), + ); +} diff --git a/packages/extension/test/handoff_routing.test.ts b/packages/extension/test/handoff_routing.test.ts new file mode 100644 index 00000000..bf5d2602 --- /dev/null +++ b/packages/extension/test/handoff_routing.test.ts @@ -0,0 +1,123 @@ +// Handoff routing tests — Stage 8 (#438) +// +// Table-driven tests for the intent-based handoff routing, pre-fill resolution, +// and intent reading from the events stream. + +import { describe, it, expect } from "vitest"; + +import { + resolveHandoffAction, + resolvePreFills, + readIntentFromState, + type IntentSlug, + type HandoffAction, + type SeedState, +} from "../src/handoff_routing"; + +// ─── AC6: Handoff routing table ───────────────────────────────────────────── + +describe("resolveHandoffAction — intent-based routing (AC6)", () => { + const cases: Array<{ intents: IntentSlug[]; expected: HandoffAction; name: string }> = [ + { intents: ["research"], expected: "pulse-designer", name: "research only → pulse-designer" }, + { intents: ["general_coding"], expected: "normal-session", name: "general coding only → normal-session" }, + { intents: ["exploring"], expected: "tour-session", name: "exploring only → tour-session" }, + { intents: ["research", "general_coding"], expected: "pulse-designer-plus", name: "research + general → pulse-designer-plus" }, + { intents: ["research", "exploring"], expected: "pulse-designer", name: "research + exploring → pulse-designer" }, + { intents: ["general_coding", "exploring"], expected: "normal-session", name: "general + exploring → normal-session" }, + { intents: ["research", "general_coding", "exploring"], expected: "pulse-designer-plus", name: "all three → pulse-designer-plus" }, + ]; + + for (const { intents, expected, name } of cases) { + it(name, () => { + expect(resolveHandoffAction(intents)).toBe(expected); + }); + } + + it("empty intents → normal-session (safe default)", () => { + expect(resolveHandoffAction([])).toBe("normal-session"); + }); +}); + +// ─── AC1-2: Pre-fill resolution ───────────────────────────────────────────── + +describe("resolvePreFills — seed-based confirmation prompts (AC1, AC2)", () => { + it("returns null seeds when no state exists", () => { + const result = resolvePreFills({}); + expect(result.environmentSeed).toBeNull(); + expect(result.deviceSeed).toBeNull(); + }); + + it("AC1: returns environment archetype as seed when available", () => { + const state: SeedState = { + environment: { slug: "stanford-qick-lab", archetype: "qick-lab" }, + }; + const result = resolvePreFills(state); + expect(result.environmentSeed).toBe("qick-lab"); + }); + + it("AC1: falls back to slug when archetype is missing", () => { + const state: SeedState = { + environment: { slug: "my-lab" }, + }; + const result = resolvePreFills(state); + expect(result.environmentSeed).toBe("my-lab"); + }); + + it("AC2: returns device name + platform as seed", () => { + const state: SeedState = { + device: { name: "Emerald-Q3", platform: "transmon" }, + }; + const result = resolvePreFills(state); + expect(result.deviceSeed).toBe("Emerald-Q3 (transmon)"); + }); + + it("AC2: device name only (no platform) works", () => { + const state: SeedState = { + device: { name: "MyDevice" }, + }; + const result = resolvePreFills(state); + expect(result.deviceSeed).toBe("MyDevice"); + }); +}); + +// ─── Intent reading from state ─────────────────────────────────────────────── + +describe("readIntentFromState — parse intent from profile (AC6)", () => { + it("returns intent array from profile state", () => { + const profile = { name: "JJ", intent: ["research", "general_coding"] }; + expect(readIntentFromState(profile)).toEqual(["research", "general_coding"]); + }); + + it("filters out invalid intent values", () => { + const profile = { intent: ["research", "invalid_thing", "exploring"] }; + expect(readIntentFromState(profile)).toEqual(["research", "exploring"]); + }); + + it("returns empty array when no profile state", () => { + expect(readIntentFromState(undefined)).toEqual([]); + }); + + it("returns empty array when intent is not an array", () => { + const profile = { intent: "research" }; + expect(readIntentFromState(profile)).toEqual([]); + }); + + it("returns empty array when intent field is missing", () => { + const profile = { name: "JJ" }; + expect(readIntentFromState(profile)).toEqual([]); + }); +}); + +// ─── AC7: Seed rejection (last-write-wins) ─────────────────────────────────── + +describe("seed rejection semantics (AC7)", () => { + it("resolvePreFills returns whatever is latest in state (last-write-wins)", () => { + // If the user overrides a seeded environment, the state will reflect the override + // because appendOnboardingEvent appends and readOnboardingState replays last-value-wins + const stateAfterOverride: SeedState = { + environment: { slug: "user-chosen-env", archetype: "local-sim" }, + }; + const result = resolvePreFills(stateAfterOverride); + expect(result.environmentSeed).toBe("local-sim"); + }); +}); diff --git a/packages/extension/test/scores/golden/compile-chained.md b/packages/extension/test/scores/golden/compile-chained.md index 0fba9f69..844ca185 100644 --- a/packages/extension/test/scores/golden/compile-chained.md +++ b/packages/extension/test/scores/golden/compile-chained.md @@ -20,29 +20,37 @@ gate's checks pass. - Q `seed_optin`: "I can scan your existing AI-tool configs to bootstrap your workspace — want me to?" — options: Yes, scan my configs (recommended) | No thanks, skip 4. **demo** (optional) - Q `demo_offer`: "Want me to show you the full workflow end-to-end? (requires Julia)" — options: Yes, show me (recommended) | Skip the demo -5. **platform** +5. **environment** + - Q `environment`: "How will pulses eventually reach hardware — what are we patching into?" — options: QICK lab (on-prem control code) | Cloud system with emulator (e.g. Pasqal) | Simulation only for now (recommended) | Something else +6. **devices** (optional) + - Q `devices`: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)" — default: skip for now +7. **goals** + - Q `goals`: "What are you hoping to accomplish with Amico?" +8. **handoff** + - Q `handoff`: "Ready to get started?" — options: Walk me through designing a pulse (recommended) | Open a normal session | Show me around first +9. **platform** - Q `platform`: "What kind of system are you working with?" — options: transmon (recommended) | neutral-atom Rydberg | cavity / bosonic | other -6. **model** +10. **model** - emits: system — record via the matching `amicode_*` tool - Q `levels`: "How many levels should the model keep? (I'll recommend based on your system — see guidance)" — default: platform-dependent (transmon 3–4; a cavity/bosonic mode wants a Fock cutoff) - Q `drives`: "Drive parameterization and amplitude bound (drive_max)?" — default: two quadratures, drive_max = 0.2 GHz -7. **mode** +11. **mode** - Q `mode`: "Simulate first, or go straight to solve?" — options: solve (recommended) | simulate - Q `warm_start`: "Warm start from a previous pulse (pulse.jld2) — including one from your pulse bank — or cold start?" — options: cold start (recommended) | warm start - skip if: mode == simulate -8. **problem** +12. **problem** - Q `target`: "What is the target — a gate, or a state to prepare?" — default: a single-qubit gate -9. **formulate** +13. **formulate** - emits: formulation — record via the matching `amicode_*` tool - Q `formulation`: "The problem shape — trajectory type (gate / state-prep / open-system), fixed-time vs min-time, and any robustness or free-phase? (the infidelity objective is DERIVED from the type; constraints default to the amplitude bound)" — default: a fixed-time gate, free-phase on for entangling gates - [Why?] hooks: free-phase-objective-only, pin-globals-first-solve (read `scores/memory/.md` on request) -10. **solve** +14. **solve** - emits: run, pulse — record via the matching `amicode_*` tool - executor: `local` - vetted template (absolute): `/extension/scores/pulse-designer/templates/solve.jl` - Q `solve_params`: "Pulse duration T (ns), timesteps N, and max_iter?" — default: T = 10 ns, N = 50, max_iter = 60 -11. **inspect** -12. **hardware** (optional) +15. **inspect** +16. **hardware** (optional) - emits: device_session — record via the matching `amicode_*` tool --- @@ -168,10 +176,53 @@ Per-stage guidance and the `amicode_profile` mapping: - The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry). - If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer. - After the demo (or skipping), advance to the next stage. **Stages 5–8 are - defined in subsequent slices** — for now, after Stage 4 completes, record - the completion marker: `amicode_profile {entity:"onboarding_completed"}` - and hand off to a normal session. + After the demo (or skipping), advance to Stage 5. + +5. **environment** — ask how pulses will reach hardware. **Pre-fill from + seeds:** call `amicode_profile {entity:"status"}` and check if an + environment is already recorded from the context-seed (Stage 3). If so, + present it as a confirmation: "I found you use {archetype} — confirm, or + change?" via the `question` tool. If no seed, ask the standard choice + question with the options above. + + Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`. + Follow up on details per archetype if confirmed (QICK: tProc version, + repo pointer; cloud-Pasqal: which provider, emulator access; etc.). + +6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded, + confirm it. Otherwise ask: "Any specific device(s) you want me to remember?" + This stage is ALWAYS skippable — "none" or "skip" is a valid answer. + + Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`. + If skipped, move on without recording. + +7. **goals** — free-text question via `question` tool with `kind: "text"`: + "What are you hoping to accomplish with Amico?" No pre-fill (goals are + personal, not inferrable from configs). + + Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`. + +8. **handoff** — the terminal stage. FIRST, record the completion marker: + `amicode_profile {entity:"onboarding_completed"}` (exactly once — this is + what lets Amico remember them next time and triggers the distiller to + materialize the vault). + + Then route by the user's intent selections (from Stage 2 — read from the + events stream, do NOT re-ask): + + - **Research** selected (alone or combined) → "Let's design your first + pulse" → continue straight into the **pulse-designer interview** in this + same session. Use everything learned (platform, environment, device) to + skip pulse-design questions already answered. + - **Research + General coding** → same as above, but acknowledge: "I'm also + your general coding companion — you can switch modes any time." + - **General coding only** (no Research) → open a normal session: "You're all + set — I'll remember your context across sessions. Ask me anything." + Highlight memory + vault features briefly. + - **Exploring only** → "Welcome aboard — want a quick tour of what I can do, + or just dive in?" Offer a brief orientation tour. + + The handoff does NOT re-ask intent — it reads what was recorded and routes. --- diff --git a/packages/extension/test/scores/overture_rewrite.test.ts b/packages/extension/test/scores/overture_rewrite.test.ts index a61548f6..75725e05 100644 --- a/packages/extension/test/scores/overture_rewrite.test.ts +++ b/packages/extension/test/scores/overture_rewrite.test.ts @@ -36,17 +36,20 @@ describe("overture SCORE.md — loads and compiles (AC1)", () => { expect(ov.manifest.schema_version).toBe(1); }); - it("has the new stage structure: orientation, intent", () => { + it("has the new stage structure: orientation, intent, context_seed, demo, environment, devices, goals, handoff", () => { const ov = overture(); const stageIds = ov.manifest.stages.map((s: { id: string }) => s.id); expect(stageIds).toContain("orientation"); expect(stageIds).toContain("intent"); - // Old stages are gone + expect(stageIds).toContain("context_seed"); + expect(stageIds).toContain("demo"); + expect(stageIds).toContain("environment"); + expect(stageIds).toContain("devices"); + expect(stageIds).toContain("goals"); + expect(stageIds).toContain("handoff"); + // Old stage name is gone expect(stageIds).not.toContain("platforms"); - expect(stageIds).not.toContain("environment"); - expect(stageIds).not.toContain("devices"); - expect(stageIds).not.toContain("goals"); - expect(stageIds).not.toContain("handoff"); + expect(stageIds).not.toContain("identity"); }); it("compiles to markdown without error (standalone)", () => { @@ -140,13 +143,20 @@ describe("overture compiled content — resume (AC8)", () => { }); }); -// ─── AC9: subsequent stages placeholder ────────────────────────────────────── +// ─── AC9: complete stage flow ──────────────────────────────────────────────── -describe("overture compiled content — stage boundary (AC9)", () => { +describe("overture compiled content — complete flow (AC9)", () => { const md = compileScore(overture()); - it("marks that stages 3+ come from subsequent slices", () => { - expect(md).toContain("subsequent"); + it("the overture score is complete: all 8 stages defined end-to-end", () => { + expect(md).toContain("orientation"); + expect(md).toContain("intent"); + expect(md).toContain("context_seed"); + expect(md).toContain("demo"); + expect(md).toContain("environment"); + expect(md).toContain("goals"); + expect(md).toContain("handoff"); + expect(md).toContain("onboarding_completed"); }); });