From c5d8a3db2511e018a2f7dc7108dc9d0394fdfd86 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 12 Jun 2026 06:03:01 -0500 Subject: [PATCH 1/4] feat(scaffold): reroute materializer calls to @stackbilt/scaffold-core Replaces direct `materializeScaffold` calls in gateway.ts and rest-scaffold.ts with `buildScaffold` from @stackbilt/scaffold-core. Because charter#220 (publish) is still open, a shim at scaffold-core-shim.ts bridges the gap: it wraps the existing scaffold-materializer.ts behind the LocalScaffoldResult API contract (files with { path, content, role }, plus nextSteps). Both call sites strip the `role` field when forwarding files to downstream consumers. When charter#220 merges: 1. `npm install` picks up @stackbilt/scaffold-core 2. Uncomment the direct import in gateway.ts and rest-scaffold.ts 3. Delete scaffold-core-shim.ts and scaffold-materializer.ts TODO comments reference charter#220 in both changed files. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + src/gateway.ts | 47 +++++++++++++++++--------------- src/rest-scaffold.ts | 16 ++++++----- src/scaffold-core-shim.ts | 56 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 src/scaffold-core-shim.ts diff --git a/package.json b/package.json index 279a169..d7506d5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.27.1", + "@stackbilt/scaffold-core": "^1.0.0", "agents": "^0.7.2", "hono": "^4.12.8", "zod": "^3.25.0" diff --git a/src/gateway.ts b/src/gateway.ts index 33eaee2..5f1a82c 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -9,7 +9,9 @@ import { OAUTH_PROVIDER_CONFIG } from './oauth-config.js'; import { resolveRoute, getToolRiskLevel, ROUTE_TABLE, type BackendRoute } from './route-table.js'; import { toBackendToolName, buildAggregatedCatalog, validateToolArguments, isInternalTool } from './tool-registry.js'; import { type AuditArtifact, generateTraceId, summarizeInput, emitAudit, queueAuditEvent } from './audit.js'; -import { materializeScaffold } from './scaffold-materializer.js'; +// TODO(charter#220): switch to direct import once @stackbilt/scaffold-core is published to npm +// import { buildScaffold } from '@stackbilt/scaffold-core'; +import { buildScaffold } from './scaffold-core-shim.js'; import { publishToGitHub } from './scaffold-publish.js'; import { classifyIntention, type IntentClassification } from './intent-classifier.js'; import { logDivergence } from './divergence-logger.js'; @@ -487,28 +489,31 @@ async function proxyRestToolCall( } } - // Merge governance files from materializer with engine code files, - // or fall back to materializer entirely if engine didn't produce files - if (result.facts) { - try { - const materialized = materializeScaffold(result.facts, intention); - if (!files) { - // Engine didn't produce files — use materializer output entirely - files = materialized.files; - nextSteps = materialized.nextSteps; - fileSource = 'basic'; - } else { - // Engine produced code files — merge in governance (.ai/) files from materializer - const enginePaths = new Set(files.map(f => f.path)); - const govFiles = materialized.files.filter(f => f.path.startsWith('.ai/') && !enginePaths.has(f.path)); - if (govFiles.length > 0) { - files = [...files, ...govFiles]; - } - nextSteps = materialized.nextSteps; + // Merge governance files from scaffold-core with engine code files, + // or fall back to scaffold-core entirely if engine didn't produce files. + // buildScaffold returns LocalScaffoldResult: { classification, knowledge, governance, files, facts } + // files is ScaffoldFile[] with { path, content, role } — strip role for downstream consumers. + try { + const coreResult = await buildScaffold(intention, { projectName: (result.facts as Record | undefined)?.project_name }); + // Normalise: drop role field so the shape matches what callers expect ({ path, content }) + const coreFiles = coreResult.files.map(({ path, content }: { path: string; content: string; role?: string }) => ({ path, content })); + const coreNextSteps: string[] | undefined = coreResult.nextSteps; + if (!files) { + // Engine didn't produce files — use scaffold-core output entirely + files = coreFiles; + nextSteps = coreNextSteps; + fileSource = 'basic'; + } else { + // Engine produced code files — merge in governance (.ai/) files from scaffold-core + const enginePaths = new Set(files.map(f => f.path)); + const govFiles = coreFiles.filter((f: { path: string }) => f.path.startsWith('.ai/') && !enginePaths.has(f.path)); + if (govFiles.length > 0) { + files = [...files, ...govFiles]; } - } catch { - // Materializer failure is non-fatal — return facts without files + nextSteps = coreNextSteps; } + } catch { + // scaffold-core failure is non-fatal — return facts without files } // Default next steps if engine produced files but no steps diff --git a/src/rest-scaffold.ts b/src/rest-scaffold.ts index 5a32d72..d0b5ac6 100644 --- a/src/rest-scaffold.ts +++ b/src/rest-scaffold.ts @@ -5,7 +5,9 @@ import type { GatewayEnv } from './types.js'; import { extractBearerToken, validateBearerToken } from './auth.js'; -import { materializeScaffold } from './scaffold-materializer.js'; +// TODO(charter#220): switch to direct import once @stackbilt/scaffold-core is published to npm +// import { buildScaffold } from '@stackbilt/scaffold-core'; +import { buildScaffold } from './scaffold-core-shim.js'; import { checkRateLimit, rateLimitHeaders } from './rate-limiter.js'; import { reserveQuota, settleQuota } from './cost-attribution.js'; import { generateTraceId, summarizeInput, emitAudit, queueAuditEvent, type AuditArtifact } from './audit.js'; @@ -245,12 +247,14 @@ export async function handleRestScaffold( } } - // Fall back to basic materializer if engine didn't produce files - if (!files && result.facts) { + // Fall back to scaffold-core if engine didn't produce files. + // buildScaffold returns LocalScaffoldResult: { classification, knowledge, governance, files, facts } + // files is ScaffoldFile[] with { path, content, role } — strip role for downstream consumers. + if (!files) { try { - const materialized = materializeScaffold(result.facts, intention); - files = materialized.files; - nextSteps = materialized.nextSteps; + const coreResult = await buildScaffold(intention, { projectName: (result.facts as Record | undefined)?.project_name }); + files = coreResult.files.map(({ path, content }: { path: string; content: string; role?: string }) => ({ path, content })); + nextSteps = coreResult.nextSteps; fileSource = 'basic'; } catch { // Non-fatal diff --git a/src/scaffold-core-shim.ts b/src/scaffold-core-shim.ts new file mode 100644 index 0000000..618b668 --- /dev/null +++ b/src/scaffold-core-shim.ts @@ -0,0 +1,56 @@ +// ─── Scaffold Core Shim ────────────────────────────────────── +// Temporary adapter that satisfies the @stackbilt/scaffold-core +// buildScaffold API contract using the local materializer. +// +// TODO(charter#220): delete this file once @stackbilt/scaffold-core is +// published and the direct import is uncommented in gateway.ts and +// rest-scaffold.ts. +// +// @stackbilt/scaffold-core buildScaffold signature (target API): +// buildScaffold(description: string, options?: { projectName?: string }) +// → Promise +// +// LocalScaffoldResult shape: +// { classification, knowledge, governance, files: ScaffoldFile[], facts } +// +// ScaffoldFile shape (from scaffold-core): +// { path: string; content: string; role: string } +// +// Both gateway.ts and rest-scaffold.ts strip `role` when forwarding +// files to consumers, so the extra field is harmless. + +import { materializeScaffold } from './scaffold-materializer.js'; + +export interface ShimScaffoldFile { + path: string; + content: string; + role: string; +} + +export interface ShimScaffoldResult { + classification?: string; + knowledge?: unknown; + governance?: unknown; + files: ShimScaffoldFile[]; + facts?: Record; + nextSteps?: string[]; +} + +export async function buildScaffold( + description: string, + options?: { projectName?: string }, +): Promise { + // materializeScaffold is synchronous and takes (facts, intention). + // We have no facts at call time in the shim path, so pass an empty + // record — the materializer derives project name from intention anyway. + const facts: Record = options?.projectName + ? { project_name: options.projectName } + : {}; + + const { files, nextSteps } = materializeScaffold(facts, description); + + return { + files: files.map(f => ({ ...f, role: 'generated' })), + nextSteps, + }; +} From 02237a0868656d7baa65a1169d8a4dbe24f5579f Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 12 Jun 2026 06:41:17 -0500 Subject: [PATCH 2/4] feat(charter#220): swap scaffold-core shim for published @stackbilt/scaffold-core@1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the local scaffold-core-shim.ts and scaffold-materializer.ts; gateway.ts and rest-scaffold.ts now import directly from the published npm package. Removed the non-existent `nextSteps` field references (LocalScaffoldResult has no such field — fallback generation handles next steps downstream). Typecheck clean, 183/183 tests green. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 10 + src/gateway.ts | 7 +- src/rest-scaffold.ts | 5 +- src/scaffold-core-shim.ts | 56 ---- src/scaffold-materializer.ts | 606 ----------------------------------- 5 files changed, 12 insertions(+), 672 deletions(-) delete mode 100644 src/scaffold-core-shim.ts delete mode 100644 src/scaffold-materializer.ts diff --git a/package-lock.json b/package-lock.json index a1e34d5..c6e2511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.27.1", + "@stackbilt/scaffold-core": "^1.0.0", "agents": "^0.7.2", "hono": "^4.12.8", "zod": "^3.25.0" @@ -1567,6 +1568,15 @@ "win32" ] }, + "node_modules/@stackbilt/scaffold-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@stackbilt/scaffold-core/-/scaffold-core-1.0.0.tgz", + "integrity": "sha512-HkOAGh30goeVtVXDT2WmKdaxsE3MshPw4/Xc2Ok0CeIg9L0xyZG+n7mYL44JkFdTZrBjxT8WscWVokTpmUxkYg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/src/gateway.ts b/src/gateway.ts index 5f1a82c..05c2a1d 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -9,9 +9,7 @@ import { OAUTH_PROVIDER_CONFIG } from './oauth-config.js'; import { resolveRoute, getToolRiskLevel, ROUTE_TABLE, type BackendRoute } from './route-table.js'; import { toBackendToolName, buildAggregatedCatalog, validateToolArguments, isInternalTool } from './tool-registry.js'; import { type AuditArtifact, generateTraceId, summarizeInput, emitAudit, queueAuditEvent } from './audit.js'; -// TODO(charter#220): switch to direct import once @stackbilt/scaffold-core is published to npm -// import { buildScaffold } from '@stackbilt/scaffold-core'; -import { buildScaffold } from './scaffold-core-shim.js'; +import { buildScaffold } from '@stackbilt/scaffold-core'; import { publishToGitHub } from './scaffold-publish.js'; import { classifyIntention, type IntentClassification } from './intent-classifier.js'; import { logDivergence } from './divergence-logger.js'; @@ -497,11 +495,9 @@ async function proxyRestToolCall( const coreResult = await buildScaffold(intention, { projectName: (result.facts as Record | undefined)?.project_name }); // Normalise: drop role field so the shape matches what callers expect ({ path, content }) const coreFiles = coreResult.files.map(({ path, content }: { path: string; content: string; role?: string }) => ({ path, content })); - const coreNextSteps: string[] | undefined = coreResult.nextSteps; if (!files) { // Engine didn't produce files — use scaffold-core output entirely files = coreFiles; - nextSteps = coreNextSteps; fileSource = 'basic'; } else { // Engine produced code files — merge in governance (.ai/) files from scaffold-core @@ -510,7 +506,6 @@ async function proxyRestToolCall( if (govFiles.length > 0) { files = [...files, ...govFiles]; } - nextSteps = coreNextSteps; } } catch { // scaffold-core failure is non-fatal — return facts without files diff --git a/src/rest-scaffold.ts b/src/rest-scaffold.ts index d0b5ac6..4e30af9 100644 --- a/src/rest-scaffold.ts +++ b/src/rest-scaffold.ts @@ -5,9 +5,7 @@ import type { GatewayEnv } from './types.js'; import { extractBearerToken, validateBearerToken } from './auth.js'; -// TODO(charter#220): switch to direct import once @stackbilt/scaffold-core is published to npm -// import { buildScaffold } from '@stackbilt/scaffold-core'; -import { buildScaffold } from './scaffold-core-shim.js'; +import { buildScaffold } from '@stackbilt/scaffold-core'; import { checkRateLimit, rateLimitHeaders } from './rate-limiter.js'; import { reserveQuota, settleQuota } from './cost-attribution.js'; import { generateTraceId, summarizeInput, emitAudit, queueAuditEvent, type AuditArtifact } from './audit.js'; @@ -254,7 +252,6 @@ export async function handleRestScaffold( try { const coreResult = await buildScaffold(intention, { projectName: (result.facts as Record | undefined)?.project_name }); files = coreResult.files.map(({ path, content }: { path: string; content: string; role?: string }) => ({ path, content })); - nextSteps = coreResult.nextSteps; fileSource = 'basic'; } catch { // Non-fatal diff --git a/src/scaffold-core-shim.ts b/src/scaffold-core-shim.ts deleted file mode 100644 index 618b668..0000000 --- a/src/scaffold-core-shim.ts +++ /dev/null @@ -1,56 +0,0 @@ -// ─── Scaffold Core Shim ────────────────────────────────────── -// Temporary adapter that satisfies the @stackbilt/scaffold-core -// buildScaffold API contract using the local materializer. -// -// TODO(charter#220): delete this file once @stackbilt/scaffold-core is -// published and the direct import is uncommented in gateway.ts and -// rest-scaffold.ts. -// -// @stackbilt/scaffold-core buildScaffold signature (target API): -// buildScaffold(description: string, options?: { projectName?: string }) -// → Promise -// -// LocalScaffoldResult shape: -// { classification, knowledge, governance, files: ScaffoldFile[], facts } -// -// ScaffoldFile shape (from scaffold-core): -// { path: string; content: string; role: string } -// -// Both gateway.ts and rest-scaffold.ts strip `role` when forwarding -// files to consumers, so the extra field is harmless. - -import { materializeScaffold } from './scaffold-materializer.js'; - -export interface ShimScaffoldFile { - path: string; - content: string; - role: string; -} - -export interface ShimScaffoldResult { - classification?: string; - knowledge?: unknown; - governance?: unknown; - files: ShimScaffoldFile[]; - facts?: Record; - nextSteps?: string[]; -} - -export async function buildScaffold( - description: string, - options?: { projectName?: string }, -): Promise { - // materializeScaffold is synchronous and takes (facts, intention). - // We have no facts at call time in the shim path, so pass an empty - // record — the materializer derives project name from intention anyway. - const facts: Record = options?.projectName - ? { project_name: options.projectName } - : {}; - - const { files, nextSteps } = materializeScaffold(facts, description); - - return { - files: files.map(f => ({ ...f, role: 'generated' })), - nextSteps, - }; -} diff --git a/src/scaffold-materializer.ts b/src/scaffold-materializer.ts deleted file mode 100644 index 63aa2f3..0000000 --- a/src/scaffold-materializer.ts +++ /dev/null @@ -1,606 +0,0 @@ -// ─── Scaffold Materializer ───────────────────────────────────── -// Transforms TarotScript scaffold-cast facts into downloadable -// project files. Zero LLM calls — deterministic from facts. -// -// Input: facts Record from scaffold-cast (~40 key-value pairs) -// Output: files[] array + nextSteps[] -// -// Card-to-file mapping: -// requirement → .ai/core.adf (product requirements section) -// interface → .ai/core.adf (UX section), src/index.ts (route stubs) -// threat → .ai/core.adf (security section) -// runtime → wrangler.toml, package.json -// test_plan → test/index.test.ts -// first_task → .ai/state.adf (sprint backlog) -// aggregates → .ai/manifest.adf, README.md - -export interface ScaffoldFile { - path: string; - content: string; -} - -export interface MaterializerResult { - files: ScaffoldFile[]; - nextSteps: string[]; -} - -type Facts = Record; - -// ─── Helpers ────────────────────────────────────────────────── - -function str(facts: Facts, key: string, fallback = ''): string { - const v = facts[key]; - if (typeof v === 'string') return v; - if (Array.isArray(v)) return v.join(', '); - return fallback; -} - -function slugify(name: string): string { - return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); -} - -function traitsInclude(facts: Facts, ...terms: string[]): boolean { - const v = facts.runtime_traits; - const joined = Array.isArray(v) ? v.join(' ') : typeof v === 'string' ? v : ''; - return terms.some(t => joined.includes(t)); -} - -function deriveProjectName(_facts: Facts, intention: string): string { - const stripped = intention.replace(/^(a|an|the|build|create|make)\s+/i, ''); - return slugify(stripped.split(/\s+/).slice(0, 3).join(' ')); -} - -// ─── Intent Detection ──────────────────────────────────────── -// Augments scaffold output based on keywords in the user's description. - -interface DomainHint { - id: string; - match: (intention: string) => boolean; - deps?: Record; - devDeps?: Record; - bindings?: string[]; - scripts?: Record; - envInterface?: string[]; - indexImports?: string[]; - indexBody?: string; - extraFiles?: ScaffoldFile[]; -} - -const DOMAIN_HINTS: DomainHint[] = [ - { - id: 'mcp-server', - match: (i) => /\bmcp\b/i.test(i) && /\bserver\b/i.test(i), - deps: { '@modelcontextprotocol/sdk': '^1.0.0' }, - envInterface: ['// MCP server bindings'], - indexBody: ` - // MCP SSE endpoint - if (url.pathname === '/sse' || url.pathname === '/mcp') { - // TODO: wire MCP server handler - // See: https://modelcontextprotocol.io/docs/server - return new Response('MCP SSE endpoint — wire server handler', { - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, - }); - } - - // MCP tool listing - if (url.pathname === '/tools') { - return Response.json({ - tools: [ - // TODO: define your MCP tools - { name: 'example_tool', description: 'An example tool', inputSchema: { type: 'object', properties: {} } }, - ], - }); - }`, - }, - { - id: 'chatroom', - match: (i) => /\bchat\s*room\b/i.test(i) || (/\bchat\b/i.test(i) && /\broom\b/i.test(i)) || /\brealtime\b/i.test(i), - deps: {}, - bindings: [ - `\n[[durable_objects.bindings]]\nname = "CHATROOM"\nclass_name = "ChatRoom"`, - `\n[[migrations]]\ntag = "v1"\nnew_classes = ["ChatRoom"]`, - ], - envInterface: ['CHATROOM: DurableObjectNamespace;'], - indexBody: ` - // WebSocket upgrade for chat - if (url.pathname.startsWith('/room/')) { - const roomId = url.pathname.split('/')[2] ?? 'default'; - const id = env.CHATROOM.idFromName(roomId); - const stub = env.CHATROOM.get(id); - return stub.fetch(request); - }`, - extraFiles: [{ - path: 'src/chatroom.ts', - content: `// Durable Object: ChatRoom -// Each room is a persistent, named instance with WebSocket sessions. - -export class ChatRoom implements DurableObject { - private sessions: Set = new Set(); - - constructor(private state: DurableObjectState, private env: Env) {} - - async fetch(request: Request): Promise { - const url = new URL(request.url); - - if (url.pathname.endsWith('/websocket')) { - const [client, server] = Object.values(new WebSocketPair()); - this.state.acceptWebSocket(server); - this.sessions.add(server); - - server.addEventListener('message', (event) => { - // Broadcast to all connected clients - for (const ws of this.sessions) { - if (ws !== server && ws.readyState === WebSocket.READY_STATE_OPEN) { - ws.send(typeof event.data === 'string' ? event.data : ''); - } - } - }); - - server.addEventListener('close', () => { - this.sessions.delete(server); - }); - - return new Response(null, { status: 101, webSocket: client }); - } - - // Room info - return Response.json({ - room: url.pathname.split('/').pop(), - connections: this.sessions.size, - }); - } - - webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void { - for (const session of this.sessions) { - if (session !== ws && session.readyState === WebSocket.READY_STATE_OPEN) { - session.send(typeof message === 'string' ? message : ''); - } - } - } - - webSocketClose(ws: WebSocket): void { - this.sessions.delete(ws); - } -} - -interface Env {} -`, - }], - }, - { - id: 'api', - match: (i) => /\bapi\b/i.test(i) || /\brest\b/i.test(i) || /\bendpoint/i.test(i), - deps: { 'hono': '^4.0.0' }, - indexImports: ["import { Hono } from 'hono';"], - indexBody: ` - // API routes (Hono) - // const app = new Hono<{ Bindings: Env }>(); - // app.get('/api/v1/items', (c) => c.json({ items: [] })); - // return app.fetch(request, env, ctx);`, - }, - { - id: 'cron', - match: (i) => /\bcron\b/i.test(i) || /\bschedul/i.test(i) || /\bperiodic/i.test(i), - scripts: {}, - indexBody: ` - // Scheduled handler (cron trigger) - // Configure in wrangler.toml: [triggers] crons = ["*/5 * * * *"]`, - }, - { - id: 'auth', - match: (i) => /\bauth\b/i.test(i) || /\blogin\b/i.test(i) || /\bjwt\b/i.test(i), - indexBody: ` - // Auth middleware - if (url.pathname.startsWith('/api/') && url.pathname !== '/api/health') { - const authHeader = request.headers.get('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - return Response.json({ error: 'unauthorized' }, { status: 401 }); - } - // TODO: validate JWT or API key - }`, - }, -]; - -function detectDomainHints(intention: string): DomainHint[] { - return DOMAIN_HINTS.filter(h => h.match(intention)); -} - -// ─── Template Renderers ─────────────────────────────────────── - -function renderManifestAdf(facts: Facts, projectName: string): string { - const confidence = str(facts, 'scaffold_confidence', 'moderate'); - const rawBalance = facts.elemental_balance; - const balance = typeof rawBalance === 'object' && rawBalance !== null - ? Object.entries(rawBalance as Record).filter(([, v]) => v > 0).map(([k, v]) => `${k}:${v}`).join(' ') || 'neutral' - : str(facts, 'elemental_balance', 'unknown'); - const shadowDensity = facts.shadow_density ?? 'unknown'; - - return `# ${projectName} — ADF Manifest -# Generated by Stackbilt scaffold engine -# Confidence: ${confidence} | Shadow density: ${shadowDensity} - -version: "0.1" -project: "${projectName}" - -## Modules - -- core.adf # Product requirements, UX, security -- state.adf # Sprint backlog and current task - -## On-Demand Triggers - -| Domain | Trigger Keywords | -|------------|--------------------------------------| -| product | ${str(facts, 'requirement_name')}, requirements, features | -| ux | ${str(facts, 'interface_name')}, layout, components | -| security | ${str(facts, 'threat_name')}, threat, mitigation | -| runtime | ${str(facts, 'runtime_name')}, deploy, worker | -| testing | ${str(facts, 'test_plan_name')}, test, coverage | -| sprint | ${str(facts, 'first_task_name')}, task, estimate | - -## Metrics - -| Metric | Value | -|---------------------|---------------| -| position_count | ${facts.position_count ?? 6} | -| shadow_density | ${shadowDensity} | -| elemental_balance | ${balance} | -| scaffold_confidence | ${confidence} | -`; -} - -function renderCoreAdf(facts: Facts, projectName: string): string { - const reqName = str(facts, 'requirement_name'); - const reqPriority = str(facts, 'requirement_priority', 'P1'); - const reqEffort = str(facts, 'requirement_effort', 'medium'); - const reqAcceptance = str(facts, 'requirement_acceptance'); - - const ifaceName = str(facts, 'interface_name'); - const ifaceRegions = str(facts, 'interface_regions'); - const ifaceGrid = str(facts, 'interface_grid'); - const ifaceComponents = str(facts, 'interface_components'); - - const threatName = str(facts, 'threat_name'); - const threatLikelihood = str(facts, 'threat_likelihood'); - const threatImpact = str(facts, 'threat_impact'); - const threatMitigation = str(facts, 'threat_mitigation'); - - return `# ${projectName} — Core -# Product requirements, UX patterns, and security constraints - -## Product Requirements - -### ${reqName} -- **Priority**: ${reqPriority} -- **Effort**: ${reqEffort} -- **Acceptance criteria**: ${reqAcceptance} - -## UX Pattern - -### ${ifaceName} -- **Regions**: ${ifaceRegions} -- **Grid**: ${ifaceGrid} -- **Components**: ${ifaceComponents} - -## Security - -### ${threatName} -- **Likelihood**: ${threatLikelihood} -- **Impact**: ${threatImpact} -- **Mitigation**: ${threatMitigation} -`; -} - -function renderStateAdf(facts: Facts, projectName: string): string { - const taskName = str(facts, 'first_task_name'); - const taskEstimate = str(facts, 'first_task_estimate'); - const taskComplexity = str(facts, 'first_task_complexity'); - const taskDeliverable = str(facts, 'first_task_deliverable'); - - return `# ${projectName} — State -# Current sprint backlog - -## Current Sprint - -### ${taskName} -- **Estimate**: ${taskEstimate} points -- **Complexity**: ${taskComplexity} -- **Deliverable**: ${taskDeliverable} -- **Status**: not_started - -## Velocity - -| Sprint | Points Planned | Points Done | -|--------|---------------|-------------| -| 1 | ${taskEstimate} | — | -`; -} - -function renderPackageJson(facts: Facts, projectName: string, hints: DomainHint[] = []): string { - const runtimeName = str(facts, 'runtime_name'); - const testFramework = str(facts, 'test_plan_framework', 'vitest'); - - // Infer if this is a Workers project from runtime card traits/name - const isWorkers = traitsInclude(facts, 'edge', 'v8-isolate', 'serverless', 'isolat') || - runtimeName.toLowerCase().includes('worker'); - - const deps: Record = {}; - const devDeps: Record = { - typescript: '^5.5.0', - }; - - if (isWorkers) { - devDeps['wrangler'] = '^3.0.0'; - devDeps['@cloudflare/workers-types'] = '^4.0.0'; - } - - // Test framework - if (testFramework.includes('vitest') || testFramework === 'vitest') { - devDeps['vitest'] = '^2.0.0'; - } else if (testFramework.includes('jest')) { - devDeps['jest'] = '^29.0.0'; - devDeps['ts-jest'] = '^29.0.0'; - } - - const scripts: Record = { - build: 'tsc', - typecheck: 'tsc --noEmit', - }; - - if (isWorkers) { - scripts.dev = 'wrangler dev'; - scripts.deploy = 'wrangler deploy'; - } - - if (testFramework.includes('vitest')) { - scripts.test = 'vitest run'; - } else { - scripts.test = 'jest'; - } - - // Merge domain-specific dependencies - for (const hint of hints) { - if (hint.deps) Object.assign(deps, hint.deps); - if (hint.devDeps) Object.assign(devDeps, hint.devDeps); - if (hint.scripts) Object.assign(scripts, hint.scripts); - } - - return JSON.stringify({ - name: projectName, - version: '0.1.0', - private: true, - scripts, - dependencies: Object.keys(deps).length > 0 ? deps : undefined, - devDependencies: devDeps, - }, null, 2); -} - -function renderWranglerToml(facts: Facts, projectName: string, hints: DomainHint[] = []): string { - // Infer bindings from runtime card traits - const bindings: string[] = []; - - if (traitsInclude(facts, 'd1', 'sql', 'database')) { - bindings.push(` -[[d1_databases]] -binding = "DB" -database_name = "${projectName}" -database_id = "" # TODO: create with wrangler d1 create ${projectName}`); - } - - if (traitsInclude(facts, 'kv', 'key-value', 'cache')) { - bindings.push(` -[[kv_namespaces]] -binding = "KV" -id = "" # TODO: create with wrangler kv namespace create ${projectName}`); - } - - if (traitsInclude(facts, 'queue', 'async', 'background')) { - bindings.push(` -[[queues.producers]] -queue = "${projectName}-tasks" -binding = "QUEUE"`); - } - - // Add domain-specific bindings - for (const hint of hints) { - if (hint.bindings) bindings.push(...hint.bindings); - } - - return `name = "${projectName}" -main = "src/index.ts" -compatibility_date = "${new Date().toISOString().split('T')[0]}" -compatibility_flags = ["nodejs_compat"] -${bindings.join('\n')} -`; -} - -function renderIndexTs(facts: Facts, hints: DomainHint[] = []): string { - const ifaceName = str(facts, 'interface_name'); - const reqName = str(facts, 'requirement_name'); - const threatMitigation = str(facts, 'threat_mitigation'); - - // Collect domain-specific imports and route bodies - const imports: string[] = []; - const routeBodies: string[] = []; - const envFields: string[] = []; - - for (const hint of hints) { - if (hint.indexImports) imports.push(...hint.indexImports); - if (hint.indexBody) routeBodies.push(hint.indexBody); - if (hint.envInterface) envFields.push(...hint.envInterface); - } - - const importsBlock = imports.length > 0 ? imports.join('\n') + '\n\n' : ''; - const routesBlock = routeBodies.length > 0 ? routeBodies.join('\n') + '\n' : ''; - const envBlock = envFields.length > 0 ? '\n ' + envFields.join('\n ') : '\n // TODO: add bindings from wrangler.toml'; - - // Check if chatroom hint is present — export the DO class - const hasChatroom = hints.some(h => h.id === 'chatroom'); - const reExports = hasChatroom ? "\nexport { ChatRoom } from './chatroom';\n" : ''; - - return `${importsBlock}// ${reqName} — main entry point -// UX pattern: ${ifaceName} -// Security: ${threatMitigation || 'standard hardening'} - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - const url = new URL(request.url); - - if (url.pathname === '/health') { - return Response.json({ status: 'ok', timestamp: new Date().toISOString() }); - } -${routesBlock} - // TODO: implement ${reqName} handler - return Response.json({ error: 'not implemented' }, { status: 501 }); - }, -} satisfies ExportedHandler; - -interface Env {${envBlock} -} -${reExports}`; -} - -function renderTestFile(facts: Facts, projectName: string): string { - const testFramework = str(facts, 'test_plan_framework', 'vitest'); - const coverageTarget = str(facts, 'test_plan_coverage_target', '80%'); - const ciStage = str(facts, 'test_plan_ci_stage', 'pre-merge'); - const reqName = str(facts, 'requirement_name'); - - if (testFramework.includes('vitest') || testFramework === 'vitest') { - return `import { describe, it, expect } from 'vitest'; - -// Test plan: ${str(facts, 'test_plan_name')} -// CI stage: ${ciStage} -// Coverage target: ${coverageTarget} - -describe('${reqName}', () => { - it('should respond to health check', async () => { - // TODO: import worker and test with miniflare or unstable_dev - expect(true).toBe(true); - }); - - it('should handle primary use case', async () => { - // TODO: implement ${reqName} test - expect(true).toBe(true); - }); -}); -`; - } - - return `// Test plan: ${str(facts, 'test_plan_name')} -// CI stage: ${ciStage} -// Coverage target: ${coverageTarget} - -describe('${reqName}', () => { - it('should respond to health check', async () => { - expect(true).toBe(true); - }); -}); -`; -} - -function renderReadme(facts: Facts, projectName: string, intention: string): string { - const reqName = str(facts, 'requirement_name'); - const reqPriority = str(facts, 'requirement_priority', 'P1'); - const ifaceName = str(facts, 'interface_name'); - const threatName = str(facts, 'threat_name'); - const runtimeName = str(facts, 'runtime_name'); - const testName = str(facts, 'test_plan_name'); - const taskName = str(facts, 'first_task_name'); - const confidence = str(facts, 'scaffold_confidence', 'moderate'); - - return `# ${projectName} - -> ${intention} - -Scaffolded by [Stackbilt](https://stackbilt.dev). Confidence: **${confidence}**. - -## Architecture - -| Mode | Card | Key Detail | -|------|------|------------| -| Product | ${reqName} | Priority: ${reqPriority} | -| UX | ${ifaceName} | ${str(facts, 'interface_regions')} | -| Risk | ${threatName} | ${str(facts, 'threat_likelihood')} likelihood, ${str(facts, 'threat_impact')} impact | -| Runtime | ${runtimeName} | ${str(facts, 'runtime_traits')} | -| Test | ${testName} | ${str(facts, 'test_plan_framework')} @ ${str(facts, 'test_plan_ci_stage')} | -| Sprint | ${taskName} | ${str(facts, 'first_task_estimate')} pts, ${str(facts, 'first_task_complexity')} | - -## Getting Started - -\`\`\`bash -npm install -npx wrangler dev -\`\`\` - -## First Task - -**${taskName}** — ${str(facts, 'first_task_deliverable')} -- Estimate: ${str(facts, 'first_task_estimate')} points -- Complexity: ${str(facts, 'first_task_complexity')} -`; -} - -function renderTsConfig(): string { - return JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'ES2022', - moduleResolution: 'bundler', - lib: ['ES2022'], - types: ['@cloudflare/workers-types'], - strict: true, - noEmit: true, - skipLibCheck: true, - forceConsistentCasingInFileNames: true, - }, - include: ['src'], - }, null, 2); -} - -// ─── Main Materializer ─────────────────────────────────────── - -export function materializeScaffold( - facts: Facts, - intention: string, -): MaterializerResult { - const projectName = deriveProjectName(facts, intention); - const hints = detectDomainHints(intention); - - const files: ScaffoldFile[] = [ - // Governance first (.ai/ before src/) - { path: '.ai/manifest.adf', content: renderManifestAdf(facts, projectName) }, - { path: '.ai/core.adf', content: renderCoreAdf(facts, projectName) }, - { path: '.ai/state.adf', content: renderStateAdf(facts, projectName) }, - - // Build config - { path: 'package.json', content: renderPackageJson(facts, projectName, hints) }, - { path: 'tsconfig.json', content: renderTsConfig() }, - { path: 'wrangler.toml', content: renderWranglerToml(facts, projectName, hints) }, - - // Source - { path: 'src/index.ts', content: renderIndexTs(facts, hints) }, - - // Tests - { path: 'test/index.test.ts', content: renderTestFile(facts, projectName) }, - - // Docs - { path: 'README.md', content: renderReadme(facts, projectName, intention) }, - ]; - - // Add domain-specific extra files (e.g. chatroom.ts for Durable Objects) - for (const hint of hints) { - if (hint.extraFiles) { - files.push(...hint.extraFiles); - } - } - - const nextSteps = [ - `npm install`, - `npx wrangler d1 create ${projectName} # if D1 binding needed`, - `npx wrangler dev`, - `# First task: ${str(facts, 'first_task_name')} — ${str(facts, 'first_task_deliverable')}`, - ]; - - return { files, nextSteps }; -} From 14b758a6f5f0342d5a95e2ca97f781d9df1897d4 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 12 Jun 2026 06:48:46 -0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(#51):=20migrate=20scaffold=5Fclassify?= =?UTF-8?q?=20to=20@stackbilt/scaffold-core=20=E2=80=94=20eliminates=20Tar?= =?UTF-8?q?otScript=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scaffold_classify was calling TarotScript /run (classify-cast spread) without auth, causing HTTP 401. Now calls classify() from @stackbilt/scaffold-core directly: zero network, zero inference, <1ms. Returns pattern/confidence/traits/qualityProfile. Updated gateway test to verify output shape instead of header pass-through. Closes #51 Co-Authored-By: Claude Sonnet 4.6 --- src/gateway.ts | 40 ++++++++-------------------------------- test/gateway.test.ts | 29 ++++++++--------------------- 2 files changed, 16 insertions(+), 53 deletions(-) diff --git a/src/gateway.ts b/src/gateway.ts index 05c2a1d..8680ee9 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -9,7 +9,7 @@ import { OAUTH_PROVIDER_CONFIG } from './oauth-config.js'; import { resolveRoute, getToolRiskLevel, ROUTE_TABLE, type BackendRoute } from './route-table.js'; import { toBackendToolName, buildAggregatedCatalog, validateToolArguments, isInternalTool } from './tool-registry.js'; import { type AuditArtifact, generateTraceId, summarizeInput, emitAudit, queueAuditEvent } from './audit.js'; -import { buildScaffold } from '@stackbilt/scaffold-core'; +import { buildScaffold, classify } from '@stackbilt/scaffold-core'; import { publishToGitHub } from './scaffold-publish.js'; import { classifyIntention, type IntentClassification } from './intent-classifier.js'; import { logDivergence } from './divergence-logger.js'; @@ -232,39 +232,15 @@ async function proxyRestToolCall( } if (toolName === 'scaffold_classify') { - const response = await binding.fetch(new Request('https://internal/run', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Gateway-Tenant-Id': session.tenantId ?? '', - }, - body: JSON.stringify({ - spreadType: 'classify-cast', - querent: { - id: session.tenantId ?? session.userId ?? 'gateway', - intention: a.message as string, - state: { - message: a.message as string, - source: (a.source as string) ?? 'user', - }, - }, - }), - })); - - if (!response.ok) { - return { content: [{ type: 'text', text: `classify-cast failed: HTTP ${response.status}` }], isError: true }; - } - - const result = await response.json() as { facts?: Record; output?: string[] }; + const intention = (a.message ?? a.intention) as string; + const result = classify(intention); return { content: [{ type: 'text', text: JSON.stringify({ - classification: result.facts?.classification, - confidence: result.facts?.classification_confidence, - executor: result.facts?.classification_executor, - complexity: result.facts?.classification_complexity, - secondary: result.facts?.secondary_classification, - compound_intent: result.facts?.compound_intent, - tiebreaker_override: result.facts?.tiebreaker_override, + pattern: result.pattern, + confidence: result.confidence, + traits: result.traits, + qualityProfile: result.qualityProfile, + enrichedIntention: result.enrichedIntention, }, null, 2) }], }; } diff --git a/test/gateway.test.ts b/test/gateway.test.ts index 64f9982..46d4f3c 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -231,29 +231,16 @@ describe('handleMcpRequest', () => { expect(body.error.code).toBe(-32602); // INVALID_PARAMS }); - it('passes identity headers to backend', async () => { - let capturedHeaders: Record = {}; - const env = makeEnv({ - TAROTSCRIPT: { - fetch: async (input: RequestInfo) => { - const req = input as Request; - capturedHeaders = { - tenantId: req.headers.get('X-Gateway-Tenant-Id') ?? '', - }; - return new Response(JSON.stringify({ - verified: true, - receipt: { hash: 'abc', createdAt: '2026-04-03' }, - }), { headers: { 'Content-Type': 'application/json' } }); - }, - connect: () => { throw new Error('not implemented'); }, - } as unknown as Fetcher, - }); - + it('scaffold_classify returns pattern and confidence from scaffold-core', async () => { + const env = makeEnv(); const sessionId = await getSession(env); const req = rpcRequest('tools/call', { name: 'scaffold_classify', arguments: { message: 'build an API' } }, { 'MCP-Session-Id': sessionId }); - await handleMcpRequest(req, env); - - expect(capturedHeaders.tenantId).toBe('tenant-1'); + const res = await handleMcpRequest(req, env); + const body = await res.json() as { result: { content: Array<{ text: string }> } }; + const result = JSON.parse(body.result.content[0].text); + expect(typeof result.pattern).toBe('string'); + expect(typeof result.confidence).toBe('number'); + expect(Array.isArray(result.traits)).toBe(true); }); }); From 62e5a27b4d718a8e824e69d587c5a67ee67184c6 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Fri, 12 Jun 2026 07:12:05 -0500 Subject: [PATCH 4/4] chore: bump @stackbilt/scaffold-core to ^1.1.0 Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6e2511..79b3fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.27.1", - "@stackbilt/scaffold-core": "^1.0.0", + "@stackbilt/scaffold-core": "^1.1.0", "agents": "^0.7.2", "hono": "^4.12.8", "zod": "^3.25.0" @@ -1569,9 +1569,9 @@ ] }, "node_modules/@stackbilt/scaffold-core": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@stackbilt/scaffold-core/-/scaffold-core-1.0.0.tgz", - "integrity": "sha512-HkOAGh30goeVtVXDT2WmKdaxsE3MshPw4/Xc2Ok0CeIg9L0xyZG+n7mYL44JkFdTZrBjxT8WscWVokTpmUxkYg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@stackbilt/scaffold-core/-/scaffold-core-1.1.0.tgz", + "integrity": "sha512-/5K9A8zz0FzOl0zcEguXDCGyEM+4+9pRhCJUzRwMYWZUvT4otQuWUsPzreWT4cJdZFLTATmeJK2w9V3ik6JhHA==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index d7506d5..1be3670 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "dependencies": { "@cloudflare/workers-oauth-provider": "^0.2.4", "@modelcontextprotocol/sdk": "^1.27.1", - "@stackbilt/scaffold-core": "^1.0.0", + "@stackbilt/scaffold-core": "^1.1.0", "agents": "^0.7.2", "hono": "^4.12.8", "zod": "^3.25.0"