Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a0f6a50
core: scene stores own their history environment
Aymericr Oct 11, 2026
c09adf1
core: reconcileDocument settles every saved-field writer synchronously
Aymericr Oct 11, 2026
3787f74
core/runtime: detached scenes with tracked reads and complete patches
Aymericr Oct 11, 2026
84d47ca
core: operations and reconcile passes read through tracked queries
Aymericr Oct 11, 2026
2eecf1e
core/runtime: applyPatchWithDependencies
Aymericr Oct 11, 2026
6556c9c
lint: shared operations select what they read
Aymericr Oct 11, 2026
b9e5ad4
core: document reconciliation runs on headless hosts
Aymericr Oct 11, 2026
ce4f3f5
mcp: SceneBridge over a scene runtime
Aymericr Oct 11, 2026
938bc78
editor: the history stair case stands off the slab
Aymericr Oct 11, 2026
3f77572
editor: geometry-script compiles take captured inputs
Aymericr Oct 11, 2026
f646e32
docs: the scene runtime, and E-017
Aymericr Oct 11, 2026
b63e07b
core/runtime: complete commits, owned data and complete dependencies
Aymericr Oct 11, 2026
9aa95f8
core: reconciliation keeps a support index per store and settles incr…
Aymericr Oct 11, 2026
576ed87
mcp: complete commits, scene-scoped handlers, optional runInScene
Aymericr Oct 11, 2026
bd5db97
docs: scene runtime follow-ups
Aymericr Oct 11, 2026
03ba205
core: scoped reconciliation and recorded planners cover what they read
Aymericr Oct 11, 2026
fc1e0ff
mcp: openings plan in scene scope; store-only operations; owned writes
Aymericr Oct 11, 2026
26edcd5
docs: scene runtime, second review follow-ups
Aymericr Oct 11, 2026
e00bbbf
core: slab edits move only their building's roofs; renames run no pla…
Aymericr Oct 11, 2026
63e3f8f
mcp: bridge replacements copy what they are given
Aymericr Oct 11, 2026
71cbe07
docs: scene runtime, slab roof selection and segment planner triggers
Aymericr Oct 11, 2026
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
1 change: 1 addition & 0 deletions .agents/skills/review-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Read these before reviewing any diff. They are the source of truth, not your tra
- `wiki/architecture/node-definitions.md` — the three-checkbox composition model (`geometry` / `renderer` / `system`)
- `wiki/architecture/plugin-authoring.md` — public contract for external node packs
- `wiki/architecture/agent-surfaces.md` — MCP ↔ hosted chat ↔ skill parity. Read whenever the diff touches `packages/mcp/**` or `skills/**`.
- `wiki/architecture/scene-runtime.md` — detached scenes, tracked reads, `reconcileDocument`. Read whenever the diff touches `packages/core/src/agent-operations/**`, `packages/core/src/store/**`, a system that writes saved fields, or `packages/core/src/runtime/**`.

Required on every review. Read the remaining pages on demand when the diff touches their subject area:

Expand Down
2 changes: 2 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples

**E-016 Plugins own their agent tools (owner, 2026-10-10).** A plugin's agent tools ship with the plugin, not in core: contracts as a `PluginAgentTools` in its `./agent-tools` export (zod only, like `core/agent-tools`, and versioned by the plugin), pure operations as an `AgentOperations` in `./agent-operations`, browser execution and cards as a `PluginChatExtension`. The `Plugin` manifest does not change (E-014), and one contract still serves both surfaces. Contract annotations are hints: the host decides which tools are offered, to whom, and with what authority. These are reviewed, trusted contributions, not a sandbox. · Why: a plugin with dozens of specialist tools keeps them versioned with its own code, and installing it extends every agent the way a connector does. · Enforced: `core/src/agent-tools/contracts-purity.test.ts` (the rule each plugin entry follows), `mcp/src/tools/shared-tools.test.ts` (a plugin's operation tool through a real client), the hosted parity test. · See `plugin-authoring.md`, `agent-surfaces.md`. · Revisit: when plugins are loaded without review.

