Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
747b28e
Merge main into feat/detached-scene-runtime
cursoragent Oct 20, 2026
5956bda
core: space detection takes in a settling host's derived writes
cursoragent Oct 20, 2026
e1b4601
core/runtime: a support query's scope is the level its subject is on
cursoragent Oct 20, 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 @@ -69,6 +69,8 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples

**E-017 Additive node editing and surface contracts (owner, 2026-10-16).** Surface boundaries, connected movement, primary inspector predicates, independent child movement and preserved level/elevation are optional contributions on `NodeDefinition` and its existing capability types. They do not add fields to the v1 `Plugin` manifest (E-014). Boundary providers own their geometry and coordinates; framework consumers dispatch through the registry. Connected-move providers return pure patches, previewed with live overrides and committed once per gesture. Inspector predicates select an ID without subscribing the card to whole nodes. `metadata.roomBoundary === false` is the explicit opt-out from room topology. Authoring tools that node packs share come from a host package, never from importing the built-in plugin (E-001). · Why: external node packs need the same authoring behavior without depending on built-in kinds or private plugin shapes. · Enforced: `core/src/services/port-connectivity.test.ts` (connected move), `editor/src/lib/direct-manipulation.test.ts` (independent children), `core/src/lib/space-detection.test.ts` (room-boundary opt-out), `nodes/src/stair/surface-transition.test.ts` (surface boundaries). · See `plugin-authoring.md`. · Revisit: when a contribution needs a field on the `Plugin` manifest itself.

**E-018 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-018: 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 @@ -115,6 +115,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 @@ -12,6 +12,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 @@ -83,9 +84,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
3 changes: 2 additions & 1 deletion packages/core/src/agent-operations/add-fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FenceNode } from '../schema'
import { type LevelTargetInput, targetLevel } from './level-target'
import { finishSurface, requireMaterialRef } from './material-refs'
import { refuseRoofLevel } from './roof-writes'
import { queryNodes } from './scene-queries'
import type { AgentOperation } from './types'

