Overview
Execution model
Every command: deterministic code, model call or human gate.
This is the real, current inventory of every construct command/capability
and how it actually executes today: deterministic code, an LLM call (and
which one), or a human approval gate — re-derived directly from the code as
of the AI Toolkit work landing. Re-verify this against packages/core/cli.mjs's command dispatch
whenever a new capability is added; a stale version of this table is worse
than none.
The governing principle#
Construct is a library of small, deterministic,
non-LLM building blocks. The only place an LLM is ever involved is where
a human explicitly opts in with --llm <provider> (CLI) or a Settings
capability (UI) — and even then, in one of exactly two shapes:
- Execution-class calls — small, scoped, mechanical: "write this one file's real implementation." One call per file, never one call for a whole batch. These may route to a local model (Ollama/Qwen Coder) as well as a hosted one (Claude).
- Plan-analysis-class calls — whole-feature, high-context reasoning:
"look at this entire existing feature and propose how to decompose it."
Exactly one call, always. Never allowed to route to a local model —
enforced in code (
ui/server/src/settings.mjs'supdateSettingsthrows ifplanAnalysisis set toollama), not just left as a UI convention.
Locating files, deciding which layers/files to scaffold, and deciding a feature's shape are never delegated to an LLM in either case — that stays deterministic code, or a human/Claude-in-conversation decision made before any of this runs.
Providers (packages/core/llm.mjs)#
| Provider | How it's called | Notes |
|---|---|---|
claude |
Shells out to the claude CLI (spawnSync, headless -p --output-format text) |
Requires the CLI installed/authenticated locally. Sync under the hood, but callLlm always awaits it. |
ollama |
POST {baseUrl}/api/generate via fetch, stream: false |
No CLI shell-out. Target model + base URL are configurable per call ({ model, baseUrl }, defaults qwen2.5-coder:7b / http://localhost:11434) — never hardcoded. Requires a local ollama serve with the model pulled. |
Both share one shape: callLlm(provider, prompt, options?) -> Promise<string>,
throwing a ConstructError on failure. Adding a new provider means adding
one entry to PROVIDERS in packages/core/llm.mjs — nothing else in the framework
needs to change to support it.
Per-command inventory#
| Command | Execution | LLM call? | Human gate? |
|---|---|---|---|
construct init |
Deterministic (scaffolds architecture.yml, AGENTS.md, a starter core feature) |
No | No |
construct feature create |
Deterministic | No | No |
construct create feature <name> |
Deterministic | No | No |
construct create <layer> <name> --feature f [--llm <provider>] |
Deterministic scaffold, always | Optional, execution-class. One call, only for that one file, only if --llm given |
No (review the diff afterward, same as import) |
construct create layer <name> --feature f --layers ... [--llm <provider>] |
Deterministic scaffold, always | Optional, execution-class. One call per generated file (not one for the batch), only if --llm given |
No |
construct generate ... |
Same as the create forms above — create just delegates to generate |
Same as above | No |
construct sync |
Deterministic (regenerates .dependency-cruiser.cjs + each feature's index.ts) |
No | No |
construct validate |
Deterministic (architecture/SoC/readability enforcers + public-API drift check) | No | No |
construct summarize |
Deterministic, read-only | No | No |
construct doctor |
Deterministic, read-only (environment check) | No | No |
construct refactor move / rename |
Deterministic, mechanical (relocate + rewrite every importer's path; never touches a file's own content) | No | No |
construct research summarize / doctor |
Deterministic, read-only | No | No |
construct import <name> ... --from <path> [--llm <provider>] |
Deterministic scaffold + TODO(import) breadcrumb, always |
Optional, execution-class. One call per generated file, only if --llm given. Never reads the old source file at all unless --llm is given. |
No |
construct import --plan <path> [--llm <provider>] |
Deterministic, batched: runs the same per-unit scaffold (+ optional fill) once per unit in the plan | Same as above, applied uniformly across every unit | No — the plan itself was already human-approved before being handed to this command (see import --route below, which is how a plan is normally produced) |
construct import --route <path> (standalone wizard) |
Deterministic scaffold once a plan is approved | Always exactly one plan-analysis call (whole-feature, provider = Settings' planAnalysis (default claude; ollama is rejected) — see "How Settings is consumed" below) up front. Optionally one execution-class fill call per generated file afterward, if the user opts in mid-wizard. |
Yes — hard gate. Shows the proposed plan and explicitly asks "Approve this plan and build it now? [y/N]" before writing anything. |
construct repl |
Interactive shell wrapping every command above | No calls of its own — whatever the dispatched command does | Whatever the dispatched command does |
The UI layer (ui/)#
ui/server's Dashboard/Wizard/Pages-Editor features wrap the exact same
core CLI functions in-process (packages/core/cli.mjs's create/refactor/
research/importCommand, called directly — never a shell-out to the
construct binary) and add zero LLM calls of their own: every LLM call
that happens because of a UI action is one of the calls listed in the table
above, just triggered through a form instead of a terminal.
ui/server/src/settings.mjs is the one place the UI adds
real logic on top of this: a per-capability provider map,
llmProviders: { importFill: 'claude' | 'ollama', createFill: 'claude' | 'ollama', planAnalysis: 'claude' }
validated against the real PROVIDERS map in packages/core/llm.mjs (never a
UI-side copy), with planAnalysis hard-rejecting ollama in
updateSettings itself. Settings only configures which provider a future
call would use — it never makes a call by itself.
How Settings is consumed — and why LLM use is still opt-in per run#
Settings selects a provider only (not a model — the ollama provider
keeps its default model, qwen2.5-coder:7b, unless a caller passes
llmOptions). Settings defaults every capability to the first provider
(claude), so a setting alone must never cause a call. The rule is: a
run only calls an LLM if that run explicitly asked, and then the provider is
whatever Settings says for that capability.
| Where | Opt-in (per run) | Provider read from Settings |
|---|---|---|
Dashboard Create form (layer / vertical slice) → POST /api/create |
"Have the LLM write the implementation" checkbox (useLlm: true, default off; hidden for "a new feature") |
createFill |
Dashboard Import form → POST /api/import |
"Have the LLM write the ported logic" checkbox (useLlm: true); the old free-text Provider field is gone. A direct API caller may still send an explicit llm: '<provider>', which wins. |
importFill |
Import Wizard (/ws/wizard → importRouteWizard) |
Plan analysis is inherent to the wizard; the per-file fill is opt-in mid-wizard ("should the LLM also write the ported logic?") | planAnalysis for the analysis call, importFill for the fill |
importRouteWizard(ask, seedRoute, { planAnalysis = 'claude', importFill = 'claude' })
takes the two providers as options (the plain CLI keeps the claude
defaults); planAnalysis: 'ollama' is rejected inside the wizard as well as
in updateSettings, before any call or write.
LLM output is validated before it is written#
Every per-file fill (import, create, generate) goes through
packages/core/llm-fill.mjs: the prompt states the reply is captured from stdout and
that the model has no file access; a reply that wraps exactly one fenced
block in prose is unwrapped; the result must parse as TypeScript/JavaScript
(parseToAst) and contain a declaration, import or export. A rejected
reply gets one corrected retry; a provider call that throws is not
retried. Whatever happens, an unfilled file keeps its scaffolded stub (plus
the TODO(import) breadcrumb on the import path), the command reports it
per file, other files continue, and the exit code is 3.
Cockpit execution mode: audit of the seven verbs#
The Cockpit runs a core activity in-process (engine, default) or as the real construct binary (cli), per
project (project.execution.mode, see README "Cockpit execution mode"). This table is the audit that decides,
verb by verb, what cli mode can honestly promise. "Contract" means construct <verb> ... --format json prints one
stable document that a test compares byte for byte with the in-process result. Status is kept current as each verb lands.
| Verb | --format json contract today |
In-process path in ui/server |
Where the two would diverge | Status |
|---|---|---|---|---|
validate |
yes (validate --format json) |
GET /api/validate (validateApi.mjs) |
none found; exit 1 is a result, not a crash | done (first slice) |
summarize |
yes for the default project/feature summary (summarize --format json [--feature f], one compact line) |
POST /api/research {action: summarize} through runCapturing(research(...)); research appends the [tool: ...] [llm: 0 calls] line |
the md, compact, prose views and since are text or concatenated text, no JSON contract, so they stay in-process in both modes; the attribution line only exists under research, so cli mode runs summarize and attaches the same constant |
done |
research |
doctor had none (added: doctor --format json); workflow, impact, spec already have one |
POST /api/research {action: doctor}; workflow, impact and spec have no Cockpit endpoint, they run only as Processes, and the bot runner already spawns the CLI |
doctor text is rendered from the same document in both modes (renderDoctorText) |
doctor done; the rest are subprocess already |
review |
yes (review <base> <head> --format json, default) |
Review mode's analysis: a Process step (review.analyze) running a forked worker (reviewWorker.mjs) |
the worker adds units (what each changed file now does, from summarizeUnit) which the CLI does not print; the plan travels as expected {features, files}, the CLI takes --plan <file>; the worker's root is the git top level, the CLI's --dir walks UP to the nearest architecture.yml, so cli mode refuses a root that would climb out of itself |
done: prHealth vs construct review byte-identical (5 scopes and a refusal); the Review analysis runs the CLI in cli mode |
create |
none: text with per-file timings ((0.01s), Total:), so never byte-stable |
POST /api/create (feature, layer, single) through runCapturing(create(...)) |
needs a deterministic result document (files created), and --llm fills cannot be in a contract (a provider call) |
done: create ... --format json added (deterministic forms only); document and project trees byte-identical |
refactor |
none: text; the core result (moveLayerFile, renameLayerFile) is structured already |
POST /api/refactor (move, rename) through runCapturing(refactor(...)) |
needs a result document (from, to, files, importers updated, follow-up validation) | done: refactor move|rename ... --format json added; document and project trees byte-identical |
import |
none: text with timings and per-file fill reports | POST /api/import (unit, plan) through runCapturing(importCommand(...)); the Import Wizard (/ws/wizard) is interactive with an LLM plan analysis |
needs a result document; --llm fills and the wizard's analysis are AI work that stays in-process |
done: import ... --format json added (no --llm); document and project trees byte-identical |
Never switchable, by rule (ui/server/src/coreExecutor.mjs header): scope links, Palette grouping, the live-preview
bridge, pane and resize state, the dev-server manager, and the read models /api/units, /api/flow, /api/features,
/api/components (they call summarizeUnit in-process on every hover or tab change). Processes (bots) already spawn
the CLI in a worktree and are not part of this switch.
Known gaps, deliberately left (each is a follow-up, not a silent difference):
refactor extract-expressionand thecreateforms with--from,--bind,--openapior--llmhave no JSON document;--format jsonrefuses them by name.- The text lines a
cli-modecreateorimportshows have no per-file timings (the timings are what made the text unfit for a byte-identical contract); the response's owndurationSecondsstill says how long it took. GET /api/validateinclimode (the first slice) is not routed through the per-login queue that the other verbs use inclimode, because its in-process twin is not queued either.- In
climode the Review analysis still computes "what each changed file now does" in the existing in-process worker; that enrichment is not part ofconstruct review's report.