diff --git a/.changeset/stack-rationale-pros-cons.md b/.changeset/stack-rationale-pros-cons.md new file mode 100644 index 0000000..fe38f67 --- /dev/null +++ b/.changeset/stack-rationale-pros-cons.md @@ -0,0 +1,15 @@ +--- +'@gemstack/ai-autopilot': minor +'@gemstack/framework': minor +--- + +Architect stack rationale: PROS/CONS + alternatives considered + +The web dashboard's edge over the CLI is showing *why* the AI chose the stack. The architect step now returns that rationale, not just a one-line why: + +- `ArchitectPlan` gains optional `pros`, `cons`, and `alternatives` (`{option, whyNot}`). The `agentArchitect` (ai-sdk) and `driverArchitect` (framework) both ask for them and parse them; absent fields are omitted, so existing producers are unaffected. +- A new exported `STACK_TRADEOFFS` block gives the architect objective, reusable Vike-vs-Next reasons (edge/Cloudflare deploy, renderer-agnostic, ecosystem size) so the justification is grounded rather than invented per run. Both architect prompts embed it. +- The `Bootstrap` orchestrator emits `pros`/`cons`/`alternatives` on the `architect` event and records the rejected alternatives to the decisions ledger as rejections, so the ledger shows what was weighed. +- The framework dashboard's "Stack & rationale" panel renders the pros, cons, and "Considered instead" alternatives. The `--fake` demo populates them. + +Part of #209. Closes #210. diff --git a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts index 3c18ee6..3db2a1d 100644 --- a/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts +++ b/packages/ai-autopilot/src/bootstrap/bootstrap.test.ts @@ -55,6 +55,47 @@ describe('Bootstrap — happy path (full scope, passes first checklist)', () => assert.equal(ledger.all()[0]?.status, 'accepted') assert.match(ledger.all()[0]!.title, /Vike for SSR/) }) + + it('emits the stack rationale (pros/cons/alternatives) and records alternatives as ledger rejections', async () => { + const events: BootstrapEvent[] = [] + const ledger = new DecisionLedger() + const boot = new Bootstrap({ + steps: stubSteps({ + architect: () => ({ + stack: 'Vike + universal-orm', + narration: 'n', + decisions: [{ choice: 'Use Vike for SSR', why: 'SEO' }], + pros: ['edge deploy'], + cons: ['smaller ecosystem'], + alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], + }), + }), + ledger, + onEvent: e => events.push(e), + }) + await boot.run() + + const arch = events.find(e => e.type === 'architect') + assert.ok(arch?.type === 'architect') + assert.deepEqual(arch.pros, ['edge deploy']) + assert.deepEqual(arch.cons, ['smaller ecosystem']) + assert.deepEqual(arch.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) + + // The rejected alternative is in the ledger, honestly marked rejected. + const rejected = ledger.all().find(d => d.status === 'rejected') + assert.ok(rejected, 'expected a rejected alternative in the ledger') + assert.match(rejected!.title, /Next\.js/) + }) + + it('omits rationale fields on the architect event when the plan has none', async () => { + const events: BootstrapEvent[] = [] + const boot = new Bootstrap({ steps: stubSteps(), onEvent: e => events.push(e) }) + await boot.run() + const arch = events.find(e => e.type === 'architect') + assert.ok(arch?.type === 'architect') + assert.equal('pros' in arch, false) + assert.equal('alternatives' in arch, false) + }) }) describe('Bootstrap — full-fledged loop', () => { diff --git a/packages/ai-autopilot/src/bootstrap/bootstrap.ts b/packages/ai-autopilot/src/bootstrap/bootstrap.ts index 3704617..5fa3e00 100644 --- a/packages/ai-autopilot/src/bootstrap/bootstrap.ts +++ b/packages/ai-autopilot/src/bootstrap/bootstrap.ts @@ -79,7 +79,17 @@ export class Bootstrap { ...(this.signal ? { signal: this.signal } : {}), }) for (const d of plan.decisions) this.ledger.accept(d.choice, d.why, ['architecture']) - this.emit({ type: 'architect', stack: plan.stack, decisions: plan.decisions }) + // Record the rejected alternatives too, so the ledger shows what was weighed + // and not re-litigated (e.g. "Next.js — no first-class edge deploy"). + for (const a of plan.alternatives ?? []) this.ledger.reject(a.option, a.whyNot, ['architecture']) + this.emit({ + type: 'architect', + stack: plan.stack, + decisions: plan.decisions, + ...(plan.pros?.length ? { pros: plan.pros } : {}), + ...(plan.cons?.length ? { cons: plan.cons } : {}), + ...(plan.alternatives?.length ? { alternatives: plan.alternatives } : {}), + }) if (plan.narration) this.emit({ type: 'narrate', phase: 'architect', message: plan.narration }) // 3. Build — Supervisor over personas + runner; forward its events as narration. diff --git a/packages/ai-autopilot/src/bootstrap/index.ts b/packages/ai-autopilot/src/bootstrap/index.ts index 67116d5..0c91d4b 100644 --- a/packages/ai-autopilot/src/bootstrap/index.ts +++ b/packages/ai-autopilot/src/bootstrap/index.ts @@ -15,6 +15,7 @@ export { Bootstrap, createBootstrap, BootstrapAborted } from './bootstrap.js' export { agentArchitect, + STACK_TRADEOFFS, supervisorBuild, loopChecklist, loopImprove, @@ -45,6 +46,7 @@ export type { BootstrapPhase, ScopeAnswer, ArchitectDecision, + ArchitectAlternative, ArchitectPlan, RenderMode, DeployPlan, diff --git a/packages/ai-autopilot/src/bootstrap/steps.test.ts b/packages/ai-autopilot/src/bootstrap/steps.test.ts index a3533e0..d3cfef6 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.test.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.test.ts @@ -42,6 +42,49 @@ describe('agentArchitect (default step over an ai-sdk agent)', () => { // the rejected idea reached the model as a briefing so it will not re-pitch it const sent = JSON.stringify(fake.getCalls()[0]) assert.match(sent, /NoSQL document store/) + // the architect is asked to justify the stack, grounded in the objective tradeoffs + assert.match(sent, /PROS and its CONS/) + assert.match(sent, /renderer-agnostic/) + } finally { + fake.restore() + } + }) + + it('parses the stack rationale (pros/cons/alternatives) when the model returns it', async () => { + const fake = AiFake.fake() + try { + fake.respondWithSequence([ + { + text: JSON.stringify({ + stack: 'Vike + universal-orm', + narration: 'n', + decisions: [], + pros: ['edge deploy'], + cons: ['smaller ecosystem'], + alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }], + }), + }, + ]) + const step = agentArchitect(agent({ instructions: 'architect' })) + const plan = await step(architectCtx({ ledger: new DecisionLedger() })) + assert.deepEqual(plan.pros, ['edge deploy']) + assert.deepEqual(plan.cons, ['smaller ecosystem']) + assert.deepEqual(plan.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }]) + } finally { + fake.restore() + } + }) + + it('omits rationale fields when the model returns none (backward compatible)', async () => { + const fake = AiFake.fake() + try { + fake.respondWithSequence([ + { text: JSON.stringify({ stack: 'Vike', narration: 'n', decisions: [] }) }, + ]) + const step = agentArchitect(agent({ instructions: 'architect' })) + const plan = await step(architectCtx({ ledger: new DecisionLedger() })) + assert.equal('pros' in plan, false) + assert.equal('alternatives' in plan, false) } finally { fake.restore() } diff --git a/packages/ai-autopilot/src/bootstrap/steps.ts b/packages/ai-autopilot/src/bootstrap/steps.ts index e444fbe..f95f82d 100644 --- a/packages/ai-autopilot/src/bootstrap/steps.ts +++ b/packages/ai-autopilot/src/bootstrap/steps.ts @@ -23,13 +23,37 @@ export interface ArchitectAgentOptions { instructions?: string } +/** + * Objective, reusable stack tradeoffs the architect grounds its justification in, + * so the PROS/CONS it reports are real reasons rather than invented per run. Kept + * as one exported block so the ai-sdk architect and the driver architect + * (framework) share the same knowledge. Extend it as the default stack evolves. + */ +export const STACK_TRADEOFFS = `Ground the stack justification in these objective tradeoffs, do not invent reasons: +- Vike (Vite + SSR, renderer-agnostic): deploys anywhere including edge/serverless + (Cloudflare, Vercel, Node); works with React, Vue, or Solid; lighter and less + opinionated. Downsides: fewer batteries-included conventions and a smaller + ecosystem than Next. +- Next.js (App Router + React Server Components): largest ecosystem, batteries + included (image/font/routing), first-class Vercel deploy. Downsides: heavier, + React-only, a more opinionated server model, and more constrained edge/Cloudflare + support. +Prefer Vike as the default for edge, multi-renderer, or portability; prefer Next +when the team wants the largest ecosystem and Vercel-native features.` + const DEFAULT_ARCHITECT_INSTRUCTIONS = `You are the lead architect. Choose the stack and structure for the app the user describes and commit to it — act like a senior engineer who decides and explains, not one who asks permission. Default to the GemStack stack (Vike + Prisma) unless the intent clearly calls for something else. Only choose packages that are published and installable on npm. Narrate what you are building and why in a sentence or two, and list the key choices so they are recorded and -not re-litigated later.` +not re-litigated later. + +Justify the stack honestly: give its real PROS and its CONS (every stack has +tradeoffs), and name the main alternative you rejected and why it lost. This is +shown to the user as the rationale, so be concrete, not promotional. + +${STACK_TRADEOFFS}` /** * An architect step backed by an `ai-sdk` agent. It prompts the agent for a @@ -44,6 +68,12 @@ export function agentArchitect(architect: Agent, opts: ArchitectAgentOptions = { decisions: z .array(z.object({ choice: z.string(), why: z.string() })) .describe('Key architectural choices and their rationale'), + pros: z.array(z.string()).describe('Why the chosen stack fits — its real upsides').optional(), + cons: z.array(z.string()).describe('Honest downsides / tradeoffs of the chosen stack').optional(), + alternatives: z + .array(z.object({ option: z.string(), whyNot: z.string() })) + .describe('Stacks considered but rejected, and why each lost') + .optional(), }) const output = Output.object({ schema }) const instructions = opts.instructions ?? DEFAULT_ARCHITECT_INSTRUCTIONS @@ -53,7 +83,17 @@ export function agentArchitect(architect: Agent, opts: ArchitectAgentOptions = { const head = briefing ? `${briefing}\n\n${instructions}` : instructions const prompt = `${head}\n\n# What the user wants (${scope})\n${intent}\n\n${output.toSystemPrompt()}` const response = await architect.prompt(prompt) - return output.parse(response.text ?? '') + const parsed = output.parse(response.text ?? '') + // Omit the rationale fields when absent rather than setting them to + // `undefined` (exactOptionalPropertyTypes), so consumers see a clean plan. + return { + stack: parsed.stack, + narration: parsed.narration, + decisions: parsed.decisions, + ...(parsed.pros?.length ? { pros: parsed.pros } : {}), + ...(parsed.cons?.length ? { cons: parsed.cons } : {}), + ...(parsed.alternatives?.length ? { alternatives: parsed.alternatives } : {}), + } } } diff --git a/packages/ai-autopilot/src/bootstrap/types.ts b/packages/ai-autopilot/src/bootstrap/types.ts index a96ff5a..e533d09 100644 --- a/packages/ai-autopilot/src/bootstrap/types.ts +++ b/packages/ai-autopilot/src/bootstrap/types.ts @@ -43,6 +43,14 @@ export interface ArchitectDecision { why: string } +/** A stack option the architect considered but did not choose, and why. */ +export interface ArchitectAlternative { + /** The rejected option (e.g. "Next.js"). */ + option: string + /** Why it lost to the chosen stack, one line (e.g. "no first-class edge/Cloudflare deploy"). */ + whyNot: string +} + /** How the app is rendered/served, which drives the deploy shape. */ export type RenderMode = 'ssr' | 'ssg' | 'spa' @@ -100,6 +108,12 @@ export interface ArchitectPlan { narration: string /** Key choices to record to the decisions ledger so they are not re-litigated. */ decisions: readonly ArchitectDecision[] + /** Why the chosen stack fits — the upsides, for the rationale panel. */ + pros?: readonly string[] + /** Honest downsides of the chosen stack — the tradeoffs it accepts. */ + cons?: readonly string[] + /** Options considered and rejected, with why — recorded to the ledger as rejections. */ + alternatives?: readonly ArchitectAlternative[] } /** @@ -108,7 +122,14 @@ export interface ArchitectPlan { */ export type BootstrapEvent = | { type: 'scope'; scope: BootstrapScope; intent: string } - | { type: 'architect'; stack: string; decisions: readonly ArchitectDecision[] } + | { + type: 'architect' + stack: string + decisions: readonly ArchitectDecision[] + pros?: readonly string[] + cons?: readonly string[] + alternatives?: readonly ArchitectAlternative[] + } | { type: 'narrate'; phase: BootstrapPhase; message: string } | { type: 'build'; event: SupervisorEvent } | { type: 'checklist'; pass: number; blockers: readonly string[]; passing: boolean } diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 5c98c08..fde967d 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -226,6 +226,7 @@ export { createBootstrap, BootstrapAborted, agentArchitect, + STACK_TRADEOFFS, supervisorBuild, loopChecklist, loopImprove, @@ -254,6 +255,7 @@ export { type BootstrapPhase, type ScopeAnswer, type ArchitectDecision, + type ArchitectAlternative, type ArchitectPlan, type RenderMode, type DeployPlan, diff --git a/packages/framework/src/dashboard/page.ts b/packages/framework/src/dashboard/page.ts index 441363e..d72edc5 100644 --- a/packages/framework/src/dashboard/page.ts +++ b/packages/framework/src/dashboard/page.ts @@ -41,6 +41,14 @@ export function dashboardHtml(title: string): string { li:last-child { border-bottom: 0; } .choice { color: #e8ecf3; } .why { color: #8b93a3; font-size: 13px; } + #rationale { margin-top: 10px; } + #rationale .rat-group { margin-top: 8px; } + #rationale .rat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; + color: #7b8496; font-weight: 600; margin-bottom: 4px; } + #rationale .pro { color: #a9d6b6; font-size: 13px; padding: 2px 0; } + #rationale .con { color: #d8b48a; font-size: 13px; padding: 2px 0; } + #rationale .alt { color: #8b93a3; font-size: 13px; padding: 2px 0; } + #rationale .alt b { color: #b7c0d0; font-weight: 600; } .pass-ok { color: #67d98f; } .pass-bad { color: #f0a35e; } .blocker { color: #f0a35e; } @@ -71,6 +79,7 @@ export function dashboardHtml(title: string): string {