Files
editor/packages/mcp/test-reports/casa-sol/build.ts
T
Adrian PerezandClaude Opus 4.7 e8d0b13ff5 feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7)
Ships the combined filesystem/Supabase storage adapter + MCP scene
lifecycle tools + Next.js API routes + editor /scene/[id] route, so
an MCP save is directly openable at /scene/<id> without any
injection hack. End-to-end verified: 10/10 e2e steps pass.

Storage (A1/A2/A3):
- SceneStore interface + error classes + slug helpers
- FilesystemSceneStore at $PASCAL_DATA_DIR (defaults XDG/~/.pascal)
  with atomic writes, .index sidecar, optimistic locking
- SupabaseSceneStore with scenes + scene_revisions tables, RLS
  migration SQL, mock-backed unit tests
- createSceneStore(env) auto-selects based on SUPABASE_URL +
  SUPABASE_SERVICE_ROLE_KEY

MCP tools (A4, A8, A9, A10):
- save_scene / load_scene / list_scenes / delete_scene / rename_scene
- list_templates / create_from_template (3 seed templates:
  empty-studio, two-bedroom, garden-house)
- generate_variants (7 mutation kinds, seeded RNG, save=true|false)
- photo_to_scene (vision sampling → scene graph → save)

Editor (A5, A6):
- /api/scenes + /api/scenes/[id] with RFC 7232 If-Match locking
- /scene/[id] and /scenes route pages with save button, SceneLoader
- Removed the window.__pascalScene dev injection hack

