Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/stack-rationale-pros-cons.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions packages/ai-autopilot/src/bootstrap/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/ai-autopilot/src/bootstrap/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/bootstrap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
export { Bootstrap, createBootstrap, BootstrapAborted } from './bootstrap.js'
export {
agentArchitect,
STACK_TRADEOFFS,
supervisorBuild,
loopChecklist,
loopImprove,
Expand Down Expand Up @@ -45,6 +46,7 @@ export type {
BootstrapPhase,
ScopeAnswer,
ArchitectDecision,
ArchitectAlternative,
ArchitectPlan,
RenderMode,
DeployPlan,
Expand Down
43 changes: 43 additions & 0 deletions packages/ai-autopilot/src/bootstrap/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
44 changes: 42 additions & 2 deletions packages/ai-autopilot/src/bootstrap/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 } : {}),
}
}
}

Expand Down
23 changes: 22 additions & 1 deletion packages/ai-autopilot/src/bootstrap/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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[]
}

/**
Expand All @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export {
createBootstrap,
BootstrapAborted,
agentArchitect,
STACK_TRADEOFFS,
supervisorBuild,
loopChecklist,
loopImprove,
Expand Down Expand Up @@ -254,6 +255,7 @@ export {
type BootstrapPhase,
type ScopeAnswer,
type ArchitectDecision,
type ArchitectAlternative,
type ArchitectPlan,
type RenderMode,
type DeployPlan,
Expand Down
26 changes: 26 additions & 0 deletions packages/framework/src/dashboard/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -71,6 +79,7 @@ export function dashboardHtml(title: string): string {
<h2>Stack &amp; rationale</h2>
<div class="stack muted" id="stack">deciding…</div>
<ul id="decisions"></ul>
<div id="rationale"></div>
</section>
<section id="loop-panel">
<h2>Loop status</h2>
Expand Down Expand Up @@ -120,6 +129,7 @@ function bootstrap(e) {
li.innerHTML = '<div class="choice">' + esc(d.choice) + '</div><div class="why">' + esc(d.why) + '</div>';
ul.appendChild(li);
}
renderRationale(e);
break;
}
case 'checklist': {
Expand Down Expand Up @@ -160,6 +170,22 @@ function driver(e) {
else if (e.type === 'error') log(' ! ' + e.message);
else if (e.type === 'start') log('\\u203a prompt sent');
}
function renderRationale(e) {
const el = $('rationale'); el.innerHTML = '';
const group = (label, items, render) => {
if (!items || !items.length) return;
const wrap = document.createElement('div'); wrap.className = 'rat-group';
const h = document.createElement('div'); h.className = 'rat-label'; h.textContent = label;
wrap.appendChild(h);
for (const it of items) { const row = document.createElement('div'); render(row, it); wrap.appendChild(row); }
el.appendChild(wrap);
};
group('Why this stack', e.pros, (row, p) => { row.className = 'pro'; row.textContent = '\\u2713 ' + p; });
group('Tradeoffs', e.cons, (row, c) => { row.className = 'con'; row.textContent = '\\u26a0 ' + c; });
group('Considered instead', e.alternatives, (row, a) => {
row.className = 'alt'; row.innerHTML = '<b>' + esc(a.option) + '</b> \\u2014 ' + esc(a.whyNot);
});
}
function setSessionLink(sessionId, sessionLink) {
const el = $('session-link');
if (sessionLink) {
Expand Down
6 changes: 6 additions & 0 deletions packages/framework/src/fake-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ const ARCHITECT = {
{ choice: 'Prisma 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' },
],
pros: [
'Deploys to the edge (Cloudflare) for low-latency per-request orders data',
'Renderer-agnostic, so the UI is not locked to one framework',
],
cons: ['Smaller ecosystem than Next.js', 'Fewer batteries-included conventions, so more is wired by hand'],
alternatives: [{ option: 'Next.js', whyNot: 'more constrained Cloudflare/edge deploy for the per-request data path' }],
}

const TURNS: FakeTurn[] = [
Expand Down
33 changes: 33 additions & 0 deletions packages/framework/src/steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { join } from 'node:path'
import { DecisionLedger, type DeployTarget, type SupervisorEvent } from '@gemstack/ai-autopilot'
import { FakeDriver } from './driver/index.js'
import {
architectPrompt,
deployWith,
driverArchitect,
driverBuild,
Expand Down Expand Up @@ -41,6 +42,38 @@ test('parseArchitectPlan falls back safely on garbage', () => {
assert.deepEqual(plan.decisions, [])
})

test('parseArchitectPlan reads the stack rationale (pros/cons/alternatives)', () => {
const text =
'```json\n' +
JSON.stringify({
stack: 'Vike',
narration: 'n',
decisions: [{ choice: 'SSR', why: 'data' }],
pros: ['edge deploy', 'renderer-agnostic'],
cons: ['smaller ecosystem'],
alternatives: [{ option: 'Next.js', whyNot: 'constrained edge deploy' }],
}) +
'\n```'
const plan = parseArchitectPlan(text, 'an app')
assert.deepEqual(plan.pros, ['edge deploy', 'renderer-agnostic'])
assert.deepEqual(plan.cons, ['smaller ecosystem'])
assert.deepEqual(plan.alternatives, [{ option: 'Next.js', whyNot: 'constrained edge deploy' }])
})

test('parseArchitectPlan omits rationale fields when absent (backward compatible)', () => {
const plan = parseArchitectPlan('```json\n{"stack":"Vike","narration":"n","decisions":[]}\n```', 'an app')
assert.equal('pros' in plan, false)
assert.equal('cons' in plan, false)
assert.equal('alternatives' in plan, false)
})

test('architectPrompt asks for pros/cons + the rejected alternative, grounded in the tradeoffs', () => {
const p = architectPrompt('a blog')
assert.match(p, /PROS and its CONS/)
assert.match(p, /"alternatives"/)
assert.match(p, /renderer-agnostic/) // STACK_TRADEOFFS is embedded
})

test('driverArchitect returns the parsed plan from the driver turn', async () => {
const session = await new FakeDriver({
turns: [{ text: '```json\n{"stack":"Next.js","narration":"n","decisions":[]}\n```' }],
Expand Down
Loading
Loading