type Pt = [number, number]
Expand Down Expand Up @@ -42,7 +43,7 @@ export const addFence: AgentOperation<AddFenceInput> = (nodes, input, context) =
`The side from [${short[0]}] to [${short[1]}] is shorter than ${FENCE_MIN_SIDE * 100} cm: drop the repeated point.`,
{ start: short[0], end: short[1] },
)
const first = Object.values(nodes).filter((node) => node.type === 'fence').length
const first = queryNodes(nodes, { type: 'fence' }).length
const fences = sides.map(([start, end], i) =>
FenceNode.parse({
name: `Fence ${first + i + 1}`,
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
Expand Up @@ -2,7 +2,7 @@ import { refuse } from '../agent-tools/refusal'
import { BuildingNode, LevelNode, SiteNode } from '../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
import { storeyInsertIndex } from '../services/roof-level'
import { levelsOf } from './scene-queries'
import { levelsOf, queryNodes } from './scene-queries'
import type { AgentContext, AgentOperation, SceneNodes } from './types'

type AddLevelInput = {
Expand Down Expand Up @@ -31,9 +31,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.')
return refuse(
Expand All @@ -48,7 +46,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 @@ -85,7 +83,7 @@ function freshStart(nodes: SceneNodes, input: AddLevelInput) {
* one), or under its lowest level — 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
5 changes: 2 additions & 3 deletions packages/core/src/agent-operations/add-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { achievedChanges } from './achieved'
import { targetLevel } from './level-target'
import { roofLevelRefusal } from './roof-writes'
import { queryNodes } from './scene-queries'
import type { AgentOperation, BatchedObject, SceneNodes } from './types'

type Vec3 = [number, number, number]
Expand Down Expand Up @@ -156,9 +157,7 @@ function shareOverFloor(
compiled: CompiledGeometryScript,
input: AddObjectInput,
) {
const slabs = Object.values(nodes).filter(
(node): node is SlabNode => node.type === 'slab' && node.parentId === levelId,
)
const slabs = queryNodes<SlabNode>(nodes, { type: 'slab', parentId: levelId })
const { min, max } = compiled.manifest.bounds
const footprintArea = (max[0] - min[0]) * (max[2] - min[2])
if (slabs.length === 0 || !(footprintArea > 0)) return 0
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/agent-operations/add-wall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WALL_MIN_LENGTH } from '../systems/wall/wall-topology'
import { applySceneChanges } from './apply-changes'
import { type LevelTargetInput, targetLevel } from './level-target'
import { refuseRoofLevel } from './roof-writes'
import { queryNodes } from './scene-queries'
import type { AgentOperation } from './types'

type Pt = [number, number]
Expand Down Expand Up @@ -34,7 +35,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
9 changes: 5 additions & 4 deletions packages/core/src/agent-operations/author-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { authoredSceneChanges } from './authored-scene-merge'
import { paint } from './paint'
import { pointInPolygon, projectWorldPointToWallLocalX, type Vec2 } from './plan-geometry'
import { resolveInlinePaintMaterial } from './scene-materials'
import { queryNodes } from './scene-queries'
import type { AgentContext, AgentOperationOutcome, SceneNodes } from './types'
import { setWallSkirting } from './wall-skirting'

Expand Down Expand Up @@ -386,10 +387,10 @@ function resolveFaceScripts(
const roomId = `room/${index}/${i}`
const zone = nodes[ids[roomId] ?? '']
if (!room.wallCode || zone?.type !== 'zone') continue
const boundaries = Object.values(nodes).filter(
(n): n is BoundaryNode =>
n.parentId === zone.parentId && (n.type === 'wall' || n.type === 'separator'),
)
const boundaries = queryNodes<BoundaryNode>(nodes, {
type: ['wall', 'separator'],
parentId: zone.parentId ?? null,
})
const zoneWalls = [...zone.boundaryWallIds].sort().join()
const derived = floorRoomFaces(boundaries).find(
(r) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { liftedManualSlab } from '../lib/floor-construction-lift'
import { withHostedCutterHoles } from '../lib/object-cuts'
import { area, difference, intersection, type Ring } from '../lib/polygon-boolean'
import { getRenderableSlabPolygon, slabPolygonContextForLevel } from '../lib/slab-polygon'
import type { AnyNode } from '../schema'
import type { AnyNode, ItemNode, SlabNode } from '../schema'
import { resolveCeilingHeight } from '../services/level-height'
import { CEILING_DRAW_OFFSET, getLevelElevations } from '../services/storey'
import { scriptItemBoundsInLevel } from './layout-clearance'
import { queryNodes } from './scene-queries'

type BoundsEvidence = {
severity?: 'info'
Expand Down Expand Up @@ -58,8 +59,8 @@ export function ceilingObjectObservations(
): CeilingObjectObservation[] {
const observations: CeilingObjectObservation[] = []
const elevations = getLevelElevations(nodes)
for (const item of Object.values(nodes)) {
if (item.type !== 'item' || item.asset.attachTo !== 'ceiling') continue
for (const item of queryNodes<ItemNode>(nodes, { type: 'item' })) {
if (item.asset.attachTo !== 'ceiling') continue
const ceiling = item.parentId ? nodes[item.parentId] : undefined
if (ceiling?.type !== 'ceiling') continue
const resolved = scriptItemBoundsInLevel(item, nodes)
Expand Down Expand Up @@ -90,8 +91,8 @@ export function ceilingObjectObservations(
attachmentElevation: attachment,
})
const footprint = projectedHull(resolved.corners)
for (const original of Object.values(nodes)) {
if (original.type !== 'slab' || original.recessed || !original.parentId) continue
for (const original of queryNodes<SlabNode>(nodes, { type: 'slab' })) {
if (original.recessed || !original.parentId) continue
const slabLevel = elevations.get(original.parentId)
// Unlinked legacy levels are not evidence that two different storeys share a building.
if (
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
5 changes: 2 additions & 3 deletions packages/core/src/agent-operations/corner-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cornerPair, wallEndPoint, windowCornerEnd } from '../lib/corner-pair'
import type { WallNode, WindowNode } from '../schema'
import { achievedChanges } from './achieved'
import { type LevelTargetInput, targetLevel } from './level-target'
import { queryNodes } from './scene-queries'
import type { AgentOperation, SceneChanges } from './types'
import { registerSceneCheck, type SceneIssue } from './verify-scene'

Expand Down Expand Up @@ -42,9 +43,7 @@ const round = (value: number) => Math.round(value * 100) / 100
export function cornerWindowIssues(nodes: Parameters<typeof cornerPair>[0]): SceneIssue[] {
const issues: SceneIssue[] = []
const atCorner = new Map<string, WindowNode[]>()
for (const node of Object.values(nodes)) {
if (node.type !== 'window') continue
const window = node as WindowNode
for (const window of queryNodes<WindowNode>(nodes, { type: 'window' })) {
const pair = cornerPair(nodes, window)
// Informational: what broke the pair (a side deleted or moved, a wall moved) was a choice.
if (window.corner && !pair) {
Expand Down
10 changes: 3 additions & 7 deletions packages/core/src/agent-operations/create-roof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { applySceneChanges } from './apply-changes'
import { requireLevel } from './level-target'
import { finishSurface, requireMaterialRef } from './material-refs'
import { buildingFrame, type RoofRectangle, roofRectangles } from './roof-footprint'
import { levelRole, levelsOf } from './scene-queries'
import { levelRole, levelsOf, queryNodes } from './scene-queries'
import type { AgentContext, AgentOperation, SceneChanges, SceneNodes } from './types'

type Pt = [number, number]
Expand Down Expand Up @@ -141,9 +141,7 @@ function coveredStorey(
* still gaps, got a roof over a few rooms only).
*/
function wallOutline(nodes: SceneNodes, storeyId: string) {
const walls = Object.values(nodes).filter(
(node): node is WallNode => node.type === 'wall' && node.parentId === storeyId,
)
const walls = queryNodes<WallNode>(nodes, { type: 'wall', parentId: storeyId })
if (walls.length < 3) return []
const bridges = gapBridges(walls).map(
(gap, index) =>
Expand Down Expand Up @@ -217,10 +215,8 @@ function footprintPolygons(nodes: SceneNodes, storeyId: string): Pt[][] {
const outline = wallOutline(nodes, storeyId)
if (outline.length) return outline
const on = (type: 'slab' | 'zone') =>
Object.values(nodes).filter(
queryNodes(nodes, { type, parentId: storeyId }).filter(
(node) =>
node.type === type &&
node.parentId === storeyId &&
!(
node.type === 'slab' &&
node.metadata &&
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 @@ -16,7 +16,7 @@ import {
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 @@ -68,9 +68,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 @@ -100,7 +98,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
Loading
Loading