Security + UX edges (A7, A8):
- AssetUrl Zod validator: asset:// blob: data:image/ /path https:
  (http://localhost for dev) + PASCAL_ALLOWED_ASSET_ORIGINS env
  allowlist. Hardens scan.url, guide.url, item.asset.src,
  material.texture.url, MaterialMaps.*Map
- Auto-frame camera on empty→non-empty scene transition
  (camera-controls:fit-scene emitter event)

Shared utilities:
- rehydrateSiteChildren() extracted to packages/mcp/src/lib/ and
  used by both create-from-template and generate-variants to work
  around the SiteNode.children-as-objects vs. ids inconsistency
  (CROSS_CUTTING §2)
- Storage + MCP subpath exports added to packages/mcp/package.json
  (CROSS_CUTTING §4)

Tests: 293 pass / 0 fail across 40 files (was 142 pre-Phase-7).
Biome: clean.

Phase-7 e2e script at packages/mcp/test-reports/phase7-e2e.ts:
MCP HTTP + editor Next.js both point at $PASCAL_DATA_DIR =
/tmp/pascal-e2e, save_scene from MCP, GET /api/scenes/<id> from
editor server, /scenes list page renders all saved scenes, scene
page renders SceneLoader, delete_scene works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 19:29:28 +02:00

875 lines
27 KiB
TypeScript

/**
* Casa del Sol — end-to-end build via MCP HTTP transport.
*
* Connects to http://localhost:3917/mcp (already running) and constructs
* the scene described in ./DESIGN.md:
* - 12x8 house footprint, 1 storey, 4 perimeter + 5 interior walls
* - 7 interior zones (living, kitchen, bed2, hallway, bath1, bath2, master)
* - 6 doors and 6 windows cut into specific walls
* - 5x3 pool zone + pool basin slab at elevation -1.8
* - 5 privacy fence segments around the 20x15 lot (with south gap)
* - 1 garden zone
*
* Emits:
* - stdout [casa] step log lines
* - ./scene.json (pretty-printed export after fences)
* - ./BUILD_REPORT.md (per-step report)
*
* Run:
* bun packages/mcp/test-reports/casa-sol/build.ts
*/
import { writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
const HERE = dirname(fileURLToPath(import.meta.url))
const SERVER_URL = 'http://localhost:3917/mcp'
let TRANSPORT_NOTE = ''
type StepRecord = {
n: number
name: string
ok: boolean
durationMs: number
summary: string
nodeIds?: string[]
errors?: string[]
}
const steps: StepRecord[] = []
function log(line: string): void {
// biome-ignore lint/suspicious/noConsole: build script
console.log(line)
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n)
}
async function step<T>(
n: number,
name: string,
fn: () => Promise<{ summary: string; nodeIds?: string[]; errors?: string[]; result: T }>,
): Promise<T | null> {
const start = Date.now()
try {
const { summary, nodeIds, errors, result } = await fn()
const durationMs = Date.now() - start
steps.push({ n, name, ok: true, durationMs, summary, nodeIds, errors })
log(
`[casa] step ${pad2(n)} ${name.padEnd(22)} ok (${summary}, ${durationMs}ms)` +
(nodeIds && nodeIds.length > 0 && nodeIds.length <= 10
? ` ids: ${nodeIds.join(', ')}`
: ''),
)
return result
} catch (err) {
const durationMs = Date.now() - start
const msg = err instanceof Error ? err.message : String(err)
steps.push({ n, name, ok: false, durationMs, summary: 'FAILED', errors: [msg] })
log(`[casa] step ${pad2(n)} ${name.padEnd(22)} FAIL (${msg}, ${durationMs}ms)`)
return null
}
}
async function callTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
const res = await client.callTool({ name, arguments: args })
if (res.isError) {
const text = Array.isArray(res.content)
? res.content
.map((c) =>
typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '',
)
.join('\n')
: ''
throw new Error(`tool ${name} error: ${text || 'unknown'}`)
}
return (res.structuredContent ?? {}) as T
}
/**
* Try to call a tool that might fail with a structured error; caller can
* continue on failure. Returns { ok, value | error }.
*/
async function tryCallTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
try {
const v = await callTool<T>(client, name, args)
return { ok: true, value: v }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
// --- Geometry spec ---------------------------------------------------------
type Vec2 = [number, number]
type WallSpec = {
key: string
designId: number // 1..9 per DESIGN.md wall numbering
start: Vec2
end: Vec2
}
const PERIMETER_WALLS: WallSpec[] = [
{ key: 'south-outer', designId: 1, start: [-8, 4], end: [4, 4] },
{ key: 'north-outer', designId: 2, start: [-8, -4], end: [4, -4] },
{ key: 'west-outer', designId: 3, start: [-8, -4], end: [-8, 4] },
{ key: 'east-outer', designId: 4, start: [4, -4], end: [4, 4] },
]
const INTERIOR_WALLS: WallSpec[] = [
{ key: 'living-kitchen-split', designId: 5, start: [-1, 0], end: [-1, 4] },
{ key: 'north-bedrooms-split', designId: 6, start: [-1, -4], end: [-1, 0] },
{ key: 'bedroom2-east', designId: 7, start: [-4, -4], end: [-4, 0] },
{ key: 'hallway-north-edge', designId: 8, start: [-4, -1], end: [-1, -1] },
{ key: 'hallway-south-edge', designId: 9, start: [-4, -2], end: [-1, -2] },
]
type ZoneSpec = {
label: string
polygon: Vec2[]
properties?: Record<string, unknown>
}
const INTERIOR_ZONES: ZoneSpec[] = [
{
label: 'living-dining',
polygon: [
[-8, 0],
[-1, 0],
[-1, 4],
[-8, 4],
],
},
{
label: 'kitchen',
polygon: [
[-1, 0],
[4, 0],
[4, 4],
[-1, 4],
],
},
{
label: 'bedroom-2',
polygon: [
[-8, -4],
[-4, -4],
[-4, 0],
[-8, 0],
],
},
{
label: 'hallway',
polygon: [
[-4, -2],
[-1, -2],
[-1, -1],
[-4, -1],
],
},
{
label: 'bathroom-2',
polygon: [
[-4, -4],
[-1, -4],
[-1, -2],
[-4, -2],
],
},
{
label: 'bathroom-1',
polygon: [
[-4, -1],
[-1, -1],
[-1, 0],
[-4, 0],
],
},
{
label: 'master-bedroom',
polygon: [
[-1, -4],
[4, -4],
[4, 0],
[-1, 0],
],
},
]
// Per DESIGN.md: (wall designId, kind, pos, width, height, label)
type OpeningSpec = {
wallDesignId: number
kind: 'door' | 'window'
position: number
width: number
height: number
label: string
}
const OPENINGS: OpeningSpec[] = [
{ wallDesignId: 1, kind: 'door', position: 0.2, width: 0.9, height: 2.1, label: 'front-door' },
{
wallDesignId: 1,
kind: 'door',
position: 0.75,
width: 2.2,
height: 2.1,
label: 'sliding-pool',
},
{ wallDesignId: 2, kind: 'door', position: 0.65, width: 0.9, height: 2.1, label: 'kitchen-back' },
{ wallDesignId: 6, kind: 'door', position: 0.3, width: 0.8, height: 2.1, label: 'master-door' },
{
wallDesignId: 7,
kind: 'door',
position: 0.5,
width: 0.8,
height: 2.1,
label: 'bedroom-2-door',
},
{ wallDesignId: 9, kind: 'door', position: 0.5, width: 0.7, height: 2.0, label: 'bath2-door' },
{
wallDesignId: 1,
kind: 'window',
position: 0.3,
width: 2.0,
height: 1.4,
label: 'living-pic',
},
{ wallDesignId: 1, kind: 'window', position: 0.45, width: 1.4, height: 1.4, label: 'living-2' },
{ wallDesignId: 3, kind: 'window', position: 0.25, width: 1.0, height: 1.1, label: 'kitchen-w' },
{ wallDesignId: 4, kind: 'window', position: 0.25, width: 1.4, height: 1.4, label: 'master-w' },
{
wallDesignId: 4,
kind: 'window',
position: 0.75,
width: 1.4,
height: 1.4,
label: 'bedroom-2-w',
},
{
wallDesignId: 2,
kind: 'window',
position: 0.3,
width: 0.8,
height: 0.6,
label: 'bath2-high',
},
]
const POOL_POLY: Vec2[] = [
[5, -1.5],
[10, -1.5],
[10, 1.5],
[5, 1.5],
]
type FenceSpec = { label: string; start: Vec2; end: Vec2 }
const FENCES: FenceSpec[] = [
{ label: 'south-west', start: [-10, 7.5], end: [-1, 7.5] },
{ label: 'south-east', start: [1, 7.5], end: [10, 7.5] },
{ label: 'east', start: [10, 7.5], end: [10, -7.5] },
{ label: 'north', start: [10, -7.5], end: [-10, -7.5] },
{ label: 'west', start: [-10, -7.5], end: [-10, 7.5] },
]
const SITE_POLY: Vec2[] = [
[-10, -7.5],
[10, -7.5],
[10, 7.5],
[-10, 7.5],
]
// --- Validation helper -----------------------------------------------------
type ValidationResult = {
valid: boolean
errors: Array<{ nodeId: string; path: string; message: string }>
}
async function runValidate(client: Client, phase: string): Promise<ValidationResult> {
const v = await callTool<ValidationResult>(client, 'validate_scene', {})
log(`[casa] validate after ${phase}: valid=${v.valid}, errors=${v.errors.length}`)
if (!v.valid && v.errors.length > 0) {
for (const e of v.errors.slice(0, 5)) {
log(`[casa] - ${e.nodeId} @ ${e.path}: ${e.message}`)
}
if (v.errors.length > 5) log(`[casa] (+${v.errors.length - 5} more)`)
}
return v
}
// --- Main -----------------------------------------------------------------
async function main(): Promise<void> {
log(`[casa] connecting to ${SERVER_URL} via StreamableHTTPClientTransport`)
const httpTransport = new StreamableHTTPClientTransport(new URL(SERVER_URL))
let client = new Client({ name: 'casa-sol-builder', version: '0.1.0' })
let closers: Array<() => Promise<void>> = []
let usedTransport: 'http' | 'in-memory' = 'http'
try {
await client.connect(httpTransport)
// Smoke probe
await client.listTools()
TRANSPORT_NOTE = 'shared HTTP server at :3917 via StreamableHTTPClientTransport'
log(`[casa] HTTP transport connected`)
closers = [async () => client.close()]
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`[casa] HTTP transport failed: ${msg}`)
log(`[casa] falling back to in-memory MCP server (same tool surface)`)
// Load the in-process MCP server to keep the build moving. This preserves
// the tool contract; the only thing we lose is the HTTP wire test.
const { SceneBridge } = await import(
'/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/bridge/scene-bridge.ts'
)
const { createPascalMcpServer } = await import(
'/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/server.ts'
)
const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const inMemClient = new Client({ name: 'casa-sol-builder-inmem', version: '0.1.0' })
await Promise.all([server.connect(srvT), inMemClient.connect(cliT)])
client = inMemClient
usedTransport = 'in-memory'
TRANSPORT_NOTE = `in-memory fallback — HTTP server returned: ${msg}`
closers = [async () => client.close(), async () => server.close()]
log(`[casa] in-memory MCP server connected`)
}
void usedTransport
// ----- Step 01: Discover building & level -----
const discovered = await step(1, 'discover', async () => {
const buildings = await callTool<{
nodes: Array<{ id: string; type: string; name?: string }>
}>(client, 'find_nodes', { type: 'building' })
const levels = await callTool<{
nodes: Array<{ id: string; type: string; name?: string; parentId?: string }>
}>(client, 'find_nodes', { type: 'level' })
if (!buildings.nodes.length) throw new Error('no building found in default scene')
if (!levels.nodes.length) throw new Error('no level found in default scene')
const building = buildings.nodes[0]!
const level = levels.nodes.find((l) => l.parentId === building.id) ?? levels.nodes[0]!
return {
summary: `building=${building.id}, level=${level.id}`,
nodeIds: [building.id, level.id],
result: { buildingId: building.id, levelId: level.id },
}
})
if (!discovered) {
log('[casa] cannot continue without discovered ids; aborting')
await client.close()
return
}
const { levelId } = discovered
// Initial scene snapshot for "before" count.
const initialAll = await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {})
const initialCount = initialAll.nodes.length
log(`[casa] initial node count: ${initialCount}`)
// ----- Step 02: Perimeter walls (via create_wall) -----
// DESIGN.md wall IDs 1..4: south-outer, north-outer, west-outer, east-outer
const perimeterIds: Record<string, string> = {}
const perimeter = await step(2, 'perimeter walls', async () => {
const ids: string[] = []
for (const w of PERIMETER_WALLS) {
const r = await callTool<{ wallId: string }>(client, 'create_wall', {
levelId,
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
})
perimeterIds[w.key] = r.wallId
ids.push(r.wallId)
}
return {
summary: `${ids.length} walls via create_wall`,
nodeIds: ids,
result: ids,
}
})
// ----- Step 03: Interior walls (via apply_patch) -----
const interiorIds: Record<string, string> = {}
const interior = await step(3, 'interior walls', async () => {
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: INTERIOR_WALLS.map((w) => ({
op: 'create',
parentId: levelId,
node: {
type: 'wall',
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
},
})),
})
const createdIds = res.createdIds
for (let i = 0; i < INTERIOR_WALLS.length; i++) {
const w = INTERIOR_WALLS[i]!
const id = createdIds[i]
if (id) interiorIds[w.key] = id
}
return {
summary: `${createdIds.length} walls via apply_patch`,
nodeIds: createdIds,
result: createdIds,
}
})
// Map designId -> wallId for opening lookup
const wallByDesignId = new Map<number, string>()
for (const w of PERIMETER_WALLS) {
const id = perimeterIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
for (const w of INTERIOR_WALLS) {
const id = interiorIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
log(`[casa] wall ids (by designId): ${JSON.stringify(Object.fromEntries(wallByDesignId))}`)
const validationAfterWalls = perimeter && interior ? await runValidate(client, 'walls') : null
void validationAfterWalls
// ----- Step 04: Zones -----
const interiorZoneIds: Record<string, string> = {}
const zones = await step(4, 'zones', async () => {
const ids: string[] = []
for (const z of INTERIOR_ZONES) {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: z.label,
polygon: z.polygon,
properties: z.properties ?? {},
})
interiorZoneIds[z.label] = r.zoneId
ids.push(r.zoneId)
}
return {
summary: `${ids.length} zones`,
nodeIds: ids,
result: ids,
}
})
void zones
const validationAfterZones = await runValidate(client, 'zones')
void validationAfterZones
// ----- Step 05: Openings (doors + windows) -----
const openingResults: Array<{
label: string
kind: string
wallId: string
ok: boolean
openingId?: string
error?: string
}> = []
const openings = await step(5, 'openings', async () => {
let doors = 0
let windows = 0
const failures: string[] = []
for (const o of OPENINGS) {
const wallId = wallByDesignId.get(o.wallDesignId)
if (!wallId) {
const err = `skipped ${o.label}: wall designId=${o.wallDesignId} not found`
log(`[casa] ${err}`)
failures.push(err)
openingResults.push({
label: o.label,
kind: o.kind,
wallId: `design-${o.wallDesignId}`,
ok: false,
error: 'wall not found',
})
continue
}
const r = await tryCallTool<{ openingId: string }>(client, 'cut_opening', {
wallId,
type: o.kind,
position: o.position,
width: o.width,
height: o.height,
})
if (r.ok) {
openingResults.push({
label: o.label,
kind: o.kind,
wallId,
ok: true,
openingId: r.value.openingId,
})
if (o.kind === 'door') doors++
else windows++
} else {
log(`[casa] cut_opening failed for ${o.label} (wall=${wallId}): ${r.error}`)
failures.push(`${o.label}: ${r.error}`)
openingResults.push({
label: o.label,
kind: o.kind,
wallId,
ok: false,
error: r.error,
})
}
}
const ids = openingResults
.filter((r) => r.ok && r.openingId)
.map((r) => r.openingId!) as string[]
return {
summary: `${doors} doors, ${windows} windows, ${failures.length} failures`,
nodeIds: ids,
errors: failures,
result: { doors, windows, failures },
}
})
void openings
const validationAfterOpenings = await runValidate(client, 'openings')
void validationAfterOpenings
// ----- Step 06: Pool zone + pool basin slab -----
const poolZoneIdRef: { id?: string } = {}
const poolSlabIdRef: { id?: string } = {}
const poolResult = await step(6, 'pool zone+slab', async () => {
const zoneRes = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'pool',
polygon: POOL_POLY,
properties: { kind: 'pool', depthM: 1.8, finish: 'tile' },
})
poolZoneIdRef.id = zoneRes.zoneId
// Create pool basin slab at elevation -1.8 via apply_patch.
const slabRes = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: [
{
op: 'create',
parentId: levelId,
node: {
type: 'slab',
polygon: POOL_POLY,
elevation: -1.8,
},
},
],
})
const slabId = slabRes.createdIds[0]
if (slabId) poolSlabIdRef.id = slabId
return {
summary: `zone=${zoneRes.zoneId}, basin slab=${slabId ?? 'NONE'}`,
nodeIds: [zoneRes.zoneId, slabId ?? ''].filter(Boolean) as string[],
result: { zoneId: zoneRes.zoneId, slabId },
}
})
void poolResult
const validationAfterPool = await runValidate(client, 'pool')
void validationAfterPool
// ----- Step 07: Fences -----
const fenceIds: string[] = []
const fencesStep = await step(7, 'privacy fences', async () => {
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: FENCES.map((f) => ({
op: 'create',
parentId: levelId,
node: {
type: 'fence',
start: f.start,
end: f.end,
height: 1.8,
style: 'privacy',
thickness: 0.08,
},
})),
})
fenceIds.push(...res.createdIds)
return {
summary: `${res.createdIds.length} fences via apply_patch`,
nodeIds: res.createdIds,
result: res.createdIds,
}
})
void fencesStep
const validationAfterFences = await runValidate(client, 'fences')
void validationAfterFences
// ----- Step 08: Garden zone -----
const gardenZoneIdRef: { id?: string } = {}
const gardenStep = await step(8, 'garden zone', async () => {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'garden',
polygon: SITE_POLY,
properties: { kind: 'garden' },
})
gardenZoneIdRef.id = r.zoneId
return {
summary: `zone=${r.zoneId}`,
nodeIds: [r.zoneId],
result: r.zoneId,
}
})
void gardenStep
// ----- Step 09: Measure — SW building corner wall -> NE lot fence -----
// SW building corner ~= start of first perimeter wall (south-outer: starts at (-8, 4))
// NE lot fence corner = east fence segment (start at (10, 7.5), end at (10, -7.5))
await step(9, 'measure cross-zone', async () => {
const fromId = perimeterIds['south-outer']
const toId = fenceIds[2] // east fence (see FENCES order)
if (!fromId) throw new Error('missing south-outer wall id')
if (!toId) throw new Error('missing east fence id')
const r = await callTool<{ distanceMeters: number; units: string }>(client, 'measure', {
fromId,
toId,
})
return {
summary: `distance=${r.distanceMeters.toFixed(3)}m`,
nodeIds: [fromId, toId],
result: r,
}
})
// ----- Step 10: Export JSON -----
const exportInfo: { bytes?: number } = {}
await step(10, 'export json', async () => {
const r = await callTool<{ json: string }>(client, 'export_json', { pretty: true })
const path = `${HERE}/scene.json`
writeFileSync(path, r.json, 'utf-8')
exportInfo.bytes = r.json.length
return {
summary: `wrote ${r.json.length} bytes -> scene.json`,
result: r.json.length,
}
})
// ----- Step 11: Duplicate level -----
const preDupCount = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const dupRef: { newLevelId?: string; cloned?: number } = {}
await step(11, 'duplicate level', async () => {
const r = await callTool<{ newLevelId: string; newNodeIds: string[] }>(
client,
'duplicate_level',
{ levelId },
)
dupRef.newLevelId = r.newLevelId
dupRef.cloned = r.newNodeIds.length
return {
summary: `newLevelId=${r.newLevelId}, cloned=${r.newNodeIds.length} nodes`,
nodeIds: [r.newLevelId],
result: r,
}
})
const postDupCount = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
// ----- Step 12: Final validate + summary counts -----
const finalValid = await runValidate(client, 'final')
const allNodes = (await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {}))
.nodes
const tally: Record<string, number> = {}
for (const n of allNodes) {
tally[n.type] = (tally[n.type] ?? 0) + 1
}
log(
`[casa] FINAL: totalNodes=${allNodes.length} walls=${tally.wall ?? 0} zones=${
tally.zone ?? 0
} doors=${tally.door ?? 0} windows=${tally.window ?? 0} fences=${
tally.fence ?? 0
} slabs=${tally.slab ?? 0} levels=${tally.level ?? 0}`,
)
log(`[casa] pre-duplicate=${preDupCount}, post-duplicate=${postDupCount}`)
log(`[casa] validation: valid=${finalValid.valid}, errors=${finalValid.errors.length}`)
// ----- Write BUILD_REPORT.md -----
const lines: string[] = []
lines.push('# Casa del Sol — Build Report')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Server: ${SERVER_URL}`)
lines.push(`Transport used: **${usedTransport}** — ${TRANSPORT_NOTE}`)
lines.push(`Initial node count: ${initialCount}`)
lines.push('')
lines.push('## Steps')
lines.push('')
lines.push('| # | Name | Status | Duration | Summary |')
lines.push('|---|------|--------|----------|---------|')
for (const s of steps) {
lines.push(
`| ${s.n} | ${s.name} | ${s.ok ? 'OK' : 'FAIL'} | ${s.durationMs}ms | ${s.summary.replace(/\|/g, '\\|')} |`,
)
}
lines.push('')
lines.push('## Per-Step Details')
lines.push('')
for (const s of steps) {
lines.push(`### Step ${s.n}${s.name}`)
lines.push('')
lines.push(`- Status: **${s.ok ? 'OK' : 'FAIL'}**`)
lines.push(`- Duration: ${s.durationMs}ms`)
lines.push(`- Summary: ${s.summary}`)
if (s.nodeIds && s.nodeIds.length > 0) {
const shown = s.nodeIds.slice(0, 15).join(', ')
lines.push(
`- Node IDs (${s.nodeIds.length}): \`${shown}${s.nodeIds.length > 15 ? ' ...' : ''}\``,
)
}
if (s.errors && s.errors.length > 0) {
lines.push(`- Errors/warnings:`)
for (const e of s.errors) lines.push(` - ${e}`)
}
lines.push('')
}
lines.push('## Opening attempts')
lines.push('')
lines.push('| Label | Kind | Wall | OK | Opening Id / Error |')
lines.push('|-------|------|------|----|--------------------|')
for (const r of openingResults) {
lines.push(
`| ${r.label} | ${r.kind} | \`${r.wallId}\` | ${r.ok ? 'yes' : 'no'} | ${
r.ok ? r.openingId : (r.error ?? 'n/a').replace(/\|/g, '\\|')
} |`,
)
}
lines.push('')
lines.push('## Final scene totals')
lines.push('')
lines.push('| Node type | Count |')
lines.push('|-----------|-------|')
const types = [
'site',
'building',
'level',
'wall',
'fence',
'zone',
'slab',
'door',
'window',
'ceiling',
'roof',
'stair',
'item',
'guide',
]
for (const t of types) {
if ((tally[t] ?? 0) > 0) lines.push(`| ${t} | ${tally[t]} |`)
}
lines.push(`| **total** | **${allNodes.length}** |`)
lines.push('')
lines.push('## Validation')
lines.push('')
lines.push(
`- Final \`validate_scene\`: valid=\`${finalValid.valid}\`, errors=${finalValid.errors.length}`,
)
if (!finalValid.valid && finalValid.errors.length > 0) {
lines.push('')
lines.push('Errors (verbatim):')
lines.push('')
for (const e of finalValid.errors) {
lines.push(`- \`${e.nodeId}\` @ \`${e.path}\`: ${e.message}`)
}
}
lines.push('')
lines.push('## Duplicate-level')
lines.push('')
lines.push(`- Pre-duplicate node count: **${preDupCount}**`)
lines.push(`- Post-duplicate node count: **${postDupCount}**`)
lines.push(`- New level id: \`${dupRef.newLevelId ?? 'N/A'}\``)
lines.push(`- Nodes cloned: ${dupRef.cloned ?? 0}`)
lines.push('')
// Known discrepancies section
const discrepancies: string[] = []
const openingFailures = openingResults.filter((r) => !r.ok)
if (openingFailures.length > 0) {
discrepancies.push(
`${openingFailures.length} cut_opening call(s) failed — see "Opening attempts" table for details. Core may have rejected an opening due to overlap or width exceeding the wall length.`,
)
}
if (!finalValid.valid) {
discrepancies.push(
'Final validate_scene returned valid=false — see Validation section for the verbatim error list.',
)
}
const gardenPolyNote =
'Garden zone polygon equals the full site polygon (20x15) — per design brief §Garden zone we set it to the site polygon and rely on the building zones overlapping visually, rather than subtracting the building footprint.'
discrepancies.push(gardenPolyNote)
if (usedTransport === 'in-memory') {
discrepancies.push(
`Build fell back to in-memory MCP transport. The HTTP server at ${SERVER_URL} rejected the SDK client's initialize with "Server already initialized" — the server uses the SDK's single-session StreamableHTTPServerTransport which only accepts one \`initialize\` POST per process lifetime. The tool surface exercised is identical; only the wire transport differs.`,
)
}
lines.push('## Known discrepancies with DESIGN.md')
lines.push('')
for (const d of discrepancies) lines.push(`- ${d}`)
lines.push('')
lines.push('## Artifacts')
lines.push('')
lines.push(
`- \`scene.json\`: full pretty-printed JSON export (${exportInfo.bytes ?? 'n/a'} bytes)`,
)
lines.push(`- \`build.log\`: stdout from this run`)
lines.push(`- \`BUILD_REPORT.md\`: this file`)
lines.push('')
writeFileSync(`${HERE}/BUILD_REPORT.md`, lines.join('\n'), 'utf-8')
log(`[casa] wrote BUILD_REPORT.md`)
log(`[casa] transport: ${usedTransport} (${TRANSPORT_NOTE})`)
log(`[casa] DONE`)
for (const c of closers) await c()
}
main().catch((err) => {
// biome-ignore lint/suspicious/noConsole: build script
console.error('[casa] FATAL:', err instanceof Error ? err.stack : String(err))
process.exit(1)
})