diff --git a/.changeset/compose-extension-skills.md b/.changeset/compose-extension-skills.md new file mode 100644 index 0000000..597287a --- /dev/null +++ b/.changeset/compose-extension-skills.md @@ -0,0 +1,8 @@ +--- +'@gemstack/ai-autopilot': minor +'@gemstack/framework': minor +--- + +Thread an active extension's own skills into the agent frame + +The extension SPI (#190) let a `FrameworkExtension` carry `skills` (llms.txt doc pointers), but `run.ts` only framed the built-in `SkillRegistry` matches, so a discovered extension's own skills were collected and then dropped. Add `composeSkills` (symmetric with `composePersonas`): it unions the registry-matched skills with every active extension's skills, deduped by name. `run.ts` uses it, so an active extension now contributes both its personas and its doc pointers to the frame. Surfaced by the new `examples/framework-discovery-demo` end-to-end proof, where the third-party `framework-hello` extension's `hello-guide` skill now reaches the agent. diff --git a/examples/framework-discovery-demo/README.md b/examples/framework-discovery-demo/README.md new file mode 100644 index 0000000..8817731 --- /dev/null +++ b/examples/framework-discovery-demo/README.md @@ -0,0 +1,32 @@ +# @gemstack/example-framework-discovery + +End-to-end proof of The Framework's **extension SPI** (#190): a third-party +capability package is discovered from a project and composed into the agent +frame, with no change to the framework core. + +This package is a real project. Its only integration with the greeting +capability is one line in `package.json`: + +```json +"dependencies": { "framework-hello": "workspace:^" } +``` + +[`framework-hello`](../framework-hello) is a plain third-party package whose +default export is a `FrameworkExtension`. The framework core has never heard of +it. When this project runs, the framework: + +1. **discovers** it — reads this project's `package.json`, finds the `framework-*` + dependency, and resolves + imports it from disk (the real dynamic-import path); +2. **composes** it — frames the agent with the extension's `greeter` persona and + its `hello-guide` skill (an `llms.txt` pointer). + +## Run it + +```bash +pnpm start # narrated offline demo (fake driver, no model, deterministic) +pnpm test # the same, asserted end-to-end +``` + +The only thing faked is the coding agent's turns. Discovery and composition are +the exact product code a live run uses. To do it for real against Claude Code, +install `framework-hello` in your own project and run `npx @gemstack/framework`. diff --git a/examples/framework-discovery-demo/package.json b/examples/framework-discovery-demo/package.json new file mode 100644 index 0000000..a82844d --- /dev/null +++ b/examples/framework-discovery-demo/package.json @@ -0,0 +1,23 @@ +{ + "name": "@gemstack/example-framework-discovery", + "version": "0.0.0", + "private": true, + "description": "End-to-end proof of The Framework's extension SPI (#190): this project depends on the third-party `framework-hello` package, and the CLI discovers, registers, and composes it into the agent frame - offline and deterministic via the fake driver.", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", + "clean": "rm -rf dist-test", + "start": "tsx src/main.ts" + }, + "dependencies": { + "@gemstack/framework": "workspace:^", + "@gemstack/ai-autopilot": "workspace:^", + "framework-hello": "workspace:^" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } +} diff --git a/examples/framework-discovery-demo/src/demo.test.ts b/examples/framework-discovery-demo/src/demo.test.ts new file mode 100644 index 0000000..e44ce6d --- /dev/null +++ b/examples/framework-discovery-demo/src/demo.test.ts @@ -0,0 +1,18 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' +import { runDemo } from './demo.js' + +test('a real third-party framework-* package is discovered and composed end-to-end (#190)', async () => { + const out = await runDemo(() => {}) + + // Discovery resolved + imported the installed package from this project, for real. + assert.ok(out.discovered.includes('framework-hello'), 'framework-hello should be discovered from the workspace') + assert.deepEqual(out.failed, [], 'no discovered package should fail to load') + + // Composition threaded its persona and its own skill into the agent frame. + assert.equal(out.greeterComposed, true, 'the greeter persona should be framed') + assert.equal(out.helloSkillComposed, true, 'the hello-guide skill pointer should be framed') + + // And the whole offline flow still ran to production-grade. + assert.equal(out.productionGrade, true) +}) diff --git a/examples/framework-discovery-demo/src/demo.ts b/examples/framework-discovery-demo/src/demo.ts new file mode 100644 index 0000000..4ff8d5d --- /dev/null +++ b/examples/framework-discovery-demo/src/demo.ts @@ -0,0 +1,106 @@ +import { fileURLToPath } from 'node:url' +import { + discoverExtensions, + fakeDriver, + formatFrameworkEvent, + readProjectSignals, + runFramework, + FAKE_INTENT, + type Driver, + type FrameworkEvent, +} from '@gemstack/framework' + +/** + * The end-to-end proof for The Framework's extension SPI (#190). + * + * This package is a *real project* that depends on the third-party + * `framework-hello` capability package. Nothing about `framework-hello` is + * hardcoded in the framework core; installing it is the only thing that turns it + * on. The demo runs the two halves of the seam for real: + * + * 1. **Discovery** - it reads this project's `package.json` signals and + * resolves + imports the installed `framework-*` packages *from disk* (the + * real dynamic-import path, not a fake loader), yielding a live + * `FrameworkExtension`. + * 2. **Composition** - it drives the whole bootstrap flow with the built-in + * fake driver (offline, no model), framed with the discovered extension, and + * captures the system prompt to prove the extension's persona and skill + * actually reached the agent. + * + * The only thing faked is the coding agent's turns; discovery and composition are + * the real product code a live run uses. + */ + +/** The one prompt the demo builds from. */ +export const DEMO_INTENT = FAKE_INTENT + +/** This package's own directory - the real project that depends on `framework-hello`. */ +export const projectDir = fileURLToPath(new URL('..', import.meta.url)) + +/** What {@link runDemo} reports after discovery + an offline run. */ +export interface DiscoveryOutcome { + /** The `framework-*` packages discovered and loaded from this project. */ + discovered: string[] + /** Any `framework-*` package that matched the convention but failed to load. */ + failed: string[] + /** The detected framework (undefined here -> the flagship Vike preset by fallback). */ + framework: string | undefined + /** Proof the discovered persona reached the agent frame (its sentinel is present). */ + greeterComposed: boolean + /** Proof the discovered extension's own skill (llms.txt pointer) reached the frame. */ + helloSkillComposed: boolean + /** The framework's own narration line naming the active extension. */ + framingLog: string | undefined + /** Whether the offline flow reached production-grade. */ + productionGrade: boolean +} + +/** A fake driver that also records the system framing it is started with. */ +function recordingFakeDriver(): { driver: Driver; system: () => string } { + const fd = fakeDriver() + let captured = '' + const driver: Driver = { + name: 'fake', + start: opts => { + captured = opts.system ?? '' + return fd.start(opts) + }, + } + return { driver, system: () => captured } +} + +/** Run the whole proof and stream one narration line per phase to `onLine`. */ +export async function runDemo(onLine: (line: string) => void): Promise { + // 1. Real discovery: resolve + import this project's installed framework-* packages. + const signals = readProjectSignals(projectDir) + const { extensions, failed } = await discoverExtensions(projectDir, signals) + onLine(`discovered ${extensions.length} framework-* extension(s): ${extensions.map(e => e.name).join(', ') || '(none)'}`) + for (const f of failed) onLine(` skipped ${f.package}: ${f.error}`) + + // 2. Real composition: drive the offline flow framed with the discovered + // extension, capturing the system prompt to prove it composed. + const { driver, system } = recordingFakeDriver() + let framingLog: string | undefined + const run = await runFramework({ + intent: DEMO_INTENT, + driver, + cwd: projectDir, + signals, + extensions, + onEvent: (event: FrameworkEvent) => { + if (event.kind === 'log' && /framing with/.test(event.message)) framingLog = event.message + onLine(formatFrameworkEvent(event)) + }, + }) + + const framed = system() + return { + discovered: extensions.map(e => e.name), + failed: failed.map(f => f.package), + framework: run.detection.framework, + greeterComposed: /FRAMEWORK-HELLO-SENTINEL/.test(framed), + helloSkillComposed: /framework-hello\/llms\.txt/.test(framed), + framingLog, + productionGrade: run.result.productionGrade, + } +} diff --git a/examples/framework-discovery-demo/src/main.ts b/examples/framework-discovery-demo/src/main.ts new file mode 100644 index 0000000..0c5bddf --- /dev/null +++ b/examples/framework-discovery-demo/src/main.ts @@ -0,0 +1,24 @@ +import { DEMO_INTENT, runDemo } from './demo.js' + +/** Run the discovery proof and print the story: live narration, then the outcome. */ +async function main(): Promise { + console.log('The Framework — third-party extension discovery (offline, deterministic)\n') + console.log(`Prompt: "${DEMO_INTENT}"\n`) + console.log('--- live narration ---') + const out = await runDemo(line => console.log(line)) + + console.log('\n--- outcome ---') + console.log(` discovered: ${out.discovered.join(', ') || '(none)'}`) + console.log(` framework: ${out.framework ?? 'Vike (default)'}`) + console.log(` greeter persona: ${out.greeterComposed ? 'composed into the frame' : 'MISSING'}`) + console.log(` hello-guide skill: ${out.helloSkillComposed ? 'composed into the frame' : 'MISSING'}`) + console.log(` production-grade: ${out.productionGrade}`) + + console.log('\nframework-hello is a plain third-party package. The framework core never') + console.log('mentions it — installing it into this project is the whole integration.') +} + +main().catch(err => { + console.error(err) + process.exitCode = 1 +}) diff --git a/examples/framework-discovery-demo/tsconfig.json b/examples/framework-discovery-demo/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/examples/framework-discovery-demo/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/examples/framework-discovery-demo/tsconfig.test.json b/examples/framework-discovery-demo/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/examples/framework-discovery-demo/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/examples/framework-hello/index.js b/examples/framework-hello/index.js new file mode 100644 index 0000000..1c72942 --- /dev/null +++ b/examples/framework-hello/index.js @@ -0,0 +1,35 @@ +import { defineFrameworkExtension, definePersona, defineSkill } from '@gemstack/ai-autopilot' + +/** + * A real, self-contained third-party capability extension for The Framework. + * + * This is what an outside author ships: a package named `framework-*` whose + * default export is a `FrameworkExtension`. Installing it into a project is the + * signal that turns it on (its own package name), so `@gemstack/framework` + * discovers it, registers it, and composes its persona + skill into the agent + * frame - with zero changes to the framework core. No build step: plain ESM. + */ +export default defineFrameworkExtension({ + name: 'framework-hello', + capability: 'greeting', + // Activate whenever a project installs this package. + signals: { dependencies: ['framework-hello'] }, + personas: [ + definePersona({ + name: 'greeter', + role: 'Adds a warm one-line greeting to the home page instead of leaving it blank', + systemPrompt: `You own the app's first impression. When you build the home page, add a warm, +one-line greeting at the top ("Welcome - glad you're here.") styled with the +app's own tokens, never a hardcoded banner color. Keep it to a single sentence; +do not turn the home page into a marketing splash. FRAMEWORK-HELLO-SENTINEL.`, + }), + ], + skills: [ + defineSkill({ + name: 'hello-guide', + title: 'Hello Guide', + description: 'Conventions for the greeting capability: placement, tone, and theming.', + url: 'https://example.com/framework-hello/llms.txt', + }), + ], +}) diff --git a/examples/framework-hello/package.json b/examples/framework-hello/package.json new file mode 100644 index 0000000..fafceb1 --- /dev/null +++ b/examples/framework-hello/package.json @@ -0,0 +1,13 @@ +{ + "name": "framework-hello", + "version": "0.0.0", + "private": true, + "description": "Example third-party framework-* capability extension. Proves The Framework's extension SPI (#190): install it into a project and the CLI discovers, registers, and composes it with no change to the framework core.", + "type": "module", + "exports": { + ".": "./index.js" + }, + "dependencies": { + "@gemstack/ai-autopilot": "workspace:^" + } +} diff --git a/packages/ai-autopilot/src/extensions/compose.ts b/packages/ai-autopilot/src/extensions/compose.ts index c0e49cf..0e04d60 100644 --- a/packages/ai-autopilot/src/extensions/compose.ts +++ b/packages/ai-autopilot/src/extensions/compose.ts @@ -31,6 +31,24 @@ export function composePersonas(input: ComposePersonasInput): Persona[] { return [...(input.base ?? []), ...input.extensions.flatMap(e => e.personas), ...neutral] } +/** + * Compose the skill set for a run: the registry-matched skills (activated by the + * project's own signals) plus every skill an active extension pulls in, deduped + * by name so a registry skill is not shadowed when an extension re-declares it + * (first occurrence wins). Keeps the two skill sources in one place, symmetric + * with {@link composePersonas}. + */ +export function composeSkills(input: { matched?: readonly Skill[]; extensions: readonly FrameworkExtension[] }): Skill[] { + const out: Skill[] = [] + const seen = new Set() + for (const skill of [...(input.matched ?? []), ...input.extensions.flatMap(e => e.skills)]) { + if (seen.has(skill.name)) continue + seen.add(skill.name) + out.push(skill) + } + 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/extensions.test.ts b/packages/ai-autopilot/src/extensions/extensions.test.ts index f5d154f..ca66273 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, skillInstructions } from './compose.js' +import { composePersonas, composeSkills, skillInstructions } from './compose.js' import { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js' import { builtinExtensionNames, @@ -88,6 +88,14 @@ test('composePersonas: extensions supersede the neutral default of their capabil assert.ok(names.includes(uiIntentDesigner.name)) // no extension owns 'ui' }) +test('composeSkills unions matched skills with active extensions own skills, deduped by name', () => { + const guide = defineSkill({ name: 'hello-guide', title: 'Hello', description: 'd', url: 'https://x/llms.txt' }) + const ext = defineFrameworkExtension({ name: 'framework-hello', capability: 'greeting', skills: [guide, vikeSkill] }) + const composed = composeSkills({ matched: [vikeSkill], extensions: [ext] }) + // vikeSkill (matched) is not duplicated by the extension re-declaring it; hello-guide is added. + assert.deepEqual(composed.map(s => s.name), ['vike', 'hello-guide']) +}) + test('skillInstructions renders the doc pointer', () => { const text = skillInstructions(vikeSkill) assert.match(text, /Vike/) diff --git a/packages/ai-autopilot/src/extensions/index.ts b/packages/ai-autopilot/src/extensions/index.ts index f594cb5..5a2e21a 100644 --- a/packages/ai-autopilot/src/extensions/index.ts +++ b/packages/ai-autopilot/src/extensions/index.ts @@ -21,7 +21,7 @@ export { builtinSkillRegistry, type MatchOptions, } from './registry.js' -export { composePersonas, skillInstructions, type ComposePersonasInput, type NeutralPersona } from './compose.js' +export { composePersonas, composeSkills, skillInstructions, type ComposePersonasInput, type NeutralPersona } from './compose.js' export { frameworkAuth, frameworkData, diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 6da8b09..9f543f8 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -322,6 +322,7 @@ export { builtinExtensionRegistry, builtinSkillRegistry, composePersonas, + composeSkills, skillInstructions, frameworkAuth, frameworkData, diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 909004f..0cef10f 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -55,6 +55,11 @@ export { type ServeConfig, type AppPreview, } from './run.js' +export { + discoverExtensions, + readProjectSignals, + type DiscoverExtensionsResult, +} from './extensions.js' export { hostExecutor, type HostExecutorOptions } from './host-exec.js' export { type FrameworkEvent, diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index e6f33cc..5980d28 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { defineFrameworkExtension, definePersona } from '@gemstack/ai-autopilot' +import { defineFrameworkExtension, definePersona, defineSkill } from '@gemstack/ai-autopilot' import { DEFAULT_MAX_PASSES, runFramework } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import type { Driver } from './driver/index.js' @@ -193,6 +193,19 @@ test('a registered extension auto-activates by its signal, no opt-in needed (#19 assert.match(system(), /AUDIT-LOG-EVERYTHING sentinel/) }) +test('an active extension pulls its own skill into the framing (#190)', async () => { + const { driver, system } = recordingDriver() + const audit = defineFrameworkExtension({ + name: 'framework-audit', + capability: 'audit', + personas: [definePersona({ name: 'auditor', role: 'audits', systemPrompt: 'audit sentinel' })], + skills: [defineSkill({ name: 'audit-guide', title: 'Audit Guide', description: 'd', url: 'https://x/audit/llms.txt' })], + signals: { dependencies: ['@prisma/client'] }, // present in FAKE_SIGNALS + }) + await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, extensions: [audit], onEvent: () => {} }) + assert.match(system(), /https:\/\/x\/audit\/llms\.txt/) +}) + test('the default pass budget is raised for from-scratch builds (#182)', () => { // 3 was too low: the first passes go to bootstrapping an empty workspace. assert.equal(DEFAULT_MAX_PASSES, 5) diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 3980ea9..d0fd1e1 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -7,6 +7,7 @@ import { builtinExtensionNames, builtinPresetRegistry, composePersonas, + composeSkills, mergeChecklists, neutralPersonas, personaInstructions, @@ -194,7 +195,7 @@ export async function runFramework(opts: RunFrameworkOptions): Promise