**E-017 Agent computation runs on a scene runtime (2026-10-11).** Agent operations can compute on a detached copy of the scene (`@pascal-app/core/runtime`) as well as on the editor's store, with the same result: a scene store owns its history environment, so a second store never reaches the editor's commits, history or subscribers; ids come from a synchronous allocator scope (`effectIds` makes a recomputation mint the same ids); every derived write a saved document owes settles synchronously in `reconcileDocument`, which the editor's systems plan with too; shared operations select what they read (`queryNodes`), so the recorded dependencies are precise; and a patch reaches the live scene only through `applyPatchWithDependencies`'s check. Core exports the runtime; the MCP or the host constructs `SceneBridge({ runtime })`. · Why: a long agent tool must neither hold the person's editor while it computes nor write the live scene before it is known to fit; one implementation must give the same patch on both paths. · Enforced: `core/src/runtime/fixture-parity.test.ts` (every shared fixture, detached = live, no deferred write), `core/src/runtime/detached-scene.test.ts` (isolation, disposal, ids, conflict table), the `raw-scene-enumeration` Biome rule on `agent-operations/`. · See `scene-runtime.md`. · Revisit: when a derived writer cannot run synchronously.

## Testing

**T-001 (2026-10-01, owner).** A test protects an observable result, a persisted or wire contract, or an explicit architecture/performance invariant — and tolerates an equivalent implementation. Therefore: test through package exports; no source-grep, prose-pinning or import-spelling tests (use Biome rules); no cross-package `src/` imports in tests; no `mock.module` that forces sibling files to re-spawn `bun test`; no real sleeps (inject a clock); no tests named after review rounds or bots; refactor scaffolding (parity tables, "matches main" goldens, frozen copies of old code) is deleted in the PR that lands the refactor; performance claims live in `__bench__` / bench lanes, not unit gates; a bug fix is one repro in the feature's existing file, not a new `*-audit*` file; a test that needs an opt-in flag must have a lane that sets it, or go. "Internal" is not the deletion criterion: core's import boundary, the writers allowlist and the migration thresholds pin internals with external consequences and stay. · Why: 231k test lines (82k of them fixtures) had become the main cost of every change for people and agents alike. · Enforced: review; Biome rules as they land.
Expand Down
5 changes: 5 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@
}
}
},
// E-017: shared operations select what they read, so a detached scene records a selection.
{
"includes": ["packages/core/src/agent-operations/**/*.ts", "!**/*.test.ts"],
"plugins": ["./scripts/biome/raw-scene-enumeration.grit"]
},
// T-001: tests go through package exports. `warn` while ~50 existing tests still reach into
// another package's src/ (mostly nodes tests driving editor internals, and shared core
// fixtures); becomes an error once that count reaches zero. The two mounted-scene fixtures are
Expand Down
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@
"types": "./dist/agent-operations/index.d.ts",
"import": "./dist/agent-operations/index.js",
"default": "./dist/agent-operations/index.js"
},
"./runtime": {
"types": "./dist/runtime/index.d.ts",
"import": "./dist/runtime/index.js",
"default": "./dist/runtime/index.js"
}
},
"files": [
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/agent-operations/add-column.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { resettledPosition } from '../lib/geometry-surfaces'
import { ColumnNode, type CompiledGeometryScript, type GeometryScriptParamValue } from '../schema'
import { editedScriptParams, refuseParamsWithoutScript } from './add-object'
import { targetLevel } from './level-target'
import { queryNodes } from './scene-queries'
import type { AgentOperation } from './types'

export type AddColumnInput = z.infer<z.ZodObject<typeof addColumnTool.input>> & {
Expand Down Expand Up @@ -80,9 +81,7 @@ export const addColumn: AgentOperation<AddColumnInput> = (nodes, input, context)
...fields,
...(compiled?.nodeId ? { id: compiled.nodeId } : {}),
name:
input.name ??
previous?.name ??
`Column ${Object.values(nodes).filter((node) => node.type === 'column').length + 1}`,
input.name ?? previous?.name ?? `Column ${queryNodes(nodes, { type: 'column' }).length + 1}`,
parentId: parent.id,
position: [
x ?? previous?.position[0] ?? 0,
Expand Down
10 changes: 4 additions & 6 deletions packages/core/src/agent-operations/add-level.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { refuse } from '../agent-tools/refusal'
import { BuildingNode, LevelNode, SiteNode } from '../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
import { levelsOf } from './scene-queries'
import { levelsOf, queryNodes } from './scene-queries'
import type { AgentContext, AgentOperation, SceneNodes } from './types'

type AddLevelInput = {
Expand Down Expand Up @@ -30,9 +30,7 @@ export function targetBuilding(
const viewed = context.activeLevelId ? nodes[context.activeLevelId] : undefined
const holder = viewed?.parentId ? nodes[viewed.parentId] : undefined
if (holder?.type === 'building') return holder
const buildings = Object.values(nodes).filter(
(node): node is BuildingNode => node.type === 'building',
)
const buildings = queryNodes<BuildingNode>(nodes, { type: 'building' })
if (buildings.length === 1) return buildings[0]!
if (!buildings.length) refuse('no_building', 'The scene has no building to add a level to.')
return refuse(
Expand All @@ -47,7 +45,7 @@ export function targetBuilding(
* building, the ground level. An agent that cleared the scene to restart begins again here.
*/
function freshStart(nodes: SceneNodes, input: AddLevelInput) {
const standing = Object.values(nodes).find((node) => node.type === 'site')
const standing = queryNodes(nodes, { type: 'site' })[0]
const site = standing ? null : SiteNode.parse({ children: [] })
const siteId = standing?.id ?? site!.id
const building = BuildingNode.parse({ parentId: siteId, children: [] })
Expand Down Expand Up @@ -81,7 +79,7 @@ function freshStart(nodes: SceneNodes, input: AddLevelInput) {

/** `add_level`: an empty level over the building's highest, or under its lowest — the editor's +. */
export const addLevel: AgentOperation<AddLevelInput> = (nodes, input, context) => {
if (!(input.buildingId || Object.values(nodes).some((node) => node.type === 'building')))
if (!(input.buildingId || queryNodes(nodes, { type: 'building' }).length))
return freshStart(nodes, input)
const building = targetBuilding(nodes, input.buildingId, context)
const floors = levelsOf(nodes)
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent-operations/add-wall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { WallNode } from '../schema'
import { normalizeWallCurveOffset } from '../systems/wall/wall-curve'
import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology'
import { type LevelTargetInput, targetLevel } from './level-target'
import { levelRole } from './scene-queries'
import { levelRole, queryNodes } from './scene-queries'
import type { AgentOperation, SceneNodes } from './types'

type Pt = [number, number]
Expand Down Expand Up @@ -42,7 +42,7 @@ export const addWall: AgentOperation<AddWallInput> = (nodes, input, context) =>
const curveOffset = input.curveOffset
? normalizeWallCurveOffset({ start, end }, input.curveOffset)
: 0
const walls = Object.values(nodes).filter((node) => node.type === 'wall').length
const walls = queryNodes(nodes, { type: 'wall' }).length
const wall = WallNode.parse({
name: `Wall ${walls + 1}`,
parentId: level.id,
Expand Down
16 changes: 9 additions & 7 deletions packages/core/src/agent-operations/collections.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { refuse } from '../agent-tools/refusal'
import { queryCollections } from '../runtime/node-queries'
import { untracked } from '../runtime/reader'
import {
type AnyNodeId,
COLLECTION_TEMPLATES,
Expand Down Expand Up @@ -103,14 +105,13 @@ export function editCollection(
scene: CollectionScene,
input: EditCollectionInput,
): AgentOperationOutcome {
const all = Object.values(scene.collections)
const nameKey = input.name?.toLowerCase()
// A lookup by template or name reads which collections match, absent ones included.
const existing = input.collectionId
? collectionById(scene, input.collectionId)
: input.template
? all.find((collection) => collection.template === input.template)
: nameKey
? all.find((collection) => collection.name.toLowerCase() === nameKey)
? queryCollections(scene.collections, { template: input.template })[0]
: input.name
? queryCollections(scene.collections, { name: input.name })[0]
: refuse('collection_required', 'Pass a collectionId, a template or a name.')

if (input.delete) {
Expand Down Expand Up @@ -140,8 +141,9 @@ export function editCollection(
const nodeIds = [...new Set([...base.nodeIds, ...((input.add ?? []) as AnyNodeId[])])].filter(
(id) => !removed.has(id),
)
// The fields this call does not set are copied through, not read.
const next: Collection = {
...base,
...untracked(base),
...(input.name ? { name: input.name } : {}),
...(input.color ? { color: input.color } : {}),
...(input.template ? { template: input.template } : {}),
Expand All @@ -163,7 +165,7 @@ export function listCollections(
): AgentOperationOutcome {
const collections = collectionId
? [collectionById(scene, collectionId)]
: Object.values(scene.collections)
: queryCollections(scene.collections, {})
return {
result: {
count: collections.length,
Expand Down
8 changes: 3 additions & 5 deletions packages/core/src/agent-operations/create-room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type AnyNode, generateId, type WallNode } from '../schema'
import { applySceneChanges } from './apply-changes'
import { type LevelTargetInput, targetLevel } from './level-target'
import { polygonArea, type Vec2 } from './plan-geometry'
import { levelRole } from './scene-queries'
import { levelRole, queryNodes } from './scene-queries'
import type { AgentOperation, SceneNodes } from './types'

type OpeningSpec = {
Expand Down Expand Up @@ -56,9 +56,7 @@ export function edgeProjection(a: Vec2, b: Vec2, point: readonly number[]) {
}

const wallsOn = (nodes: SceneNodes, levelId: string) =>
Object.values(nodes).filter(
(node): node is WallNode => node.type === 'wall' && node.parentId === levelId,
)
queryNodes<WallNode>(nodes, { type: 'wall', parentId: levelId })

/** The walls of the level that run along the edge a → b, both ends on its line. */
function wallsAlong(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2) {
Expand Down Expand Up @@ -88,7 +86,7 @@ function wallAt(nodes: SceneNodes, levelId: string, a: Vec2, b: Vec2, point: Vec

/** The construction the host derived for the room: its floor plate and its ceiling. */
function derivedSurfaces(nodes: SceneNodes, levelId: string, zoneId: string) {
const children = Object.values(nodes).filter((node) => node.parentId === levelId)
const children = queryNodes(nodes, { parentId: levelId })
const slab = children.find(
(node) => node.type === 'slab' && node.boundary === 'auto' && node.zoneIds?.includes(zoneId),
)
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent-operations/create-stair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { applySceneChanges } from './apply-changes'
import { type LevelTargetInput, requireLevel, targetLevel } from './level-target'
import { finishSurface, requireMaterialRef } from './material-refs'
import { pointInPolygon, type Vec2 } from './plan-geometry'
import { levelsOf } from './scene-queries'
import { levelsOf, queryNodes } from './scene-queries'
import type { AgentOperation, SceneChanges } from './types'

type CreateStairInput = LevelTargetInput & {
Expand Down Expand Up @@ -158,7 +158,7 @@ export const createStair: AgentOperation<CreateStairInput> = (nodes, input, cont
const withUpper = applySceneChanges(nodes, changes)
const rotation = input.rotation ?? 0
const width = input.width ?? 1
const stairs = Object.values(nodes).filter((node) => node.type === 'stair').length
const stairs = queryNodes(nodes, { type: 'stair' }).length
const railingMode = input.railingMode ?? 'both'
const draft = StairNode.parse({
parentId: from.id,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agent-operations/find-by-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const findByType: AgentOperation<{ type: string; levelId?: string }> = (
if (levelId) requireLevel(nodes, levelId)
const word = type.trim().toLowerCase().replace(/s$/, '')
const results: Record<string, unknown>[] = []
// biome-ignore lint/plugin: any node, or any typed part of an authored object, may match
for (const node of Object.values(nodes)) {
const level = levelIdOf(nodes, node.id)
if (levelId && level !== levelId) continue
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/agent-operations/furnish-room.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FURNISHED_ROOM_TYPES } from '../agent-tools/furnish-room'
import { refuse } from '../agent-tools/refusal'
import { type AnyNode, type AssetInput, ItemNode } from '../schema'
import { type AnyNode, type AssetInput, ItemNode, type WallNode } from '../schema'
import { edgeProjection } from './create-room'
import {
collectDoorKeepouts,
Expand All @@ -13,7 +13,7 @@ import {
import { collectOccupiedFootprints, findValidPlacement } from './layout-clearance'
import { type LevelTargetInput, targetLevel } from './level-target'
import { polygonArea, polygonBounds, type Vec2 } from './plan-geometry'
import { levelIdOf, levelRole } from './scene-queries'
import { levelIdOf, levelRole, queryNodes } from './scene-queries'
import type { AgentContext, AgentOperation, SceneNodes } from './types'

type RoomType = (typeof FURNISHED_ROOM_TYPES)[number]
Expand Down Expand Up @@ -58,8 +58,7 @@ function roomToFurnish(nodes: SceneNodes, input: FurnishRoomInput, context: Agen
/** The doors of the level that stand on an edge of the room, by where they stand. */
function doorsOfRoom(nodes: SceneNodes, levelId: string, polygon: Vec2[]) {
const found: { doorId: string; edge: number }[] = []
for (const wall of Object.values(nodes)) {
if (wall.type !== 'wall' || wall.parentId !== levelId) continue
for (const wall of queryNodes<WallNode>(nodes, { type: 'wall', parentId: levelId })) {
const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
if (length < 1e-9) continue
for (const childId of wall.children) {
Expand Down Expand Up @@ -253,6 +252,7 @@ export const furnishRoom: AgentOperation<FurnishRoomInput> = (nodes, input, cont

const doors = doorsOfRoom(nodes, levelId, polygon)
const doorWall = input.doorWallIndex ?? doors[0]?.edge ?? 0
// biome-ignore lint/plugin: door keep-outs and occupied footprints resolve each item through its parents
const all = Object.values(nodes)
const doorKeepouts = collectDoorKeepouts(all, { levelId })
const keepouts: PlanAabb[] = doorKeepouts.map((door) => door.aabb)
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/agent-operations/place-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
geometryUndersideAt,
mountsFlush,
} from '../lib/geometry-surfaces'
import { type AnyNode, type AssetInput, ItemNode, type WallNode } from '../schema'
import { type AnyNode, type AssetInput, ItemNode, type WallNode, type ZoneNode } from '../schema'
import { getWallLocalFaceZ } from '../systems/wall/wall-frame'
import { collectDoorKeepouts, type DoorKeepout } from './door-clearance'
import { type LevelTargetInput, targetLevel } from './level-target'
Expand All @@ -16,6 +16,7 @@ import {
type Vec2,
wallLength,
} from './plan-geometry'
import { queryNodes } from './scene-queries'
import type { AgentOperation, SceneChanges, SceneNodes } from './types'

type Entry = {
Expand Down Expand Up @@ -55,10 +56,8 @@ const isOutdoor = (asset: AssetInput) =>
OUTDOOR_ASSET.test(`${asset.name} ${asset.category ?? ''} ${(asset.tags ?? []).join(' ')}`)

function roomsOn(nodes: SceneNodes, levelId: string): Vec2[][] {
return Object.values(nodes).flatMap((node) =>
node.type === 'zone' && node.parentId === levelId && node.polygon.length >= 3
? [node.polygon as Vec2[]]
: [],
return queryNodes<ZoneNode>(nodes, { type: 'zone', parentId: levelId }).flatMap((node) =>
node.polygon.length >= 3 ? [node.polygon as Vec2[]] : [],
)
}

Expand Down Expand Up @@ -292,7 +291,10 @@ export const placeItems: AgentOperation<PlaceItemsInput> = (nodes, input, contex
'outside_rooms',
`"${asset.name}" at (${x}, ${z}) is outside every room on this level: indoor items go inside a room (read the rooms with get_zones); trees and garden items may stand outside.`,
)
doorsOn[floorId] ??= collectDoorKeepouts(Object.values(nodes), { levelId: floorId })
doorsOn[floorId] ??= collectDoorKeepouts(
queryNodes(nodes, { type: ['door', 'wall', 'level'] }),
{ levelId: floorId },
)
const misfit = floorFit(nodes, floorId, entry, asset, doorsOn[floorId])
if (misfit) return misfit
const node = make(asset, {
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/agent-operations/scene-queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { queryNodes } from '../runtime/node-queries'
import type { AnyNode } from '../schema'
import type { SceneNodes } from './types'

export { orderedChildIds, queryNodes } from '../runtime/node-queries'

export type LevelRole = 'occupied' | 'roof' | 'support'
export type ContentCounts = {
walls: number
Expand All @@ -21,9 +24,9 @@ const metadataString = (node: AnyNode, key: string) => {

/** Every level, in floor order (ties by id). */
export function levelsOf(nodes: SceneNodes): (AnyNode & { type: 'level' })[] {
return Object.values(nodes)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level')
.sort((a, b) => a.level - b.level || a.id.localeCompare(b.id))
return queryNodes<AnyNode & { type: 'level' }>(nodes, { type: 'level' }).sort(
(a, b) => a.level - b.level || a.id.localeCompare(b.id),
)
}

/** The level a node sits on, walking up its parents (a level resolves to itself). */
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/agent-operations/scene-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export function sceneViewBounds(
// With no walls yet, what the level holds frames it: furniture placed from a plan before the
// walls are built, a site with its paving and fences.
const held: SceneViewBox[] = []
// biome-ignore lint/plugin: a view frames whatever the level holds
for (const node of Object.values(nodes)) {
const level = levelOf(node)
if (level?.type !== 'level') continue
Expand Down
Loading
Loading