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
8 changes: 8 additions & 0 deletions .changeset/compose-extension-skills.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions examples/framework-discovery-demo/README.md
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 23 additions & 0 deletions examples/framework-discovery-demo/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions examples/framework-discovery-demo/src/demo.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
106 changes: 106 additions & 0 deletions examples/framework-discovery-demo/src/demo.ts
Original file line number Diff line number Diff line change
@@ -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<DiscoveryOutcome> {
// 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,
}
}
24 changes: 24 additions & 0 deletions examples/framework-discovery-demo/src/main.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
})
5 changes: 5 additions & 0 deletions examples/framework-discovery-demo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "noEmit": true, "rootDir": "src" },
"include": ["src"]
}
5 changes: 5 additions & 0 deletions examples/framework-discovery-demo/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist-test", "rootDir": "src" },
"include": ["src"]
}
35 changes: 35 additions & 0 deletions examples/framework-hello/index.js
Original file line number Diff line number Diff line change
@@ -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',
}),
],
})
13 changes: 13 additions & 0 deletions examples/framework-hello/package.json
Original file line number Diff line number Diff line change
@@ -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:^"
}
}
18 changes: 18 additions & 0 deletions packages/ai-autopilot/src/extensions/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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
Expand Down
10 changes: 9 additions & 1 deletion packages/ai-autopilot/src/extensions/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/)
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-autopilot/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export {
builtinExtensionRegistry,
builtinSkillRegistry,
composePersonas,
composeSkills,
skillInstructions,
frameworkAuth,
frameworkData,
Expand Down
5 changes: 5 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading