From d16bfa5fee4fb8f1012be6fdfaa39762dbdc6259 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Sat, 4 Jul 2026 20:18:17 +0300 Subject: [PATCH] feat(framework): move the page builder off the preset onto its skill A framework was represented twice: its page-builder persona rode the preset seam while its docs rode the skill seam. This finishes the framework=skill design: a Skill now carries its curated framing personas alongside the doc pointer, so a framework's whole knowledge is one unit. vikeSkill carries vikePageBuilder; new nextSkill carries nextPageBuilder. A preset is now a pure detector pointing at its framework skill, and run.ts frames the page builder through the skill set (the selected preset's skill is always framed, so an empty from-scratch project still gets the flagship page builder). New exports: nextSkill, skillPersonas. Part of #190. --- .changeset/page-builder-rides-skill.md | 8 +++++ .../ai-autopilot/src/extensions/compose.ts | 20 ++++++++++- .../ai-autopilot/src/extensions/define.ts | 1 + .../src/extensions/extensions.test.ts | 26 ++++++++++++++- packages/ai-autopilot/src/extensions/index.ts | 10 +++++- .../ai-autopilot/src/extensions/library.ts | 32 +++++++++++++++--- packages/ai-autopilot/src/extensions/types.ts | 27 +++++++++------ packages/ai-autopilot/src/index.ts | 9 +++-- packages/ai-autopilot/src/personas/library.ts | 2 +- .../ai-autopilot/src/presets/define.test.ts | 8 +++++ packages/ai-autopilot/src/presets/define.ts | 1 + packages/ai-autopilot/src/presets/index.ts | 2 +- .../ai-autopilot/src/presets/library.test.ts | 11 +++++-- packages/ai-autopilot/src/presets/library.ts | 25 +++++++------- packages/ai-autopilot/src/presets/types.ts | 27 ++++++++------- packages/framework/src/run.test.ts | 16 +++++++++ packages/framework/src/run.ts | 33 ++++++++++++------- 17 files changed, 199 insertions(+), 59 deletions(-) create mode 100644 .changeset/page-builder-rides-skill.md diff --git a/.changeset/page-builder-rides-skill.md b/.changeset/page-builder-rides-skill.md new file mode 100644 index 0000000..7202e97 --- /dev/null +++ b/.changeset/page-builder-rides-skill.md @@ -0,0 +1,8 @@ +--- +'@gemstack/ai-autopilot': minor +'@gemstack/framework': minor +--- + +Move the framework page builder off the preset onto its skill (fully skill-driven framing) + +A framework was represented twice: its page-builder persona rode the preset seam (`preset.personas`, framed as the run's base) while its docs rode the skill seam (`vike.dev/llms.txt`). This finishes the "framework = skill, adapter axis gone" design: a `Skill` now carries its own curated framing personas alongside the doc pointer, so all of a framework's knowledge lives in one unit. `vikeSkill` carries `vikePageBuilder`; the new `nextSkill` carries `nextPageBuilder`. A preset is now a pure detector that points at its framework `skill`, and `run.ts` frames the page builder through the skill set (the detected preset's skill is always framed, even on an empty from-scratch project where nothing signal-matched, since preset selection is the fallback). New exports: `nextSkill`, `skillPersonas`. `presetPersonas` and the framing narration are unchanged. Part of #190. diff --git a/packages/ai-autopilot/src/extensions/compose.ts b/packages/ai-autopilot/src/extensions/compose.ts index 0e04d60..39a98dc 100644 --- a/packages/ai-autopilot/src/extensions/compose.ts +++ b/packages/ai-autopilot/src/extensions/compose.ts @@ -9,7 +9,7 @@ export interface NeutralPersona { /** Inputs to {@link composePersonas}. */ export interface ComposePersonasInput { - /** Always-on base personas (e.g. a preset's page builder). */ + /** Always-on base personas (e.g. the framework skill's page builder). */ base?: readonly Persona[] /** The active capability extensions. */ extensions: readonly FrameworkExtension[] @@ -49,6 +49,24 @@ export function composeSkills(input: { matched?: readonly Skill[]; extensions: r return out } +/** + * The framing personas an active skill set brings — every skill's curated + * {@link Skill.personas} in order, deduped by name (first occurrence wins). + * These are the base personas for a run (e.g. the detected framework's page + * builder), symmetric with {@link composeSkills}: the same skill carries both + * its doc pointer and the personas that knowledge always frames the agent with. + */ +export function skillPersonas(skills: readonly Skill[]): Persona[] { + const out: Persona[] = [] + const seen = new Set() + for (const persona of skills.flatMap(s => s.personas)) { + if (seen.has(persona.name)) continue + seen.add(persona.name) + out.push(persona) + } + return out +} + /** * Render a doc-pointer {@link Skill} as a system-prompt fragment: what the * knowledge is and where its `llms.txt` lives, so the agent consults the source diff --git a/packages/ai-autopilot/src/extensions/define.ts b/packages/ai-autopilot/src/extensions/define.ts index 3209f38..9c7cdb1 100644 --- a/packages/ai-autopilot/src/extensions/define.ts +++ b/packages/ai-autopilot/src/extensions/define.ts @@ -60,6 +60,7 @@ export function defineSkill(spec: SkillSpec): Skill { title: spec.title.trim(), description: spec.description.trim(), url, + personas: Object.freeze([...(spec.personas ?? [])]), signals: frozenSignals(spec.signals), }) } diff --git a/packages/ai-autopilot/src/extensions/extensions.test.ts b/packages/ai-autopilot/src/extensions/extensions.test.ts index ca66273..97edb34 100644 --- a/packages/ai-autopilot/src/extensions/extensions.test.ts +++ b/packages/ai-autopilot/src/extensions/extensions.test.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' import { definePersona } from '../personas/define.js' import { dataModeler, uiIntentDesigner } from '../personas/library.js' -import { composePersonas, composeSkills, skillInstructions } from './compose.js' +import { composePersonas, composeSkills, skillPersonas, skillInstructions } from './compose.js' import { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js' import { builtinExtensionNames, @@ -31,10 +31,34 @@ test('defineFrameworkExtension validates and freezes', () => { test('defineSkill validates the required fields', () => { const s = defineSkill({ name: 'vike', title: 'Vike', description: 'd', url: 'https://x/llms.txt' }) assert.equal(s.url, 'https://x/llms.txt') + assert.deepEqual(s.personas, []) // a pure doc pointer frames no personas assert.throws(() => defineSkill({ name: 'vike', title: 'Vike', description: 'd', url: '' }), ExtensionError) assert.throws(() => defineSkill({ name: 'Vike', title: 'Vike', description: 'd', url: 'u' }), ExtensionError) }) +test('a skill carries its curated framing personas (page builder rides the skill, not a preset)', () => { + const pb = definePersona({ name: 'astro-page-builder', role: 'builds Astro pages', systemPrompt: 'astro' }) + const s = defineSkill({ + name: 'astro', + title: 'Astro', + description: 'd', + url: 'https://astro.build/llms.txt', + personas: [pb], + }) + assert.deepEqual(s.personas.map(p => p.name), ['astro-page-builder']) + // The built-in framework skills each ship their page builder. + assert.equal(vikeSkill.personas[0]?.name, 'vike-page-builder') +}) + +test('skillPersonas flattens the framing personas of a skill set, deduped by name', () => { + const pb = definePersona({ name: 'astro-page-builder', role: 'builds', systemPrompt: 'a' }) + const astro = defineSkill({ name: 'astro', title: 'Astro', description: 'd', url: 'https://x/llms.txt', personas: [pb] }) + const docOnly = defineSkill({ name: 'domain', title: 'Domain', description: 'd', url: 'https://x/d.txt' }) + // vikeSkill re-listed twice contributes its page builder once. + const names = skillPersonas([vikeSkill, astro, docOnly, vikeSkill]).map(p => p.name) + assert.deepEqual(names, ['vike-page-builder', 'astro-page-builder']) +}) + test('matchSignals scores deps over files; selectActive unions signal-match and opt-in', () => { const ext = defineFrameworkExtension({ name: 'framework-auth', capability: 'auth', signals: { dependencies: ['vike-auth'] } }) assert.equal(matchSignals(ext.signals, { dependencies: ['vike-auth'] }).score, 2) diff --git a/packages/ai-autopilot/src/extensions/index.ts b/packages/ai-autopilot/src/extensions/index.ts index 5a2e21a..73c8287 100644 --- a/packages/ai-autopilot/src/extensions/index.ts +++ b/packages/ai-autopilot/src/extensions/index.ts @@ -21,7 +21,14 @@ export { builtinSkillRegistry, type MatchOptions, } from './registry.js' -export { composePersonas, composeSkills, skillInstructions, type ComposePersonasInput, type NeutralPersona } from './compose.js' +export { + composePersonas, + composeSkills, + skillPersonas, + skillInstructions, + type ComposePersonasInput, + type NeutralPersona, +} from './compose.js' export { frameworkAuth, frameworkData, @@ -31,6 +38,7 @@ export { builtinExtensions, builtinExtensionNames, vikeSkill, + nextSkill, builtinSkills, neutralPersonas, } from './library.js' diff --git a/packages/ai-autopilot/src/extensions/library.ts b/packages/ai-autopilot/src/extensions/library.ts index c62ff5d..ee016e2 100644 --- a/packages/ai-autopilot/src/extensions/library.ts +++ b/packages/ai-autopilot/src/extensions/library.ts @@ -1,9 +1,11 @@ import { dataModeler, + nextPageBuilder, uiIntentDesigner, vikeAuthComposer, vikeCrudComposer, vikeDataModeler, + vikePageBuilder, vikeRbacComposer, vikeShellComposer, } from '../personas/library.js' @@ -72,9 +74,10 @@ export function builtinExtensions(): FrameworkExtension[] { export const builtinExtensionNames: readonly string[] = Object.freeze(builtinExtensions().map(e => e.name)) /** - * Vike as a {@link Skill}: framework knowledge arrives as a doc pointer to - * `vike.dev/llms.txt`, not a special adapter package. Auto-activates whenever - * Vike is detected. Proof that a framework rides the skill seam. + * Vike as a {@link Skill}: the whole framework rides the skill seam — its page + * builder ({@link vikePageBuilder}) plus a doc pointer to `vike.dev/llms.txt`, + * not a special adapter package or a preset-supplied persona. Auto-activates + * whenever Vike is detected. */ export const vikeSkill: Skill = defineSkill({ name: 'vike', @@ -82,15 +85,34 @@ export const vikeSkill: Skill = defineSkill({ description: 'Vike is the Vite-based, renderer-agnostic meta-framework the app is built on (filesystem routing under `pages/`, `+` config files, server-side `+data` loading).', url: 'https://vike.dev/llms.txt', + personas: [vikePageBuilder], signals: { dependencies: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], files: [/(^|\/)\+Page(\.[\w-]+)?\.[jt]sx?$/, /(^|\/)\+config\.[jt]s$/], }, }) -/** The built-in skills (doc pointers). */ +/** + * Next.js as a {@link Skill}: same seam as {@link vikeSkill} — its App Router + * page builder plus a doc pointer to `nextjs.org/llms.txt`. The second framework + * is another skill, not a runtime fork. + */ +export const nextSkill: Skill = defineSkill({ + name: 'next', + title: 'Next.js', + description: + 'Next.js with the App Router (React Server Components): filesystem routing under `app/`, `page.tsx`/`layout.tsx`, server components by default, server actions for mutations.', + url: 'https://nextjs.org/llms.txt', + personas: [nextPageBuilder], + signals: { + dependencies: ['next'], + files: [/(^|\/)next\.config\.[cm]?[jt]s$/, /(^|\/)app\/.*\/page\.[jt]sx?$/, /(^|\/)app\/layout\.[jt]sx?$/], + }, +}) + +/** The built-in framework skills (page builder + doc pointer), flagship first. */ export function builtinSkills(): Skill[] { - return [vikeSkill] + return [vikeSkill, nextSkill] } /** diff --git a/packages/ai-autopilot/src/extensions/types.ts b/packages/ai-autopilot/src/extensions/types.ts index cdb766f..5207624 100644 --- a/packages/ai-autopilot/src/extensions/types.ts +++ b/packages/ai-autopilot/src/extensions/types.ts @@ -8,11 +8,13 @@ import type { FrameworkSignals, PresetSignals } from '../presets/types.js' * * - {@link FrameworkExtension} — a capability (auth, data, ...) that frames the * agent with personas when it matches a project. - * - {@link Skill} — a doc pointer (framework/domain knowledge = an `llms.txt`), - * the shared unit with Open Loop (#204). + * - {@link Skill} — framework/domain knowledge: a doc pointer (an `llms.txt`) + * plus the curated personas it frames the agent with, the shared unit with + * Open Loop (#204). * - * A framework is not a special package: it is a {@link Skill} pointing at its - * `llms.txt`. There is no adapter axis. + * A framework is not a special package: it is a {@link Skill} carrying its page + * builder and pointing at its `llms.txt`. There is no adapter axis, and no + * separate seam supplies the page builder. */ /** How a unit is recognized in a project — the same deps/files shape presets use. */ @@ -22,11 +24,13 @@ export type ExtensionSignals = PresetSignals export type { FrameworkSignals } from '../presets/types.js' /** - * A doc-pointer skill: framework or domain knowledge an agent pulls in by - * reading an `llms.txt`. Distinct from `@gemstack/ai-skills`' on-disk - * `SKILL.md`/`LoadedSkill` (instructions + tools) — a {@link Skill} is just a - * pointer, the lightweight unit shared with Open Loop (#204). Vike is a skill - * (https://vike.dev/llms.txt), not an adapter package. + * A skill: framework or domain knowledge an agent pulls in. It carries two + * things — a doc pointer (an `llms.txt` the agent consults) and, optionally, the + * curated framing {@link Persona}s that knowledge always brings (e.g. Vike's page + * builder). Distinct from `@gemstack/ai-skills`' on-disk `SKILL.md`/`LoadedSkill` + * (instructions + tools) — a {@link Skill} is the lightweight unit shared with + * Open Loop (#204). A framework is a skill (Vike -> https://vike.dev/llms.txt + + * its page builder), not an adapter package; there is no adapter axis. */ export interface Skill { /** Stable kebab-case id (e.g. `vike`). */ @@ -37,16 +41,19 @@ export interface Skill { readonly description: string /** The `llms.txt` (or other LLM-optimized doc) URL the agent should consult. */ readonly url: string + /** Curated personas this knowledge always frames the agent with (e.g. a page builder). Empty for a pure doc pointer. */ + readonly personas: readonly Persona[] /** When to auto-activate it; empty means opt-in only. */ readonly signals: ExtensionSignals } -/** The author-facing shape for {@link defineSkill}; `signals` defaults to empty. */ +/** The author-facing shape for {@link defineSkill}; `personas`/`signals` default to empty. */ export interface SkillSpec { name: string title: string description: string url: string + personas?: readonly Persona[] signals?: ExtensionSignals } diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 9f543f8..5c98c08 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -85,13 +85,14 @@ * - {@link agentOverview} / {@link overviewLoopPrompt} — regenerate with an agent, * and wire the maintainer into the loop * - * Presets are the web-app layer: framework-specific personas selected by detecting - * the app's framework (Vike flagship, Next.js second), on top of the agnostic core. + * Presets are the web-app layer: detect the app's framework (Vike flagship, + * Next.js second) and point at its {@link Skill} (page builder + `llms.txt`), on + * top of the agnostic core. * * - {@link PresetRegistry} — register presets, {@link PresetRegistry.select} one * - {@link detectFramework} — score a project's deps/files against presets * - {@link vikePreset} / {@link nextPreset} — the built-ins - * - {@link presetPersonas} — a preset's personas + the shared neutral ones + * - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -323,6 +324,7 @@ export { builtinSkillRegistry, composePersonas, composeSkills, + skillPersonas, skillInstructions, frameworkAuth, frameworkData, @@ -332,6 +334,7 @@ export { builtinExtensions, builtinExtensionNames, vikeSkill, + nextSkill, builtinSkills, neutralPersonas, EXTENSION_NAME_RE, diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts index 18faad4..331502f 100644 --- a/packages/ai-autopilot/src/personas/library.ts +++ b/packages/ai-autopilot/src/personas/library.ts @@ -463,7 +463,7 @@ const neutralGuardrails: readonly Persona[] = Object.freeze([uiIntentDesigner]) /** * The framework-neutral personas shared by every preset — the data layer and the * intent-based UI guardrail apply the same whether the app is on Vike or Next. - * A preset adds its framework-specific page builder on top (see the presets seam). + * The framework's own skill adds its page builder on top (see the skill seam). */ export const sharedPersonas: readonly Persona[] = Object.freeze([ dataModeler, diff --git a/packages/ai-autopilot/src/presets/define.test.ts b/packages/ai-autopilot/src/presets/define.test.ts index 232d0f5..b8da37a 100644 --- a/packages/ai-autopilot/src/presets/define.test.ts +++ b/packages/ai-autopilot/src/presets/define.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { definePreset, PresetError } from './define.js' +import { defineSkill } from '../extensions/define.js' describe('definePreset', () => { it('validates and freezes a preset, defaulting personas/signals', () => { @@ -9,11 +10,18 @@ describe('definePreset', () => { assert.equal(preset.framework, 'Astro') assert.deepEqual(preset.personas, []) assert.deepEqual(preset.signals.dependencies, []) + assert.equal(preset.skill, undefined) // no framework skill unless supplied assert.throws(() => { ;(preset as { name: string }).name = 'x' }) }) + it('carries the framework skill it points at', () => { + const skill = defineSkill({ name: 'astro', title: 'Astro', description: 'd', url: 'https://astro.build/llms.txt' }) + const preset = definePreset({ name: 'astro', framework: 'Astro', skill }) + assert.equal(preset.skill, skill) + }) + it('rejects a missing/non-kebab name and a missing framework', () => { assert.throws(() => definePreset({ name: '', framework: 'X' }), PresetError) assert.throws(() => definePreset({ name: 'Not Kebab', framework: 'X' }), /kebab-case/) diff --git a/packages/ai-autopilot/src/presets/define.ts b/packages/ai-autopilot/src/presets/define.ts index b4274eb..2723d01 100644 --- a/packages/ai-autopilot/src/presets/define.ts +++ b/packages/ai-autopilot/src/presets/define.ts @@ -23,6 +23,7 @@ export function definePreset(spec: PresetSpec): Preset { return Object.freeze({ name, framework: spec.framework.trim(), + ...(spec.skill ? { skill: spec.skill } : {}), personas: Object.freeze([...(spec.personas ?? [])]), signals: Object.freeze({ dependencies: Object.freeze([...(spec.signals?.dependencies ?? [])]), diff --git a/packages/ai-autopilot/src/presets/index.ts b/packages/ai-autopilot/src/presets/index.ts index 2dcca63..94cf40d 100644 --- a/packages/ai-autopilot/src/presets/index.ts +++ b/packages/ai-autopilot/src/presets/index.ts @@ -8,7 +8,7 @@ * - {@link vikePreset} / {@link nextPreset} — the built-ins * - {@link detectFramework} — score a project's deps/files against presets * - {@link PresetRegistry} — register presets and {@link PresetRegistry.select} one - * - {@link presetPersonas} — a preset's personas + the shared neutral ones + * - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones */ export { definePreset, PresetError } from './define.js' export { detectFramework } from './detect.js' diff --git a/packages/ai-autopilot/src/presets/library.test.ts b/packages/ai-autopilot/src/presets/library.test.ts index a2d336b..36b4dfa 100644 --- a/packages/ai-autopilot/src/presets/library.test.ts +++ b/packages/ai-autopilot/src/presets/library.test.ts @@ -10,10 +10,15 @@ import { } from './library.js' describe('built-in presets', () => { - it('ship Vike (flagship) and Next, each with its page builder', () => { + it('ship Vike (flagship) and Next, each pointing at its framework skill', () => { assert.deepEqual(builtinPresets().map(p => p.name), ['vike', 'next']) - assert.equal(vikePreset.personas[0]?.name, 'vike-page-builder') - assert.equal(nextPreset.personas[0]?.name, 'next-page-builder') + // The page builder rides the framework skill, not the preset itself. + assert.deepEqual(vikePreset.personas, []) + assert.deepEqual(nextPreset.personas, []) + assert.equal(vikePreset.skill?.name, 'vike') + assert.equal(nextPreset.skill?.name, 'next') + assert.equal(vikePreset.skill?.personas[0]?.name, 'vike-page-builder') + assert.equal(nextPreset.skill?.personas[0]?.name, 'next-page-builder') }) }) diff --git a/packages/ai-autopilot/src/presets/library.ts b/packages/ai-autopilot/src/presets/library.ts index 1cb4a86..5e1befe 100644 --- a/packages/ai-autopilot/src/presets/library.ts +++ b/packages/ai-autopilot/src/presets/library.ts @@ -1,28 +1,30 @@ -import { vikePageBuilder, nextPageBuilder, sharedPersonas } from '../personas/library.js' +import { sharedPersonas } from '../personas/library.js' +import { vikeSkill, nextSkill } from '../extensions/library.js' import type { Persona } from '../personas/types.js' import { definePreset } from './define.js' import { detectFramework } from './detect.js' import type { FrameworkDetection, FrameworkSignals, Preset } from './types.js' /** - * The flagship preset: Vike (Vite + SSR), renderer-agnostic. Its page builder - * plus the shared neutral personas make up the Vike stack. + * The flagship preset: Vike (Vite + SSR), renderer-agnostic. Detection points at + * the {@link vikeSkill}, whose page builder plus the shared neutral personas make + * up the Vike stack. */ export const vikePreset: Preset = definePreset({ name: 'vike', framework: 'Vike', - personas: [vikePageBuilder], + skill: vikeSkill, signals: { dependencies: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], files: [/(^|\/)\+Page(\.[\w-]+)?\.[jt]sx?$/, /(^|\/)\+config\.[jt]s$/], }, }) -/** The second preset: Next.js (App Router + React Server Components). */ +/** The second preset: Next.js (App Router + React Server Components). Points at {@link nextSkill}. */ export const nextPreset: Preset = definePreset({ name: 'next', framework: 'Next.js', - personas: [nextPageBuilder], + skill: nextSkill, signals: { dependencies: ['next'], files: [/(^|\/)next\.config\.[cm]?[jt]s$/, /(^|\/)app\/.*\/page\.[jt]sx?$/, /(^|\/)app\/layout\.[jt]sx?$/], @@ -35,13 +37,14 @@ export function builtinPresets(): Preset[] { } /** - * The full worker roster for a preset: its framework-specific personas followed - * by the shared, framework-neutral ones (data layer + intent UI). This is what - * you hand to `personaWorkers` / a planner roster, so only the page builder - * changes between frameworks while the rest of the stack stays put. + * The full worker roster for a preset: its framework skill's page builder (plus + * any extra preset personas) followed by the shared, framework-neutral ones (data + * layer + intent UI). This is what you hand to `personaWorkers` / a planner + * roster, so only the page builder changes between frameworks while the rest of + * the stack stays put. */ export function presetPersonas(preset: Preset, shared: readonly Persona[] = sharedPersonas): Persona[] { - return [...preset.personas, ...shared] + return [...(preset.skill?.personas ?? []), ...preset.personas, ...shared] } /** diff --git a/packages/ai-autopilot/src/presets/types.ts b/packages/ai-autopilot/src/presets/types.ts index 2c49a25..0058801 100644 --- a/packages/ai-autopilot/src/presets/types.ts +++ b/packages/ai-autopilot/src/presets/types.ts @@ -1,16 +1,18 @@ import type { Persona } from '../personas/types.js' +import type { Skill } from '../extensions/types.js' /** * The web-app preset seam (#115). The engine (loop + state layer) is - * framework-agnostic; the framework-specific knowledge lives here, at the - * persona layer, and is selected by *detecting* the app's framework rather than - * forking the runtime. Vike is the flagship preset; Next.js is the second. New - * frameworks are a new {@link Preset}, not a change to the core. + * framework-agnostic; a preset *detects* which framework a project is on and + * points at that framework's {@link Skill} (its page builder + `llms.txt`). + * Vike is the flagship preset; Next.js is the second. New frameworks are a new + * {@link Preset}, not a change to the core. * - * A preset is data: a name, its framework-specific personas, and the signals that - * identify it in a project. {@link detectFramework} scores the signals; the - * shared, framework-neutral personas (data layer, intent UI) are added on top by - * {@link presetPersonas}, so a preset only carries what is genuinely per-framework. + * A preset is data: a name, the {@link Skill} carrying its framework knowledge, + * and the signals that identify it in a project. {@link detectFramework} scores + * the signals; the framework's page builder rides its skill (not the preset), and + * the shared, framework-neutral personas (data layer, intent UI) are added on top + * by {@link presetPersonas} — so the preset itself only carries detection. */ /** How to recognize a framework in a project. */ @@ -21,22 +23,25 @@ export interface PresetSignals { files?: readonly RegExp[] } -/** A framework preset: framework-specific personas + the signals that detect it. */ +/** A framework preset: detection signals + the {@link Skill} carrying its framework knowledge. */ export interface Preset { /** Stable id, kebab-case (e.g. `vike`, `next`). */ readonly name: string /** Human framework name (e.g. `Vike`, `Next.js`). */ readonly framework: string - /** The framework-specific personas (the neutral shared ones are added separately). */ + /** The framework's knowledge unit (page builder + `llms.txt`); its personas frame the run. */ + readonly skill?: Skill + /** Extra always-on personas beyond the skill's — usually empty; the page builder rides {@link skill}. */ readonly personas: readonly Persona[] /** How this preset is detected in a project. */ readonly signals: PresetSignals } -/** The author-facing shape for {@link definePreset}; personas/signals default to empty. */ +/** The author-facing shape for {@link definePreset}; `skill`/`personas`/`signals` default to empty. */ export interface PresetSpec { name: string framework: string + skill?: Skill personas?: readonly Persona[] signals?: PresetSignals } diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index 5980d28..942b3e7 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -174,6 +174,22 @@ test('Vike arrives as a skill (llms.txt pointer) in the framing (#190)', async ( assert.match(system(), /https:\/\/vike\.dev\/llms\.txt/) }) +test('the framework page builder is framed via its skill, not a preset persona', async () => { + const { driver, system } = recordingDriver() + await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, onEvent: () => {} }) + // The vike-page-builder persona (page-builder conventions) rides the Vike skill. + assert.match(system(), /You build UI on Vike \(Vite \+ SSR\), which is renderer-agnostic/) +}) + +test('an empty from-scratch project is still framed with the flagship page builder (skill fallback)', async () => { + const { driver, system } = recordingDriver() + // No signals match any skill, so only preset selection (fallback = flagship Vike) + // brings the framework skill in. Its page builder must still frame the agent. + await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: {}, onEvent: () => {} }) + assert.match(system(), /You build UI on Vike \(Vite \+ SSR\), which is renderer-agnostic/) + assert.match(system(), /https:\/\/vike\.dev\/llms\.txt/) +}) + test('a registered extension auto-activates by its signal, no opt-in needed (#190)', async () => { const { driver, system } = recordingDriver() const audit = defineFrameworkExtension({ diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index d0fd1e1..9a26290 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -13,6 +13,7 @@ import { personaInstructions, serveCheck, skillInstructions, + skillPersonas, type BootstrapEvent, type BootstrapResult, type BootstrapScope, @@ -154,7 +155,7 @@ export interface RunFrameworkResult { /** * Run the whole turnkey flow: detect the framework preset, frame the wrapped - * agent with that preset's personas, then drive ai-autopilot's `Bootstrap` + * agent with its framework skill (page builder + docs), then drive ai-autopilot's `Bootstrap` * (scope → architect → build → full-fledged loop → deploy) entirely *through* * the driver (option A). Every phase, plus the agent's own progress, streams as * a {@link FrameworkEvent}. Reversible: swap in a real deploy target, or a @@ -173,14 +174,16 @@ export async function runFramework(opts: RunFrameworkOptions): Promise vike.dev/llms.txt) that ride the same seam. + // 1. Detect the framework, then compose the active capability extensions and + // framework skills on top — no framework is hardcoded (#190). Extensions + // activate by signal (a dep is present) or, with --compose-extensions, by opting + // the built-ins in for a from-scratch build. The built-in composers are Vike-only + // (they resolve inside the vike-data workspace), so the blanket opt-in is guarded + // to the Vike preset; on any other preset it is ignored with a log and only + // signal-matched extensions compose (#202). The framework rides the skill seam: + // the detected preset points at its skill (page builder + vike.dev/llms.txt), + // which is always framed — even on an empty project where nothing signal-matched, + // since preset selection is the fallback. No preset-supplied page-builder persona. const signals = opts.signals ?? {} const { preset, detection } = builtinPresetRegistry().select(signals) const optInBuiltins = opts.composeExtensions === true && preset.name === 'vike' @@ -194,8 +197,16 @@ export async function runFramework(opts: RunFrameworkOptions): Promise