diff --git a/examples/bootstrap-quickstart/README.md b/examples/bootstrap-quickstart/README.md new file mode 100644 index 0000000..e92eefe --- /dev/null +++ b/examples/bootstrap-quickstart/README.md @@ -0,0 +1,41 @@ +# Bootstrap quickstart (capstone) + +The whole `@gemstack/ai-autopilot` AI-framework epic in one offline flow — from a +project's dependencies to a scaffolded, production-grade, deploy-decided app. + +``` +detect framework (preset) + → Bootstrap: scope → architect → build → full-fledged loop → deploy + → scale mode: CODE-OVERVIEW.md +``` + +## Run it + +```bash +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 deploy +decision, and the generated `CODE-OVERVIEW.md`. + +## 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. +- **Bootstrap (#116)** — one scoping question, then an **architect** picks the stack + and records its choices to the **decisions ledger**, a **build** scaffolds the app + with the persona workers inside a **runner**, the **full-fledged loop** repeats the + production-grade checklist until its `{ blockers }` verdict is empty, and a + **deploy** is decided behind the `DeployTarget` seam. +- **Surfaces (#100/#120)** — every phase streams as narration over the generic + `launchAutopilot` handle. +- **Scale mode (#114)** — `CODE-OVERVIEW.md` is generated from the scaffold. + +## Live verification (infra-gated) + +The framework pieces verify offline here with fakes. The honest "zero to a running +app" proof needs a live model + `LocalRunner` producing a real app end to end — +that half is infra-gated (#124). Swapping the two fakes for a real model and +`LocalRunner` is the only code change; the flow is identical. diff --git a/examples/bootstrap-quickstart/package.json b/examples/bootstrap-quickstart/package.json new file mode 100644 index 0000000..33c1f5d --- /dev/null +++ b/examples/bootstrap-quickstart/package.json @@ -0,0 +1,23 @@ +{ + "name": "@gemstack/example-bootstrap-quickstart", + "version": "0.0.0", + "private": true, + "description": "Runnable capstone for @gemstack/ai-autopilot: preset detection + Bootstrap (scope → architect → build → full-fledged loop → deploy) + scale mode, streamed over surfaces, offline via AiFake + FakeRunner.", + "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/ai-autopilot": "workspace:^", + "@gemstack/ai-sdk": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.4.0" + } +} diff --git a/examples/bootstrap-quickstart/src/bootstrap.test.ts b/examples/bootstrap-quickstart/src/bootstrap.test.ts new file mode 100644 index 0000000..688a578 --- /dev/null +++ b/examples/bootstrap-quickstart/src/bootstrap.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { runCapstone, INTENT } from './bootstrap.js' + +describe('bootstrap capstone: the whole epic composes end-to-end (offline)', () => { + it('detects the preset, runs scope → architect → build → loop → deploy, and maps the code', async () => { + const lines: string[] = [] + const { detection, result, events, files, overview } = await runCapstone(line => lines.push(line)) + + // Preset: the Vike framework was detected from the project deps. + assert.equal(detection.preset?.name, 'vike') + assert.equal(detection.framework, 'Vike') + + // Architect: chose the stack and recorded its choices to the ledger. + assert.match(result.plan.stack, /Vike \+ universal-orm/) + assert.equal(result.plan.decisions.length, 2) + + // Build: each preset persona 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) + assert.ok('package.json' in files) // the seed survived + assert.equal(result.run.results.length, 3) + assert.ok(result.run.results.every(r => r.ok)) + + // Full-fledged loop: blocked on pass 1, clean on pass 2 → production-grade. + assert.equal(result.passes, 2) + assert.deepEqual(result.blockers, []) + assert.equal(result.productionGrade, true) + + // Deploy: decided SSR + a target and reported the (fake) shipped URL. + assert.equal(result.deploy?.plan.render, 'ssr') + assert.equal(result.deploy?.plan.target, 'dockploy') + assert.equal(result.deploy?.result.url, 'https://orders.example.app') + + // Surface: the stream ran scope-first, done-last, and the terminal printed it. + assert.equal(events[0]?.type, 'scope') + assert.equal(events.at(-1)?.type, 'done') + assert.ok(lines.some(l => l.includes('scope:'))) + assert.ok(lines.some(l => l.includes('deploy:'))) + + // Scale mode: an overview was generated from the scaffold. + assert.match(overview?.summary ?? '', /orders app/) + assert.ok((overview?.sections.length ?? 0) >= 1) + }) + + it('exposes the intent constant for the runnable demo', () => { + assert.match(INTENT, /Orders page/) + }) +}) diff --git a/examples/bootstrap-quickstart/src/bootstrap.ts b/examples/bootstrap-quickstart/src/bootstrap.ts new file mode 100644 index 0000000..a7f7111 --- /dev/null +++ b/examples/bootstrap-quickstart/src/bootstrap.ts @@ -0,0 +1,239 @@ +import { AiFake, agent, type ToolCall } from '@gemstack/ai-sdk' +import { + Bootstrap, + agentArchitect, + supervisorBuild, + loopChecklist, + loopImprove, + agentDeploy, + FakeDeployTarget, + Loop, + definePrompt, + defineRule, + personaInstructions, + personaTools, + builtinPresetRegistry, + presetPersonas, + CodeOverviewMaintainer, + DecisionLedger, + FakeRunner, + runnerTools, + launchAutopilot, + type BootstrapEvent, + type BootstrapResult, + type CodeOverview, + type FrameworkDetection, + type Planner, + type RunnerSession, +} from '@gemstack/ai-autopilot' + +/** + * The capstone: the whole AI-framework epic in one offline flow. + * + * detect framework (preset) → Bootstrap + * scope → architect → 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, has an **architect** choose the stack and record its + * choices to the **decisions ledger**, **builds** the app with the persona 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. Every phase streams as narration over + * the generic **surface**. Finally **scale mode** generates `CODE-OVERVIEW.md` + * from the scaffold. + * + * It runs offline: `AiFake` scripts the model and `FakeRunner` is an in-memory + * sandbox, so there is no API key and the output is deterministic. Swapping the + * fakes for a real model + `LocalRunner` is the only change to run it for real — + * that live proof is the infra-gated half of this example (#124). + */ + +/** What the user wants built (the one thing scope asks about). */ +export const INTENT = 'A paginated Orders page backed by an orders table, with sign-in.' + +/** 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', '@universal-orm/core': '1.0.0' } + +/** Each build subtask, the persona that owns it, and the file it writes. */ +const WORK = [ + { + worker: 'universal-orm-modeler', + description: 'Define the orders schema and a migration', + file: 'database/schema.ts', + contents: "export const orders = table('orders', { id: id(), total: integer(), createdAt: timestamp() })\n", + }, + { + worker: 'vike-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', + 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", + }, +] as const + +/** The architect's structured decision (what `agentArchitect` parses). */ +const ARCHITECT_PLAN = { + stack: 'Vike + universal-orm on Postgres, with vike-auth', + narration: 'Server-rendered orders app: Vike pages, a universal-orm data layer, sessions via vike-auth.', + decisions: [ + { choice: 'universal-orm on Postgres', why: 'the orders catalog is relational and needs typed queries' }, + { choice: 'SSR over SPA', why: 'orders need per-request data and auth on the server' }, + ], +} + +/** The deploy decision (what `agentDeploy` parses). */ +const DEPLOY_DECISION = { render: 'ssr', target: 'dockploy', reason: 'per-request orders data + server-side auth' } + +/** + * Script the fake provider. Order (concurrency 1) is: the architect's plan, then + * each build worker's (write-file tool call, final text) pair, then the deploy + * decision. The full-fledged loop uses scripted local prompts, not the model. + */ +function scriptModel(fake: AiFake): void { + const workerSteps = WORK.flatMap((w, i) => { + const toolCalls: ToolCall[] = [ + { id: `write-${i}`, name: 'write_file', arguments: { path: w.file, contents: w.contents } }, + ] + return [{ toolCalls }, { text: `Wrote ${w.file}` }] + }) + fake.respondWithSequence([ + { text: JSON.stringify(ARCHITECT_PLAN) }, + ...workerSteps, + { text: JSON.stringify(DEPLOY_DECISION) }, + ]) +} + +/** 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) { + const sandbox = runnerTools(session) + return Object.fromEntries( + personas.map(p => [ + p.name, + agent({ instructions: personaInstructions(p), tools: [...personaTools(p), ...sandbox] }), + ]), + ) +} + +/** The full-fledged loop: the checklist blocks once, then clears after the fix. */ +function buildLoop(): Loop { + const verdicts = [ + '```json\n{ "blockers": ["No authentication on the orders page yet"] }\n```', + '```json\n{ "blockers": [] }\n```', + ] + let pass = 0 + return new Loop({ + rules: [ + defineRule({ on: 'production-check', run: ['production-grade'] }), + defineRule({ on: 'major-change', run: ['address-blockers'] }), + ], + prompts: [ + definePrompt({ id: 'production-grade', run: () => verdicts[Math.min(pass++, verdicts.length - 1)]! }), + definePrompt({ id: 'address-blockers', run: () => 'Added a +guard to the orders page (vike-auth).' }), + ], + }) +} + +/** Everything the capstone exposes, for the runnable demo and the smoke test. */ +export interface CapstoneResult { + detection: FrameworkDetection + result: BootstrapResult + events: BootstrapEvent[] + files: Record + overview: CodeOverview | undefined +} + +/** Render a bootstrap event as one human-readable narration line. */ +export function formatBootstrapEvent(event: BootstrapEvent): string { + switch (event.type) { + case 'scope': + return `▶ scope: ${event.scope} — "${event.intent}"` + case 'architect': + return `▶ architect: ${event.stack}\n${event.decisions.map(d => ` · ${d.choice} — ${d.why}`).join('\n')}` + case 'narrate': + return ` ${event.message}` + case 'build': + return ` build/${event.event.type}` + case 'checklist': + return event.passing + ? ` ✓ checklist pass ${event.pass}: production-grade` + : ` ✗ checklist pass ${event.pass}: ${event.blockers.join('; ')}` + case 'improve': + return ` → improving: ${event.blockers.join('; ')}` + case 'deploy': + return `▶ deploy: ${event.plan.render.toUpperCase()} → ${event.plan.target} (${event.plan.reason})` + case 'done': + return `✓ done: ${event.result.productionGrade ? 'production-grade' : 'prototype'} in ${event.result.passes} pass(es)` + } +} + +/** + * Run the whole capstone once and return everything the surfaces exposed. + * + * @param write where narration lines go (default: no-op; `main.ts` prints). + */ +export async function runCapstone(write: (line: string) => void = () => {}): Promise { + const fake = AiFake.fake() + scriptModel(fake) + try { + // 0. Preset: detect the framework from the project's deps, pick its personas. + const { preset, detection } = builtinPresetRegistry().select({ dependencies: PROJECT_DEPS }) + const personas = presetPersonas(preset) + + // Runner: an in-memory sandbox seeded with a minimal project. + const runner = new FakeRunner({ + onExec: cmd => (cmd.includes('build') ? { stdout: 'built', stderr: '', exitCode: 0 } : { stdout: '', stderr: '', exitCode: 0 }), + }) + const session = await runner.boot({ files: { 'package.json': JSON.stringify({ name: 'orders-app' }) + '\n' } }) + + const loop = buildLoop() + const ledger = new DecisionLedger() + const deployTarget = new FakeDeployTarget({ result: { deployed: true, url: 'https://orders.example.app' } }) + + // Surfaces: run bootstrap detached; the terminal prints as events stream. + const handle = launchAutopilot(onEvent => + new Bootstrap({ + ledger, + onEvent: e => { + write(formatBootstrapEvent(e)) + onEvent(e) + }, + steps: { + scope: () => ({ scope: 'full', intent: INTENT }), + architect: agentArchitect(agent({ instructions: 'architect' })), + build: supervisorBuild({ plan: staticPlanner, workers: presetWorkers(session, personas), concurrency: 1 }), + checklist: loopChecklist({ loop }), + improve: loopImprove({ loop }), + deploy: agentDeploy(agent({ instructions: 'deployer' }), { target: deployTarget }), + }, + }).run(), + ) + const result = await handle.result() + const files = session.snapshot() + + // Scale mode: generate CODE-OVERVIEW.md from the scaffold (a material change). + const maintainer = new CodeOverviewMaintainer({ + regenerate: () => ({ + summary: 'A server-rendered orders app on Vike + universal-orm.', + sections: [ + { title: 'Structure', body: '- `pages/orders/` — the paginated orders page\n- `database/` — the orders schema + migrations' }, + { title: 'Conventions', body: 'Data goes through the universal-orm model builder; pages stay thin.' }, + ], + }), + }) + await maintainer.handle({ kind: 'major-change', summary: 'scaffolded the app', paths: [...Object.keys(files), 'package.json'] }) + + return { detection, result, events: handle.events(), files, overview: maintainer.get() } + } finally { + fake.restore() + } +} diff --git a/examples/bootstrap-quickstart/src/main.ts b/examples/bootstrap-quickstart/src/main.ts new file mode 100644 index 0000000..f2ae679 --- /dev/null +++ b/examples/bootstrap-quickstart/src/main.ts @@ -0,0 +1,29 @@ +import { runCapstone, INTENT } from './bootstrap.js' + +/** Run the capstone and print what each phase and surface exposed. Offline (AiFake). */ +async function main(): Promise { + console.log(`Bootstrap: "${INTENT}"\n`) + console.log('--- live narration (surface stream) ---') + const { detection, result, files, overview } = await runCapstone(line => process.stdout.write(line + '\n')) + + console.log('\n--- preset ---') + console.log(` detected: ${detection.framework} (confidence ${detection.confidence})`) + + console.log('\n--- app scaffolded (runner sandbox) ---') + for (const path of Object.keys(files).sort()) console.log(` ${path}`) + + console.log('\n--- outcome ---') + console.log(` scope: ${result.scope}`) + console.log(` production-grade: ${result.productionGrade} (after ${result.passes} checklist pass(es))`) + console.log(` deploy: ${result.deploy?.plan.render.toUpperCase()} → ${result.deploy?.plan.target}, url ${result.deploy?.result.url}`) + console.log(` decisions recorded: ${result.plan.decisions.length}`) + + console.log('\n--- scale mode: CODE-OVERVIEW.md ---') + console.log(` ${overview?.summary}`) + for (const s of overview?.sections ?? []) console.log(` ## ${s.title}`) +} + +main().catch(err => { + console.error(err) + process.exitCode = 1 +}) diff --git a/examples/bootstrap-quickstart/tsconfig.json b/examples/bootstrap-quickstart/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/examples/bootstrap-quickstart/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/examples/bootstrap-quickstart/tsconfig.test.json b/examples/bootstrap-quickstart/tsconfig.test.json new file mode 100644 index 0000000..eebda2f --- /dev/null +++ b/examples/bootstrap-quickstart/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist-test", "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eb1ca6..2455d56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,28 @@ importers: specifier: ^5.4.0 version: 5.9.3 + examples/bootstrap-quickstart: + dependencies: + '@gemstack/ai-autopilot': + specifier: workspace:^ + version: link:../../packages/ai-autopilot + '@gemstack/ai-sdk': + specifier: workspace:^ + version: link:../../packages/ai-sdk + zod: + specifier: ^4.0.0 + version: 4.4.3 + 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/connectors-quickstart: dependencies: '@gemstack/connectors':