Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/ai-meta-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@gemstack/framework': minor
---

feat(framework): AI meta-select — auto-pick the Open Loop domain preset, modes, and build event kind from the prompt + workspace (#270)

A live run with no `--preset` (and none in `the-framework.yml`) now infers the best-fit domain preset, its modes (technical / autopilot), and the build event kind from what you asked for and the project you are in, then runs under it. So `framework "add a login page"` in a web app picks Web Development on its own. `--no-auto-preset` opts out (plain framework flow); `--fake` stays deterministic. Any failed or empty pick falls back to the plain flow, so the auto-pick never blocks a run.
52 changes: 52 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
activeModes,
autoSelectPreset,
buildDeployTarget,
chooseSessionLink,
claudeDriverOptions,
Expand All @@ -10,8 +14,10 @@ import {
parseArgs,
resolveDomainPreset,
runCli,
workspaceSummary,
type CliIO,
} from './cli.js'
import { FakeDriver } from './driver/index.js'

function capture(): { io: CliIO; out: string[]; err: string[] } {
const out: string[] = []
Expand Down Expand Up @@ -77,6 +83,52 @@ test('parseArgs reads --kind as the build event (#265)', () => {
assert.equal(parseArgs(['x']).buildEvent, undefined)
})

test('parseArgs defaults autoPreset on, and --no-auto-preset turns it off (#204)', () => {
assert.equal(parseArgs(['x']).autoPreset, true)
assert.equal(parseArgs(['--no-auto-preset', 'x']).autoPreset, false)
})

test('workspaceSummary describes an empty vs an existing workspace', async () => {
const empty = await mkdtemp(join(tmpdir(), 'framework-ws-'))
try {
assert.match(workspaceSummary(empty, {}), /empty/)
await writeFile(join(empty, 'index.ts'), 'export const x = 1\n')
const summary = workspaceSummary(empty, { dependencies: { react: '^19', vite: '^5' } })
assert.match(summary, /existing project/)
assert.match(summary, /react/)
assert.match(summary, /vite/)
} finally {
await rm(empty, { recursive: true, force: true })
}
})

test('autoSelectPreset routes the pick through an injected driver and returns the selection (#204)', async () => {
const empty = await mkdtemp(join(tmpdir(), 'framework-ws-'))
try {
const driver = new FakeDriver({
turns: [{ text: '```json\n{ "preset": "software-development", "modes": ["technical"], "event": "bug-fix", "why": "a fix" }\n```' }],
})
const { io } = capture()
const selection = await autoSelectPreset({ intent: 'fix a crash', cwd: empty, signals: {}, claudeOpts: {}, io, driver })
assert.deepEqual(selection, { preset: 'software-development', modes: ['technical'], buildEvent: 'bug-fix', why: 'a fix' })
} finally {
await rm(empty, { recursive: true, force: true })
}
})

test('autoSelectPreset degrades to undefined when the driver errors', async () => {
const empty = await mkdtemp(join(tmpdir(), 'framework-ws-'))
try {
const driver = new FakeDriver({ respond: () => { throw new Error('agent unavailable') } })
const { io, err } = capture()
const selection = await autoSelectPreset({ intent: 'anything', cwd: empty, signals: {}, claudeOpts: {}, io, driver })
assert.equal(selection, undefined)
assert.ok(err.some(l => /auto-select skipped/.test(l)))
} finally {
await rm(empty, { recursive: true, force: true })
}
})

test('activeModes maps the mode flags to Open Loop mode names', () => {
assert.deepEqual(activeModes({ autopilot: false, technical: false }), [])
assert.deepEqual(activeModes({ autopilot: true, technical: false }), ['autopilot'])
Expand Down
156 changes: 129 additions & 27 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
selectPreset,
type DeployTarget,
type DomainPreset,
type FrameworkSignals,
} from '@gemstack/ai-autopilot'
import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type PermissionMode } from './driver/index.js'
import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type DriverSession, type PermissionMode } from './driver/index.js'
import { hostExecutor } from './host-exec.js'
import { startDashboard, type Dashboard } from './dashboard/index.js'
import { formatFrameworkEvent, CLAUDE_CODE_SESSION_LINK, type FrameworkEvent } from './events.js'
Expand All @@ -23,6 +24,13 @@ import {
} from './run.js'
import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js'
import { discoverExtensions, readProjectSignals } from './extensions.js'
import {
META_SELECT_SYSTEM,
metaSelect,
presetCatalog,
type MetaSelection,
} from './meta-select.js'
import { isWorkspaceEmpty } from './steps.js'
import { loadFrameworkConfig, type FrameworkFileConfig } from './config.js'
import { loadRepoMemory } from './memory.js'
import { preflight } from './preflight.js'
Expand Down Expand Up @@ -74,6 +82,10 @@ Options:
--scope <prototype|full> How much app to build (default: full).
--preset <name> Run under an Open Loop domain preset (its loops + prompts
+ skills frame the build), e.g. software-development.
Omit it and a live run auto-picks the best-fit preset +
modes from your prompt + workspace (--no-auto-preset off).
--no-auto-preset Do not auto-pick a preset; run the plain framework flow
unless --preset / the-framework.yml sets one.
--autopilot Activate the preset's Autopilot mode variants.
--technical Activate the preset's Technical mode variants.
(--preset / --autopilot / --technical / --kind can also be
Expand Down Expand Up @@ -133,6 +145,7 @@ export interface CliOptions {
model?: string | undefined
scope: 'prototype' | 'full'
preset?: string | undefined
autoPreset: boolean
autopilot: boolean
technical: boolean
buildEvent?: string | undefined
Expand Down Expand Up @@ -167,6 +180,7 @@ export function parseArgs(argv: string[]): CliOptions {
skipPreflight: false,
intent: '',
scope: 'full',
autoPreset: true,
autopilot: false,
technical: false,
dashboard: true,
Expand Down Expand Up @@ -200,6 +214,9 @@ export function parseArgs(argv: string[]): CliOptions {
case '--preset':
opts.preset = argv[++i]
break
case '--no-auto-preset':
opts.autoPreset = false
break
case '--autopilot':
opts.autopilot = true
break
Expand Down Expand Up @@ -373,6 +390,57 @@ export async function resolveDomainPreset(
return { preset }
}

/** A one-line summary of the workspace for the meta-select router: empty vs existing + a few deps. */
export function workspaceSummary(cwd: string, signals: FrameworkSignals): string {
if (isWorkspaceEmpty(cwd)) return 'empty (a from-scratch build)'
const deps = signals.dependencies
const names = Array.isArray(deps) ? deps : Object.keys(deps ?? {})
if (names.length === 0) return 'an existing project'
const shown = names.slice(0, 12).join(', ')
return `an existing project (dependencies: ${shown}${names.length > 12 ? ', …' : ''})`
}

/**
* AI meta-select (#204): infer the best-fit domain preset (+ modes + build event)
* from the intent + workspace when the user did not pick one. Spins up a
* short-lived driver session for a single routing prompt, then disposes it. Any
* failure (agent error, junk reply) degrades to `undefined` = the plain flow, so
* a run is never blocked by the auto-pick.
*/
export async function autoSelectPreset(opts: {
intent: string
cwd: string
signals: FrameworkSignals
claudeOpts: ClaudeCodeDriverOptions
signal?: AbortSignal
io: CliIO
/** The driver to route the pick through. Defaults to a Claude Code driver; injected in tests. */
driver?: Driver
}): Promise<MetaSelection | undefined> {
const catalog = presetCatalog(await builtinDomainPresets())
if (catalog.length === 0) return undefined
const driver = opts.driver ?? new ClaudeCodeDriver(opts.claudeOpts)
let session: DriverSession | undefined
try {
session = await driver.start({
cwd: opts.cwd,
system: META_SELECT_SYSTEM,
...(opts.signal ? { signal: opts.signal } : {}),
})
return await metaSelect(session, {
intent: opts.intent,
catalog,
workspace: workspaceSummary(opts.cwd, opts.signals),
...(opts.signal ? { signal: opts.signal } : {}),
})
} catch (err) {
opts.io.err(`auto-select skipped (${err instanceof Error ? err.message : String(err)}); using the plain framework flow.`)
return undefined
} finally {
await session?.dispose()
}
}

/**
* The `framework` command. Wires the parsed options into {@link runFramework}
* over a live dashboard + terminal narration, and resolves with an exit code.
Expand Down Expand Up @@ -422,25 +490,31 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
const fromFile = describeConfigSource(opts, fileConfig)
if (fromFile) io.out(`◆ the-framework.yml: ${fromFile}`)

// Resolve an Open Loop domain preset by name (#256), loaded with the active mode
// variants. A bad name is a usage error before we do any work. The mode flags
// only act on a preset, so note when they are given without one.
// Resolve which Open Loop domain preset (+ modes + build event) to run under.
// Precedence: an explicit --preset / the-framework.yml wins; else, on a live run,
// AI meta-select infers the best fit from the intent + workspace (#204). CLI mode
// flags still OR in on top of an inferred preset; --no-auto-preset / --fake skip it.
const merged = mergeRunConfig(opts, fileConfig)
const modes = activeModes(merged)
const { preset: domainPreset, error: presetError } = await resolveDomainPreset(merged.presetName, modes)
if (presetError) {
io.err(presetError)
io.err('Run `framework --help` for usage.')
return 2
}
if (modes.length && !domainPreset) {
io.err(`note: ${modes.join(' + ')} mode(s) have no effect without a preset.`)
}
if (merged.buildEvent && !domainPreset) {
io.err(`note: build event "${merged.buildEvent}" has no effect without a preset.`)
let presetName = merged.presetName
let modeList = activeModes(merged)
let buildEvent = merged.buildEvent
let domainPreset: DomainPreset | undefined

// An explicit --preset / the-framework.yml preset is validated up front — a bad
// name is a usage error, independent of the environment, so it fails the same way
// whether or not the agent is installed (before preflight).
if (presetName) {
const resolved = await resolveDomainPreset(presetName, modeList)
if (resolved.error) {
io.err(resolved.error)
io.err('Run `framework --help` for usage.')
return 2
}
domainPreset = resolved.preset
}

// Fail early and clearly if a live run's prerequisites are missing.
// Fail early and clearly if a live run's prerequisites are missing — before
// auto-select and the run, which both need the wrapped agent.
if (!fake && !opts.skipPreflight) {
const pre = await preflight()
if (!pre.ok) {
Expand All @@ -451,6 +525,38 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}

const claudeOpts = claudeDriverOptions(opts)
// Detection signals: fixed for the fake demo, read from the project otherwise.
// Computed once here, reused by auto-select, extension discovery, and the run.
const signals = fake ? FAKE_SIGNALS : readProjectSignals(cwd)
// One controller for the whole run (and the auto-select turn before it): the
// dashboard Stop button aborts it once wired below.
const controller = new AbortController()

// AI meta-select: with no preset chosen explicitly, infer the best fit (+ modes +
// build event) from the intent + workspace, then resolve it with the active modes.
if (!fake && opts.autoPreset && !presetName) {
const selection = await autoSelectPreset({ intent, cwd, signals, claudeOpts, signal: controller.signal, io })
if (selection?.preset) {
presetName = selection.preset
modeList = [...new Set([...modeList, ...selection.modes])]
buildEvent = buildEvent ?? selection.buildEvent
domainPreset = (await resolveDomainPreset(presetName, modeList)).preset
const modeNote = modeList.length ? ` (modes: ${modeList.join(', ')})` : ''
const kindNote = selection.buildEvent && !merged.buildEvent ? `, ${selection.buildEvent}` : ''
io.out(`◆ auto-selected preset: ${presetName}${modeNote}${kindNote}${selection.why ? ` — ${selection.why}` : ''}`)
} else {
io.out(`◆ auto-select: no preset fits${selection?.why ? ` (${selection.why})` : ''}; using the plain framework flow.`)
}
}

// A mode/kind given with no preset in effect has nothing to act on: note it.
if (modeList.length && !domainPreset) {
io.err(`note: ${modeList.join(' + ')} mode(s) have no effect without a preset.`)
}
if (buildEvent && !domainPreset) {
io.err(`note: build event "${buildEvent}" has no effect without a preset.`)
}

const driver: Driver = fake ? fakeDriver() : new ClaudeCodeDriver(claudeOpts)
// The fake demo defaults to a Cloudflare deploy decision so the flow ends with
// a deploy phase; a live run only narrates deploy when asked.
Expand Down Expand Up @@ -487,11 +593,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
: undefined

// One controller for the whole run: the dashboard Stop button aborts it.
// The dashboard Stop button aborts the run-wide controller created above.
// runFramework checks the signal between phases and the driver kills its current
// turn on it, so a stop takes effect promptly.
const controller = new AbortController()

let dashboard: Dashboard | undefined
if (opts.dashboard) {
try {
Expand Down Expand Up @@ -524,11 +628,9 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
void store?.append(event)
}

// Detection signals: fixed for the fake demo, read from the project otherwise so
// preset detection and extension auto-activation reflect what is actually
// installed. Then discover installed `framework-*` capability packages (#190) and
// register them; each still activates by signal or --compose-extensions opt-in.
const signals = fake ? FAKE_SIGNALS : readProjectSignals(cwd)
// Discover installed `framework-*` capability packages (#190) from the signals
// read above and register them; each still activates by signal or the
// --compose-extensions opt-in.
let discovered: RunFrameworkOptions['extensions']
if (!fake) {
const { extensions, failed } = await discoverExtensions(cwd, signals)
Expand Down Expand Up @@ -557,8 +659,8 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
...(serve ? { serve } : {}),
...(discovered ? { extensions: discovered } : {}),
...(opts.composeExtensions ? { composeExtensions: true } : {}),
...(domainPreset ? { preset: domainPreset, ...(modes.length ? { modes } : {}) } : {}),
...(merged.buildEvent ? { buildEvent: merged.buildEvent } : {}),
...(domainPreset ? { preset: domainPreset, ...(modeList.length ? { modes: modeList } : {}) } : {}),
...(buildEvent ? { buildEvent } : {}),
...(memory.length ? { memory } : {}),
...((): { sessionLink?: string } => {
const link = chooseSessionLink(opts, fake)
Expand Down
12 changes: 11 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,17 @@ export {
type RunStatus,
type OpenStoreOptions,
} from './store/index.js'
export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js'
export { runCli, parseArgs, buildDeployTarget, workspaceSummary, autoSelectPreset, type CliIO, type CliOptions } from './cli.js'
export {
metaSelect,
metaSelectPrompt,
parseMetaSelection,
presetCatalog,
META_SELECT_MODES,
META_SELECT_SYSTEM,
type MetaSelection,
type PresetCatalogEntry,
} from './meta-select.js'
export {
loadFrameworkConfig,
parseFrameworkConfig,
Expand Down
Loading
Loading