diff --git a/.changeset/remove-persona-skill-memory-547.md b/.changeset/remove-persona-skill-memory-547.md new file mode 100644 index 00000000..2d3442b6 --- /dev/null +++ b/.changeset/remove-persona-skill-memory-547.md @@ -0,0 +1,21 @@ +--- +'@gemstack/framework': minor +'@gemstack/ai-autopilot': minor +--- + +Remove the persona, skill, and project-memory framing. A run's system prompt is now the built-in #326 prompt plus your own `SYSTEM.md`, and nothing else. Nothing is read off disk and appended when the run starts. + +A build used to append the personas and skills for its detected stack plus the full contents of the repo's memory files. On one measured 8,852-character build prompt that framing was about 5,000 characters, more than the designed prompt it was wrapping. None of that text was designed; it accumulated. Prompt text nobody reviewed is a defect, not a feature (#547). + +Two things fall out of this: + +- **The prompt preview is now exact.** The dashboard's "See actual prompt sent" (#520) had to carry a caveat that a build run appends more at run time. It no longer does, so the caveat is gone and what you read before the run is what the agent gets, for every run kind. +- **A build stops being opinionated about the stack.** #545 removed the architect turn, but the personas still hard-instructed a stack ("Default to Prisma"). With both gone, nothing tells the agent what to build on. + +Skills and project memory are worth having as designed features later. They are not worth keeping in this shape. + +Removed from `@gemstack/framework`: the `memory` / `extensions` / `composeExtensions` options on `runFramework`, the `framing` option on `composeRunSystem` (`RunSystemOptions` is now `SystemPromptOptions`), `loadRepoMemory` / `memoryFraming` / `MEMORY_FILES`, `discoverExtensions`, and the `--compose-extensions` flag. `readProjectSignals` moved to `project.js` and is still exported from the root; preset detection still runs and still narrates `Detected `. + +Removed from `@gemstack/ai-autopilot`: the `personas/` and `extensions/` modules (`definePersona`, `composePersonas`, `personaInstructions`, `personaTools`, `personaAgent`, `personaWorkers`, `personaRoster`, `stackPersonas`, `neutralPersonas`, `presetPersonas`, `defineSkill`, `SkillRegistry`, `composeSkills`, `skillInstructions`, `FrameworkExtension` and its registry). `Preset` is now `{name, framework, signals}`, a pure detector; `DomainPreset` loses `skills` and keeps its loops, prompts, and modes. + +`@gemstack/ai-skills` is untouched. It is a different thing (an on-disk `SKILL.md` loader for ai-sdk agents) and is unaffected by any of this. diff --git a/.changeset/see-system-prompt.md b/.changeset/see-system-prompt.md index a7d0f462..25ad9f8c 100644 --- a/.changeset/see-system-prompt.md +++ b/.changeset/see-system-prompt.md @@ -4,6 +4,6 @@ Let the user see the system prompt. Under the prompt editor, a `▶ See actual prompt sent` toggle shows the built-in prompt in full, with the `Vanilla` checkbox renamed to `Disable system prompt` (nobody knows what "Vanilla" means; the persisted `vanilla` key is unchanged). The preview renders through `composeRunSystem`, the same function a run composes with, so the toggles are shown doing what they really do. -The event log now renders the run's `system-prompt` event in full too. That event has always carried the exact text handed to the agent, and the dashboard was reducing it to a char count and dropping the rest, so the true prompt was already reaching the browser and being thrown away. That is the one that is byte-exact for a build run, where the repo's memory files, personas and skills are read off disk at run time. +The event log now renders the run's `system-prompt` event in full too. That event has always carried the exact text handed to the agent, and the dashboard was reducing it to a char count and dropping the rest, so the true prompt was already reaching the browser and being thrown away. Reading the user's `SYSTEM.md` moves to `system-prompt-file.ts`, leaving `system-prompt.ts` free of Node imports so the composition can be imported in a browser. A test walks the compiled client barrel's import graph and fails if anything reachable from it imports `node:*`. diff --git a/.changeset/show-real-prompts-343.md b/.changeset/show-real-prompts-343.md index cdd34195..1123c776 100644 --- a/.changeset/show-real-prompts-343.md +++ b/.changeset/show-real-prompts-343.md @@ -2,4 +2,4 @@ '@gemstack/framework': minor --- -Show the real prompts on the dashboard (#343). The framework now emits the exact system prompt it runs the agent under, the #326 block plus any persona / skill / memory framing, as a `system-prompt` event at session start (both the direct-prompt path and the full build path). A new "Prompts sent to Claude Code" panel renders it alongside each turn's user prompt (harvested from the `driver` `start` events already in the stream), so the normally-hidden prompt is fully visible for transparency. Prompt text renders as inert text, never markup. Read-only; nothing is gated on it. +Show the real prompts on the dashboard (#343). The framework now emits the exact system prompt it runs the agent under as a `system-prompt` event at session start (both the direct-prompt path and the full build path). A new "Prompts sent to Claude Code" panel renders it alongside each turn's user prompt (harvested from the `driver` `start` events already in the stream), so the normally-hidden prompt is fully visible for transparency. Prompt text renders as inert text, never markup. Read-only; nothing is gated on it. diff --git a/.changeset/single-system-assembly-501.md b/.changeset/single-system-assembly-501.md index e7c0c75f..d4ccec01 100644 --- a/.changeset/single-system-assembly-501.md +++ b/.changeset/single-system-assembly-501.md @@ -2,4 +2,4 @@ "@gemstack/framework": minor --- -Assemble a run's system prompt in one place. The build path (`runFramework`) and the direct-prompt path (`runPrompt`) each inlined the same composition (the #326 prompt block, the always-on emit protocols, then the run's persona/skill/memory framing), and the two drifted apart, which is what dropped the #326 action layer from `--vanilla` builds (#500). Both now go through a single exported `composeRunSystem()` in `system-prompt.ts`, with unit tests pinning the order and the unconditional emit protocols so the two paths can never diverge again. +Assemble a run's system prompt in one place. The build path (`runFramework`) and the direct-prompt path (`runPrompt`) each inlined the same composition (the #326 prompt block, then the always-on emit protocols), and the two drifted apart, which is what dropped the #326 action layer from `--vanilla` builds (#500). Both now go through a single exported `composeRunSystem()` in `system-prompt.ts`, with unit tests pinning the order and the unconditional emit protocols so the two paths can never diverge again. diff --git a/Architecture.md b/Architecture.md index 7cbc8a5b..257b589b 100644 --- a/Architecture.md +++ b/Architecture.md @@ -130,7 +130,7 @@ The enduring design rule: What shipped, built up from that seed (the state layer + the loop are the moat, not the prompts): - **Supervisor** — the seed topology: plan -> dispatch subagents (bounded concurrency + token budget + per-subtask isolation) -> synthesize. -- **Personas** — reusable, stack-aware roles materialized into worker agents; **presets** (Vike flagship, Next.js) select the framework-specific ones by detecting the project's framework, on top of a framework-neutral core. +- **Presets** — framework detection (Vike flagship, Next.js) from a project's dependencies, so a run can narrate what it is working in. - **Runner** — a pluggable sandbox seam (`FakeRunner` + a real `LocalRunner`; Docker/WebContainer/Flue are infra-gated adapters) where agents build and run an app. - **Surfaces** — the same run in a terminal, an in-page UI, or a detached background handle, over one replayable event stream. - **The loop** — an event-to-prompt-chain policy (a major change fires review + code-quality + security; a new UI flow fires QA + UX), gating on a `{ blockers }` verdict, with a data-driven **prompt library**. diff --git a/README.md b/README.md index eab5fd93..6bd63a41 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Full documentation lives in [`docs/`](./docs/guide/index.md) (a hosted site is o |---|---|---|---| | [`ai‑sdk`](./packages/ai-sdk) | The agent runtime: providers, the agent loop, tools, streaming, middleware, structured output, memory, and evals. The engine the rest of the AI family builds on. | [Guide](./docs/packages/ai-sdk/index.md) | [![npm](https://img.shields.io/npm/v/@gemstack/ai-sdk)](https://www.npmjs.com/package/@gemstack/ai-sdk) | | [`ai‑skills`](./packages/ai-skills) | Portable capability bundles: load `SKILL.md` skills (instructions + tools + resources) and compose them onto an agent on demand. | [Guide](./docs/packages/ai-skills.md) | [![npm](https://img.shields.io/npm/v/@gemstack/ai-skills)](https://www.npmjs.com/package/@gemstack/ai-skills) | -| [`ai‑autopilot`](./packages/ai-autopilot) | The AI‑building framework: a Supervisor (plan → dispatch → synthesize) plus stack‑aware personas, a runner sandbox, surfaces, a decisions ledger, an event‑triggered review/QA loop with a built‑in prompt library, framework presets (Vike/Next), and a bootstrap flow that takes an app from nothing to production‑grade. | [Guide](./docs/packages/ai-autopilot.md) | [![npm](https://img.shields.io/npm/v/@gemstack/ai-autopilot)](https://www.npmjs.com/package/@gemstack/ai-autopilot) | +| [`ai‑autopilot`](./packages/ai-autopilot) | The AI‑building framework: a Supervisor (plan → dispatch → synthesize) plus a runner sandbox, surfaces, an event‑triggered review/QA loop with a built‑in prompt library, framework presets (Vike/Next), and a bootstrap flow that takes an app from nothing to production‑grade. | [Guide](./docs/packages/ai-autopilot.md) | [![npm](https://img.shields.io/npm/v/@gemstack/ai-autopilot)](https://www.npmjs.com/package/@gemstack/ai-autopilot) | | [`framework`](./packages/framework) | **The (AI) Framework**: turnkey, zero‑config orchestration built on `ai-autopilot`. Wraps a coding‑agent CLI (Claude Code) as a black box and takes an idea to a running app, with a live dashboard, Open Loop domain presets, an optional Docker sandbox, and a run relay for shared sessions. | [README](./packages/framework/README.md) | [![npm](https://img.shields.io/npm/v/@gemstack/framework)](https://www.npmjs.com/package/@gemstack/framework) | | [`ai‑mcp`](./packages/ai-mcp) | The agent/MCP bridge: consume a remote MCP server's tools as agent tools, and expose an agent as an MCP server. | [Guide](./docs/packages/ai-mcp.md) | [![npm](https://img.shields.io/npm/v/@gemstack/ai-mcp)](https://www.npmjs.com/package/@gemstack/ai-mcp) | | [`mcp`](./packages/mcp) | A standalone framework for *authoring* MCP servers: tools, resources, prompts, decorators, OAuth 2.1, a framework-neutral HTTP handler, and a test client. Agent-agnostic. | [Guide](./docs/packages/mcp.md) | [![npm](https://img.shields.io/npm/v/@gemstack/mcp)](https://www.npmjs.com/package/@gemstack/mcp) | @@ -67,7 +67,7 @@ pnpm typecheck pnpm test ``` -This is a pnpm + Turborepo + Changesets monorepo. Runnable examples live under [`examples/`](./examples) — e.g. [`mcp-quickstart`](./examples/mcp-quickstart), [`autopilot-quickstart`](./examples/autopilot-quickstart) (personas + Supervisor + runner + surfaces), and [`bootstrap-quickstart`](./examples/bootstrap-quickstart) (the whole bootstrap flow, offline). See [`.changeset/README.md`](./.changeset/README.md) for the release flow. +This is a pnpm + Turborepo + Changesets monorepo. Runnable examples live under [`examples/`](./examples) — e.g. [`mcp-quickstart`](./examples/mcp-quickstart), [`autopilot-quickstart`](./examples/autopilot-quickstart) (Supervisor + runner + surfaces), and [`bootstrap-quickstart`](./examples/bootstrap-quickstart) (the whole bootstrap flow, offline). See [`.changeset/README.md`](./.changeset/README.md) for the release flow. ## Origin diff --git a/docs/packages/ai-autopilot.md b/docs/packages/ai-autopilot.md index 3f4ed94b..41edd55d 100644 --- a/docs/packages/ai-autopilot.md +++ b/docs/packages/ai-autopilot.md @@ -12,7 +12,7 @@ pnpm add @gemstack/ai-autopilot @gemstack/ai-sdk The Supervisor (below) is the seed topology. Built up from it, the package is a full framework for building software with agents — the state layer and the loop are the moat, not the prompts: -- **Personas + presets** — reusable, stack-aware roles materialized into worker agents; `presetPersonas` selects the framework-specific ones (Vike flagship, Next.js) by detecting the project's framework, on top of a framework-neutral core. +- **Presets** — framework detection (Vike flagship, Next.js) from a project's dependencies. - **Runner** — a pluggable sandbox seam (`FakeRunner` + a real `LocalRunner`) where agents build and run an app; expose it to an agent with `runnerTools`. - **Surfaces** — the same run in a terminal, an in-page UI, or a detached background handle, over one replayable event stream (`launchAutopilot`). - **Decisions ledger** — durable memory of rejected ideas + settled choices (round-trips `DECISIONS.md`) so a run stops re-pitching what was turned down. diff --git a/examples/autopilot-quickstart/README.md b/examples/autopilot-quickstart/README.md index 0a272886..4e35b32f 100644 --- a/examples/autopilot-quickstart/README.md +++ b/examples/autopilot-quickstart/README.md @@ -3,10 +3,12 @@ A runnable, end-to-end quickstart for [`@gemstack/ai-autopilot`](../../packages/ai-autopilot) — the four layers of the epic composed into one "build a feature" flow: ``` -personas → Supervisor → runner (sandbox) → surfaces +workers → Supervisor → runner (sandbox) → surfaces ``` -A lead planner decomposes the task **"Add a paginated Orders page backed by an orders table"** and routes each subtask to a stack-aware **persona** (`universal-orm-modeler`, `vike-page-builder`, `ui-intent-designer`). The **Supervisor** dispatches them; each persona worker acts inside a **runner** sandbox — writing Vike/ORM files through `runnerTools` — and progress is rendered through the **surfaces** (a terminal sink for live output, plus a background handle exposing events + result). +A lead planner decomposes the task **"Add a paginated Orders page backed by an orders table"** and routes each subtask to one of the example's own **worker** agents (`data-modeler`, `page-builder`, `ui-designer`). The **Supervisor** dispatches them; each worker acts inside a **runner** sandbox — writing files through `runnerTools` — and progress is rendered through the **surfaces** (a terminal sink for live output, plus a background handle exposing events + result). + +The roster is the app's own: autopilot orchestrates the agents you hand it and adds nothing to their instructions. It runs **offline**: `AiFake` scripts the model, so there's no API key and the output is deterministic. @@ -27,10 +29,10 @@ pnpm --filter @gemstack/example-autopilot-quickstart test ## Going real -The only fakes here are `AiFake` (the model) and `FakeRunner` (the sandbox). To run it for real, drop `AiFake` and give the personas real model strings, and swap `FakeRunner` for a real runner adapter (a `FlueRunner`, WebContainer, or Docker sandbox) — the `Runner` interface is the same, so nothing else in the flow changes. +The only fakes here are `AiFake` (the model) and `FakeRunner` (the sandbox). To run it for real, drop `AiFake` and give the workers real model strings, and swap `FakeRunner` for a real runner adapter (a `FlueRunner`, WebContainer, or Docker sandbox) — the `Runner` interface is the same, so nothing else in the flow changes. ## What each file shows -- **`src/autopilot.ts`** — the composition: `personaWorkers` + `runnerTools`, `agentPlanner` fed the `personaRoster`, a `Supervisor`, and `launchAutopilot` + `terminalSink` for the surfaces. +- **`src/autopilot.ts`** — the composition: the worker roster + `runnerTools`, `agentPlanner` fed that roster, a `Supervisor`, and `launchAutopilot` + `terminalSink` for the surfaces. - **`src/main.ts`** — the runnable demo. -- **`src/autopilot.test.ts`** — asserts the four layers actually compose (plan routes by persona, files land in the sandbox, surfaces capture events). +- **`src/autopilot.test.ts`** — asserts the four layers actually compose (plan routes by role, files land in the sandbox, surfaces capture events). diff --git a/examples/autopilot-quickstart/package.json b/examples/autopilot-quickstart/package.json index 77afedf5..65f54f76 100644 --- a/examples/autopilot-quickstart/package.json +++ b/examples/autopilot-quickstart/package.json @@ -2,7 +2,7 @@ "name": "@gemstack/example-autopilot-quickstart", "version": "0.0.8", "private": true, - "description": "Runnable end-to-end quickstart for @gemstack/ai-autopilot: personas + Supervisor + runner + surfaces composed into one build-a-feature flow, offline via AiFake.", + "description": "Runnable end-to-end quickstart for @gemstack/ai-autopilot: workers + Supervisor + runner + surfaces composed into one build-a-feature flow, offline via AiFake.", "type": "module", "scripts": { "typecheck": "tsc --noEmit", diff --git a/examples/autopilot-quickstart/src/autopilot.test.ts b/examples/autopilot-quickstart/src/autopilot.test.ts index d15bb598..5ed2d6c8 100644 --- a/examples/autopilot-quickstart/src/autopilot.test.ts +++ b/examples/autopilot-quickstart/src/autopilot.test.ts @@ -3,19 +3,16 @@ import assert from 'node:assert/strict' import { runQuickstart, TASK } from './autopilot.js' describe('autopilot quickstart: the four layers compose end-to-end', () => { - it('plans by persona, dispatches, acts in the sandbox, and surfaces progress', async () => { + it('plans by role, dispatches, acts in the sandbox, and surfaces progress', async () => { const lines: string[] = [] const result = await runQuickstart(line => lines.push(line)) - // Supervisor: three subtasks, each routed to a stack persona, all succeeded. + // Supervisor: three subtasks, each routed to a worker, all succeeded. assert.equal(result.run.plan.length, 3) - assert.deepEqual( - result.run.plan.map(s => s.worker).sort(), - ['data-modeler', 'ui-intent-designer', 'vike-page-builder'], - ) + assert.deepEqual(result.run.plan.map(s => s.worker).sort(), ['data-modeler', 'page-builder', 'ui-designer']) assert.ok(result.run.results.every(r => r.ok), 'every subtask succeeded') - // Runner: each persona worker wrote its file into the sandbox via runnerTools. + // Runner: each worker wrote its file into the sandbox via runnerTools. assert.ok('database/schema.ts' in result.files) assert.ok('pages/orders/+Page.jsx' in result.files) assert.match(result.files['database/schema.ts']!, /orders/) diff --git a/examples/autopilot-quickstart/src/autopilot.ts b/examples/autopilot-quickstart/src/autopilot.ts index 8f0d96ab..29904573 100644 --- a/examples/autopilot-quickstart/src/autopilot.ts +++ b/examples/autopilot-quickstart/src/autopilot.ts @@ -2,10 +2,6 @@ import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk' import { Supervisor, agentPlanner, - stackPersonas, - personaRoster, - personaInstructions, - personaTools, FakeRunner, runnerTools, terminalSink, @@ -18,13 +14,16 @@ import { /** * The end-to-end shape of the ai-autopilot epic, in one flow: * - * personas → Supervisor → runner (sandbox) → surfaces + * workers → Supervisor → runner (sandbox) → surfaces * - * A lead planner decomposes a build task and routes each subtask to a - * stack-aware **persona**; the **Supervisor** dispatches them; each persona - * worker acts inside a **runner** sandbox (writing Vike/ORM files via - * `runnerTools`); progress is rendered through the **surfaces** (a terminal - * sink plus a background handle with a live stream). + * A lead planner decomposes a build task and routes each subtask to one of the + * **worker** agents defined below; the **Supervisor** dispatches them; each + * worker acts inside a **runner** sandbox (writing files via `runnerTools`); + * progress is rendered through the **surfaces** (a terminal sink plus a + * background handle with a live stream). + * + * The roster is the app's own: autopilot orchestrates whatever agents you hand + * it and never injects instructions of its own. * * It runs offline: `AiFake` scripts the model, so there is no API key and the * output is deterministic. Swapping `FakeRunner` for a real runner and dropping @@ -34,7 +33,14 @@ import { /** The feature we ask autopilot to build. */ export const TASK = 'Add a paginated Orders page backed by an orders table' -/** Each subtask, the persona that should own it, and the file it writes. */ +/** The worker roster: the role name the planner routes to, and how that agent is briefed. */ +const WORKERS = [ + { name: 'data-modeler', role: 'Design database schemas and migrations.' }, + { name: 'page-builder', role: 'Build pages and their routing.' }, + { name: 'ui-designer', role: 'Design the UI for a page.' }, +] as const + +/** Each subtask, the worker that should own it, and the file it writes. */ const WORK = [ { worker: 'data-modeler', @@ -43,13 +49,13 @@ const WORK = [ contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", }, { - worker: 'vike-page-builder', + worker: 'page-builder', description: 'Build the /orders page that lists orders, paginated', file: 'pages/orders/+Page.jsx', contents: "export default function Page({ orders }) { return }\n", }, { - worker: 'ui-intent-designer', + worker: 'ui-designer', description: 'Express the orders list as intent, not hardcoded markup', file: 'pages/orders/+config.js', contents: "export default { meta: { OrderList: { env: { server: true, client: true } } } }\n", @@ -70,15 +76,15 @@ function scriptModel(fake: AiFake): void { fake.respondWithSequence([{ text: plannerOutput }, ...workerSteps]) } -/** Build one worker agent per persona, each with hands inside the sandbox. */ -function personaWorkersWithSandbox(session: RunnerSession): Record> { +/** The roster, as a line per worker, so the planner knows who it can route to. */ +function roster(): string { + return WORKERS.map(w => `- ${w.name}: ${w.role}`).join('\n') +} + +/** Build one worker agent per role, each with hands inside the sandbox. */ +function workersWithSandbox(session: RunnerSession): Record> { const sandbox = runnerTools(session) - return Object.fromEntries( - stackPersonas.map(p => [ - p.name, - agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }), - ]), - ) + return Object.fromEntries(WORKERS.map(w => [w.name, agent({ instructions: w.role, tools: sandbox })])) } export interface QuickstartResult { @@ -102,7 +108,7 @@ export async function runQuickstart(write: (line: string) => void = () => {}): P const fake = AiFake.fake() scriptModel(fake) try { - // Runner: an in-memory sandbox seeded with a minimal Vike project. + // Runner: an in-memory sandbox seeded with a minimal project. const runner = new FakeRunner({ onExec: cmd => cmd.includes('build') @@ -111,14 +117,14 @@ export async function runQuickstart(write: (line: string) => void = () => {}): P }) const session = await runner.boot({ files: { 'package.json': '{ "name": "shop" }\n' } }) - // Personas → Supervisor. The planner is told the roster so it routes by role. + // Workers → Supervisor. The planner is told the roster so it routes by role. const planner = agentPlanner( - agent(`You are the lead engineer. Decompose the task and route each subtask to a persona.\n\n${personaRoster(stackPersonas)}`), + agent(`You are the lead engineer. Decompose the task and route each subtask to a worker.\n\n${roster()}`), ) const start = (onEvent: (e: SupervisorEvent) => void) => new Supervisor({ plan: planner, - workers: personaWorkersWithSandbox(session), + workers: workersWithSandbox(session), concurrency: 1, onEvent, }).run(TASK) diff --git a/examples/bootstrap-quickstart/README.md b/examples/bootstrap-quickstart/README.md index 88730de8..d773450a 100644 --- a/examples/bootstrap-quickstart/README.md +++ b/examples/bootstrap-quickstart/README.md @@ -17,7 +17,7 @@ pnpm --filter @gemstack/example-bootstrap-quickstart start No API key: `AiFake` scripts the model and `FakeRunner` is an in-memory sandbox, so the run is deterministic. You'll see the narration stream live, the files the -persona workers wrote, the checklist blocking once and then clearing, the app +workers wrote, the checklist blocking once and then clearing, the app shipped to a Cloudflare URL, and the generated `CODE-OVERVIEW.md`. The deploy runs the real `cloudflareTarget` adapter over a simulated `wrangler`, so the whole flow ends at a live-looking URL with no credentials. @@ -25,9 +25,9 @@ ends at a live-looking URL with no credentials. ## What it shows - **Presets (#115)** — the framework is detected from the project deps (Vike here), - so the build's workers are that framework's personas plus the shared neutral ones. + so the build's workers are briefed on the right one. - **Bootstrap (#116)** — one scoping question, then a **build** scaffolds the app - with the persona workers inside a **runner**, the **full-fledged loop** repeats the + with those workers inside a **runner**, the **full-fledged loop** repeats the production-grade checklist until its `{ blockers }` verdict is empty, and a **deploy** is decided and shipped through the `DeployTarget` seam — here the real `cloudflareTarget` (SSR → Workers), run over a simulated `wrangler` offline. diff --git a/examples/bootstrap-quickstart/src/bootstrap.test.ts b/examples/bootstrap-quickstart/src/bootstrap.test.ts index 5912b07a..a0f0c47b 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.test.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.test.ts @@ -11,7 +11,7 @@ describe('bootstrap capstone: the whole epic composes end-to-end (offline)', () assert.equal(detection.preset?.name, 'vike') assert.equal(detection.framework, 'Vike') - // Build: each preset persona wrote its file into the sandbox. + // Build: each worker wrote its file into the sandbox. assert.ok('database/schema.ts' in files) assert.ok('pages/orders/+Page.jsx' in files) assert.ok('pages/orders/+config.js' in files) diff --git a/examples/bootstrap-quickstart/src/bootstrap.ts b/examples/bootstrap-quickstart/src/bootstrap.ts index 0d3e917d..b77f2629 100644 --- a/examples/bootstrap-quickstart/src/bootstrap.ts +++ b/examples/bootstrap-quickstart/src/bootstrap.ts @@ -9,10 +9,7 @@ import { LoopEngine, definePrompt, defineLoop, - personaInstructions, - personaTools, builtinPresetRegistry, - presetPersonas, CodeOverviewMaintainer, FakeRunner, runnerTools, @@ -32,10 +29,10 @@ import { * scope → build → full-fledged loop → deploy * → scale mode (CODE-OVERVIEW.md) * - * A **preset** is picked from the project's dependencies, so the build's workers - * are the right framework's personas. **Bootstrap** then sequences the flow: it - * asks one scoping question, **builds** the app with the persona workers inside a - * **runner** sandbox, runs the **full-fledged loop** until the production-grade + * A **preset** detects the project's framework from its dependencies, so the + * build's workers are briefed on the right one. **Bootstrap** then sequences the + * flow: it asks one scoping question, **builds** the app with those workers inside + * a **runner** sandbox, runs the **full-fledged loop** until the production-grade * checklist's `{ blockers }` verdict is empty, and **decides a deploy** behind the * `DeployTarget` seam. What stack to build on is the build agent's call, not ours. * Every phase streams as narration over the generic **surface**. Finally **scale @@ -53,7 +50,7 @@ export const INTENT = 'A paginated Orders page backed by an orders table, with s /** The project we detect a framework from — Vike here, so the Vike preset wins. */ const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@prisma/client': '1.0.0' } -/** Each build subtask, the persona that owns it, and the file it writes. */ +/** Each build subtask, the worker that owns it, and the file it writes. */ const WORK = [ { worker: 'data-modeler', @@ -62,13 +59,13 @@ const WORK = [ contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", }, { - worker: 'vike-page-builder', + worker: 'page-builder', description: 'Build the /orders page that lists orders, paginated', file: 'pages/orders/+Page.jsx', contents: "export default function Page({ orders }) { return }\n", }, { - worker: 'ui-intent-designer', + worker: 'ui-designer', description: 'Express the orders list as intent, not hardcoded markup', file: 'pages/orders/+config.js', contents: "export default { meta: { OrderList: { env: { server: true, client: true } } } }\n", @@ -99,15 +96,19 @@ function scriptModel(fake: AiFake): void { /** A static planner: the build subtasks, in the order the fake scripts them. */ const staticPlanner: Planner = () => WORK.map(w => ({ description: w.description, worker: w.worker })) -/** Build one worker agent per preset persona, each with hands inside the sandbox. */ -function presetWorkers(session: RunnerSession, personas: ReturnType) { +/** The worker roster: the role name the plan routes to, and how that agent is briefed. */ +function roster(framework: string) { + return [ + { name: 'data-modeler', role: 'Design database schemas and migrations.' }, + { name: 'page-builder', role: `Build pages and their routing for ${framework}.` }, + { name: 'ui-designer', role: 'Design the UI for a page.' }, + ] +} + +/** Build one worker agent per role, each with hands inside the sandbox. */ +function buildWorkers(session: RunnerSession, framework: string) { const sandbox = runnerTools(session) - return Object.fromEntries( - personas.map(p => [ - p.name, - agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }), - ]), - ) + return Object.fromEntries(roster(framework).map(w => [w.name, agent({ instructions: w.role, tools: sandbox })])) } /** The full-fledged loop: the checklist blocks once, then clears after the fix. */ @@ -169,9 +170,8 @@ export async function runCapstone(write: (line: string) => void = () => {}): Pro const fake = AiFake.fake() scriptModel(fake) try { - // 0. Preset: detect the framework from the project's deps, pick its personas. + // 0. Preset: detect the framework from the project's deps to brief the workers. const { preset, detection } = builtinPresetRegistry().select({ dependencies: PROJECT_DEPS }) - const personas = presetPersonas(preset) // Runner: an in-memory sandbox seeded with a minimal project. `wrangler` is // simulated (prints a live-looking URL) so the real cloudflareTarget adapter @@ -202,7 +202,7 @@ export async function runCapstone(write: (line: string) => void = () => {}): Pro }, steps: { scope: () => ({ scope: 'full', intent: INTENT }), - build: supervisorBuild({ plan: staticPlanner, workers: presetWorkers(session, personas), concurrency: 1 }), + build: supervisorBuild({ plan: staticPlanner, workers: buildWorkers(session, preset.framework), concurrency: 1 }), checklist: loopChecklist({ loop }), improve: loopImprove({ loop }), deploy: agentDeploy(agent({ instructions: 'deployer' }), { target: deployTarget }), diff --git a/examples/bootstrap-quickstart/src/live.ts b/examples/bootstrap-quickstart/src/live.ts index c932f912..15bb05f3 100644 --- a/examples/bootstrap-quickstart/src/live.ts +++ b/examples/bootstrap-quickstart/src/live.ts @@ -9,10 +9,7 @@ import { LoopEngine, definePrompt, defineLoop, - personaInstructions, - personaTools, builtinPresetRegistry, - presetPersonas, CodeOverviewMaintainer, LocalRunner, runnerTools, @@ -47,12 +44,12 @@ const MODEL = process.env['GEMSTACK_MODEL'] ?? 'anthropic/claude-haiku-4-5-20251 /** The project we detect a framework from — Vike here, so the Vike preset wins. */ const PROJECT_DEPS = { 'vike-react': '1.0.0', react: '18.0.0', '@prisma/client': '1.0.0' } -/** The build plan: three subtasks, each owned by a preset persona (by name). The real +/** The build plan: three subtasks, each owned by a worker (by name). The real * worker decides the file contents; only the decomposition is fixed. */ const WORK = [ { worker: 'data-modeler', description: 'Define the orders schema and a migration in database/schema.ts' }, - { worker: 'vike-page-builder', description: 'Build pages/orders/+Page.jsx: a server-rendered, paginated list of orders' }, - { worker: 'ui-intent-designer', description: 'Express the orders list as intent (a +config.js meta), not hardcoded markup' }, + { worker: 'page-builder', description: 'Build pages/orders/+Page.jsx: a server-rendered, paginated list of orders' }, + { worker: 'ui-designer', description: 'Express the orders list as intent (a +config.js meta), not hardcoded markup' }, ] as const const livePlanner: Planner = () => WORK.map(w => ({ description: w.description, worker: w.worker })) @@ -64,13 +61,22 @@ export function registerModel(): void { AiRegistry.register(new AnthropicProvider({ apiKey })) } -/** One real worker agent per preset persona, each with hands (runner tools) in the sandbox. */ -function presetWorkers(session: RunnerSession, personas: ReturnType) { +/** The worker roster: the role name the plan routes to, and how that agent is briefed. */ +function roster(framework: string) { + return [ + { name: 'data-modeler', role: 'You design database schemas and migrations.' }, + { name: 'page-builder', role: `You build pages and their routing for ${framework}.` }, + { name: 'ui-designer', role: 'You design the UI for a page.' }, + ] +} + +/** One real worker agent per role, each with hands (runner tools) in the sandbox. */ +function buildWorkers(session: RunnerSession, framework: string) { const sandbox = runnerTools(session) return Object.fromEntries( - personas.map(p => [ - p.name, - agent({ model: MODEL, instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }), + roster(framework).map(w => [ + w.name, + agent({ model: MODEL, instructions: `${w.role} Write your work to disk with your tools.`, tools: sandbox }), ]), ) } @@ -127,9 +133,8 @@ async function snapshot(session: RunnerSession): Promise> export async function runLiveCapstone(write: (line: string) => void = () => {}): Promise { registerModel() - // 0. Preset: detect the framework from the project's deps, pick its personas. + // 0. Preset: detect the framework from the project's deps to brief the workers. const { preset, detection } = builtinPresetRegistry().select({ dependencies: PROJECT_DEPS }) - const personas = presetPersonas(preset) // Runner: a REAL isolated workspace on the host filesystem, seeded with a minimal project. const runner = new LocalRunner() @@ -146,7 +151,7 @@ export async function runLiveCapstone(write: (line: string) => void = () => {}): }, steps: { scope: () => ({ scope: 'full', intent: INTENT }), - build: supervisorBuild({ plan: livePlanner, workers: presetWorkers(session, personas), concurrency: 1 }), + build: supervisorBuild({ plan: livePlanner, workers: buildWorkers(session, preset.framework), concurrency: 1 }), checklist: loopChecklist({ loop }), improve: loopImprove({ loop }), // Real Cloudflare deploy when CLOUDFLARE_API_TOKEN is set, else plan-only. diff --git a/examples/framework-discovery-demo/CHANGELOG.md b/examples/framework-discovery-demo/CHANGELOG.md deleted file mode 100644 index f67cc31d..00000000 --- a/examples/framework-discovery-demo/CHANGELOG.md +++ /dev/null @@ -1,82 +0,0 @@ -# @gemstack/example-framework-discovery - -## 0.0.6 - -### Patch Changes - -- Updated dependencies [385c953] - - @gemstack/framework@0.8.0 - -## 0.0.5 - -### Patch Changes - -- Updated dependencies [cc6a8db] -- Updated dependencies [5f319ff] -- Updated dependencies [9f62be7] -- Updated dependencies [08f5710] - - @gemstack/framework@0.7.0 - - @gemstack/ai-autopilot@0.9.0 - - framework-hello@0.0.4 - -## 0.0.4 - -### Patch Changes - -- Updated dependencies [4a6311e] -- Updated dependencies [b81e563] -- Updated dependencies [c28c373] -- Updated dependencies [74a9907] -- Updated dependencies [8c3e7d0] -- Updated dependencies [03e06aa] -- Updated dependencies [c24ae22] -- Updated dependencies [3c72f14] -- Updated dependencies [e45e4d0] -- Updated dependencies [396dc7f] -- Updated dependencies [edd242b] -- Updated dependencies [24944b9] -- Updated dependencies [d2acba4] - - @gemstack/ai-autopilot@0.8.0 - - @gemstack/framework@0.6.0 - - framework-hello@0.0.3 - -## 0.0.3 - -### Patch Changes - -- Updated dependencies [6f7e7e3] - - @gemstack/ai-autopilot@0.7.0 - - framework-hello@0.0.2 - - @gemstack/framework@0.5.1 - -## 0.0.2 - -### Patch Changes - -- Updated dependencies [16f6bb8] -- Updated dependencies [c7eae83] - - @gemstack/framework@0.5.0 - -## 0.0.1 - -### Patch Changes - -- Updated dependencies [1db19e2] -- Updated dependencies [c3e7e9e] -- Updated dependencies [5288ca6] -- Updated dependencies [6625ca7] -- Updated dependencies [bd13fcf] -- Updated dependencies [11f76da] -- Updated dependencies [c79f567] -- Updated dependencies [f156cf8] -- Updated dependencies [97b2943] -- Updated dependencies [93892d7] -- Updated dependencies [de37e7e] -- Updated dependencies [c9cf96b] -- Updated dependencies [d98d4ad] -- Updated dependencies [a06e845] -- Updated dependencies [c79f567] -- Updated dependencies [f1d11d9] - - @gemstack/ai-autopilot@0.6.0 - - @gemstack/framework@0.4.0 - - framework-hello@0.0.1 diff --git a/examples/framework-discovery-demo/README.md b/examples/framework-discovery-demo/README.md deleted file mode 100644 index 88177317..00000000 --- a/examples/framework-discovery-demo/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# @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 deleted file mode 100644 index 56982504..00000000 --- a/examples/framework-discovery-demo/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@gemstack/example-framework-discovery", - "version": "0.0.6", - "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 deleted file mode 100644 index e44ce6dd..00000000 --- a/examples/framework-discovery-demo/src/demo.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 4ff8d5dc..00000000 --- a/examples/framework-discovery-demo/src/demo.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 0c5bddfa..00000000 --- a/examples/framework-discovery-demo/src/main.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 404aab40..00000000 --- a/examples/framework-discovery-demo/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index eebda2fc..00000000 --- a/examples/framework-discovery-demo/tsconfig.test.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, - "include": ["src"] -} diff --git a/examples/framework-hello/CHANGELOG.md b/examples/framework-hello/CHANGELOG.md deleted file mode 100644 index be030c48..00000000 --- a/examples/framework-hello/CHANGELOG.md +++ /dev/null @@ -1,45 +0,0 @@ -# framework-hello - -## 0.0.4 - -### Patch Changes - -- Updated dependencies [08f5710] - - @gemstack/ai-autopilot@0.9.0 - -## 0.0.3 - -### Patch Changes - -- Updated dependencies [4a6311e] -- Updated dependencies [8c3e7d0] -- Updated dependencies [03e06aa] -- Updated dependencies [3c72f14] -- Updated dependencies [e45e4d0] -- Updated dependencies [396dc7f] -- Updated dependencies [24944b9] -- Updated dependencies [d2acba4] - - @gemstack/ai-autopilot@0.8.0 - -## 0.0.2 - -### Patch Changes - -- Updated dependencies [6f7e7e3] - - @gemstack/ai-autopilot@0.7.0 - -## 0.0.1 - -### Patch Changes - -- Updated dependencies [1db19e2] -- Updated dependencies [c3e7e9e] -- Updated dependencies [6625ca7] -- Updated dependencies [bd13fcf] -- Updated dependencies [11f76da] -- Updated dependencies [c79f567] -- Updated dependencies [93892d7] -- Updated dependencies [de37e7e] -- Updated dependencies [d98d4ad] -- Updated dependencies [f1d11d9] - - @gemstack/ai-autopilot@0.6.0 diff --git a/examples/framework-hello/index.js b/examples/framework-hello/index.js deleted file mode 100644 index 1c729420..00000000 --- a/examples/framework-hello/index.js +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 8f12a7e2..00000000 --- a/examples/framework-hello/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "framework-hello", - "version": "0.0.4", - "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/README.md b/packages/ai-autopilot/README.md index debe31f6..4876637b 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -40,50 +40,30 @@ Each stage is a plain function, so you mix LLM and deterministic logic freely: - **`workers`** — a single `Agent` (all subtasks), a `Record` (routed by `subtask.worker`), or a `WorkerRouter` function. - **`synthesize`** — a `Synthesizer`: `(task, results) => string`. Defaults to `defaultSynthesize` (concatenate successes, no LLM call); pass `agentSynthesizer(agent)` for an LLM pass. -## Personas — the stack-aware layer +## Bring your own agents -The Supervisor is stack-agnostic. **Personas** add the opinionated knowledge that -makes autopilot know the GemStack stack (Vike + Prisma) instead of -guessing. A persona is *data*: a name, a one-line role, a system-prompt fragment, -and the skills/tools it brings (composed over [`@gemstack/ai-skills`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-skills)). - -Three are built in: `vikePageBuilder` (Vike `+` file conventions, renderer-agnostic), -`dataModeler` (schema-first data on Prisma, derived migrations), and -`uiIntentDesigner` — the "declare intent, decouple implementation" guardrail that -expresses UI as intent so an AI can't hardcode the wrong markup. +The Supervisor is stack-agnostic, and stays that way: you hand it the agents, and +it orchestrates them as-is. Autopilot never appends framing, a role, or knowledge +of its own to an agent's instructions — what you write is the whole system prompt. ```ts import { Supervisor, agentPlanner } from '@gemstack/ai-autopilot' -import { stackPersonas, personaWorkers, personaRoster } from '@gemstack/ai-autopilot' +import { agent } from '@gemstack/ai-sdk' + +const roster = [ + { name: 'data-modeler', role: 'Design database schemas and migrations.' }, + { name: 'page-builder', role: 'Build pages and their routing.' }, +] const supervisor = new Supervisor({ - // Tell the planner which personas exist so it tags each subtask's `worker`. - plan: agentPlanner(agent(`Decompose the task.\n\n${personaRoster(stackPersonas)}`)), - // Materialize the personas into a worker pool keyed by name. - workers: personaWorkers(stackPersonas, { model: 'anthropic/claude-sonnet-4-5' }), + // Tell the planner which workers exist so it tags each subtask's `worker`. + plan: agentPlanner(agent(`Decompose the task.\n\n${roster.map(w => `- ${w.name}: ${w.role}`).join('\n')}`)), + workers: Object.fromEntries(roster.map(w => [w.name, agent({ instructions: w.role })])), }) await supervisor.run('Add a paginated orders page backed by an orders table') ``` -Define your own with `definePersona({ name, role, systemPrompt, skills?, tools? })`, -or materialize a single persona into an agent with `personaAgent(persona)`. Because -a persona is data, it can be inspected and listed without building an agent first. - -### Composing vike-* extensions (opt-in) - -For a Vike app, an opt-in set swaps the hand-rolled default for personas that -**compose** the vike-* extensions instead of reinventing them: `vikeAuthComposer` -(vike-auth for identity/sessions), `vikeDataModeler` (the universal-orm data layer), -`vikeRbacComposer` (vike-rbac roles/permissions), `vikeCrudComposer` -(vike-crud/vike-admin for schema-derived CRUD + admin UI), and `vikeShellComposer` -(vike-themes/vike-layouts for styling and the app shell), plus the shared -`uiIntentDesigner` guardrail. They ship as the `vikeExtensionPersonas` array. - -This path is opt-in because the vike-* packages currently resolve only inside the -vike-data workspace (they are not published to npm), so the default `stackPersonas` -path stays publish-safe. `@gemstack/framework` exposes it as `--compose-extensions`. - ## Runner — the pluggable execution seam Autopilot builds and runs an app somewhere. A **`Runner`** boots an isolated @@ -117,14 +97,13 @@ await session.dispose() // removes the temp workspace ```ts import { FakeRunner, runnerTools } from '@gemstack/ai-autopilot' -import { personaAgent, vikePageBuilder } from '@gemstack/ai-autopilot' +import { agent } from '@gemstack/ai-sdk' const runner = new FakeRunner() const session = await runner.boot({ files: { 'pages/+config.js': '…' } }) -// Give a persona hands inside the sandbox: read/write files, exec, preview. -const agent = personaAgent(vikePageBuilder, { model: 'anthropic/claude-sonnet-4-5' }) -const withTools = agent // compose runnerTools(session) into its tools() +// Give an agent hands inside the sandbox: read/write files, exec, preview. +const builder = agent({ instructions: 'Build pages.', tools: runnerTools(session) }) await session.exec('pnpm build') const { url } = (await session.preview?.({ port: 5173 })) ?? {} @@ -298,20 +277,20 @@ own bodies from a directory with `loadPromptsFrom(dir)`, or add one to a library with `library.add(...)`. The bodies are the main open-source contribution surface; PRs that sharpen them are welcome. -## Domain presets — a loop + prompts + skills bundled per domain +## Domain presets — loops + prompts bundled per domain -A **domain preset** packages a domain's review loops, prompt bodies, and skill -pointers as one `{ loops, prompts, skills }` unit, so a run is framed for a *kind* -of work (software, web, data, product, science) instead of the generic default. +A **domain preset** packages a domain's review loops and prompt bodies as one +`{ loops, prompts }` unit, so a run's review policy suits a *kind* of work +(software, web, data, product, science) instead of the generic default. Each preset is a directory of markdown under `presets/` — a `preset.md` manifest -plus `loops/`, `prompts/`, and `skills/` — so a contributor adds one by writing -files, not code. +plus `loops/` and `prompts/` — so a contributor adds one by writing files, not +code. ```ts import { builtinDomainPresets, selectPreset } from '@gemstack/ai-autopilot' const presets = await builtinDomainPresets() // discovers every shipped preset -const sw = selectPreset(presets, 'software-development') // sw.loops / sw.prompts / sw.skills +const sw = selectPreset(presets, 'software-development') // sw.loops / sw.prompts ``` Five ship built in (`software-development`, `web-development`, `data-science`, diff --git a/packages/ai-autopilot/presets/biological-science/skills/reproducible-research.md b/packages/ai-autopilot/presets/biological-science/skills/reproducible-research.md deleted file mode 100644 index 4db448ec..00000000 --- a/packages/ai-autopilot/presets/biological-science/skills/reproducible-research.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: reproducible-research -description: Ten Simple Rules for Reproducible Computational Research (PLOS Computational Biology). -metadata: - title: Reproducible Computational Research - url: https://doi.org/10.1371/journal.pcbi.1003285 ---- - -A concise, widely-cited reference for making computational biology reproducible: track -how every result is produced, version data and code, record provenance, and make the -path from raw data to figure re-runnable. Consult it when framing analysis and review -work. diff --git a/packages/ai-autopilot/presets/data-science/skills/rules-of-ml.md b/packages/ai-autopilot/presets/data-science/skills/rules-of-ml.md deleted file mode 100644 index b8b69d7d..00000000 --- a/packages/ai-autopilot/presets/data-science/skills/rules-of-ml.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: rules-of-ml -description: Google's Rules of Machine Learning — practical guidance for ML engineering. -metadata: - title: Rules of Machine Learning - url: https://developers.google.com/machine-learning/guides/rules-of-ml ---- - -A field guide to doing machine learning well: start simple, get the pipeline and -metrics right before the model, watch for training/serving skew and leakage, and -know when added complexity pays off. Consult it when framing data and modeling -review. diff --git a/packages/ai-autopilot/presets/product-management/skills/shape-up.md b/packages/ai-autopilot/presets/product-management/skills/shape-up.md deleted file mode 100644 index 95cf04b6..00000000 --- a/packages/ai-autopilot/presets/product-management/skills/shape-up.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: shape-up -description: Basecamp's Shape Up — how to shape, bet on, and build product work at the right level of abstraction. -metadata: - title: Shape Up - url: https://basecamp.com/shapeup ---- - -A reference for framing product work: shaping a problem before building, setting -appetite instead of estimates, and defining scope by the outcome rather than the task. -Consult it when framing requirements and reviewing whether a change serves the bet it -was meant to. diff --git a/packages/ai-autopilot/presets/software-development/skills/eng-practices.md b/packages/ai-autopilot/presets/software-development/skills/eng-practices.md deleted file mode 100644 index 52e7b9a6..00000000 --- a/packages/ai-autopilot/presets/software-development/skills/eng-practices.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: eng-practices -description: Google's engineering practices — code review and change-authoring guidelines. -metadata: - title: Engineering Practices - url: https://google.github.io/eng-practices/ ---- - -A stack-agnostic reference for what a good change and a good review look like: -the reviewer standard, what to look for, and how to write a change that is easy to -review. Consult it when framing review and authoring work. diff --git a/packages/ai-autopilot/presets/web-development/skills/web-platform.md b/packages/ai-autopilot/presets/web-development/skills/web-platform.md deleted file mode 100644 index baacf95c..00000000 --- a/packages/ai-autopilot/presets/web-development/skills/web-platform.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: web-platform -description: web.dev — the web platform's guidance on accessibility, performance, and security. -metadata: - title: Web Platform Practices - url: https://web.dev/ ---- - -The reference for what good looks like on the web: accessibility patterns, Core -Web Vitals and the performance budget, and the security guidance behind CSP, -cross-site protections, and safe rendering. Consult it when framing web review and -authoring work. diff --git a/packages/ai-autopilot/src/extensions/compose.ts b/packages/ai-autopilot/src/extensions/compose.ts deleted file mode 100644 index 39a98dc9..00000000 --- a/packages/ai-autopilot/src/extensions/compose.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Persona } from '../personas/types.js' -import type { FrameworkExtension, Skill } from './types.js' - -/** A neutral default persona keyed by the capability an extension would supersede. */ -export interface NeutralPersona { - capability: string - persona: Persona -} - -/** Inputs to {@link composePersonas}. */ -export interface ComposePersonasInput { - /** Always-on base personas (e.g. the framework skill's page builder). */ - base?: readonly Persona[] - /** The active capability extensions. */ - extensions: readonly FrameworkExtension[] - /** Neutral defaults; one is dropped when an active extension owns its capability. */ - neutral?: readonly NeutralPersona[] -} - -/** - * Compose the persona set for a run: the base personas, the active extensions' - * personas, then the neutral defaults for any capability no active extension - * covers. An extension supersedes a neutral default of the same capability (e.g. - * a `data` extension replaces the default ORM modeler), so the agent is never - * framed with two conflicting personas for one concern. Order is stable: - * base → extensions (registration order) → surviving neutral defaults. - */ -export function composePersonas(input: ComposePersonasInput): Persona[] { - const covered = new Set(input.extensions.map(e => e.capability)) - const neutral = (input.neutral ?? []).filter(n => !covered.has(n.capability)).map(n => n.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 -} - -/** - * 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 - * of truth for that framework/domain instead of guessing. - */ -export function skillInstructions(skill: Skill): string { - return [ - `# Skill: ${skill.title}`, - '', - skill.description, - '', - `Authoritative, LLM-optimized docs: ${skill.url}`, - `When you need ${skill.title} specifics, consult that document rather than relying on memory.`, - ].join('\n') -} diff --git a/packages/ai-autopilot/src/extensions/define.ts b/packages/ai-autopilot/src/extensions/define.ts deleted file mode 100644 index 9c7cdb10..00000000 --- a/packages/ai-autopilot/src/extensions/define.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ExtensionSignals, FrameworkExtension, FrameworkExtensionSpec, Skill, SkillSpec } from './types.js' - -/** Thrown when an extension or skill spec is malformed. Fails fast at definition time. */ -export class ExtensionError extends Error { - constructor(message: string) { - super(`[ai-autopilot] ${message}`) - this.name = 'ExtensionError' - } -} - -const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - -function frozenSignals(signals: ExtensionSignals | undefined): ExtensionSignals { - return Object.freeze({ - dependencies: Object.freeze([...(signals?.dependencies ?? [])]), - files: Object.freeze([...(signals?.files ?? [])]), - }) -} - -/** - * Validate a {@link FrameworkExtensionSpec} and return a frozen - * {@link FrameworkExtension}. Optional fields default to empty so callers never - * null-check them. A third-party `framework-*` package's default export is the - * result of this call. - */ -export function defineFrameworkExtension(spec: FrameworkExtensionSpec): FrameworkExtension { - const name = spec.name?.trim() - if (!name) throw new ExtensionError('extension name is required') - if (!KEBAB.test(name)) throw new ExtensionError(`extension name must be kebab-case: ${JSON.stringify(spec.name)}`) - const capability = spec.capability?.trim() - if (!capability) throw new ExtensionError(`extension "${name}" needs a capability`) - if (!KEBAB.test(capability)) { - throw new ExtensionError(`extension "${name}" capability must be kebab-case: ${JSON.stringify(spec.capability)}`) - } - - return Object.freeze({ - name, - capability, - personas: Object.freeze([...(spec.personas ?? [])]), - skills: Object.freeze([...(spec.skills ?? [])]), - signals: frozenSignals(spec.signals), - }) -} - -/** - * Validate a {@link SkillSpec} and return a frozen {@link Skill} — a doc pointer - * (an `llms.txt` URL) an agent consults for framework/domain knowledge. - */ -export function defineSkill(spec: SkillSpec): Skill { - const name = spec.name?.trim() - if (!name) throw new ExtensionError('skill name is required') - if (!KEBAB.test(name)) throw new ExtensionError(`skill name must be kebab-case: ${JSON.stringify(spec.name)}`) - if (!spec.title?.trim()) throw new ExtensionError(`skill "${name}" needs a title`) - if (!spec.description?.trim()) throw new ExtensionError(`skill "${name}" needs a description`) - const url = spec.url?.trim() - if (!url) throw new ExtensionError(`skill "${name}" needs a url (its llms.txt pointer)`) - - return Object.freeze({ - name, - 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 deleted file mode 100644 index 97edb348..00000000 --- a/packages/ai-autopilot/src/extensions/extensions.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -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, skillPersonas, skillInstructions } from './compose.js' -import { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js' -import { - builtinExtensionNames, - builtinExtensions, - frameworkAuth, - frameworkData, - neutralPersonas, - vikeSkill, -} from './library.js' -import { extensionPackageNames, isFrameworkExtension, loadExtensionsFromModules } from './load.js' -import { matchSignals, selectActive } from './match.js' -import { ExtensionRegistry, SkillRegistry } from './registry.js' - -const persona = (name: string) => definePersona({ name, role: name, systemPrompt: `I am ${name}.` }) - -test('defineFrameworkExtension validates and freezes', () => { - const ext = defineFrameworkExtension({ name: 'framework-x', capability: 'x', personas: [persona('p')] }) - assert.equal(ext.name, 'framework-x') - assert.equal(ext.capability, 'x') - assert.ok(Object.isFrozen(ext)) - assert.deepEqual(ext.skills, []) - assert.throws(() => defineFrameworkExtension({ name: 'Bad Name', capability: 'x' }), ExtensionError) - assert.throws(() => defineFrameworkExtension({ name: 'ok', capability: '' }), ExtensionError) -}) - -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) - assert.equal(matchSignals(ext.signals, { dependencies: ['other'] }).score, 0) - - const units = [ext] - // Not installed, not opted-in -> inactive. - assert.deepEqual(selectActive(units, { dependencies: [] }), []) - // Installed -> active by signal. - assert.deepEqual(selectActive(units, { dependencies: ['vike-auth'] }), [ext]) - // Opted in by name even without the dep -> active. - assert.deepEqual(selectActive(units, { dependencies: [] }, ['framework-auth']), [ext]) -}) - -test('ExtensionRegistry.match honors signals and include; addAll registers extras', () => { - const reg = new ExtensionRegistry() - assert.deepEqual(reg.match({ dependencies: [] }), []) // nothing installed, no opt-in - assert.deepEqual( - reg.match({ dependencies: ['vike-auth'] }).map(e => e.name), - ['framework-auth'], - ) - // Opt every built-in in by name (the --compose-extensions set). - assert.deepEqual( - reg.match({ dependencies: [] }, { include: builtinExtensionNames }).map(e => e.name), - [...builtinExtensionNames], - ) - // A discovered third-party extension registers and auto-activates by its signal. - const third = defineFrameworkExtension({ name: 'framework-sentry', capability: 'tracking', signals: { dependencies: ['@sentry/node'] } }) - reg.addAll([third]) - assert.ok(reg.match({ dependencies: ['@sentry/node'] }).some(e => e.name === 'framework-sentry')) -}) - -test('SkillRegistry activates the Vike skill when Vike is detected', () => { - const reg = new SkillRegistry() - assert.deepEqual(reg.match({ dependencies: ['vike-react'] }).map(s => s.name), ['vike']) - assert.deepEqual(reg.match({ dependencies: ['react'] }), []) - assert.deepEqual(reg.match({ files: ['pages/+config.js'] }).map(s => s.name), ['vike']) -}) - -test('composePersonas: extensions supersede the neutral default of their capability', () => { - const base = [persona('page-builder')] - // No extensions: base + both neutral defaults. - const dflt = composePersonas({ base, extensions: [], neutral: neutralPersonas }) - assert.deepEqual(dflt.map(p => p.name), ['page-builder', dataModeler.name, uiIntentDesigner.name]) - - // framework-data (capability 'data') drops the default data modeler; ui stays. - const withData = composePersonas({ base, extensions: [frameworkData], neutral: neutralPersonas }) - const names = withData.map(p => p.name) - assert.ok(names.includes('vike-data-modeler')) - assert.ok(!names.includes(dataModeler.name)) // superseded - 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/) - assert.match(text, /https:\/\/vike\.dev\/llms\.txt/) -}) - -test('the built-in auth extension composes vike-auth and the data extension owns data', () => { - assert.equal(frameworkAuth.capability, 'auth') - assert.equal(frameworkAuth.personas[0]!.name, 'vike-auth-composer') - assert.equal(frameworkData.capability, 'data') - assert.deepEqual([...builtinExtensionNames], builtinExtensions().map(e => e.name)) -}) - -test('extensionPackageNames filters to the framework-* convention and excludes the core', () => { - const names = extensionPackageNames( - ['react', 'framework-auth', '@gemstack/framework', '@acme/framework-sentry', 'vike', 'framework-auth'], - { exclude: ['@gemstack/framework'] }, - ) - assert.deepEqual(names, ['@acme/framework-sentry', 'framework-auth']) // deduped + sorted, core excluded -}) - -test('isFrameworkExtension duck-types a loaded export', () => { - assert.ok(isFrameworkExtension(frameworkAuth)) - assert.ok(!isFrameworkExtension({ name: 'x' })) - assert.ok(!isFrameworkExtension(null)) -}) - -test('loadExtensionsFromModules loads good exports and reports bad ones without throwing', async () => { - const good = defineFrameworkExtension({ name: 'framework-good', capability: 'g' }) - const modules: Record = { - 'framework-good': { default: good }, - 'framework-named': { extension: defineFrameworkExtension({ name: 'framework-named', capability: 'n' }) }, - 'framework-empty': { something: 1 }, - 'framework-throws': undefined, // triggers the throw branch below - } - const load = async (name: string) => { - if (name === 'framework-throws') throw new Error('boom') - return modules[name] - } - const { loaded, failed } = await loadExtensionsFromModules( - ['framework-good', 'framework-named', 'framework-empty', 'framework-throws'], - load, - ) - assert.deepEqual(loaded.map(l => l.package), ['framework-good', 'framework-named']) - assert.deepEqual( - failed.map(f => f.package), - ['framework-empty', 'framework-throws'], - ) - assert.match(failed.find(f => f.package === 'framework-throws')!.error, /boom/) -}) diff --git a/packages/ai-autopilot/src/extensions/index.ts b/packages/ai-autopilot/src/extensions/index.ts deleted file mode 100644 index 73c8287a..00000000 --- a/packages/ai-autopilot/src/extensions/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * The framework extension SPI (#190) — The Framework made modular. Installed - * capability packages self-register instead of the CLI hardcoding the list. Two - * agnostic units: - * - * - {@link FrameworkExtension} — a capability (auth, data, ...) matched by - * signals or opt-in, defined with {@link defineFrameworkExtension}. - * - {@link Skill} — a doc pointer (an `llms.txt`), defined with {@link defineSkill}; - * a framework (Vike) is a skill, not an adapter. Shared unit with Open Loop (#204). - * - * Match with {@link ExtensionRegistry} / {@link SkillRegistry}, compose personas - * with {@link composePersonas}, frame a skill with {@link skillInstructions}, and - * discover third-party `framework-*` packages with {@link loadExtensionsFromModules}. - */ -export { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js' -export { matchSignals, selectActive } from './match.js' -export { - ExtensionRegistry, - SkillRegistry, - builtinExtensionRegistry, - builtinSkillRegistry, - type MatchOptions, -} from './registry.js' -export { - composePersonas, - composeSkills, - skillPersonas, - skillInstructions, - type ComposePersonasInput, - type NeutralPersona, -} from './compose.js' -export { - frameworkAuth, - frameworkData, - frameworkRbac, - frameworkCrud, - frameworkShell, - builtinExtensions, - builtinExtensionNames, - vikeSkill, - nextSkill, - builtinSkills, - neutralPersonas, -} from './library.js' -export { - EXTENSION_NAME_RE, - extensionPackageNames, - isFrameworkExtension, - loadExtensionsFromModules, - type LoadedExtension, - type FailedExtension, - type DiscoverResult, -} from './load.js' -export type { - FrameworkExtension, - FrameworkExtensionSpec, - Skill, - SkillSpec, - ExtensionSignals, - FrameworkSignals, - SignalMatch, -} from './types.js' diff --git a/packages/ai-autopilot/src/extensions/library.ts b/packages/ai-autopilot/src/extensions/library.ts deleted file mode 100644 index ee016e2e..00000000 --- a/packages/ai-autopilot/src/extensions/library.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - dataModeler, - nextPageBuilder, - uiIntentDesigner, - vikeAuthComposer, - vikeCrudComposer, - vikeDataModeler, - vikePageBuilder, - vikeRbacComposer, - vikeShellComposer, -} from '../personas/library.js' -import { defineFrameworkExtension, defineSkill } from './define.js' -import type { NeutralPersona } from './compose.js' -import type { FrameworkExtension, Skill } from './types.js' - -/** - * The built-in capability extensions — the vike-* composers, each packaged as a - * self-describing {@link FrameworkExtension} so `run.ts` composes them through - * the registry instead of a hardcoded, Vike-gated list. Each auto-activates when - * its package is present in the project, and can be opted in by name for a - * from-scratch build (where nothing is installed yet). Agnostic: a third-party - * `framework-*` package registers the same way. - */ - -/** Composes vike-auth for authentication instead of hand-rolling sessions/cookies/login. */ -export const frameworkAuth: FrameworkExtension = defineFrameworkExtension({ - name: 'framework-auth', - capability: 'auth', - personas: [vikeAuthComposer], - signals: { dependencies: ['vike-auth'] }, -}) - -/** Models domain data on the universal-orm data layer (one registered adapter, no ORM install). */ -export const frameworkData: FrameworkExtension = defineFrameworkExtension({ - name: 'framework-data', - capability: 'data', - personas: [vikeDataModeler], - signals: { dependencies: ['@universal-orm/core', '@vike-data/vike-schema', '@vike-data/universal-schema'] }, -}) - -/** Composes vike-rbac for roles/permissions (`can()`/`hasRole()`) instead of hand-rolled authz. */ -export const frameworkRbac: FrameworkExtension = defineFrameworkExtension({ - name: 'framework-rbac', - capability: 'rbac', - personas: [vikeRbacComposer], - signals: { dependencies: ['vike-rbac'] }, -}) - -/** Composes vike-crud / vike-admin to derive CRUD + admin UI from the schema. */ -export const frameworkCrud: FrameworkExtension = defineFrameworkExtension({ - name: 'framework-crud', - capability: 'crud', - personas: [vikeCrudComposer], - signals: { dependencies: ['vike-crud', 'vike-admin'] }, -}) - -/** Composes vike-themes + vike-layouts for styling and the app shell. */ -export const frameworkShell: FrameworkExtension = defineFrameworkExtension({ - name: 'framework-shell', - capability: 'shell', - personas: [vikeShellComposer], - signals: { dependencies: ['vike-themes', 'vike-layouts', 'vike-toolbar'] }, -}) - -/** - * The built-in capability extensions, in composition order (data leads so its - * modeler frames the agent before the composers that ride on it). - */ -export function builtinExtensions(): FrameworkExtension[] { - return [frameworkData, frameworkAuth, frameworkRbac, frameworkCrud, frameworkShell] -} - -/** The names of the built-in extensions — the opt-in set behind `--compose-extensions`. */ -export const builtinExtensionNames: readonly string[] = Object.freeze(builtinExtensions().map(e => e.name)) - -/** - * 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', - title: 'Vike', - 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$/], - }, -}) - -/** - * 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, nextSkill] -} - -/** - * The neutral default personas, keyed by the capability an active extension - * supersedes. `data` is the default Prisma modeler (replaced by `framework-data`); - * `ui` is the intent-based UI guardrail (no extension owns it, so it is always on). - */ -export const neutralPersonas: readonly NeutralPersona[] = Object.freeze([ - { capability: 'data', persona: dataModeler }, - { capability: 'ui', persona: uiIntentDesigner }, -]) diff --git a/packages/ai-autopilot/src/extensions/load.ts b/packages/ai-autopilot/src/extensions/load.ts deleted file mode 100644 index ac744ae6..00000000 --- a/packages/ai-autopilot/src/extensions/load.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { FrameworkExtension } from './types.js' - -/** - * The `framework-*` package-name convention: a bare `framework-` or a - * scoped `@scope/framework-`. Matching packages in a project's - * dependencies are candidate {@link FrameworkExtension}s to discover. - */ -export const EXTENSION_NAME_RE = /^(?:@[a-z0-9-]+\/)?framework-[a-z0-9-]+$/ - -/** - * Filter a dependency list down to `framework-*` extension package names. - * Deduped and sorted for determinism; `exclude` drops packages that match the - * convention but are not extensions (e.g. the framework core `@gemstack/framework`). - */ -export function extensionPackageNames(deps: Iterable, opts: { exclude?: readonly string[] } = {}): string[] { - const exclude = new Set(opts.exclude ?? []) - return [...new Set(deps)].filter(name => EXTENSION_NAME_RE.test(name) && !exclude.has(name)).sort() -} - -/** A duck-typed check that a loaded module's export is a {@link FrameworkExtension}. */ -export function isFrameworkExtension(value: unknown): value is FrameworkExtension { - if (!value || typeof value !== 'object') return false - const v = value as Record - return ( - typeof v.name === 'string' && - typeof v.capability === 'string' && - Array.isArray(v.personas) && - Array.isArray(v.skills) && - typeof v.signals === 'object' && - v.signals !== null - ) -} - -/** A successfully discovered extension and the package it came from. */ -export interface LoadedExtension { - package: string - extension: FrameworkExtension -} - -/** A package that matched the convention but could not be loaded as an extension. */ -export interface FailedExtension { - package: string - error: string -} - -/** The outcome of {@link loadExtensionsFromModules}: what loaded and what didn't. */ -export interface DiscoverResult { - loaded: LoadedExtension[] - failed: FailedExtension[] -} - -/** - * Load {@link FrameworkExtension}s from `framework-*` package names using a - * caller-supplied `load` function (an `import`-like). Pure with respect to the - * filesystem: the CLI passes a loader that resolves from the user's workspace, - * tests pass a fake map — so the SPI stays testable without disk or real - * packages. An extension export is taken from `default`, then `extension`, then - * the module itself. A package that fails to load or exports no extension is - * collected in `failed`, never thrown, so one bad package cannot abort a run. - */ -export async function loadExtensionsFromModules( - packageNames: readonly string[], - load: (name: string) => Promise, -): Promise { - const loaded: LoadedExtension[] = [] - const failed: FailedExtension[] = [] - for (const name of packageNames) { - try { - const mod = (await load(name)) as Record | undefined - const candidate = mod?.['default'] ?? mod?.['extension'] ?? mod - if (isFrameworkExtension(candidate)) loaded.push({ package: name, extension: candidate }) - else failed.push({ package: name, error: 'no FrameworkExtension export (default/extension)' }) - } catch (err) { - failed.push({ package: name, error: err instanceof Error ? err.message : String(err) }) - } - } - return { loaded, failed } -} diff --git a/packages/ai-autopilot/src/extensions/match.ts b/packages/ai-autopilot/src/extensions/match.ts deleted file mode 100644 index ecdc010d..00000000 --- a/packages/ai-autopilot/src/extensions/match.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ExtensionSignals, FrameworkSignals, SignalMatch } from './types.js' - -/** Weight of a matched dependency vs a matched file — deps are the stronger signal. */ -const DEP_WEIGHT = 2 -const FILE_WEIGHT = 1 - -function depNames(deps: FrameworkSignals['dependencies']): Set { - if (!deps) return new Set() - return new Set(Array.isArray(deps) ? deps : Object.keys(deps)) -} - -/** - * Score a unit's {@link ExtensionSignals} against a project's - * {@link FrameworkSignals}. Deterministic and mirrors preset detection: deps - * weigh more than files. A `score > 0` means the unit is present in the project - * and should auto-activate. Unlike preset selection (exactly one wins), many - * extensions can match at once. - */ -export function matchSignals(signals: ExtensionSignals, project: FrameworkSignals): SignalMatch { - const deps = depNames(project.dependencies) - const files = project.files ?? [] - const reasons: string[] = [] - let score = 0 - - for (const dep of signals.dependencies ?? []) { - if (deps.has(dep)) { - score += DEP_WEIGHT - reasons.push(`dependency "${dep}"`) - } - } - for (const pattern of signals.files ?? []) { - if (files.some(f => pattern.test(f))) { - score += FILE_WEIGHT - reasons.push(`file matching ${pattern}`) - } - } - return { score, reasons } -} - -/** - * Select the units active for a project: those whose signals matched, unioned - * with any explicitly included by name (opt-in, regardless of signals — how a - * from-scratch build activates a capability whose package is not installed yet). - * Registration order is preserved. - */ -export function selectActive( - units: readonly T[], - project: FrameworkSignals, - include: Iterable = [], -): T[] { - const forced = new Set(include) - return units.filter(u => forced.has(u.name) || matchSignals(u.signals, project).score > 0) -} diff --git a/packages/ai-autopilot/src/extensions/registry.ts b/packages/ai-autopilot/src/extensions/registry.ts deleted file mode 100644 index 0a50b1f6..00000000 --- a/packages/ai-autopilot/src/extensions/registry.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { selectActive } from './match.js' -import { builtinExtensions, builtinSkills } from './library.js' -import type { FrameworkExtension, FrameworkSignals, Skill } from './types.js' - -/** Options for {@link ExtensionRegistry.match} / {@link SkillRegistry.match}. */ -export interface MatchOptions { - /** Force these units active by name regardless of signals (explicit opt-in). */ - include?: readonly string[] -} - -/** - * A set of {@link FrameworkExtension}s with signal + opt-in matching. Register - * the built-ins (or discovered `framework-*` packages), then {@link match} the - * ones active for a project. The registry only decides *which* capabilities to - * compose; composition itself is `composePersonas`. - */ -export class ExtensionRegistry { - private readonly byName = new Map() - - constructor(extensions: readonly FrameworkExtension[] = builtinExtensions()) { - for (const e of extensions) this.byName.set(e.name, e) - } - - /** The extension with this name, or `undefined`. */ - get(name: string): FrameworkExtension | undefined { - return this.byName.get(name) - } - - /** All extensions, in registration order. */ - all(): FrameworkExtension[] { - return [...this.byName.values()] - } - - /** Add or replace an extension (e.g. a discovered third-party one). Returns `this`. */ - add(extension: FrameworkExtension): this { - this.byName.set(extension.name, extension) - return this - } - - /** Add many extensions. Returns `this`. */ - addAll(extensions: Iterable): this { - for (const e of extensions) this.add(e) - return this - } - - /** The extensions active for a project: signal-matched ∪ explicitly included. */ - match(project: FrameworkSignals, opts: MatchOptions = {}): FrameworkExtension[] { - return selectActive(this.all(), project, opts.include ?? []) - } -} - -/** A set of {@link Skill}s (doc pointers) with the same signal + opt-in matching. */ -export class SkillRegistry { - private readonly byName = new Map() - - constructor(skills: readonly Skill[] = builtinSkills()) { - for (const s of skills) this.byName.set(s.name, s) - } - - /** The skill with this name, or `undefined`. */ - get(name: string): Skill | undefined { - return this.byName.get(name) - } - - /** All skills, in registration order. */ - all(): Skill[] { - return [...this.byName.values()] - } - - /** Add or replace a skill. Returns `this`. */ - add(skill: Skill): this { - this.byName.set(skill.name, skill) - return this - } - - /** Add many skills. Returns `this`. */ - addAll(skills: Iterable): this { - for (const s of skills) this.add(s) - return this - } - - /** The skills active for a project: signal-matched ∪ explicitly included. */ - match(project: FrameworkSignals, opts: MatchOptions = {}): Skill[] { - return selectActive(this.all(), project, opts.include ?? []) - } -} - -/** The built-in extensions as a ready-to-use {@link ExtensionRegistry}. */ -export function builtinExtensionRegistry(): ExtensionRegistry { - return new ExtensionRegistry(builtinExtensions()) -} - -/** The built-in skills as a ready-to-use {@link SkillRegistry}. */ -export function builtinSkillRegistry(): SkillRegistry { - return new SkillRegistry(builtinSkills()) -} diff --git a/packages/ai-autopilot/src/extensions/types.ts b/packages/ai-autopilot/src/extensions/types.ts deleted file mode 100644 index 52076246..00000000 --- a/packages/ai-autopilot/src/extensions/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Persona } from '../personas/types.js' -import type { FrameworkSignals, PresetSignals } from '../presets/types.js' - -/** - * The framework extension SPI (#190). The Framework is modular: installed - * capability packages self-register instead of the CLI hardcoding the list. The - * model is two units, both agnostic (nothing is Vike-gated): - * - * - {@link FrameworkExtension} — a capability (auth, data, ...) that frames the - * agent with personas when it matches a project. - * - {@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} 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. */ -export type ExtensionSignals = PresetSignals - -/** Re-export the project-side signal shape (a project's deps + file list) for callers. */ -export type { FrameworkSignals } from '../presets/types.js' - -/** - * 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`). */ - readonly name: string - /** Human title (e.g. `Vike`). */ - readonly title: string - /** One-line summary of the knowledge this skill points at. */ - 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}; `personas`/`signals` default to empty. */ -export interface SkillSpec { - name: string - title: string - description: string - url: string - personas?: readonly Persona[] - signals?: ExtensionSignals -} - -/** - * A framework capability extension: a cross-cutting concern that self-registers - * and composes into an autopilot run. Agnostic — a matched extension frames the - * agent with its personas and pulls in its skills. The `capability` it owns lets - * it supersede the neutral default persona for that concern (e.g. a `data` - * extension replaces the default ORM modeler) so the agent never gets two - * conflicting personas for one concern. - */ -export interface FrameworkExtension { - /** Package/id, kebab-case, `framework-*` by convention (e.g. `framework-auth`). */ - readonly name: string - /** The concern it owns (e.g. `auth`, `data`) — the supersession key. */ - readonly capability: string - /** Personas it frames the agent with when active. */ - readonly personas: readonly Persona[] - /** Doc-pointer skills it pulls in when active. */ - readonly skills: readonly Skill[] - /** Deps/files that auto-activate it in a project. */ - readonly signals: ExtensionSignals -} - -/** The author-facing shape for {@link defineFrameworkExtension}; optional fields default to empty. */ -export interface FrameworkExtensionSpec { - name: string - capability: string - personas?: readonly Persona[] - skills?: readonly Skill[] - signals?: ExtensionSignals -} - -/** One unit's match against a project's {@link FrameworkSignals}. */ -export interface SignalMatch { - /** 0 when nothing matched; deps weigh more than files. */ - score: number - /** The concrete signals that matched, for narration. */ - reasons: string[] -} diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 86a4454d..66e535cf 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -12,15 +12,6 @@ * - {@link agentPlanner} — turn a planning agent into a {@link Planner} * - {@link agentSynthesizer} / {@link defaultSynthesize} — combine results * - * Personas add the stack-aware knowledge layer: reusable roles that know the - * GemStack stack (Vike/Next + Prisma), materialized into worker agents. - * - * - {@link definePersona} — define a stack-aware role - * - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run - * - {@link personaRoster} — describe personas to a planner - * - {@link stackPersonas} — the built-in Vike + Prisma personas - * - {@link sharedPersonas} — the framework-neutral core (data layer + intent UI) - * * The runner is the pluggable execution seam: a workspace (filesystem + shell + * optional preview) where autopilot builds and runs an app. Shaped after Flue's * `sandbox` so WebContainer / Docker / Flue drop in behind one interface. @@ -87,21 +78,19 @@ * and wire the maintainer into the loop * * 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. + * Next.js second), on top of the agnostic core. A preset is a pure detector. * * - {@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} — its framework skill's page builder + the shared neutral ones * * Domain presets are the Open Loop bundle unit (#204): one selectable, composable - * bundle of {loops, prompts, skills}. Author in code or load a directory of `.md` - * files; compose several into one (presets-of-presets). Distinct from the - * framework `Preset` above — this is the user-picked domain, not a detector. + * bundle of {loops, prompts}. Author in code or load a directory of `.md` files; + * compose several into one (presets-of-presets). Distinct from the framework + * `Preset` above — this is the user-picked domain, not a detector. * * - {@link defineDomainPreset} / {@link loadDomainPreset} — author, or load from a directory - * - {@link composeDomainPresets} — merge presets into one (later wins on prompt/skill id) + * - {@link composeDomainPresets} — merge presets into one (later wins on prompt id) * - {@link builtinDomainPresets} / {@link loadDomainPresetsFrom} — enumerate a set (the picker primitive) * - {@link selectPreset} — pick the user's chosen domain by name * - {@link softwareDevelopmentPreset} — the shipped, stack-agnostic built-in @@ -111,30 +100,6 @@ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' export { agentSynthesizer, defaultSynthesize } from './synthesizer.js' -export { - definePersona, - PersonaError, - personaInstructions, - personaTools, - personaAgent, - personaWorkers, - personaRoster, - vikePageBuilder, - nextPageBuilder, - dataModeler, - uiIntentDesigner, - vikeAuthComposer, - vikeDataModeler, - vikeRbacComposer, - vikeCrudComposer, - vikeShellComposer, - sharedPersonas, - vikeExtensionPersonas, - stackPersonas, - type Persona, - type PersonaSpec, - type PersonaAgentOptions, -} from './personas/index.js' export { FakeRunner, FakeRunnerSession, @@ -315,7 +280,6 @@ export { vikePreset, nextPreset, builtinPresets, - presetPersonas, PresetRegistry, builtinPresetRegistry, type Preset, @@ -334,7 +298,6 @@ export { loadDomainPresetsFrom, builtinDomainPresets, loadLoopsFrom, - loadSkillsFrom, builtinPresetsDir, softwareDevelopmentPreset, selectWinners, @@ -346,48 +309,6 @@ export { type DomainPresetSpec, type DomainPresetMeta, } from './preset/index.js' -export { - defineFrameworkExtension, - defineSkill, - ExtensionError, - matchSignals, - selectActive, - ExtensionRegistry, - SkillRegistry, - builtinExtensionRegistry, - builtinSkillRegistry, - composePersonas, - composeSkills, - skillPersonas, - skillInstructions, - frameworkAuth, - frameworkData, - frameworkRbac, - frameworkCrud, - frameworkShell, - builtinExtensions, - builtinExtensionNames, - vikeSkill, - nextSkill, - builtinSkills, - neutralPersonas, - EXTENSION_NAME_RE, - extensionPackageNames, - isFrameworkExtension, - loadExtensionsFromModules, - type MatchOptions, - type ComposePersonasInput, - type NeutralPersona, - type LoadedExtension, - type FailedExtension, - type DiscoverResult, - type FrameworkExtension, - type FrameworkExtensionSpec, - type Skill, - type SkillSpec, - type ExtensionSignals, - type SignalMatch, -} from './extensions/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/personas/compose.test.ts b/packages/ai-autopilot/src/personas/compose.test.ts deleted file mode 100644 index c4e6aba8..00000000 --- a/packages/ai-autopilot/src/personas/compose.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { z } from 'zod' -import { dynamicTool, type AnyTool } from '@gemstack/ai-sdk' -import type { LoadedSkill } from '@gemstack/ai-skills' -import { definePersona } from './define.js' -import { - personaInstructions, - personaTools, - personaAgent, - personaWorkers, - personaRoster, -} from './compose.js' -import { stackPersonas } from './library.js' - -function fakeTool(name: string): AnyTool { - return dynamicTool({ name, description: name, inputSchema: z.object({}) }).server( - async () => 'ok', - ) as unknown as AnyTool -} - -function fakeSkill(name: string, instructions: string, tools: AnyTool[] = []): LoadedSkill { - return { - manifest: { name, description: name }, - instructions, - tools, - resources: [], - } -} - -describe('personaInstructions', () => { - it('puts the systemPrompt first, then skill bodies under headers', () => { - const p = definePersona({ - name: 'p', - role: 'r', - systemPrompt: 'BASE IDENTITY', - skills: [fakeSkill('refunds', 'HOW TO REFUND')], - }) - const out = personaInstructions(p) - assert.ok(out.startsWith('BASE IDENTITY'), 'systemPrompt leads') - assert.match(out, /# Skill: refunds/) - assert.match(out, /HOW TO REFUND/) - }) - - it('is just the systemPrompt when there are no skills', () => { - const p = definePersona({ name: 'p', role: 'r', systemPrompt: 'ONLY THIS' }) - assert.equal(personaInstructions(p), 'ONLY THIS') - }) -}) - -describe('personaTools', () => { - it('unions own tools with skill tools; own tools win a name collision', () => { - const p = definePersona({ - name: 'orders', - role: 'r', - systemPrompt: 's', - tools: [fakeTool('lookup')], - skills: [fakeSkill('refunds', '', [fakeTool('lookup'), fakeTool('refund')])], - }) - const names = personaTools(p).map(t => t.definition.name) - assert.deepEqual(names.slice(0, 1), ['lookup']) // own tool first, authoritative - assert.ok(names.includes('refund')) - // colliding skill tool is namespaced, not dropped - assert.ok(names.some(n => n.includes('lookup') && n !== 'lookup')) - }) -}) - -describe('personaAgent', () => { - it('materializes an agent carrying the composed instructions and tools', () => { - const p = definePersona({ - name: 'p', - role: 'r', - systemPrompt: 'BASE', - tools: [fakeTool('go')], - skills: [fakeSkill('s', 'SKILL BODY')], - }) - const a = personaAgent(p, { model: 'anthropic/claude-sonnet-4-5' }) as unknown as { - instructions(): string - tools(): AnyTool[] - model(): string | undefined - } - assert.match(a.instructions(), /BASE/) - assert.match(a.instructions(), /SKILL BODY/) - assert.deepEqual(a.tools().map(t => t.definition.name), ['go']) - assert.equal(a.model(), 'anthropic/claude-sonnet-4-5') - }) -}) - -describe('personaWorkers', () => { - it('keys agents by persona name', () => { - const workers = personaWorkers(stackPersonas) - assert.deepEqual( - Object.keys(workers).sort(), - ['data-modeler', 'ui-intent-designer', 'vike-page-builder'], - ) - }) - - it('throws on a duplicate persona name', () => { - const p = definePersona({ name: 'dup', role: 'r', systemPrompt: 's' }) - assert.throws(() => personaWorkers([p, p]), /duplicate persona name/) - }) -}) - -describe('personaRoster', () => { - it('lists each persona name + role for a planner to route on', () => { - const roster = personaRoster(stackPersonas) - assert.match(roster, /`vike-page-builder`/) - assert.match(roster, /`data-modeler`/) - assert.match(roster, /`ui-intent-designer`/) - assert.match(roster, /worker/) - }) - - it('handles an empty list', () => { - assert.match(personaRoster([]), /No personas/) - }) -}) diff --git a/packages/ai-autopilot/src/personas/compose.ts b/packages/ai-autopilot/src/personas/compose.ts deleted file mode 100644 index 1784343f..00000000 --- a/packages/ai-autopilot/src/personas/compose.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { agent } from '@gemstack/ai-sdk' -import type { Agent, AnyTool } from '@gemstack/ai-sdk' -import { composeInstructions, composeTools, composeMiddleware } from '@gemstack/ai-skills' -import type { Persona } from './types.js' - -/** - * The full instructions a persona contributes: its `systemPrompt` first - * (authoritative), then each skill's body under a `# Skill:` header. Reuses the - * `@gemstack/ai-skills` composition so a persona layers skills exactly like a - * `SkillfulAgent` does. - */ -export function personaInstructions(persona: Persona): string { - return composeInstructions(persona.systemPrompt, [...persona.skills]) -} - -/** - * The persona's own tools unioned with its skills' tools. The persona's own - * tools win a name collision; a colliding skill tool is namespaced, never - * dropped (see `composeTools`). - */ -export function personaTools(persona: Persona): AnyTool[] { - return composeTools([...persona.tools], [...persona.skills]) -} - -/** Options for materializing a persona into a runnable agent. */ -export interface PersonaAgentOptions { - /** Model string (e.g. `anthropic/claude-sonnet-4-5`). Omit to use the agent default. */ - model?: string -} - -/** - * Materialize a persona into a runnable `ai-sdk` {@link Agent}: its composed - * instructions, tools, and any skill middleware. Kept separate from the persona - * data so the same persona can be inspected or listed without building an agent. - */ -export function personaAgent(persona: Persona, opts: PersonaAgentOptions = {}): Agent { - return agent({ - instructions: personaInstructions(persona), - tools: personaTools(persona), - middleware: composeMiddleware([], [...persona.skills]), - model: opts.model, - }) -} - -/** - * Build a `Record` keyed by persona name, ready to drop into - * `Supervisor`'s `workers` option so a plan's `subtask.worker` routes to the - * right persona. - * - * @throws if two personas share a name (the key would collide silently). - */ -export function personaWorkers( - personas: readonly Persona[], - opts: PersonaAgentOptions = {}, -): Record { - const workers: Record = {} - for (const persona of personas) { - if (persona.name in workers) { - throw new Error(`[ai-autopilot] duplicate persona name in workers: "${persona.name}"`) - } - workers[persona.name] = personaAgent(persona, opts) - } - return workers -} - -/** - * A planner-facing prompt fragment listing the available personas by name and - * role. Inject it into a planning agent's instructions so it decomposes a task - * into subtasks tagged with the `worker` that should run each one — the bridge - * that makes the Supervisor's plan stack-aware. - */ -export function personaRoster(personas: readonly Persona[]): string { - if (personas.length === 0) return 'No personas are available.' - const lines = personas.map(p => `- \`${p.name}\` — ${p.role}`) - return [ - 'Available personas (route each subtask to one by setting its `worker` to the persona name):', - ...lines, - ].join('\n') -} diff --git a/packages/ai-autopilot/src/personas/define.test.ts b/packages/ai-autopilot/src/personas/define.test.ts deleted file mode 100644 index c3b44321..00000000 --- a/packages/ai-autopilot/src/personas/define.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import { definePersona, PersonaError } from './define.js' - -describe('definePersona', () => { - it('trims fields and defaults optional arrays to empty', () => { - const p = definePersona({ - name: 'my-persona', - role: ' builds things ', - systemPrompt: ' you build things ', - }) - assert.equal(p.name, 'my-persona') - assert.equal(p.role, 'builds things') - assert.equal(p.systemPrompt, 'you build things') - assert.deepEqual([...p.skills], []) - assert.deepEqual([...p.tools], []) - assert.deepEqual([...p.appliesTo], []) - }) - - it('freezes the persona so it cannot be mutated after definition', () => { - const p = definePersona({ name: 'p', role: 'r', systemPrompt: 's' }) - assert.throws(() => { - ;(p as { name: string }).name = 'other' - }) - assert.throws(() => { - ;(p.appliesTo as string[]).push('x') - }) - }) - - it('rejects a missing name', () => { - assert.throws(() => definePersona({ name: ' ', role: 'r', systemPrompt: 's' }), PersonaError) - }) - - it('rejects a non-kebab-case name', () => { - assert.throws(() => definePersona({ name: 'My Persona', role: 'r', systemPrompt: 's' }), PersonaError) - assert.throws(() => definePersona({ name: 'my_persona', role: 'r', systemPrompt: 's' }), PersonaError) - }) - - it('rejects a missing role or systemPrompt', () => { - assert.throws(() => definePersona({ name: 'p', role: '', systemPrompt: 's' }), PersonaError) - assert.throws(() => definePersona({ name: 'p', role: 'r', systemPrompt: ' ' }), PersonaError) - }) -}) diff --git a/packages/ai-autopilot/src/personas/define.ts b/packages/ai-autopilot/src/personas/define.ts deleted file mode 100644 index abd6f7a3..00000000 --- a/packages/ai-autopilot/src/personas/define.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Persona, PersonaSpec } from './types.js' - -/** Thrown when a `PersonaSpec` is malformed. Fails fast at definition time. */ -export class PersonaError extends Error { - constructor(message: string) { - super(`[ai-autopilot] ${message}`) - this.name = 'PersonaError' - } -} - -/** - * Validate a {@link PersonaSpec} and return a frozen {@link Persona}. - * - * Optional fields default to empty arrays so the rest of the library never has - * to null-check them. The result is deep-frozen at the top level: a persona is - * a shared, reusable identity, so mutating one after definition is a bug. - */ -export function definePersona(spec: PersonaSpec): Persona { - const name = spec.name?.trim() - if (!name) throw new PersonaError('persona name is required') - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { - throw new PersonaError(`persona name must be kebab-case: ${JSON.stringify(spec.name)}`) - } - if (!spec.role?.trim()) throw new PersonaError(`persona "${name}" needs a role`) - if (!spec.systemPrompt?.trim()) throw new PersonaError(`persona "${name}" needs a systemPrompt`) - - const persona: Persona = { - name, - role: spec.role.trim(), - systemPrompt: spec.systemPrompt.trim(), - skills: Object.freeze([...(spec.skills ?? [])]), - tools: Object.freeze([...(spec.tools ?? [])]), - appliesTo: Object.freeze([...(spec.appliesTo ?? [])]), - } - return Object.freeze(persona) -} diff --git a/packages/ai-autopilot/src/personas/index.ts b/packages/ai-autopilot/src/personas/index.ts deleted file mode 100644 index 8143e485..00000000 --- a/packages/ai-autopilot/src/personas/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Personas — the stack-aware knowledge layer of `@gemstack/ai-autopilot`. - * - * A {@link Persona} is a reusable role (identity + skills + tools) that knows - * the GemStack stack. Define one with {@link definePersona}, materialize it into - * an agent with {@link personaAgent}, wire a set as Supervisor workers with - * {@link personaWorkers}, and describe them to a planner with - * {@link personaRoster}. - */ -export { definePersona, PersonaError } from './define.js' -export { - personaInstructions, - personaTools, - personaAgent, - personaWorkers, - personaRoster, - type PersonaAgentOptions, -} from './compose.js' -export { - vikePageBuilder, - nextPageBuilder, - dataModeler, - uiIntentDesigner, - vikeAuthComposer, - vikeDataModeler, - vikeRbacComposer, - vikeCrudComposer, - vikeShellComposer, - sharedPersonas, - vikeExtensionPersonas, - stackPersonas, -} from './library.js' -export type { Persona, PersonaSpec } from './types.js' diff --git a/packages/ai-autopilot/src/personas/library.test.ts b/packages/ai-autopilot/src/personas/library.test.ts deleted file mode 100644 index 410964f1..00000000 --- a/packages/ai-autopilot/src/personas/library.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { strict as assert } from 'node:assert' -import { describe, it } from 'node:test' -import { personaInstructions } from './compose.js' -import { - uiIntentDesigner, - vikeAuthComposer, - vikeCrudComposer, - vikeDataModeler, - vikeExtensionPersonas, - vikeRbacComposer, - vikeShellComposer, -} from './library.js' - -describe('vike extension personas', () => { - it('vikeAuthComposer teaches composing vike-auth instead of hand-rolling auth', () => { - assert.equal(vikeAuthComposer.name, 'vike-auth-composer') - const text = personaInstructions(vikeAuthComposer) - // Real install + wiring, so the agent composes rather than reinventing. - assert.match(text, /npm install vike-auth/) - assert.match(text, /vike-auth\/react/) - assert.match(text, /setAdapter\(createMemoryAdapter\(\)\)/) - assert.match(text, /useUser\(\)/) - // And it must forbid re-modeling users/sessions in the app's own ORM. - assert.match(text, /Do NOT write auth UI/) - assert.match(text, /do NOT model users or\s*\n?\s*sessions/i) - }) - - it('vikeDataModeler teaches the universal-orm data layer, not a hand-installed ORM', () => { - assert.equal(vikeDataModeler.name, 'vike-data-modeler') - const text = personaInstructions(vikeDataModeler) - assert.match(text, /@universal-orm\/core/) - assert.match(text, /defineSchema/) - assert.match(text, /createRepository/) - assert.match(text, /getAdapter\(\)/) - // The dev default: do NOT reach for Prisma/Drizzle/SQLite/migrations. - assert.match(text, /Do NOT add Prisma, Drizzle,\s*\n?\s*SQLite/i) - // But it must also teach the opt-in real-persistence path (drizzle + pglite), - // so a composed app can survive a restart without changing schema/queries. - assert.match(text, /Make it real/) - assert.match(text, /registerDrizzle/) - assert.match(text, /pglite/) - assert.match(text, /vikeSchema\(\)/) - assert.match(text, /drizzle-kit generate/) - }) - - it('vikeCrudComposer teaches deriving CRUD/admin UI from the schema, not hand-written screens', () => { - assert.equal(vikeCrudComposer.name, 'vike-crud-composer') - const text = personaInstructions(vikeCrudComposer) - // Derive the screens with vike-crud / vike-admin rather than hand-authoring them. - assert.match(text, /crud\(\{ table \}\)/) - assert.match(text, /crudBlocks\(\{ table \}\)/) - assert.match(text, /definePage\(\{ route, sections \}\)/) - assert.match(text, /vike-admin/) - assert.match(text, /instead of hand-writing list\/record\/form/) - // Mutations go through named actions, never inline closures. - assert.match(text, /crudActions\(\{ table, tables, scope \}\)/) - assert.match(text, /BY NAME/) - // The customization ladder ends at eject, reached last. - assert.match(text, /ejectView\(view, \{ framework \}\)/) - }) - - it('vikeRbacComposer teaches composing vike-rbac, not a hand-rolled authz schema/checker', () => { - assert.equal(vikeRbacComposer.name, 'vike-rbac-composer') - const text = personaInstructions(vikeRbacComposer) - // Declare permissions + the one check everywhere, riding on vike-auth. - assert.match(text, /definePermissions/) - assert.match(text, /can\(user, permission\)/) - assert.match(text, /hasRole/) - // Seed from the registry, and do not model the RBAC tables by hand. - assert.match(text, /seedRbac/) - assert.match(text, /Do NOT model\s*\n?\s*`roles`/i) - }) - - it('vikeShellComposer teaches composing vike-themes/vike-layouts, not hand-rolled CSS + shell', () => { - assert.equal(vikeShellComposer.name, 'vike-shell-composer') - const text = personaInstructions(vikeShellComposer) - // Styling comes from vike-themes (a theme + CSS vars), not a hand-written design system. - assert.match(text, /defineTheme/) - assert.match(text, /vike-themes\/react/) - assert.match(text, /appearance/) - assert.match(text, /var\(--primary\)/) - // The app shell comes from vike-layouts, not a hand-written topbar/sidebar. - assert.match(text, /vike-layouts\/react/) - assert.match(text, /'centered'/) - assert.match(text, /do NOT write your own\s*\n?\s*color system/i) - // The toolbar is an install-and-forget one-liner. - assert.match(text, /vike-toolbar\/react/) - }) - - it('vikeExtensionPersonas composes data + auth + rbac + crud + shell (no Prisma, no hand-rolled auth/UI/CSS)', () => { - const names = vikeExtensionPersonas.map(p => p.name) - assert.deepEqual(names, [ - 'vike-data-modeler', - 'vike-auth-composer', - 'vike-rbac-composer', - 'vike-crud-composer', - 'vike-shell-composer', - 'ui-intent-designer', - ]) - assert.ok(vikeExtensionPersonas.includes(uiIntentDesigner)) - }) -}) diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts deleted file mode 100644 index 082bd173..00000000 --- a/packages/ai-autopilot/src/personas/library.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { definePersona } from './define.js' -import type { Persona } from './types.js' - -/** - * The built-in, stack-aware personas — the opinionated knowledge that makes - * autopilot know the GemStack stack (Vike + Prisma) instead of guessing. - * Each carries conventions-level guidance; detailed how-to arrives via skills - * attached to a persona at use time. - */ - -/** Builds pages, routes, and layouts with Vike's `+` file convention. */ -export const vikePageBuilder: Persona = definePersona({ - name: 'vike-page-builder', - role: 'Builds Vike pages, routes, and layouts using the `+` file convention', - appliesTo: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], - systemPrompt: `You build UI on Vike (Vite + SSR), which is renderer-agnostic — the same -conventions hold whether the project uses vike-react, vike-vue, or vike-solid. - -Conventions to follow: -- Routing is filesystem-based under \`pages/\`. A page is a folder with a - \`+Page\` file; the folder path is the URL. Use \`+route\` only for - parameterized or programmatic routes. -- Configure a page with sibling \`+\` files: \`+config\` (page config), \`+data\` - (server-side data loading), \`+Head\`, \`+Layout\`, \`+guard\` (access control), - \`+onBeforeRender\`. Put shared config in a parent folder's \`+config\` and let - it cascade. -- Load data in \`+data\` on the server; never fetch your own API from a page when - the data can be loaded server-side. Pass it to the page via the data hook. -- Prefer composing existing vike-* extensions (auth, data, notifications) over - hand-rolling their concerns. - -Keep pages thin: routing + layout + data wiring. Business logic and persistence -belong to the data layer, not the page.`, -}) - -/** Builds pages, routes, and layouts with Next.js' App Router + React Server Components. */ -export const nextPageBuilder: Persona = definePersona({ - name: 'next-page-builder', - role: 'Builds Next.js pages, routes, and layouts using the App Router and Server Components', - appliesTo: ['next'], - systemPrompt: `You build UI on Next.js with the App Router (React Server Components by default). - -Conventions to follow: -- Routing is filesystem-based under \`app/\`. A route is a folder with a - \`page.tsx\`; the folder path is the URL. Use \`layout.tsx\` for shared shells, - \`loading.tsx\` / \`error.tsx\` for states, and \`route.ts\` for API/route handlers. -- Components are Server Components by default: fetch data directly in an async - server component, and never ship server-only code or secrets to the client. - Add \`'use client'\` only for the leaf that genuinely needs interactivity. -- Prefer server actions for mutations over hand-rolled API routes when the caller - is your own UI. Validate input at the edge either way. -- Keep pages thin: routing + layout + data wiring. Business logic and persistence - belong to the data layer, not the page. - -Do not reach for \`getServerSideProps\`/\`getStaticProps\` (that is the older Pages -Router); use the App Router data model.`, -}) - -/** Defines schema, derives migrations, and writes typed queries with Prisma (the default ORM). */ -export const dataModeler: Persona = definePersona({ - name: 'data-modeler', - role: 'Models data schema-first: derive migrations, generate a typed client, write typed queries', - appliesTo: ['prisma', '@prisma/client', 'drizzle-orm', 'drizzle-kit'], - systemPrompt: `You own the data layer. Default to Prisma — schema-first, migrations derived -from the schema, and a fully typed client — unless the app already uses -another published SQL ORM (e.g. Drizzle). Never assume an unpublished/private ORM -is installable: use only packages that resolve on npm. - -Set up Prisma concretely, do not investigate whether it exists: -- Install: \`npm install -D prisma\` and \`npm install @prisma/client\`. -- Init once: \`npx prisma init --datasource-provider postgresql\` (or sqlite for a - quick start). This creates \`prisma/schema.prisma\` and a \`DATABASE_URL\` in \`.env\`. -- Define your models in \`prisma/schema.prisma\`, then \`npx prisma migrate dev --name - \` to derive and apply a migration, and \`npx prisma generate\` for the client. -- Import the typed client from \`@prisma/client\` and query through it. - -Conventions to follow: -- The schema is the source of truth. Define models in the schema; derive - migrations from it rather than hand-writing DDL. Regenerate the client after a - schema change so queries stay typed. -- Reads and writes go through the typed client, not raw SQL. Reach for raw SQL - only when the client genuinely cannot express the query, and prefer batch - helpers over N+1 loops. -- Keep the data layer separate from the framework: it does not import Vike or - know about pages. A page's \`+data\` hook calls into it; it never calls back. -- Migrations are forward-only and reviewed. Never edit an applied migration — - add a new one. - -When unsure whether a change belongs in the schema or the query, put durable -shape in the schema and per-request logic in the query.`, -}) - -/** - * Expresses UI as *intent*, keeping the implementation decoupled — the guardrail - * against an AI hardcoding the wrong markup. The app says "render this as a - * toaster"; what a toaster is stays swappable. - */ -export const uiIntentDesigner: Persona = definePersona({ - name: 'ui-intent-designer', - role: 'Expresses UI as decoupled intent, not hardcoded markup — the "declare intent" guardrail', - appliesTo: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], - systemPrompt: `You express UI as intent, not as concrete markup. The app declares what a -region *means* ("this is a notification", "render this record as a card"); the -implementation of that meaning is decoupled and owned elsewhere. - -Why: an AI that hardcodes markup gets the details wrong and locks the app into -one look. Declaring intent keeps a hard boundary — the implementation can change -(theme, framework, component library) without touching the app's stated intent. - -Conventions to follow: -- Reach for a named semantic component/slot that carries the intent before - writing raw elements. Only drop to primitive markup when no intent-level - component fits, and when you do, keep it small and local. -- Pass data and meaning, not layout decisions. Let the implementation choose - spacing, color, and element structure. -- Never inline styling or copy that belongs to a shared design decision. -- When you cannot express something as intent, say so explicitly and propose the - intent-level primitive that is missing rather than hardcoding around it. - -Your output should read as a declaration of what the UI is, leaving how it looks -to the decoupled implementation.`, -}) - -/** - * Composes `vike-auth` for authentication instead of hand-rolling it. Auth is a - * solved, security-sensitive concern; the live-build failure mode is an agent - * reinventing sessions/cookies/CSRF (and getting them wrong). This persona hands - * that whole surface to the extension. Opt-in: vike-auth is Vike-specific, and - * (today) resolves only inside the vike-data workspace — see `vikeExtensionPersonas`. - */ -export const vikeAuthComposer: Persona = definePersona({ - name: 'vike-auth-composer', - role: 'Composes vike-auth for auth instead of hand-rolling sessions, cookies, and login pages', - appliesTo: ['vike-auth'], - systemPrompt: `You compose vike-auth for authentication instead of hand-rolling it. Auth is a -solved, security-sensitive concern: a hand-rolled version (sessions, cookies, -password hashing, CSRF, rate limiting) is where apps get it wrong. vike-auth owns it. - -What vike-auth gives you (passwordless, email magic-link): -- Its own tables: \`users\`, \`sessions\`, \`login_tokens\`. Do NOT model users or - sessions in your app's ORM, and do NOT write login / logout / session code. -- The \`/login\` and \`/account\` pages, shipped by the extension. Do NOT write auth UI. -- The current user on \`pageContext.user\` (server) and via \`useUser()\` (React). - -Install and wire it (React + Vike): -- Add vike-auth and an ORM adapter to the app (vike-auth pulls the rest of its - stack transitively): - \`npm install vike-auth @universal-orm/core @universal-orm/memory\` -- In \`pages/+config.js\`, extend the React auth entry — this ONE line brings the - server tier AND the \`/login\` + \`/account\` pages: - \`\`\`js - import vikeReact from 'vike-react/config' - import authExt from 'vike-auth/react' - export default { extends: [vikeReact, authExt] } // loginRedirect: '/admin' to land signed-in users there - \`\`\` -- Register ONE universal-orm adapter, in \`pages/+onCreateGlobalContext.js\`, so - vike-auth's tables persist (memory is fine for dev; swap for a real DB later): - \`\`\`js - import { setAdapter, getAdapter } from '@universal-orm/core' - import { createMemoryAdapter } from '@universal-orm/memory' - export default async function onCreateGlobalContext() { - if (getAdapter()) return - setAdapter(createMemoryAdapter()) - } - \`\`\` - Memory resets on restart, so accounts vanish on reboot. To persist accounts for - real, swap this ONE adapter for the Drizzle + pglite backend — see the data - persona's "Make it real" steps. Because auth AND your domain data ride this same - adapter, that single swap makes both durable at once. - -Use it: -- Read the user server-side from \`pageContext.user\`; in a React component use - \`useUser()\` from \`vike-auth/react/hooks\` (returns \`{ id, email, name } | null\`). -- Protect a page by checking \`pageContext.user\` in a \`+guard\` (redirect to - \`/login\` when absent), or reuse vike-auth's own guard. - -Your app's OWN domain data (posts, comments, etc.) still goes through the data -persona's ORM. vike-auth owns only identity and sessions — do not duplicate them.`, -}) - -/** - * Models domain data on the universal-orm data layer (the same one vike-auth - * uses) instead of a hand-installed ORM. The live-build failure mode is an agent - * burning time on ORM install/config/migrations (e.g. Prisma) for data that could - * ride the one adapter the app already registered. Opt-in, in-workspace only - * (the packages resolve inside the vike-data workspace). - */ -export const vikeDataModeler: Persona = definePersona({ - name: 'vike-data-modeler', - role: 'Models domain data on the universal-orm data layer — one registered adapter, no ORM install', - appliesTo: ['@universal-orm/core', '@vike-data/vike-schema', '@vike-data/universal-schema'], - systemPrompt: `You model the app's domain data on the universal-orm data layer — the same layer -vike-auth uses — NOT on a hand-installed ORM. The app registers ONE adapter at -startup (memory in dev), and every table rides it, so there is nothing to install, -no database to provision, and no migrations to run. Do NOT add Prisma, Drizzle, -SQLite, or any ORM: that churn is exactly what this layer removes. - -Define tables and build a repository over the already-registered adapter: -\`\`\`js -import { defineSchema } from '@vike-data/vike-schema/schema' -import { mergeSchemas } from '@vike-data/universal-schema' -import { createRepository, getAdapter } from '@universal-orm/core' - -const posts = defineSchema('posts', (t) => { - t.integer('id').primary() - t.string('title') - t.text('content') - t.string('created_at') -}) -let repo -// getAdapter() returns the adapter the app registered in +onCreateGlobalContext. -export const db = () => (repo ??= createRepository(mergeSchemas([posts]), getAdapter())) -\`\`\` - -Read and write through the narrow repository: -- \`db().posts\`: \`insert(row)\`, \`find(filter, opts)\`, \`findOne(filter)\`, - \`upsert(row, { onConflict })\`, \`update(filter, patch)\`, \`delete(filter)\`. -- Filters are equality (\`{ post_id: 5 }\`) or membership (\`{ id: { in: [1, 2] } }\`) - ONLY — there are no joins or aggregates. For "a post with its comments", do two - finds and combine them in JS. \`opts\` is \`{ limit, offset, orderBy }\`. -- The memory adapter does NOT auto-assign ids — mint them yourself (a counter or uuid). -- Do NOT call \`setAdapter\` again; the app already registered one. - -Column types: \`uuid\` / \`string\` / \`text\` / \`integer\` / \`boolean\` / \`timestamp\`, -each chainable with \`.nullable()\` / \`.unique()\` / \`.primary()\` / -\`.references('table.col', { onDelete })\`. Read data in Vike \`+data\` hooks on the -server. - -Make it real (opt-in persistence). The memory adapter resets on every restart. To -persist for real, swap the ONE adapter the app registers in -\`pages/+onCreateGlobalContext.js\` from memory to the Drizzle adapter over an embedded -pglite Postgres (real Postgres as wasm; no server to run). Your \`defineSchema\` tables -and every \`db().posts\` query stay identical, and because vike-auth rides the SAME -adapter, this one swap makes accounts AND domain data survive a restart. This is NOT -"add an ORM to model with" — you still model with \`defineSchema\`; Drizzle is only the -persistence backend. The steps: -1. Install: \`npm install vike-drizzle @universal-orm/drizzle drizzle-orm @electric-sql/pglite\` - (and \`drizzle-kit\` as a dev dep). -2. In \`vite.config.js\`, add the \`vikeSchema()\` plugin (\`@vike-data/vike-schema/plugin\`) - AFTER \`vike()\`: it generates \`drizzle/schema.generated.ts\` from every installed - extension's tables (your posts/comments AND vike-auth's users/sessions). Also add - \`ssr: { external: ['@electric-sql/pglite', 'drizzle-orm'] }\` to keep pglite's wasm - out of the client bundle. -3. Add \`drizzle.config.js\` (\`{ schema: './drizzle/schema.generated.ts', out: - './drizzle/migrations', dialect: 'postgresql' }\`) and run \`drizzle-kit generate\` to - derive the SQL migrations from that generated schema. -4. In \`pages/+onCreateGlobalContext.js\`, guard the DB setup with - \`if (!import.meta.env.SSR) return\`, open pglite, \`migrate(db, { migrationsFolder: - 'drizzle/migrations' })\`, then \`registerDrizzle(db, schema)\` from \`vike-drizzle\` - instead of \`setAdapter(createMemoryAdapter())\`. -Reference: the proven \`examples/drizzle-pglite\` twin in the vike-data monorepo. Note: -\`integer('id').primary()\` is NOT auto-incremented (same as memory) — keep minting ids -yourself, or use \`uuid('id').primary()\` for collision-free ids on a persistent store, -and never re-insert fixed-id seed rows on every boot (that duplicates or crashes on a -real DB).`, -}) - -/** - * Composes `vike-rbac` for roles/permissions instead of hand-rolling an authz - * schema and a permission checker. It is the guard subject vike-admin and - * vike-actions are built around (`can()` / `hasRole()`), and it closes the gap the - * crud composer half-covers with ad-hoc `user.role` checks. Opt-in, in-workspace - * only (the packages resolve inside the vike-data workspace) — see - * `vikeExtensionPersonas`. - */ -export const vikeRbacComposer: Persona = definePersona({ - name: 'vike-rbac-composer', - role: 'Composes vike-rbac for roles/permissions (can()/hasRole()) instead of hand-rolling authz', - appliesTo: ['vike-rbac'], - systemPrompt: `You compose vike-rbac for roles and permissions instead of hand-rolling an authz -schema and a permission checker. It rides on vike-auth (the user is the permission -subject) and is the same check vike-admin, page guards, and vike-actions all use. - -When to reach for it: only for REAL roles/permissions. Signed-in-vs-not stays a -plain \`pageContext.user\` check (or a \`canView: (user) => !!user\`); reach for rbac -once the app has named permissions or more than one role. Do NOT model -\`roles\` / \`permissions\` / \`role_user\` / \`permission_role\` in your own schema — -vike-rbac owns those tables. - -Declare the permissions and extend the config (rbac self-installs vike-auth): -\`\`\`js -import { definePermissions } from 'vike-rbac' -// +config.js: export default { -// extends: ['import:vike-rbac/config:default'], -// permissions: definePermissions([{ name: 'widgets.edit', roles: ['admin'] }]), -// defaultRoles: ['member'], // granted to a brand-new signup on first request -// } -\`\`\` - -One check everywhere — \`can(user, permission)\` / \`hasRole(user, role)\` from -\`vike-rbac\`. Resolution runs in vike-auth's resolve seam, so the check is sync on -\`pageContext.user\` on every request. Route the crud composer's \`canView\` / \`canEdit\`, -page guards, session \`scope\`, and vike-actions guards through the SAME \`can()\` -instead of ad-hoc \`user.role === 'admin'\` comparisons: -\`\`\`js -import { can, hasRole } from 'vike-rbac' -if (!can(pageContext.user, 'widgets.edit')) throw render(403) -// vike-actions: guard: (ctx) => can(ctx.user, 'posts.publish') -\`\`\` - -Seed from the registry, do NOT hand-write a seed list: \`seedRbac()\` / \`assignRoles()\` -from \`vike-rbac/seed\` materialize the roles/permissions/grants from the composed -\`permissions\` registry, idempotently. To guard a Telefunc RPC with the same check, -\`requirePermission('users.view')\` from \`vike-rbac/telefunc\` (one universal middleware, -via \`import:vike-rbac/telefunc-middleware:default\`).`, -}) - -/** - * Composes `vike-crud` (+ `vike-admin`) for the CRUD/admin UI instead of - * hand-writing list/record/form screens. Those screens are the largest chunk of - * fresh, churn-prone AI code, and they are fully derivable: the composed schema - * is the source of truth, so the views come from it. This persona hands that - * surface to the extension. Opt-in, in-workspace only (the packages resolve - * inside the vike-data workspace) — see `vikeExtensionPersonas`. - */ -export const vikeCrudComposer: Persona = definePersona({ - name: 'vike-crud-composer', - role: 'Composes vike-crud / vike-admin to derive CRUD + admin UI from the schema, not hand-written screens', - appliesTo: ['vike-crud', 'vike-admin'], - systemPrompt: `You derive the CRUD/admin UI from the schema with vike-crud (and vike-admin), -instead of hand-writing list/record/form components. Those screens are the biggest -source of churn-prone AI code, and they are redundant: the composed schema already -says what a table's columns and fields are, so the views derive from it. The schema -stays \`defineSchema\` (the data persona's layer); vike-crud reads it. (\`defineView\` -is gone — the package was renamed vike-view -> vike-crud.) - -Derive the screens, do NOT hand-author them: -- \`crud({ table })\` is the CRUD preset for a table. Everything is optional except - \`table\` — omit \`list\`/\`record\`/\`form\` and each derives from the schema (every - non-hidden column; \`id\`, \`*_hash\`, and \`created_at\`/\`updated_at\` are hidden by - convention). Refine only where reality demands it with the \`column()\` / - \`display()\` / \`field()\` builders (imported from \`vike-crud\`): - \`\`\`js - import { crud, column, display, field } from 'vike-crud' - crud({ - table: 'posts', - list: [column('title').sortable(), column('created_at').format('since')], - form: [field('title').required(), field('status').type('select')], - canView: (user) => !!user, - canEdit: (user) => user?.role === 'admin', - scope: (table, ctx) => (ctx.user?.role === 'admin' ? null : { user_id: ctx.user.id }), - }) - \`\`\` -- A page is a composition of blocks. \`definePage({ route, sections })\` (imported - from \`vike-crud\`, which registers the schema-derived \`list\`/\`record\`/\`form\` - blocks) composes them; \`crudBlocks({ table })\` expands the crud preset into those - three block descriptors to drop into \`sections\` alongside bespoke ones - (\`{ block: 'stat' }\`, \`{ block: 'markdown' }\`, \`{ block: 'custom', component }\`): - \`\`\`js - import { definePage, crudBlocks } from 'vike-crud' - definePage({ route: '/posts', sections: [...crudBlocks({ table: 'posts' })] }) - \`\`\` -- Render with one import: \`import { Page } from 'vike-crud/react'\` (or - \`vike-crud/vue\`) registers the schema renderers and re-exports \`\`; hand it - the resolved view + tables. Do NOT write your own \`ListView\`/\`FormView\`. - -For a whole-DB admin panel, drop in vike-admin (a preset over vike-crud): extend -\`vike-admin/react\` in \`+config.js\` and contribute a resource per table via the -cumulative \`adminResources\` seam — you get \`/admin/*\` list/create/edit/delete pages, -auth-gated, in the themed layout, writing no ORM code: -\`\`\`js -import admin from 'vike-admin/react' -import { defineResource, column, field } from 'vike-admin/define' -export const postsResource = defineResource({ table: 'posts', label: 'Posts', - list: [column('title').sortable()], form: [field('title').required()], - canEdit: (user) => user?.role === 'admin' }) -// +config.js: export default { extends: [vikeReact, admin], adminResources: [postsResource] } -\`\`\` - -Mutations go through named actions, never inline closures (config stays -serializable). \`crudActions({ table, tables, scope })\` registers owner-scoped -\`.create\` / \`.update\` / \`.delete\` over vike-actions on the same repo and -scope the views use; a row action references one BY NAME: -\`button('Delete').action('posts.delete').params({ id: '$row.id' })\`. A domain -action (e.g. \`publish\`) stays a hand-written \`defineAction\`. Access is guarded by -the same \`canView\`/\`canEdit\` predicates and the \`(table, ctx)\` \`scope\` (row-level -ownership), re-forced on writes so a client cannot reassign ownership. - -Everything rides the ONE universal-orm adapter the app already registered (memory -in dev, drizzle+pglite when made real) — there is nothing extra to install or wire -for persistence. - -Customization ladder — start generated, refine only where reality demands it: -1. Config: pick/rename/order columns, widgets, filters, default sort via \`crud\` + - the \`column()\`/\`display()\`/\`field()\` builders. -2. Slot override: drop your own component for ONE field/column with \`.slot(token)\` - (register it with \`registerFieldWidget\` from \`vike-crud/react/widgets\`), keeping - the rest derived. An unregistered token degrades to the derived cell. -3. Eject: only when config + slots cannot express it, \`ejectView(view, { framework })\` - from \`vike-crud/eject\` hands you the whole page as plain owned source. Reach for - it last, not by hand-rolling screens from the start.`, -}) - -/** - * Composes `vike-themes` (styling) + `vike-layouts` (app shell) instead of - * hand-rolling a CSS design system and a layout/nav shell. Styling is the most - * over-polished AI surface — freshly-written CSS is infinitely pickable, which is - * the root of the loop's over-polish churn — and the app shell is re-invented on - * every build. Both are declarations here. Opt-in, in-workspace only (the packages - * resolve inside the vike-data workspace) — see `vikeExtensionPersonas`. - */ -export const vikeShellComposer: Persona = definePersona({ - name: 'vike-shell-composer', - role: 'Composes vike-themes + vike-layouts for styling and the app shell, not hand-rolled CSS and nav', - appliesTo: ['vike-themes', 'vike-layouts', 'vike-toolbar'], - systemPrompt: `You compose vike-themes for styling and vike-layouts for the app shell, instead -of hand-writing a CSS design system, a dark-mode toggle, and a layout/nav shell. -Freshly-generated CSS is the most over-polished surface there is (the review loop -always finds something to pick at), and the shell is re-invented on every build. -Both are a declaration here, and both render through the same vike-blocks system -the crud screens use. - -Styling = vike-themes, NOT hand-written CSS: -- Define a brand and extend the theming core; a single \`primary\` expands into the - full color ramp, and you get flash-free system dark mode plus a theme picker: - \`\`\`js - import themesExt from 'vike-themes/react' - import { defineTheme } from 'vike-themes' - const acme = defineTheme({ - name: 'acme', radius: '4px', - light: { bg: '#fffdf7', text: '#2a2016', primary: '#3b82f6' }, - dark: { bg: '#1a140d', text: '#f3ead9', primary: '#e0772a' }, - }) - // +config.js: export default { extends: [themesExt], appearance: 'system', theme: 'acme', themes: [acme] } - \`\`\` -- \`appearance\` is \`'system'\` (follows the OS, no flash) | \`'light'\` | \`'dark'\`. - \`themes\` is cumulative — built-ins and theme packages compose in. -- Style AGAINST the theme's CSS variables (\`var(--bg)\`, \`var(--text)\`, - \`var(--primary)\`, etc.); do NOT hard-code colors, and do NOT write your own - color system or dark-mode switch. That is what the theme owns. - -App shell = vike-layouts, NOT a hand-written layout/nav: -- Extend the layouts core, pick a shell, and fill its slots: - \`\`\`js - import layoutsExt from 'vike-layouts/react' - // +config.js: export default { - // extends: [layoutsExt], layout: 'topbar', logo: '◆ Acme', - // nav: [{ label: 'Home', href: '/' }, { label: 'Admin', href: '/admin' }], - // } - \`\`\` -- \`layout\` is \`'centered'\` (public pages) | \`'topbar'\` | \`'sidebar'\`, and a page - can override the app default. Slots (\`logo\`, the cumulative \`nav\`, footer, user - menu) are config; a shell renders only the slots it declares. Since the shells - are vike-blocks \`layout\` variants, this is the same block system the crud - screens render through. Do NOT hand-write a topbar/sidebar or a nav component. - -Toolbar (one line, not its own concern): \`extends: [toolbarExt]\` from -\`vike-toolbar/react\` gives a settings popover a home; installed extensions (the -theme picker, a locale switcher) populate it automatically. Install it and move on. - -Reach for a bespoke component only for a genuinely custom region, and even then -style it with the theme's CSS variables so it tracks light/dark and the brand.`, -}) - -/** - * The framework- and path-neutral guardrails that apply on every path — plain - * Prisma or composed extensions, Vike or Next. Kept as one named tail so both - * {@link sharedPersonas} and {@link vikeExtensionPersonas} spread it instead of - * re-listing it, and adding a neutral guardrail can't silently drop from a path. - */ -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. - * The framework's own skill adds its page builder on top (see the skill seam). - */ -export const sharedPersonas: readonly Persona[] = Object.freeze([ - dataModeler, - ...neutralGuardrails, -]) - -/** - * The opt-in vike-extension stack: compose `vike-auth` for authentication, the - * universal-orm data layer for domain data (both ride one registered adapter), and - * `vike-rbac` for roles/permissions, `vike-crud` / `vike-admin` for the CRUD/admin - * UI derived from the schema, and `vike-themes` / `vike-layouts` for styling and the - * app shell — instead of hand-rolling auth, hand-installing an ORM, or hand-writing - * an authz schema, list/record/form screens, a CSS design system, or a layout/nav - * shell. Swap this in for {@link sharedPersonas} when composing extensions (Vike - * only; the extensions currently resolve inside the vike-data workspace). - */ -export const vikeExtensionPersonas: readonly Persona[] = Object.freeze([ - vikeDataModeler, - vikeAuthComposer, - vikeRbacComposer, - vikeCrudComposer, - vikeShellComposer, - ...neutralGuardrails, -]) - -/** All built-in stack personas (the Vike stack), in a stable order. */ -export const stackPersonas: readonly Persona[] = Object.freeze([ - vikePageBuilder, - ...sharedPersonas, -]) diff --git a/packages/ai-autopilot/src/personas/types.ts b/packages/ai-autopilot/src/personas/types.ts deleted file mode 100644 index 0ad70219..00000000 --- a/packages/ai-autopilot/src/personas/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { AnyTool } from '@gemstack/ai-sdk' -import type { LoadedSkill } from '@gemstack/ai-skills' - -/** - * A reusable, stack-aware role an agent can take on. A persona is *data*: a - * name, a one-line role, a system-prompt fragment, and the skills/tools it - * brings. It carries opinionated knowledge of the GemStack stack (Vike + - * Prisma) so an autopilot run is not generic — it knows where pages - * live, how the schema drives migrations, and to express UI as intent. - * - * A persona is materialized into an `Agent` on demand (see `personaAgent`), - * composing its `systemPrompt` with the instructions/tools of its `skills` via - * `@gemstack/ai-skills`. Keeping it as data means it can be inspected, listed - * in a planner roster, and routed to as a Supervisor worker — without building - * an agent first. - */ -export interface Persona { - /** Unique id, kebab-case by convention (e.g. `vike-page-builder`). */ - readonly name: string - /** One-line human description — what this persona is for. */ - readonly role: string - /** The persona's identity/instructions fragment. Skill bodies append after it. */ - readonly systemPrompt: string - /** Skills the persona brings, composed over `@gemstack/ai-skills`. */ - readonly skills: readonly LoadedSkill[] - /** Extra tools beyond those its skills contribute; authoritative on name collision. */ - readonly tools: readonly AnyTool[] - /** Stack hints (package names / globs) the persona applies to; documents intent. */ - readonly appliesTo: readonly string[] -} - -/** The author-facing shape passed to `definePersona`; optional fields default to empty. */ -export interface PersonaSpec { - name: string - role: string - systemPrompt: string - skills?: LoadedSkill[] - tools?: AnyTool[] - appliesTo?: string[] -} diff --git a/packages/ai-autopilot/src/preset/builtin-presets.test.ts b/packages/ai-autopilot/src/preset/builtin-presets.test.ts index 0052259a..ecff81a4 100644 --- a/packages/ai-autopilot/src/preset/builtin-presets.test.ts +++ b/packages/ai-autopilot/src/preset/builtin-presets.test.ts @@ -9,28 +9,24 @@ const CASES = [ { name: 'web-development', title: 'Web Development', - skillUrl: 'https://web.dev/', majorRun: ['accessibility-review', 'performance-budget', 'web-security'], technicalRun: ['accessibility-review'], }, { name: 'data-science', title: 'Data Science', - skillUrl: 'https://developers.google.com/machine-learning/guides/rules-of-ml', majorRun: ['reproducibility-review', 'data-validation', 'methodology-review'], technicalRun: ['reproducibility-review'], }, { name: 'product-management', title: 'Product Management', - skillUrl: 'https://basecamp.com/shapeup', majorRun: ['requirements-review', 'user-experience-review', 'metrics-review'], technicalRun: ['requirements-review'], }, { name: 'biological-science', title: 'Biological Science', - skillUrl: 'https://doi.org/10.1371/journal.pcbi.1003285', majorRun: ['experimental-design-review', 'data-provenance-review', 'statistical-rigor-review'], technicalRun: ['experimental-design-review'], }, @@ -41,15 +37,13 @@ const majorLoop = (p: { loops: readonly { on: readonly string[]; run: readonly s for (const c of CASES) { describe(`${c.name} (shipped built-in)`, () => { - it('loads the {loops, prompts, skills} bundle from the shipped directory', async () => { + it('loads the {loops, prompts} bundle from the shipped directory', async () => { const preset = selectPreset(await builtinDomainPresets(), c.name) assert.ok(preset, `${c.name} is discovered`) assert.equal(preset!.title, c.title) assert.ok(preset!.description.length > 0) assert.equal(preset!.loops.length, 2) // major-change + bug-fix assert.ok(preset!.prompts.length >= 5) - assert.equal(preset!.skills.length, 1) - assert.equal(preset!.skills[0]!.url, c.skillUrl) }) it('targets non-web loop events (major-change, bug-fix)', async () => { diff --git a/packages/ai-autopilot/src/preset/compose.test.ts b/packages/ai-autopilot/src/preset/compose.test.ts index c7fe0e30..3f8e3313 100644 --- a/packages/ai-autopilot/src/preset/compose.test.ts +++ b/packages/ai-autopilot/src/preset/compose.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict' import { composeDomainPresets, selectPreset } from './compose.js' import { defineDomainPreset } from './define.js' import { defineLoop } from '../loop/define.js' -import { defineSkill } from '../extensions/define.js' const prompt = (id: string, instructions = id) => ({ id, name: id, title: id, description: '', instructions, passes: 1, appliesTo: [] }) @@ -11,18 +10,16 @@ const base = defineDomainPreset({ name: 'base', loops: [defineLoop({ on: 'major-change', run: ['review'] })], prompts: [prompt('review', 'base-review'), prompt('security')], - skills: [defineSkill({ name: 'vike', title: 'Vike', description: 'v', url: 'https://vike.dev/llms.txt' })], }) const overlay = defineDomainPreset({ name: 'overlay', loops: [defineLoop({ on: 'ui-flow', run: ['qa'] })], prompts: [prompt('review', 'overlay-review'), prompt('qa')], - skills: [defineSkill({ name: 'vike', title: 'Vike v2', description: 'v2', url: 'https://vike.dev/llms.txt' })], }) describe('composeDomainPresets', () => { - it('concatenates loops and merges prompts/skills with later-wins', () => { + it('concatenates loops and merges prompts with later-wins', () => { const merged = composeDomainPresets({ name: 'combined', title: 'Combined' }, base, overlay) assert.equal(merged.name, 'combined') @@ -35,10 +32,6 @@ describe('composeDomainPresets', () => { // prompts merge by id, later preset wins, sorted by id assert.deepEqual(merged.prompts.map(p => p.id), ['qa', 'review', 'security']) assert.equal(merged.prompts.find(p => p.id === 'review')!.instructions, 'overlay-review') - - // skills merge by name, later preset wins - assert.equal(merged.skills.length, 1) - assert.equal(merged.skills[0]!.title, 'Vike v2') }) it('composing is presets-of-presets: the result is itself a preset', () => { diff --git a/packages/ai-autopilot/src/preset/compose.ts b/packages/ai-autopilot/src/preset/compose.ts index 9d6beaad..c2ed3105 100644 --- a/packages/ai-autopilot/src/preset/compose.ts +++ b/packages/ai-autopilot/src/preset/compose.ts @@ -1,7 +1,6 @@ import { defineDomainPreset } from './define.js' import type { DomainPreset, DomainPresetMeta } from './types.js' import type { Prompt } from '../prompts/types.js' -import type { Skill } from '../extensions/types.js' /** * Compose several {@link DomainPreset}s into one under a new label — this is what @@ -11,20 +10,17 @@ import type { Skill } from '../extensions/types.js' * Merge rules: * - **loops** concatenate in preset order (the loop engine already de-dupes the * prompt ids a chain resolves to, so overlapping loops are harmless). - * - **prompts** merge by `id` and **skills** by `name`, later presets winning, so - * a preset later in the list overrides a shared body or doc pointer. Both come - * out sorted by their key for a stable result. + * - **prompts** merge by `id`, later presets winning, so a preset later in the + * list overrides a shared body. They come out sorted by id for a stable result. */ export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainPreset[]): DomainPreset { const prompts = new Map() - const skills = new Map() const loops = presets.flatMap(p => [...p.loops]) // Carry the default build event kind; a later preset that declares one wins. const defaultEvent = presets.reduce((acc, p) => p.defaultEvent ?? acc, undefined) for (const preset of presets) { for (const prompt of preset.prompts) prompts.set(prompt.id, prompt) - for (const skill of preset.skills) skills.set(skill.name, skill) } return defineDomainPreset({ @@ -32,7 +28,6 @@ export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainP ...(defaultEvent ? { defaultEvent } : {}), loops, prompts: [...prompts.values()].sort((a, b) => a.id.localeCompare(b.id)), - skills: [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)), }) } diff --git a/packages/ai-autopilot/src/preset/define.test.ts b/packages/ai-autopilot/src/preset/define.test.ts index 166f69de..31043eea 100644 --- a/packages/ai-autopilot/src/preset/define.test.ts +++ b/packages/ai-autopilot/src/preset/define.test.ts @@ -2,10 +2,8 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { defineDomainPreset, DomainPresetError } from './define.js' import { defineLoop } from '../loop/define.js' -import { defineSkill } from '../extensions/define.js' const aLoop = defineLoop({ on: 'major-change', run: ['review'] }) -const aSkill = defineSkill({ name: 'vike', title: 'Vike', description: 'Vike knowledge', url: 'https://vike.dev/llms.txt' }) const aPrompt = { id: 'review', name: 'review', title: 'Review', description: '', instructions: 'do', passes: 1, appliesTo: [] } describe('defineDomainPreset', () => { @@ -16,24 +14,21 @@ describe('defineDomainPreset', () => { assert.equal(preset.description, '') assert.deepEqual(preset.loops, []) assert.deepEqual(preset.prompts, []) - assert.deepEqual(preset.skills, []) assert.ok(Object.isFrozen(preset)) assert.throws(() => ((preset as { name: string }).name = 'x')) }) - it('carries the three content types', () => { + it('carries the content types', () => { const preset = defineDomainPreset({ name: 'sw-dev', title: 'Software Development', description: 'General engineering.', loops: [aLoop], prompts: [aPrompt], - skills: [aSkill], }) assert.equal(preset.title, 'Software Development') assert.deepEqual(preset.loops, [aLoop]) assert.deepEqual(preset.prompts, [aPrompt]) - assert.deepEqual(preset.skills, [aSkill]) }) it('rejects a missing or non-kebab name', () => { diff --git a/packages/ai-autopilot/src/preset/define.ts b/packages/ai-autopilot/src/preset/define.ts index d9369b91..81793dd3 100644 --- a/packages/ai-autopilot/src/preset/define.ts +++ b/packages/ai-autopilot/src/preset/define.ts @@ -12,8 +12,8 @@ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ /** * Validate a {@link DomainPresetSpec} and return a frozen {@link DomainPreset}. - * `title` defaults to `name`, `description` to empty, and the three content lists - * to empty so callers never null-check them. + * `title` defaults to `name`, `description` to empty, and the content lists to + * empty so callers never null-check them. */ export function defineDomainPreset(spec: DomainPresetSpec): DomainPreset { const name = spec.name?.trim() @@ -29,6 +29,5 @@ export function defineDomainPreset(spec: DomainPresetSpec): DomainPreset { ...(defaultEvent ? { defaultEvent } : {}), loops: Object.freeze([...(spec.loops ?? [])]), prompts: Object.freeze([...(spec.prompts ?? [])]), - skills: Object.freeze([...(spec.skills ?? [])]), }) } diff --git a/packages/ai-autopilot/src/preset/index.ts b/packages/ai-autopilot/src/preset/index.ts index 1c14292b..76a41a60 100644 --- a/packages/ai-autopilot/src/preset/index.ts +++ b/packages/ai-autopilot/src/preset/index.ts @@ -1,8 +1,8 @@ /** - * The Open Loop bundle unit (#204, #242): a **domain preset** = {loops, prompts, - * skills}. Author one in code with {@link defineDomainPreset}, load one from a - * directory of `.md` files with {@link loadDomainPreset}, and merge several into - * one with {@link composeDomainPresets} (so presets-of-presets falls out). + * The Open Loop bundle unit (#204, #242): a **domain preset** = {loops, prompts}. + * Author one in code with {@link defineDomainPreset}, load one from a directory + * of `.md` files with {@link loadDomainPreset}, and merge several into one with + * {@link composeDomainPresets} (so presets-of-presets falls out). * * Distinct from the framework `Preset` in `presets/` (a project detector); this * is the user-picked domain bundle. @@ -14,7 +14,6 @@ export { loadDomainPresetsFrom, builtinDomainPresets, loadLoopsFrom, - loadSkillsFrom, builtinPresetsDir, softwareDevelopmentPreset, type LoadPresetOptions, diff --git a/packages/ai-autopilot/src/preset/load.test.ts b/packages/ai-autopilot/src/preset/load.test.ts index 28120f19..25f23469 100644 --- a/packages/ai-autopilot/src/preset/load.test.ts +++ b/packages/ai-autopilot/src/preset/load.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { loadDomainPreset, loadLoopsFrom, loadSkillsFrom } from './load.js' +import { loadDomainPreset, loadLoopsFrom } from './load.js' import { DomainPresetError } from './define.js' async function writeFixture(root: string) { @@ -25,11 +25,6 @@ async function writeFixture(root: string) { join(root, 'prompts', 'review.technical.md'), `---\nname: review-technical\ndescription: Review a change (technical mode).\nmetadata:\n loopId: review\n conditions: technical\n---\nDeep technical review: trace every path.\n`, ) - await mkdir(join(root, 'skills')) - await writeFile( - join(root, 'skills', 'vike.md'), - `---\nname: vike\ndescription: Vike page/routing knowledge.\nmetadata:\n title: Vike\n url: https://vike.dev/llms.txt\n---\n`, - ) } describe('loadDomainPreset', () => { @@ -42,7 +37,7 @@ describe('loadDomainPreset', () => { await rm(dir, { recursive: true, force: true }) }) - it('loads {loops, prompts, skills} from a directory of .md files', async () => { + it('loads {loops, prompts} from a directory of .md files', async () => { const preset = await loadDomainPreset(dir) assert.equal(preset.name, 'software-development') assert.equal(preset.title, 'Software Development') @@ -55,11 +50,6 @@ describe('loadDomainPreset', () => { assert.equal(preset.prompts.length, 1) assert.equal(preset.prompts[0]!.id, 'review') assert.match(preset.prompts[0]!.instructions, /correctness/) - - assert.equal(preset.skills.length, 1) - assert.equal(preset.skills[0]!.name, 'vike') - assert.equal(preset.skills[0]!.url, 'https://vike.dev/llms.txt') - assert.equal(preset.skills[0]!.title, 'Vike') }) it('loads only base files when no mode is active', async () => { @@ -85,10 +75,9 @@ describe('loadDomainPreset', () => { }) }) -describe('loadLoopsFrom / loadSkillsFrom', () => { - it('return [] for a directory that does not exist', async () => { +describe('loadLoopsFrom', () => { + it('returns [] for a directory that does not exist', async () => { assert.deepEqual(await loadLoopsFrom(join(tmpdir(), 'no-such-loops-dir-xyz')), []) - assert.deepEqual(await loadSkillsFrom(join(tmpdir(), 'no-such-skills-dir-xyz')), []) }) }) diff --git a/packages/ai-autopilot/src/preset/load.ts b/packages/ai-autopilot/src/preset/load.ts index 36040713..683812ad 100644 --- a/packages/ai-autopilot/src/preset/load.ts +++ b/packages/ai-autopilot/src/preset/load.ts @@ -4,11 +4,9 @@ import { join } from 'node:path' import { parseSkillManifest } from '@gemstack/ai-skills' import type { SkillManifest } from '@gemstack/ai-skills' import { defineLoop } from '../loop/define.js' -import { defineSkill } from '../extensions/define.js' import { parsePrompt } from '../prompts/parse.js' import type { Loop } from '../loop/types.js' import type { Prompt } from '../prompts/types.js' -import type { Skill } from '../extensions/types.js' import { readConditions, selectWinners, stemOf } from './conditions.js' import { defineDomainPreset, DomainPresetError } from './define.js' import type { DomainPreset } from './types.js' @@ -28,10 +26,9 @@ export interface LoadPresetOptions { * preset.md # required: name + description (frontmatter), metadata.title * loops/*.md # metadata.on (kind or kinds) + metadata.run (prompt ids) * prompts/*.md # prompt bodies (the existing prompt bundle format) - * skills/*.md # metadata.url (llms.txt) + metadata.title; body ignored * ``` * - * The three content subdirectories are all optional — a missing one yields an + * Both content subdirectories are optional — a missing one yields an * empty list. The preset's identity comes from `preset.md`. Pass `modes` to * activate `conditions` variants (see `conditions.ts`); with none, only base * files load. @@ -49,10 +46,9 @@ export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {} const title = str(manifest.metadata, 'title') const event = str(manifest.metadata, 'event') - const [loops, prompts, skills] = await Promise.all([ + const [loops, prompts] = await Promise.all([ loadLoopsFrom(join(dir, 'loops'), { modes }), loadPromptsIn(join(dir, 'prompts'), { modes }), - loadSkillsFrom(join(dir, 'skills'), { modes }), ]) return defineDomainPreset({ @@ -62,7 +58,6 @@ export async function loadDomainPreset(dir: string, opts: LoadPresetOptions = {} description: manifest.description, loops, prompts, - skills, }) } @@ -131,22 +126,6 @@ async function loadPromptsIn(dir: string, opts: LoadPresetOptions = {}): Promise return winners.map(({ raw, path }) => parsePrompt(raw, path)) } -/** Load the skill pointers in a directory, applying mode overrides (a missing directory yields `[]`). */ -export async function loadSkillsFrom(dir: string, opts: LoadPresetOptions = {}): Promise { - const winners = selectWinners(await manifestEntries(dir), opts.modes ?? []) - return winners.map(({ path, manifest }) => { - const meta = manifest.metadata ?? {} - const url = str(meta, 'url') - if (!url) throw new DomainPresetError(`skill ${JSON.stringify(path)} is missing metadata.url (its llms.txt pointer)`) - return defineSkill({ - name: manifest.name, - title: str(meta, 'title') ?? manifest.name, - description: manifest.description, - url, - }) - }) -} - // ─── Internals ─────────────────────────────────────────────────── interface Entry { diff --git a/packages/ai-autopilot/src/preset/software-development.test.ts b/packages/ai-autopilot/src/preset/software-development.test.ts index d689108c..52f0ffc0 100644 --- a/packages/ai-autopilot/src/preset/software-development.test.ts +++ b/packages/ai-autopilot/src/preset/software-development.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { softwareDevelopmentPreset } from './load.js' describe('softwareDevelopmentPreset (shipped built-in)', () => { - it('loads the {loops, prompts, skills} bundle from the shipped directory', async () => { + it('loads the {loops, prompts} bundle from the shipped directory', async () => { const preset = await softwareDevelopmentPreset() assert.equal(preset.name, 'software-development') assert.equal(preset.title, 'Software Development') @@ -11,8 +11,6 @@ describe('softwareDevelopmentPreset (shipped built-in)', () => { assert.equal(preset.loops.length, 2) assert.ok(preset.prompts.length >= 5) - assert.equal(preset.skills.length, 1) - assert.equal(preset.skills[0]!.url, 'https://google.github.io/eng-practices/') }) it('every id a loop dispatches resolves to a shipped prompt body', async () => { diff --git a/packages/ai-autopilot/src/preset/types.ts b/packages/ai-autopilot/src/preset/types.ts index d078e82a..05be3476 100644 --- a/packages/ai-autopilot/src/preset/types.ts +++ b/packages/ai-autopilot/src/preset/types.ts @@ -1,26 +1,23 @@ import type { Loop } from '../loop/types.js' import type { Prompt } from '../prompts/types.js' -import type { Skill } from '../extensions/types.js' /** * The Open Loop bundle unit (#204, #242): a **domain preset** ties together the - * three data types the framework already ships separately into one selectable, + * two data types the framework already ships separately into one selectable, * composable thing. * * - **loops** ({@link Loop}) — the meta prompts: which prompt chains fire for * which change kinds. * - **prompts** ({@link Prompt}) — the prompt bodies (frontmatter + markdown) the * loops dispatch by id. - * - **skills** ({@link Skill}) — the framing knowledge: an `llms.txt` pointer plus - * any curated personas. * * A preset is authored in code with {@link defineDomainPreset} or loaded from a * directory of `.md` files with {@link loadDomainPreset}. Presets compose * ({@link composeDomainPresets}), so presets-of-presets falls out for free. * * This is deliberately distinct from the framework `Preset` in `presets/` (a - * project *detector* that points at a framework skill) — that one is skipped for - * the Open Loop MVP; this is the user-picked domain bundle. + * project *detector*) — that one is skipped for the Open Loop MVP; this is the + * user-picked domain bundle. */ export interface DomainPreset { /** Stable kebab-case id (e.g. `software-development`). */ @@ -38,8 +35,6 @@ export interface DomainPreset { readonly loops: readonly Loop[] /** The prompt bodies the loops dispatch by id. */ readonly prompts: readonly Prompt[] - /** The framing knowledge (llms.txt pointers + personas). */ - readonly skills: readonly Skill[] } /** Author-facing shape for {@link defineDomainPreset}; every content field defaults to empty, `title` to `name`. */ @@ -50,7 +45,6 @@ export interface DomainPresetSpec { defaultEvent?: string loops?: readonly Loop[] prompts?: readonly Prompt[] - skills?: readonly Skill[] } /** Identity fields for a composed preset — the merge of its parts is derived, only the label is new. */ diff --git a/packages/ai-autopilot/src/presets/define.test.ts b/packages/ai-autopilot/src/presets/define.test.ts index b8da37a4..9f448aff 100644 --- a/packages/ai-autopilot/src/presets/define.test.ts +++ b/packages/ai-autopilot/src/presets/define.test.ts @@ -1,27 +1,19 @@ 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', () => { + it('validates and freezes a preset, defaulting signals', () => { const preset = definePreset({ name: 'astro', framework: 'Astro' }) assert.equal(preset.name, 'astro') 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.deepEqual(preset.signals.files, []) 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 2723d018..3faf61ac 100644 --- a/packages/ai-autopilot/src/presets/define.ts +++ b/packages/ai-autopilot/src/presets/define.ts @@ -23,8 +23,6 @@ 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 ?? [])]), files: Object.freeze([...(spec.signals?.files ?? [])]), diff --git a/packages/ai-autopilot/src/presets/index.ts b/packages/ai-autopilot/src/presets/index.ts index 94cf40d1..c05fefd6 100644 --- a/packages/ai-autopilot/src/presets/index.ts +++ b/packages/ai-autopilot/src/presets/index.ts @@ -1,25 +1,16 @@ /** - * The web-app preset seam (#115) — framework-specific knowledge (personas) picked - * by *detecting* the app's framework, on top of the agnostic core. Vike is the - * flagship; Next.js is the second. A new framework is a new {@link Preset}, not a - * runtime fork. + * The web-app preset seam (#115) — *detect* the app's framework, on top of the + * agnostic core. Vike is the flagship; Next.js is the second. A new framework is + * a new {@link Preset}, not a runtime fork. * * - {@link definePreset} — define a framework preset * - {@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} — its framework skill's page builder + the shared neutral ones */ export { definePreset, PresetError } from './define.js' export { detectFramework } from './detect.js' -export { - vikePreset, - nextPreset, - builtinPresets, - presetPersonas, - PresetRegistry, - builtinPresetRegistry, -} from './library.js' +export { vikePreset, nextPreset, builtinPresets, PresetRegistry, builtinPresetRegistry } from './library.js' export type { Preset, PresetSpec, diff --git a/packages/ai-autopilot/src/presets/library.test.ts b/packages/ai-autopilot/src/presets/library.test.ts index 36b4dfa7..231b2d87 100644 --- a/packages/ai-autopilot/src/presets/library.test.ts +++ b/packages/ai-autopilot/src/presets/library.test.ts @@ -1,39 +1,14 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { - vikePreset, - nextPreset, - builtinPresets, - presetPersonas, - PresetRegistry, - builtinPresetRegistry, -} from './library.js' +import { vikePreset, nextPreset, builtinPresets, PresetRegistry, builtinPresetRegistry } from './library.js' describe('built-in presets', () => { - it('ship Vike (flagship) and Next, each pointing at its framework skill', () => { + it('ship Vike (flagship) and Next, each a pure detector', () => { assert.deepEqual(builtinPresets().map(p => p.name), ['vike', 'next']) - // 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') - }) -}) - -describe('presetPersonas', () => { - it('is the preset page builder followed by the shared neutral personas', () => { - const names = presetPersonas(vikePreset).map(p => p.name) - assert.deepEqual(names, ['vike-page-builder', 'data-modeler', 'ui-intent-designer']) - }) - - it('swaps only the page builder between frameworks — the rest of the stack is shared', () => { - const vike = presetPersonas(vikePreset).map(p => p.name) - const next = presetPersonas(nextPreset).map(p => p.name) - assert.equal(vike[0], 'vike-page-builder') - assert.equal(next[0], 'next-page-builder') - assert.deepEqual(vike.slice(1), next.slice(1)) // identical shared core + assert.equal(vikePreset.framework, 'Vike') + assert.equal(nextPreset.framework, 'Next.js') + assert.ok(vikePreset.signals.dependencies?.includes('vike')) + assert.ok(nextPreset.signals.dependencies?.includes('next')) }) }) diff --git a/packages/ai-autopilot/src/presets/library.ts b/packages/ai-autopilot/src/presets/library.ts index 5e1befe2..f64d767d 100644 --- a/packages/ai-autopilot/src/presets/library.ts +++ b/packages/ai-autopilot/src/presets/library.ts @@ -1,30 +1,21 @@ -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. Detection points at - * the {@link vikeSkill}, whose page builder plus the shared neutral personas make - * up the Vike stack. - */ +/** The flagship preset: Vike (Vite + SSR), renderer-agnostic. */ export const vikePreset: Preset = definePreset({ name: 'vike', framework: 'Vike', - 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). Points at {@link nextSkill}. */ +/** The second preset: Next.js (App Router + React Server Components). */ export const nextPreset: Preset = definePreset({ name: 'next', framework: 'Next.js', - skill: nextSkill, signals: { dependencies: ['next'], files: [/(^|\/)next\.config\.[cm]?[jt]s$/, /(^|\/)app\/.*\/page\.[jt]sx?$/, /(^|\/)app\/layout\.[jt]sx?$/], @@ -36,21 +27,9 @@ export function builtinPresets(): Preset[] { return [vikePreset, nextPreset] } -/** - * 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.skill?.personas ?? []), ...preset.personas, ...shared] -} - /** * A set of {@link Preset}s with detection. Register the built-ins (or your own), * then {@link select} the preset for a project by its {@link FrameworkSignals}. - * One shared core; the registry only decides which framework knowledge to layer. */ export class PresetRegistry { private readonly byName = new Map() diff --git a/packages/ai-autopilot/src/presets/types.ts b/packages/ai-autopilot/src/presets/types.ts index 0058801f..22e0b142 100644 --- a/packages/ai-autopilot/src/presets/types.ts +++ b/packages/ai-autopilot/src/presets/types.ts @@ -1,18 +1,11 @@ -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; 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 + * framework-agnostic; a preset *detects* which framework a project is on. 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, 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. + * A preset is a pure detector: a name, a human framework label, and the signals + * that identify it in a project. {@link detectFramework} scores the signals. */ /** How to recognize a framework in a project. */ @@ -23,26 +16,20 @@ export interface PresetSignals { files?: readonly RegExp[] } -/** A framework preset: detection signals + the {@link Skill} carrying its framework knowledge. */ +/** A framework preset: the detection signals that identify a framework in a project. */ 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'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}; `skill`/`personas`/`signals` default to empty. */ +/** The author-facing shape for {@link definePreset}; `signals` defaults to empty. */ export interface PresetSpec { name: string framework: string - skill?: Skill - personas?: readonly Persona[] signals?: PresetSignals } diff --git a/packages/framework-dashboard/components/SystemPromptDisclosure.tsx b/packages/framework-dashboard/components/SystemPromptDisclosure.tsx index 7c1706ff..535e28a1 100644 --- a/packages/framework-dashboard/components/SystemPromptDisclosure.tsx +++ b/packages/framework-dashboard/components/SystemPromptDisclosure.tsx @@ -11,11 +11,8 @@ import { composeRunSystem, type EcoOptions } from '@gemstack/framework/client' * is the whole point: no second copy of the wrapping logic to drift from the real * one. * - * What it honestly cannot show is the rest of a build run's prompt. Personas, - * skills, the detected preset and the repo's own memory files are read off disk - * and appended at run time, and on a build run they can dwarf this block. So the - * caveat below says so, and the run's own `system-prompt` event carries the true - * final text into the event log the moment a run starts. + * Since #547 nothing is read off disk and appended at run time, so this is the + * whole prompt for every run kind, not a preview of most of it. */ export function SystemPromptDisclosure({ prompt, @@ -72,9 +69,8 @@ export function SystemPromptDisclosure({ {text}

- {text.length.toLocaleString()} characters. A build run also appends this project's memory files and - the personas and skills for its detected stack, which are read at run time — the run's{' '} - system prompt entry in the event log below carries the final text in full. + {text.length.toLocaleString()} characters. This is the whole system prompt: nothing else is appended + when the run starts.

) : ( diff --git a/packages/framework/README.md b/packages/framework/README.md index 11a01f31..018139d6 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -82,7 +82,6 @@ framework relay Host a run relay so teammates can watch a run (se --technical Activate the preset's Technical mode variants. --kind Build event kind the preset's review loop fires for (e.g. bug-fix or major-change; default: the preset's own). - --compose-extensions Opt the built-in capability extensions in (Vike-only; see below). --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: bypassPermissions, so the headless loop can run installs / builds / tests). @@ -181,8 +180,8 @@ sessions, not the final hosted product. Accounts, teams, and steering layer on l ## Open Loop domain presets (#204) -A **domain preset** bundles the review loops, prompts, and skills a kind of work -wants, so a run is framed for that domain instead of the generic web-app default. +A **domain preset** bundles the review loops and prompts a kind of work wants, so +a run is reviewed for that domain instead of the generic web-app default. Five ship built in: - `software-development` — code-review + test-coverage + security-review on a @@ -252,63 +251,6 @@ travels with the repo, like the memory files). To remove the built-in prompt and only your own, set `antiLazyPill: false` in `the-framework.yml` (the key keeps its historical name). The run narrates what it picked up (`◆ system prompt: SYSTEM.md`). -## Extensions (#190) - -The Framework is modular: it composes **capability extensions** and **skills** -into the agent frame instead of hardcoding a fixed list. Nothing is framework-gated. - -- A **capability extension** (`framework-auth`, `framework-data`, ...) owns a - cross-cutting concern. When it matches a project it frames the agent with its - personas. An extension with the same `capability` as a built-in default - supersedes it (e.g. `framework-data` replaces the default ORM modeler), so the - agent never gets two conflicting personas for one concern. -- A **skill** is a doc pointer — an `llms.txt` the agent consults for - framework/domain knowledge. A framework is a skill, not an adapter package: - Vike is `https://vike.dev/llms.txt`. - -An extension activates two ways: by **signal** (one of its dependencies is in the -project's `package.json`) or by **opt-in** (`--compose-extensions` turns the -built-ins on, for a from-scratch build where nothing is installed yet). - -The built-ins are the vike-* composers: `framework-auth` (vike-auth for identity), -`framework-data` (the universal-orm data layer for domain data), `framework-rbac` -(vike-rbac for roles/permissions), `framework-crud` (vike-crud/vike-admin for -schema-derived CRUD + admin UI), and `framework-shell` (vike-themes/vike-layouts -for styling and the app shell). They resolve only inside the vike-data workspace, -so `--compose-extensions` is Vike-only and ignored on any other preset; the default -path (hand-rolled auth + Prisma) is the one that stays publishable. - -### Authoring a `framework-*` extension - -Publish a package named `framework-` (or `@scope/framework-`) whose -default export is a `FrameworkExtension`: - -```ts -// framework-sentry/src/index.ts -import { defineFrameworkExtension, definePersona, defineSkill } from '@gemstack/ai-autopilot' - -export default defineFrameworkExtension({ - name: 'framework-sentry', - capability: 'tracking', - // deps/files that auto-activate it in a project: - signals: { dependencies: ['@sentry/node', '@sentry/react'] }, - // personas frame the agent; skills point it at authoritative docs: - personas: [definePersona({ - name: 'error-tracker', - role: 'Wires Sentry for error tracking instead of hand-rolling logging', - systemPrompt: 'Install @sentry/node and wrap the server; never hand-roll error capture. ...', - })], - skills: [defineSkill({ - name: 'sentry', title: 'Sentry', description: 'Error tracking + performance.', - url: 'https://docs.sentry.io/llms.txt', - })], -}) -``` - -When a user's project depends on both `@gemstack/framework` and -`framework-sentry`, the CLI discovers the package, registers it, and composes it -whenever its signal matches — no change to the framework core. - ## Status MVP (#166): the driver seam, the Claude Code driver, the CLI, and the dashboard, diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index bc30bea8..fab882f7 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -27,9 +27,8 @@ import { type ServeConfig, } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' -import { discoverExtensions, readProjectSignals } from './extensions.js' +import { readProjectSignals } from './project.js' import { loadFrameworkConfig, type FrameworkFileConfig } from './config.js' -import { loadRepoMemory } from './memory.js' import { type EcoOptions } from './system-prompt.js' import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.js' import { checkForUpdate, formatUpdateStatus, nodeVersionFetcher } from './update-check.js' @@ -166,10 +165,6 @@ Options: bug-fix or major-change (default: the-framework.yml's event, else the preset's own, else major-change). Selects which review chain gates the run. - --compose-extensions Opt the built-in capability extensions in (auth, data, - rbac, crud, shell) so the agent composes them instead of - hand-rolling. Vike-only; installed framework-* extensions - auto-activate either way (default: off, hand-rolled + Prisma). --max-passes Full-fledged loop pass budget (default: 5). --max-cost Stop the run once it has spent this much (USD). --no-todo-loop Do not consume the agent's TODO backlog after the build @@ -260,7 +255,6 @@ export interface CliOptions { dashboard: boolean relayServe: boolean share?: string | undefined - composeExtensions: boolean sessionLink?: string | undefined permissionMode?: PermissionMode | undefined skipPermissions: boolean @@ -306,7 +300,6 @@ export function parseArgs(argv: string[]): CliOptions { browser: false, dashboard: true, relayServe: false, - composeExtensions: false, skipPermissions: false, resume: false, persist: true, @@ -338,9 +331,6 @@ export function parseArgs(argv: string[]): CliOptions { case '--no-dashboard': opts.dashboard = false break - case '--compose-extensions': - opts.composeExtensions = true - break case '--preset': opts.preset = argv[++i] break @@ -1119,22 +1109,6 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise m.content)) io.out(`◆ project memory: ${memory.filter(m => m.content).map(m => m.name).join(', ')}`) - // A user SYSTEM.md and the-framework.yml's anti-lazy-pill toggle shape the system // prompt injected into every prompt (#301). The built-in pill is on unless removed. const userSystemPrompt = await loadUserSystemPrompt(cwd) @@ -1173,14 +1147,11 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { - const dir = workspace({ dependencies: { 'vike-auth': '1.0.0' }, devDependencies: { vike: '0.4.0' } }) - const signals = readProjectSignals(dir) - const deps = signals.dependencies as Record - assert.ok('vike-auth' in deps) - assert.ok('vike' in deps) -}) - -test('readProjectSignals returns empty signals when there is no package.json', () => { - const dir = workspace(undefined) - assert.deepEqual(readProjectSignals(dir), {}) -}) - -test('discoverExtensions finds nothing when no framework-* packages are declared', async () => { - const dir = workspace({ dependencies: { vike: '0.4.0', react: '18.0.0' } }) - const result = await discoverExtensions(dir) - assert.deepEqual(result.extensions, []) - assert.deepEqual(result.failed, []) -}) - -test('discoverExtensions reports a declared-but-uninstalled framework-* package instead of throwing', async () => { - const dir = workspace({ dependencies: { 'framework-ghost': '1.0.0' } }) - const result = await discoverExtensions(dir) - assert.deepEqual(result.extensions, []) - assert.equal(result.failed.length, 1) - assert.equal(result.failed[0]!.package, 'framework-ghost') -}) diff --git a/packages/framework/src/extensions.ts b/packages/framework/src/extensions.ts deleted file mode 100644 index f67ae8bd..00000000 --- a/packages/framework/src/extensions.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { - extensionPackageNames, - loadExtensionsFromModules, - type FailedExtension, - type FrameworkExtension, - type FrameworkSignals, -} from '@gemstack/ai-autopilot' - -/** The framework core package — matches the `framework-*` convention loosely but is not an extension. */ -const FRAMEWORK_CORE = '@gemstack/framework' - -/** - * Read a project's detection signals from its `package.json`: the union of - * `dependencies` + `devDeps` names. Returns empty signals when there is no - * `package.json` (a from-scratch build in an empty workspace) so detection and - * extension activation simply find nothing rather than throwing. - */ -export function readProjectSignals(cwd: string): FrameworkSignals { - let pkg: { dependencies?: Record; devDependencies?: Record } - try { - pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) - } catch { - return {} - } - const dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } - return { dependencies } -} - -/** The result of {@link discoverExtensions}: what resolved and what didn't. */ -export interface DiscoverExtensionsResult { - extensions: FrameworkExtension[] - failed: FailedExtension[] -} - -/** - * Discover installed `framework-*` capability packages in `cwd` and load their - * {@link FrameworkExtension} exports. Resolves each package from the user's - * workspace (not the CLI's own tree), so a project that installs - * `framework-sentry` gets it composed without the CLI knowing about it. Bad or - * missing packages are reported in `failed`, never thrown — one broken extension - * cannot abort a run. - */ -export async function discoverExtensions(cwd: string, signals?: FrameworkSignals): Promise { - const deps = signals?.dependencies ?? readProjectSignals(cwd).dependencies ?? {} - const depNames = Array.isArray(deps) ? deps : Object.keys(deps) - const names = extensionPackageNames(depNames, { exclude: [FRAMEWORK_CORE] }) - if (names.length === 0) return { extensions: [], failed: [] } - - // Resolve extension packages from the user's workspace, not the CLI's tree. - const require = createRequire(pathToFileURL(join(cwd, 'package.json')).href) - const load = async (name: string): Promise => import(pathToFileURL(require.resolve(name)).href) - - const { loaded, failed } = await loadExtensionsFromModules(names, load) - return { extensions: loaded.map(l => l.extension), failed } -} diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index ae079a32..49c04a17 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -27,8 +27,8 @@ * - {@link driverBuild} / {@link driverChecklist} / {@link driverImprove} * * ## Run + product shell - * - {@link runFramework} — detect the preset, frame the agent with its personas, - * drive the whole bootstrap flow, and stream {@link FrameworkEvent}s + * - {@link runFramework} — detect the preset, drive the whole bootstrap flow, and + * stream {@link FrameworkEvent}s * - {@link startDashboard} — the localhost UI over the event stream * - {@link runCli} / {@link parseArgs} — the `framework` command */ @@ -95,11 +95,6 @@ export { type RelayOptions, type RelayPublisher, } from './relay.js' -export { - discoverExtensions, - readProjectSignals, - type DiscoverExtensionsResult, -} from './extensions.js' export { hostExecutor, type HostExecutorOptions } from './host-exec.js' export { type FrameworkEvent, @@ -176,6 +171,7 @@ export { nodeProjectFs, crawlRepoFiles, nodeGitRunner, + readProjectSignals, type ProjectFs, type GitRunner, } from './project.js' @@ -223,13 +219,6 @@ export { FRAMEWORK_CONFIG_FILES, type FrameworkFileConfig, } from './config.js' -export { - loadRepoMemory, - memoryFraming, - MEMORY_FILES, - type MemoryFile, - type LoadedMemory, -} from './memory.js' export { systemPromptBlock, composeRunSystem, diff --git a/packages/framework/src/memory.test.ts b/packages/framework/src/memory.test.ts deleted file mode 100644 index b7adbc64..00000000 --- a/packages/framework/src/memory.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 { loadRepoMemory, memoryFraming, MEMORY_FILES } from './memory.js' - -test('loadRepoMemory reads present files and leaves missing ones without content', async () => { - const dir = await mkdtemp(join(tmpdir(), 'repo-memory-')) - try { - await writeFile(join(dir, 'CODE-OVERVIEW.md'), ' A tiny app.\n') - await writeFile(join(dir, 'KNOWLEDGE-BASE.md'), '') // empty file -> no content - const loaded = await loadRepoMemory(dir) - // every canonical file comes back, so the agent is told to create missing ones - assert.equal(loaded.length, MEMORY_FILES.length) - const overview = loaded.find(m => m.name === 'CODE-OVERVIEW.md') - assert.equal(overview?.content, 'A tiny app.') // trimmed - assert.equal(loaded.find(m => m.name === 'KNOWLEDGE-BASE.md')?.content, undefined) - assert.equal(loaded.find(m => m.name === 'BRAINSTORMING.md')?.content, undefined) - } finally { - await rm(dir, { recursive: true, force: true }) - } -}) - -test('memoryFraming is empty for an empty list', () => { - assert.equal(memoryFraming([]), '') -}) - -test('memoryFraming lists every file to keep up to date, and shows current contents', () => { - const framing = memoryFraming([ - { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'It is a blog.' }, - { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts' }, // absent - ]) - // every file is agent-maintained: one list under the "keep up to date" instruction - assert.match(framing, /Keep these up to date[\s\S]*CODE-OVERVIEW\.md[\s\S]*KNOWLEDGE-BASE\.md/) - // present contents are inlined - assert.match(framing, /### CODE-OVERVIEW\.md\nIt is a blog\./) -}) - -test('memoryFraming tells the agent to start the files when none exist yet', () => { - const framing = memoryFraming([{ name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase' }]) - assert.match(framing, /None exist yet/) -}) diff --git a/packages/framework/src/memory.ts b/packages/framework/src/memory.ts deleted file mode 100644 index 62beaaa5..00000000 --- a/packages/framework/src/memory.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - -/** - * A canonical repo memory file (#204, #260): a special file at the workspace root - * the agent reads at the start of a run for context and keeps current as it works, - * so the project's memory lives in the repo as plain markdown (Rom's idea). This - * doubles as persistence: the next run picks up where the last left off. - */ -export interface MemoryFile { - /** File name at the workspace root. */ - name: string - /** What it holds, shown to the agent so it knows what belongs where. */ - purpose: string -} - -/** The canonical repo memory files, in the order they are framed. */ -export const MEMORY_FILES: readonly MemoryFile[] = [ - { name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase: structure, key modules, and how they fit together' }, - { name: 'KNOWLEDGE-BASE.md', purpose: 'durable facts and conventions learned about this project' }, - { name: 'BRAINSTORMING.md', purpose: 'open ideas and things to explore later' }, -] - -/** A memory file paired with its current contents. */ -export interface LoadedMemory extends MemoryFile { - /** Current file contents (trimmed), or undefined when the file does not exist yet. */ - content?: string -} - -/** - * Read the canonical memory files present in a workspace. Every entry comes back - * (so the agent is told to create the missing ones); a file that does not exist - * yet simply has no `content`. - */ -export async function loadRepoMemory( - dir: string, - files: readonly MemoryFile[] = MEMORY_FILES, -): Promise { - return Promise.all( - files.map(async file => { - try { - const content = (await readFile(join(dir, file.name), 'utf8')).trim() - return content ? { ...file, content } : { ...file } - } catch { - return { ...file } - } - }), - ) -} - -/** - * Build the system-prompt block that turns the repo's special files into the - * agent's persistent memory: the current contents of any files present (so the - * agent starts with that context), plus an instruction to keep them current. - * Returns `''` when there is nothing to frame. - */ -export function memoryFraming(memories: readonly LoadedMemory[]): string { - if (memories.length === 0) return '' - const present = memories.filter(m => m.content) - - const lines: string[] = ['## Project memory'] - lines.push( - "This project keeps its long-term memory in these files at the repo root. Read them for context before you start.", - ) - lines.push('\nKeep these up to date as you work, creating one when it does not exist yet:') - for (const m of memories) lines.push(`- ${m.name}: ${m.purpose}`) - if (present.length) { - lines.push('\nCurrent contents:') - for (const m of present) lines.push(`\n### ${m.name}\n${m.content}`) - } else { - lines.push('\nNone exist yet. Start them as you learn about the project.') - } - return lines.join('\n') -} diff --git a/packages/framework/src/preset-run.test.ts b/packages/framework/src/preset-run.test.ts index ca673f54..f7659162 100644 --- a/packages/framework/src/preset-run.test.ts +++ b/packages/framework/src/preset-run.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' -import { defineDomainPreset, defineLoop, defineSkill } from '@gemstack/ai-autopilot' +import { defineDomainPreset, defineLoop } from '@gemstack/ai-autopilot' import { runFramework } from './run.js' import { FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import type { Driver } from './driver/index.js' @@ -26,10 +26,9 @@ const domainPreset = defineDomainPreset({ description: 'General engineering.', loops: [defineLoop({ on: 'major-change', run: ['code-review'] })], prompts: [{ id: 'code-review', name: 'code-review', title: 'Code review', description: '', instructions: 'Review the change for correctness.', passes: 1, appliesTo: [] }], - skills: [defineSkill({ name: 'eng-practices', title: 'Engineering Practices', description: 'Code review guidelines.', url: 'https://google.github.io/eng-practices/' })], }) -test('a domain preset frames the run and is narrated', async () => { +test('a domain preset drives the run and is narrated', async () => { const events: FrameworkEvent[] = [] const { driver, system } = recordingDriver() const { result } = await runFramework({ @@ -42,11 +41,7 @@ test('a domain preset frames the run and is narrated', async () => { onEvent: e => events.push(e), }) - // The preset's skill framed the session system prompt. - assert.match(system(), /Skill: Engineering Practices/) - assert.match(system(), /google\.github\.io\/eng-practices/) - - // That exact system prompt is surfaced for transparency (#343), not just handed + // The exact system prompt is surfaced for transparency (#343), not just handed // to the driver. const sys = events.find(e => e.kind === 'system-prompt') assert.ok(sys, 'a system-prompt event is emitted') diff --git a/packages/framework/src/project.ts b/packages/framework/src/project.ts index 17b038bf..7e731703 100644 --- a/packages/framework/src/project.ts +++ b/packages/framework/src/project.ts @@ -1,13 +1,32 @@ +import { readFileSync } from 'node:fs' import { join } from 'node:path' +import type { FrameworkSignals } from '@gemstack/ai-autopilot' import { THE_FRAMEWORK_DIR } from './logs.js' /** * Project-level repo helpers (#380): the `.the-framework/` activation marker - * check and a `git ls-files` crawl. Read-only building blocks for the #314 - * sidebars; activation/install (creating the dir, the install commit) is a - * separate, deferred concern. + * check, a `git ls-files` crawl, and the project's detection signals. Read-only + * building blocks for the #314 sidebars; activation/install (creating the dir, + * the install commit) is a separate, deferred concern. */ +/** + * Read a project's detection signals from its `package.json`: the union of + * `dependencies` + `devDependencies` names. Returns empty signals when there is + * no `package.json` (a from-scratch build in an empty workspace) so preset + * detection simply finds nothing rather than throwing. + */ +export function readProjectSignals(cwd: string): FrameworkSignals { + let pkg: { dependencies?: Record; devDependencies?: Record } + try { + pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) + } catch { + return {} + } + const dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } + return { dependencies } +} + /** The `.the-framework/` path under a project root. */ export function theFrameworkDir(cwd: string): string { return join(cwd, THE_FRAMEWORK_DIR) diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index 2a386b95..56c0f7fb 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -3,11 +3,12 @@ import { test } from 'node:test' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { defineDomainPreset, defineFrameworkExtension, defineLoop, definePersona, defineSkill } from '@gemstack/ai-autopilot' +import { defineDomainPreset, defineLoop } from '@gemstack/ai-autopilot' import type { Prompt } from '@gemstack/ai-autopilot' import { DEFAULT_MAX_PASSES, requestChoices, requestMultiSelect, runFramework, type ChoicesOption, type MultiSelectOption } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import { FakeDriver, type Driver, type DriverSession } from './driver/index.js' +import { composeRunSystem } from './system-prompt.js' import type { ChoiceRequest, FrameworkEvent } from './events.js' /** A driver that records the `system` framing it is started with, delegating the run to the fake. */ @@ -47,7 +48,7 @@ test('runFramework drives the whole flow through the driver, offline, to product // The deploy phase decided SSR -> cloudflare. assert.equal(result.deploy?.plan.target, 'cloudflare') - // We surfaced the wrapped agent's own progress and framed with personas. + // We surfaced the wrapped agent's own progress. assert.ok(events.some(e => e.kind === 'driver')) assert.ok(events.some(e => e.kind === 'session' && e.fake === true)) assert.equal(events.at(-1)!.kind, 'end') @@ -170,101 +171,19 @@ test('runFramework shows a literal session link immediately (no template)', asyn assert.equal(session.sessionLink, 'https://code.example.com/live') }) -test('--compose-extensions frames the agent with vike-auth, not hand-rolled auth (#186)', async () => { - const { driver, system } = recordingDriver() - await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - composeExtensions: true, - onEvent: () => {}, - }) - assert.match(system(), /vike-auth/) - assert.match(system(), /npm install vike-auth/) -}) - -test('--compose-extensions is ignored on a non-Vike preset and falls back with a log (#202)', async () => { - const events: FrameworkEvent[] = [] - const { driver, system } = recordingDriver() - const { detection } = await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: { dependencies: ['next'] }, // detect Next, not Vike - composeExtensions: true, - onEvent: e => events.push(e), - }) - - // The vike-* extensions are Vike-only, so a Next project does not get them. - assert.equal(detection.framework, 'Next.js') - assert.doesNotMatch(system(), /vike-auth/) - // And we say why, rather than silently framing Next with vike composers. - assert.ok( - events.some(e => e.kind === 'log' && /--compose-extensions ignored/.test(e.message)), - 'expected a log explaining the compose fallback', - ) -}) - -test('without --compose-extensions the default framing has no vike-auth (publish-safe)', async () => { - const { driver, system } = recordingDriver() - await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - onEvent: () => {}, - }) - assert.doesNotMatch(system(), /vike-auth/) -}) - -test('Vike arrives as a skill (llms.txt pointer) in the framing (#190)', async () => { - const { driver, system } = recordingDriver() - await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, // has vike-react -> the Vike skill activates - onEvent: () => {}, - }) - assert.match(system(), /https:\/\/vike\.dev\/llms\.txt/) -}) - -test('the framework page builder is framed via its skill, not a preset persona', async () => { +test('the run system channel is exactly composeRunSystem, with nothing appended (#547)', 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('repo memory files frame the agent: contents + a maintain instruction (#260)', async () => { - const { driver, system } = recordingDriver() - await runFramework({ - intent: FAKE_INTENT, - driver, - cwd: '/tmp/ws', - signals: FAKE_SIGNALS, - memory: [{ name: 'CODE-OVERVIEW.md', purpose: 'a map of the codebase', content: 'A blog with comments.' }], - onEvent: () => {}, - }) - assert.match(system(), /Project memory/) - assert.match(system(), /A blog with comments\./) // contents inlined as context - assert.match(system(), /Keep these up to date[\s\S]*CODE-OVERVIEW\.md/) + // runFramework composes no framing of its own: detection narrates, it never reaches the prompt. + assert.equal(system(), composeRunSystem({ tf: { prompt: FAKE_INTENT, params: { autopilot: false } } })) }) -test('no memory option leaves the framing unchanged (#260)', async () => { +test('detected deps never reach the system channel (#547)', async () => { const { driver, system } = recordingDriver() - await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, onEvent: () => {} }) - assert.doesNotMatch(system(), /Project memory/) + // FAKE_SIGNALS carries vike-react + @prisma/client; none of it may frame the agent. + const { detection } = await runFramework({ intent: FAKE_INTENT, driver, cwd: '/tmp/ws', signals: FAKE_SIGNALS, onEvent: () => {} }) + assert.equal(detection.framework, 'Vike') // detection still happens... + assert.doesNotMatch(system(), /vike-auth|llms\.txt|Skill:|Persona:|Project memory/) // ...but stays out of the prompt }) /** A minimal domain preset whose major-change loop runs one review prompt. */ @@ -283,7 +202,6 @@ function reviewPreset() { title: 'Test Domain', loops: [defineLoop({ on: 'major-change', run: ['review'] })], prompts: [review], - skills: [], }) } @@ -352,7 +270,6 @@ function dualLoopPreset(opts: { defaultEvent?: string } = {}) { defineLoop({ on: 'bug-fix', run: ['bug-review'] }), ], prompts: [prompt('major-review', 'MAJOR-SENTINEL'), prompt('bug-review', 'BUGFIX-SENTINEL')], - skills: [], }) } @@ -427,38 +344,6 @@ test('a preset defaultEvent selects the loop; an explicit buildEvent overrides i assert.ok(overridden.prompts().some(p => p.includes('MAJOR-SENTINEL'))) // run choice wins over the preset default }) -test('a registered extension auto-activates by its signal, no opt-in needed (#190)', async () => { - const { driver, system } = recordingDriver() - const audit = defineFrameworkExtension({ - name: 'framework-audit', - capability: 'audit', - personas: [definePersona({ name: 'auditor', role: 'audits', systemPrompt: 'AUDIT-LOG-EVERYTHING sentinel.' })], - 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(), /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 e69806ef..cadef1b6 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -1,21 +1,12 @@ import { Bootstrap, DockerRunner, - ExtensionRegistry, LocalRunner, LoopEngine, dockerAvailable, - SkillRegistry, - builtinExtensionNames, builtinPresetRegistry, - composePersonas, - composeSkills, mergeChecklists, - neutralPersonas, - personaInstructions, serveCheck, - skillInstructions, - skillPersonas, type BootstrapEvent, type BootstrapResult, type BootstrapScope, @@ -24,7 +15,6 @@ import { type DeployTarget, type DomainPreset, type FrameworkDetection, - type FrameworkExtension, type FrameworkSignals, type LoopPassContext, type RunnerSession, @@ -34,7 +24,6 @@ import { import { snapshotWorkspace } from './sandbox.js' import { CONSUMPTION_LIMIT_LABEL, type ConsumptionWindow } from './consumption.js' import type { Driver, DriverEvent, DriverSession } from './driver/index.js' -import { memoryFraming, type LoadedMemory } from './memory.js' import { composeRunSystem, type EcoOptions, type TfContext } from './system-prompt.js' import { AWAIT_PROTOCOL, CONFIRM_APPROVED, CONFIRM_DECLINED, PLAN_DECLINED_MESSAGE, isDeclinedConfirmation, parseAwaitGate, parseMarkdownViews, parseSessionName, parseReadyForMerge, type ParsedAwaitGate } from './turn-gate.js' // Value import from todo-loop.js is a benign cycle: todo-loop.js only calls @@ -100,13 +89,6 @@ export interface RunFrameworkOptions { model?: string /** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */ signals?: FrameworkSignals - /** - * The repo's memory files ({@link LoadedMemory}) to frame the agent with (#260): - * their current contents become context and the agent is told to keep the ones - * it owns current (persistence lives in the repo as markdown). Load with - * `loadRepoMemory(cwd)`. Omit or pass `[]` to frame no memory. - */ - memory?: readonly LoadedMemory[] /** * A user-authored system prompt (from `SYSTEM.md`) injected into every prompt * (#301). Load with `loadUserSystemPrompt(cwd)`. Composed after the built-in @@ -129,9 +111,8 @@ export interface RunFrameworkOptions { */ bootstrap?: boolean /** - * A user-picked Open Loop domain preset ({loops, prompts, skills}) to run the - * build under (#251). Its skills (and their personas) frame every phase, and - * its loops + prompts are materialized into a driver-backed {@link LoopEngine} + * A user-picked Open Loop domain preset ({loops, prompts}) to run the build + * under (#251). Its loops + prompts are materialized into a driver-backed {@link LoopEngine} * exposed as {@link RunFrameworkResult.loop}. Load it with `loadDomainPreset` / * `softwareDevelopmentPreset` (pass `modes` there to activate variants). Omit * for the framework-only run. @@ -151,21 +132,6 @@ export interface RunFrameworkOptions { * checklist, so a run is never left unreviewed. No-op without a preset. */ buildEvent?: string - /** - * Opt the built-in capability extensions in (auth, data, rbac, crud, shell) so - * a from-scratch build is framed to compose them instead of hand-rolling - * auth/data/UI. Vike-only: the built-in composers resolve inside the vike-data - * workspace, so the opt-in is ignored on a non-Vike preset. Off by default: the - * publish-safe path (hand-rolled + Prisma) still stands, and installed - * extensions auto-activate by signal either way (see #190). - */ - composeExtensions?: boolean - /** - * Extra {@link FrameworkExtension}s to register on top of the built-ins — - * discovered `framework-*` packages from the project, or explicit ones. Each - * still activates by signal or opt-in; registering does not force it on. - */ - extensions?: readonly FrameworkExtension[] /** Max full-fledged passes. Default {@link DEFAULT_MAX_PASSES} (5). */ maxPasses?: number /** A deploy decision to narrate at the end. Omit to skip the deploy phase. */ @@ -305,68 +271,28 @@ export async function runFramework(opts: RunFrameworkOptions): Promise e.name).join(' + ')}` : '' - const skillNote = skills.length ? `, ${skills.length} skill(s)` : '' emit({ kind: 'log', - message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)${extensionNote}${skillNote}`, + message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence})`, }) if (domainPreset) { const modeNote = opts.modes?.length ? ` (modes: ${opts.modes.join(', ')})` : '' diff --git a/packages/framework/src/system-prompt.test.ts b/packages/framework/src/system-prompt.test.ts index 4e3a9d02..224e4e2a 100644 --- a/packages/framework/src/system-prompt.test.ts +++ b/packages/framework/src/system-prompt.test.ts @@ -168,11 +168,29 @@ test('vanilla (antiLazyPill false) wins over eco: no built-in prompt at all (#31 assert.equal(block, '') }) -test('composeRunSystem: the built-in block, then both emit protocols, then framing, in order (#501)', () => { - const system = composeRunSystem({ framing: ['PERSONA-A', 'SKILL-B'] }) - // The one assembly path both runFramework and runPrompt go through. - const expected = [renderSystemPrompt().system, AWAIT_PROTOCOL, SIGNAL_PROTOCOL, 'PERSONA-A', 'SKILL-B'].join('\n\n') - assert.equal(system, expected) +test('composeRunSystem is exactly the #326 block + both emit protocols, and nothing else (#547)', () => { + // The one assembly path both runFramework and runPrompt go through. Exact equality is + // the point: no persona, skill, or memory framing may ever be appended again. + const system = composeRunSystem() + assert.equal(system, [renderSystemPrompt().system, AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n')) +}) + +test('composeRunSystem appends nothing after the protocols, whatever the options (#547)', () => { + // Every supported option feeds the #326 block; none of them can add a trailing section. + const system = composeRunSystem({ + user: 'Ship small PRs.', + context: ['/work/api'], + bootstrap: true, + tf: { prompt: 'build a todo app', params: { autopilot: true } }, + }) + const block = systemPromptBlock({ + user: 'Ship small PRs.', + context: ['/work/api'], + bootstrap: true, + tf: { prompt: 'build a todo app', params: { autopilot: true } }, + }) + assert.equal(system, [block, AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n')) + assert.ok(system.endsWith(SIGNAL_PROTOCOL), 'the signal protocol is the last thing in the channel') }) test('composeRunSystem keeps the emit protocols even with the built-in prompt off (#500/#501)', () => { @@ -182,8 +200,3 @@ test('composeRunSystem keeps the emit protocols even with the built-in prompt of assert.ok(!system.includes('# System prompt'), 'built-in #326 prompt is off') assert.equal(system, [AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n')) }) - -test('composeRunSystem drops empty framing entries (e.g. an absent memory block) (#501)', () => { - const system = composeRunSystem({ antiLazyPill: false, framing: ['PERSONA-A', '', 'SKILL-B'] }) - assert.equal(system, [AWAIT_PROTOCOL, SIGNAL_PROTOCOL, 'PERSONA-A', 'SKILL-B'].join('\n\n')) -}) diff --git a/packages/framework/src/system-prompt.ts b/packages/framework/src/system-prompt.ts index 47099376..c9429f34 100644 --- a/packages/framework/src/system-prompt.ts +++ b/packages/framework/src/system-prompt.ts @@ -218,14 +218,8 @@ export function systemPromptBlock(opts: SystemPromptOptions = {}): string { return parts.join('\n\n') } -/** Inputs to {@link composeRunSystem}: a {@link systemPromptBlock} plus the run's own framing. */ -export interface RunSystemOptions extends SystemPromptOptions { - /** - * Extra framing appended after the emit protocols: the build run's persona / skill / - * memory blocks. The direct-prompt run has none. Empty entries are dropped. - */ - framing?: readonly string[] | undefined -} +/** Inputs to {@link composeRunSystem}. */ +export type RunSystemOptions = SystemPromptOptions /** * Assemble a run's full system channel — the single place it is composed (#501), so the @@ -235,18 +229,14 @@ export interface RunSystemOptions extends SystemPromptOptions { * inside the built-in-prompt branch. * * Order is fixed: the #326 prompt block (context / bootstrap / built-in prompt / user - * SYSTEM.md) first, then the always-on emit protocols, then any caller framing. The - * protocols are unconditional — they are the *emit contract* (how the agent signals an - * awaited choice and the setSessionName()/setReadyForMerge() lifecycle), not prompt - * content — so the agent needs them even with the built-in prompt off. + * SYSTEM.md) first, then the always-on emit protocols. Nothing else is appended — a + * build run's system channel is exactly this (#547), which is what lets the dashboard + * show the whole prompt before a run starts (#520). The protocols are unconditional — + * they are the *emit contract* (how the agent signals an awaited choice and the + * setSessionName()/setReadyForMerge() lifecycle), not prompt content — so the agent + * needs them even with the built-in prompt off. */ export function composeRunSystem(opts: RunSystemOptions = {}): string { - const { framing, ...blockOpts } = opts - const promptBlock = systemPromptBlock(blockOpts) - return [ - ...(promptBlock ? [promptBlock] : []), - AWAIT_PROTOCOL, - SIGNAL_PROTOCOL, - ...(framing ?? []).filter(Boolean), - ].join('\n\n') + const promptBlock = systemPromptBlock(opts) + return [...(promptBlock ? [promptBlock] : []), AWAIT_PROTOCOL, SIGNAL_PROTOCOL].join('\n\n') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2edae50..3430ddb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,34 +115,6 @@ importers: specifier: ^5.4.0 version: 5.9.3 - examples/framework-discovery-demo: - dependencies: - '@gemstack/ai-autopilot': - specifier: workspace:^ - version: link:../../packages/ai-autopilot - '@gemstack/framework': - specifier: workspace:^ - version: link:../../packages/framework - framework-hello: - specifier: workspace:^ - version: link:../framework-hello - devDependencies: - '@types/node': - specifier: ^20.0.0 - version: 20.19.43 - tsx: - specifier: ^4.19.0 - version: 4.22.4 - typescript: - specifier: ^5.4.0 - version: 5.9.3 - - examples/framework-hello: - dependencies: - '@gemstack/ai-autopilot': - specifier: workspace:^ - version: link:../../packages/ai-autopilot - examples/mcp-quickstart: dependencies: '@gemstack/mcp':