Constructdocs
section · guides

Architecture

Diagrams + the request lifecycle + where things live. Read once when you want to understand the bones.

39 min read·guides / concepts / architecture

How to read this page

Start with the diagrams. They're the fastest way to build a mental model of the wiring. The text after explains the same thing twice on purpose: once in plain language, once in codebase terms. Pick whichever flavor lands faster, then read the other to lock it in.

The 30-second version

Construct is one front door (a persona) that you talk to in OpenCode, Claude Code, or your preferred editor. Behind that door is a team of specialists, shaped by your active org profile, who challenge each other and ship verified work. The default profile (rnd) is the software R&D team you would expect (architect, engineer, reviewer, security, QA, and others); operations, creative, and research are curated alternatives, and custom profiles drop into .construct/scope.json. A small CLI keeps the team installed, healthy, and aligned with your project state.

While Construct provides deep integration with Claude Code and editor-based tools like Cursor and VS Code, OpenCode is our primary, heavily optimized surface. OpenCode's native multi-model routing (OpenRouter, Ollama) and its mature plugin ecosystem give Construct a first-class, terminal-native experience that prioritizes privacy, flexibility, and performance. The intake loop turns signals into outcomes: anything dropped into inbox/ is classified against the active profile's taxonomy, owner-assigned, planned as a task graph, routed to the right persona with the right context, executed in a bounded worker, evidenced, evaluated, and persisted. That's the whole product. The rest of this page is wiring.

Intake loop

inbox/any signalembed daemonclassifyRdIntake deterministic, no LLM.construct/intake/pending/triage block type · stage · owner · chainintake done.construct/task-graphs/node per persona in recommendedChaincontext routerper-role artifact bundleworker planebounded exec + timeout + path policyevidence + memorylinked to graph node + durable state

The signal-to-memory path is deterministic where it should be (classification, routing, evidence) and LLM-driven where it must be (the agent in the user's editor does the actual analysis). Daemon code never calls an LLM.

How a request moves through Construct

User
@construct fix the login redirect
Persona
personas/construct.md — the only public surface
Orchestration policy
classifies intent + execution track
gate / check
Gates
framing · external research · doc owner
preconditions met
Contract chain
specialists/org/contracts/ — typed handoffs with pre/post conditions
Specialist sequence
architect → engineer → reviewer → qa → security
gate / check
Verification gates
npm test · lint:comments · docs:verify · contract postconditions
pass
Result
back through the persona, in one voice

The loop is the differentiator. Other agent tools dispatch to specialists; Construct dispatches and gates and re-runs until verification passes or a real blocker surfaces.

Interaction surfaces

The CLI registry is the substrate (~118 commands). OpenCode is the first-class conversational surface; other supported hosts reach the same engine through MCP, ACP, or generated adapter files (ADR-0039):

SurfaceEntryRole
OpenCodeconstruct agent in OpenCodePrimary conversational authoring surface
Agent / MCP@construct or MCP tools in Claude, Codex, Cursor, Copilot, VS CodeOrchestration policy, skills, workflows
Thin CLIconstruct status, construct doctor, construct intake, …Operator diagnostics, setup, queue inspection, CI/headless contracts

Oracle (construct oracle status) sits alongside as an L0.5 bounded-auto health layer — not a user-facing conversation surface.

Document I/O

Ingestion and export are optional halves bound to external tooling (ADR-0024, ADR-0036):

HalfEntryEngine
Ingestconstruct ingestDocling sidecar (high fidelity), unpdf/mammoth (fast), whisper (AV)
Exportconstruct export / construct publishPandoc + Typst (PDF), branded HTML/deck, pptxgenjs (PPTX), LibreOffice (legacy .doc)

In accepts PDF, Office, email, AV, and plain text → normalized markdown. Authoring stays typed markdown artifacts. Out spans PDF, DOCX, DOC, HTML, deck, PPTX, RTF, ODT, EPUB, and more.

Canonical format matrix: Document I/O reference. Brand tokens live in lib/brand-tokens.mjs and flow to PDF/HTML/deck/PPTX via lib/publish-template.mjs.

Where things live

core/ — orchestration brain

Physical: bin/construct, lib/cli-commands.mjs, lib/mcp/, lib/orchestration-policy.mjs, lib/storage/, lib/hooks/, lib/oracle/. ADR-0002 logical core/ maps here — not a top-level directory.

providers/ — external systems

Physical: lib/providers/ (REST data-source adapters + credentials), lib/providers/contract/ (transport-agnostic contract harness + CLI adapters), lib/embed/providers/ (embed daemon). Root-level providers/ was removed (ADR-0044).

runtime/ — keeps the lights on

Physical: lib/embed/ (daemon + supervisor), lib/service-manager.mjs, scheduler jobs under lib/embed/.

deploy/ — ship it

Multi-stage hardened Dockerfile · Terraform modules (ECS, ALB, RDS+pgvector) · release.yml SEA binaries, GHCR, npm publish

Runtime directory semantics. Construct distinguishes packageRoot (the npm install directory — source of bundled personas, skills, schemas, hooks, and templates) from projectRoot (the user's repo, discovered by walking upward from cwd). Services that read or write durable state (.construct/, oracle, telemetry, beads) must use projectRoot; asset loaders must use packageRoot. process.cwd() is the raw fallback before projectRoot is resolved. See Root semantics for the full subsystem table and a worked example.

Physical root map (tool repository)

construct/
├── apps/           docs (shipped UI)
├── bin/            CLI entrypoint
├── lib/            runtime (hooks, MCP, providers, oracle, embed)
├── docs/           human + generated reference
├── deploy/         terraform / docker
├── packages/       cx-ui shared workspace
├── specialists/    org under specialists/org/** (runtime merge) + prompts  ─┐
├── registry/       product capability registry          │ org surface
├── platforms/      host adapter capabilities            │ (see ADR-0044)
├── schemas/        registry JSON Schema only            │
├── profiles/       profile catalog                      │
├── personas/       public persona                       │
├── commands/       slash command assets                 │
├── skills/         domain knowledge                     │
├── rules/          coding standards                     │
├── templates/      doc templates                        ─┘
├── config/         tag-vocabulary.json
├── scripts/        audit, alignment, release
├── tests/
└── examples/

Repo layout hygiene is enforced by audit phase 03c-root-layout (ratchet + alignment census). Oracle consumes census rootLayout signals on the tool repo. See ADR-0044.

Two registries, two jobs: registry/capabilities.json (product capabilities + verification tests) vs platforms/capabilities.json (host adapter configs). Handoff artifact schemas live in lib/contract-schemas/; registry JSON Schema lives in schemas/.

In human terms

Picture a small company. You are the founder. You walk in and say "fix the login redirect" or "ship the customer portal." The persona is the chief of staff. the one person you actually talk to. They don't fix the login themselves; they walk down the hall and pull in the right people. The architect sketches the trade-offs. The engineer reads the code and writes the change. The reviewer pushes back on what the engineer didn't test. The QA runs the thing. The security specialist asks "what if someone's hostile?" Nothing leaves the building until the gates say it's done. Beads is the project tracker. The docs are the durable record of decisions. Memory is what the team remembers from yesterday. You never had to assign anything by name. the chief of staff did.

In technical terms

A user request hits the Construct persona (personas/construct.md). The orchestration policy (lib/orchestration-policy.mjs) classifies the intent and resolves the execution track (immediate, focused, or orchestrated), the gate set (framingChallenge, externalResearch, docAuthoring), the contract chain (specialists/org/contracts/ — typed producer→consumer handoffs with preconditions, postconditions, and input/output schemas validated at runtime by lib/specialist-contracts.mjs), and the specialist sequence (specialists/prompts/cx-*.md). Specialists dispatch through the MCP server (lib/mcp/server.mjs). Verification fires four hard gates locally before any commit (npm test, lint:comments, docs:verify, docs:update --check) plus contract postconditions on every handoff; failures get a chain-hashed JSONL entry in ~/.cx/contract-violations.jsonl. Providers (lib/providers/) are stateless adapters with their own transport choice; durable state lives in lib/storage/ and ~/.cx/.

Why it's built this way

  • One public surface. The persona is the only thing the user talks to. Specialists can be reorganized, renamed, or replaced without breaking the user contract. they're implementation detail.
  • Stateless providers. Providers never own durable state. Swapping GitHub for a custom Git provider, or adding Salesforce, doesn't migrate any data. Construct's stores stay put.
  • Mode-driven topology. Deployment mode (solo | team | enterprise) selects the backends for the intake queue, run store, memory, telemetry, and workers. Queue and run-store selection resolve through extension manifests (kind:"queue" / kind:"storage") with builtin defaults; an explicit backend that is not registered degrades visibly instead of silently falling through. Solo runs everything locally and degrades gracefully when an optional resource is missing. Team and enterprise now default to the Postgres intake queue, which supplies row-locked claims, expiring leases, and heartbeat renewal; if no SQL client is configured, the mode fails closed unless CONSTRUCT_DEGRADED_OK=postgres-queue explicitly permits a visible git-queue fallback. Shared run storage uses Postgres when configured. Brokered MCP dispatch is staged/experimental and not yet active in built-in deployments (tracked: construct-9oi4.10). See deployment model.
  • Hard gates over soft hooks. Comment policy, doc verification, template policy, and contract postconditions fail the build. They are not advisory. The default is "stop and fix"; CI is a backstop, not the primary check.
  • No fabrication, enforced. Every load-bearing claim in every artifact traces to a source. Lint blocks unsupported assertions in real time. Intake traceability stamps every artifact's origin packet into its frontmatter. Contract postconditions verify structural requirements before a handoff crosses a phase boundary. See Integrity and trust.
  • Specialists challenge each other. Devil's advocate, reviewer, QA, security are peers, not rubber stamps. Agreement at every step is treated as a smell. if everyone always says yes, the gates aren't doing their job.

Architecture layers

ADR-0002 logical layers — physical paths in ADR-0044:

core/        → bin/, lib/ (CLI, MCP, orchestration, memory, sessions)
providers/   → lib/providers/, lib/providers/contract/, lib/embed/providers/
runtime/     → lib/embed/, lib/service-manager.mjs
deploy/      → deploy/

Core

The foundation. Handles orchestration, specialist dispatch, memory, sessions, and the MCP server. Zero external npm dependencies.

  • CLI surface. bin/construct and lib/cli-commands.mjs
  • MCP server. lib/mcp/server.mjs, tools split across lib/mcp/tools/
  • Orchestration policy. lib/orchestration-policy.mjs (intent classification, execution track selection, gate evaluation, contract-chain resolution)
  • Agent contracts. specialists/org/contracts/ and lib/specialist-contracts.mjs (producer→consumer service contracts with preconditions, postconditions, input/output schemas). Every postcondition is classified executable or advisory (lib/contracts/coverage.mjs, construct-rf26.12) rather than left as undifferentiated prose.
  • Observation store. .construct/observations/ (role-scoped, capped insights for continuous learning) plus a machine-scoped LanceDB vector index at <stateRoot>/lancedb (ADR-0066). Both the vector index and the ONNX embedding model provision lazily — first construct ingest/observation store or search, not construct init (opt in early with --seed-index/construct install --with-embeddings). TTL/size eviction on the vector-indexed rows (resources.disk.observationsMaxDays/observationsMaxRows in construct.config.json, default 90d/5000 rows) runs on every construct ingest and is surfaced in construct doctor/construct prune (lib/storage/admin.mjs's purgeExpiredData, construct-rf26.17).
  • Session persistence. lib/session-store.mjs, .construct/sessions/ (distilled session records for resumption)
  • Hybrid search. lib/storage/ (file-state source, SQL store, vector index, hybrid query facade). One consolidated path: lib/storage/hybrid-query.mjs runs file BM25 + native pgvector HNSW ANN (<=>, with hnsw.iterative_scan on pgvector ≥ 0.8) + keyword, and fuses them by Reciprocal Rank Fusion (lib/storage/rrf.mjs) so incompatible score scales merge by rank. lib/knowledge/search.mjs accepts tags + tagMatch for tag-filtered BM25 over file content.
  • Plugin engine. lib/engine/ with six contracts (Embedder, Chunker, Indexer, Fuser, Reranker, Compressor). Each layer resolves to a built-in default unless overridden by ~/.config/construct/plugins.json, <project>/.construct/plugins.json, or CONSTRUCT_PLUGIN_<LAYER> env. Failed overrides fall back to the default and surface in construct doctor. External git projects plug in by exporting a factory that satisfies the contract. assertContract(layer, plugin) runs at load time so the failure is loud, not silent.
  • Tag system. config/tag-vocabulary.json is the repo-wide controlled vocabulary (five orthogonal facets: intake-type, lifecycle, priority, signal-strength, reversibility). Project-local overrides at .construct/tags/vocabulary-overrides.json. lib/tags/vocabulary.mjs merges both tiers; lib/tags/lifecycle.mjs manages the active/deprecated/archived lifecycle with Postgres-backed migration audit at db/schema/007_tags.sql. Auto-attribution: suggestTags(triage, related, vocab) in lib/intake/classify.mjs stamps intake/<type> tags from classification confidence; related-doc inheritance at 0.70. Four cross-cutting tags promote to graph entities: signal/high-customer-interest, priority/p0, priority/p1, reversibility/irreversible.
  • OTel telemetry. lib/telemetry/otel-tracer.mjs provides a graceful-degrade facade over @opentelemetry/api (optional dep). When OTEL_EXPORTER_OTLP_ENDPOINT is set, real GenAI client spans emit gen_ai.* attributes per the OpenTelemetry GenAI semconv. W3C traceparent propagates across MCP boundaries via params._meta (SEP-414). When the SDK is absent or no endpoint is configured, a no-op path runs with zero overhead. lib/telemetry/skill-calls.mjs captures per-skill load events with latency, agent identity, session id, and token size; construct skills usage|orphans|hot|correlate-quality surfaces the rollup.
  • Workflow templates. templates/workflows/ YAML templates (new-feature, engineering-onboarding, cross-team-handoff) define inputs, artifact chains, and linked beads items with dependency order. lib/workflows/instantiate.mjs renders and creates the chain. schemas/workflow.schema.json validates all templates.
  • Hooks / enforcement. lib/hooks/ (session-start, bash-output-logger, repeated-read-guard, context-watch, audit-trail)
  • Audit trail. lib/audit-trail.mjs, ~/.cx/audit-trail.jsonl with tamper-evidence chain
  • Doc stamps. UUIDv7 front-matter on all generated .md files for identity, provenance, and tamper detection

Providers

Transport-agnostic interface to external systems. Each provider implements a capability matrix and chooses its own transport (MCP, REST, GraphQL, SDK, CLI, webhooks). Core dispatches through the interface. it never knows the transport.

Capability matrix:

CapabilityDescription
readFetch items, pages, messages, files from the external system
writeCreate or update items (work items, messages, pages, PRs)
searchQuery the external system's index
watchPoll or subscribe for changes
webhookReceive inbound events from the external system

Provider contract:

  • Providers are stateless adapters. Durable state lives in core stores.
  • Auth is per-provider, configured in .construct/providers.yaml or environment.
  • A provider may support any subset of capabilities; unsupported capabilities return a typed error.
  • Provider implementations live in lib/providers/ (data-source adapters) and lib/providers/contract/adapters/ (contract harness). See ADR-0044.

Provider scope framing. Every provider config carries an allowlist (repoAllowlist/repoAllowGlob for GitHub, projectAllowlist for Jira, spaceAllowlist for Confluence, channelAllowlist for Slack). Requests targeting out-of-scope resources return a typed OUT_OF_SCOPE error. construct creds set|rotate|list|revoke|test <provider> manages credentials in ~/.config/construct/config.env (0600 enforced), and construct creds login copilot runs the GitHub Copilot OAuth device flow (ADR-0042). Values may be plain keys or 1Password op:// references, resolved at call time by lib/providers/secret-resolver.mjs. For hosted LLM providers (openrouter, anthropic, openai), construct creds test performs a lightweight authenticated probe through lib/providers/connection-probe.mjs and reports provider health without exposing credential values. construct providers discover <name> runs provider-specific discovery with TTL-cached results.

Shipped providers:

ProviderTransportCapabilities
Git repogit CLIread, write, watch
Project tracker (Jira)REST API v3read, write, search, webhook
Messaging (Slack)Slack Web APIread, write, watch, webhook
Code host (GitHub)gh CLIread, write, search, webhook
Knowledge base (Confluence)REST API v2read, write, search

Runtime

Docker service management, embed daemon, and scheduler.

  • Service manager. lib/service-manager.mjs (memory server lifecycle; local Postgres process management is out of scope — solo mode uses embedded LanceDB, and the optional SQL client opens only when DATABASE_URL/CONSTRUCT_DATABASE_URL is configured)
  • Embed daemon. scheduled or long-running process that monitors sources through providers, produces snapshots, manages approval queue. construct embed supervise installs a platform-native supervisor (launchd/systemd/Task Scheduler) for auto-restart on crash.
  • Scheduler abstraction. lib/scheduler/index.mjsregisterJob, runJobOnce, listJobs. Three modes: solo (native triggers via launchctl/systemd/Task Scheduler), team/enterprise (Postgres advisory-lock leader election), one-shot CI. Three built-in jobs: tag-candidate-mining, skill-usage-rollup, doc-hygiene-scan. CLI: construct scheduler run|list|runner.
  • Doc hygiene scanner. lib/hygiene/scan.mjs selects stale lifecycle-tagged documents for review: lifecycle/approved older than 30 days, lifecycle/draft older than 7 days, any doc missing last_verified_at. stampVerified writes the field to frontmatter. Integrated into the doc-hygiene-scan scheduler job.
  • Resource bootstrap. lib/bootstrap/resources.mjs probes optional resources (Postgres, ONNX model, Docker, git); construct init runs the full wizard.
  • Backup. lib/storage/backup.mjs creates timestamped tar.gz archives covering observations, sessions, config.env (secrets redacted), registry snapshot, and Postgres dump. SHA-256 manifest for tamper detection. construct backup create|verify|restore|list|prune. create auto-prunes to CONSTRUCT_BACKUP_RETAIN (default 10) unless --no-prune is passed. Optional embed-daemon job auto-backup runs every CONSTRUCT_AUTO_BACKUP_DAYS days (off by default).

Conversational surfaces

Construct does not ship its own terminal conversation loop. OpenCode is the primary conversation surface; Claude Code, Codex, VS Code, Cursor, Copilot, and ACP clients remain supported through generated adapters and the Construct MCP server. Operator inspection stays in the thin CLI (construct status, construct doctor); credentials are managed via construct creds / construct providers; approvals route through the messaging provider, construct oracle, or the host's approval surface.

Deploy

Infrastructure as code and container packaging.

  • Dockerfile. multi-stage hardened image: builder stage (npm ci + global tools), runtime stage (no bash, no build tooling). Supports --read-only rootfs with /data volume. Target < 500 MB.
  • Terraform modules. deploy/terraform/ (VPC, ECS/Fargate, RDS+pgvector, Secrets Manager, ALB, IAM). ECS runs ≥ 2 tasks behind an ALB with sticky sessions and CPU/request-count auto-scaling. RDS parameter group loads shared_preload_libraries = 'vector'.
  • Release pipeline. .github/workflows/release.yml: quality gate → parallel Node SEA binary builds (linux/darwin/windows × x64/arm64) → Docker push to GHCR + Trivy scan → GitHub Release with binaries → npm publish --provenance.
  • Bun-compiled binary (exploratory). scripts/build-binary.mjs / scripts/build-sea.mjs / scripts/install.sh / Formula/construct.rb / .github/workflows/bun-binary-smoke.yml — a candidate Bun --compile distribution path, not yet wired into release.yml. LanceDB's N-API binding and the MCP SDK both verified working under a Bun-compiled binary; bin/construct itself does not yet run standalone because it resolves its data directories (skills/, specialists/, templates/, etc.) via import.meta.dirname, which resolves to Bun's virtual bundle root rather than a real path. See the Unreleased section of CHANGELOG.md for the full verification record.
  • Webhook ingestion endpoint for provider events

Operating model: gates + contracts + specialists

Every request flows through three structural layers:

  1. Gates (routeRequest returns framingChallenge, externalResearch, docAuthoring, and for research-shaped asks researchExecutionPolicy): preconditions that must hold before work begins. Frame the problem independent of tickets; route authorship to the owning specialist; cx-researcher returns primary sources first; and the research policy tells every host whether to use local evidence, knowledge_search, Context7, official-doc web fetches, or other domain-primary sources.
  2. Contract chain (routeRequest.contractChain): ordered typed handoffs from specialists/org/contracts/. Each contract names a producer→consumer pair, required input fields, preconditions, expected output shape, and postconditions. Postconditions are classified executable (mechanically checked in lib/contracts/validate.mjs — output-shape, nested-schema, or a binary-postcondition rule) or advisory (irreducible prose kept verbatim); 39 of 111 postconditions across the 40 contracts are executable today (construct-rf26.12), reported as a postcondition-coverage category in construct doctor consistency (lib/contracts/coverage.mjs). Dispatch itself is moving onto the flow engine below — a contract's handoff becomes a delegation spec on a flow step rather than prose injected into the agent's context.
  3. Specialist sequence: dispatch plan with ordering and parallel markers. Gate-required specialists (cx-reviewer, cx-researcher, doc owner) are auto-prepended.

Flow engine

Sequencing that used to live as prose injected into an agent's context (lib/orchestration-policy.mjs's contract-chain resolution) is moving to a deterministic engine (ADR-0067). lib/flows/ is a zero-dependency typed-state engine: defineFlow/loadFlow validate a flow's state schema, step graph, and fan-out rules at load time, so a flow that loads at all is structurally valid — failures surface at authoring time, not mid-run. runStep/advanceRun/runFlow execute steps and route between them via pure, synchronous routers (router(state) must be deterministic; no LLM calls belong there), with andJoin/anyJoin combinators for reconvergence. Fan-out is restricted, not just discouraged: a step can only declare fanOut: true if it is also readOnly: true and names a synthesis step every branch joins into, encoding the same read-only/breadth-first restriction ADR-0065's evidence argues for. A step's per-run usage against a declared budget produces a typed budget-exhausted result instead of a silent truncation. See Flow authoring for the full authoring guide, including the step contract (workerBackend/run/router/budget) and the idempotent-re-entry convention.

Checkpoint and resume. lib/flows/checkpoint.mjs persists a run after every completed step to the project's machine-scoped state root (runtime/flows/runs/<runId>.json, ADR-0066), atomic write via temp-file-then-rename — the same pattern lib/orchestration/run-store.mjs uses for orchestration runs. resumeRun/startRun/runCheckpointed reconstruct a run's state on resume and throw FlowCheckpointError rather than silently reinterpreting history if the checkpoint's flow id doesn't match the flow definition it's resumed against. construct flow resume <run-id> --flow=<path> [--state=<json>] drives a run (new or resumed) to completion; construct flow status <run-id> reads a checkpoint without driving it.

Delegation ported onto flows (construct-rf26.9). lib/orchestration/delegation-flow.mjs converts a resolved routeRequest() route into a flow definition — one step per specialist, mirroring the same per-task delegation shape lib/orchestration/runtime.mjs's buildTasks() already computes — and drives it one checkpointed step at a time: a caller receives only the current specialist's delegation (role/reason/handoffContract), never the rest of the chain, and resumes across separate calls or process restarts by reusing the same run_id. The MCP tool that once exposed this (orchestration_delegation_next) was removed under the tool-surface budget (commit db90bbf2); ADR-0074 / construct-1in3v kept the flow as engine-internal machinery guarded by ordering and routePath parity tests rather than re-expose a dispatch surface or delete the port. No live caller drives it today — the orchestrator persona's dispatch model reverts to consuming orchestration_policy's specialist list until a surface re-adopts the flow. Classification itself (classifyIntent, detectRiskFlags, selectSpecialists in lib/orchestration-policy.mjs) is untouched — the flow engine takes over sequencing, not routing. This port is additive, not a full replacement: dispatchPlan/dispatchSummary (lib/orchestration-policy.mjs) remain in place because dispatchPlan is a mustContain field in the protected specialists/org/contracts/user-to-construct.json contract, and both fields have other live consumers (lib/workflow-state.mjs, lib/artifact-loop-core.mjs). Full retirement of the prompt-injection path is follow-on work requiring license to edit specialists/org/**.

Construct on Construct

The goal is for Construct to run itself like a real organization. a department-style model where deterministic watchers handle the boring stuff and LLM personas are summoned only when judgment is needed. The user stays in the loop for novel decisions and explicit approval, not routine ops.

Four operational tiers:

  1. L0. Deterministic agents (non-LLM, always-on). The construct-doctor daemon (lib/doctor/) holds watchers for process-pressure (extends lib/runtime-pressure.mjs), service-health (probes Postgres / cm; auto-restarts docker services), disk (rotates ~/.cx/*.jsonl, prunes ~/.local/state/construct/runtime/), cost (per-persona daily token ledger; the gateway hard-stops invocations at the cap), and mcp-protocol (performs a real MCP handshake against every configured MCP server in Claude Code and OpenCode configs, surfaces servers that fail initialize or tools/list). Recoverable actions are auto-taken and audit-logged to ~/.cx/doctor-log.jsonl. L0 escalates a service.down role event when deterministic rules can't resolve the problem (e.g., 2 failed restarts or repeated pressure churn).
  2. L0.5. Oracle meta-review (deterministic synthesis + bounded auto). The construct oracle daemon (lib/oracle/) joins session observations, outcomes, contract violations, doctor log entries, alignment census, org graph (policy inventory, workflow gates, intake sign-offs), beads hygiene, and adapter parity into per-tick verdicts at .construct/oracle/verdicts/<date>.json. Auto actions (census, registry validate when needed, adapter sync in the tool repo) run without approval; high-severity actionable gaps auto-raise idempotent beads (persistent gapId dedup, open-bead check) when CONSTRUCT_ORACLE_AUTO_RAISE is not off — hygiene/meta gaps (beads-hygiene, workflow-misaligned, …) are verdict-only via construct oracle gaps and never file duplicate tracker beads; detectBeadsDrift excludes [oracle/*] meta beads from stuck/stale counts. Orchestration queues to .construct/oracle/pending.jsonl for explicit approval with sign-off metadata (construct oracle approve <id> executes by default). Routing artifacts land in .construct/oracle/routing/; duplicate hygiene beads: construct oracle reconcile. Oracle starts with construct dev unless CONSTRUCT_ORACLE=off. See ADR 0043.
  3. L1. LLM personas (event-driven, fenced, rate-limited). The role framework in lib/roles/. Events published by hooks or L0 are routed by lib/orchestration/routing-tables.mjs, which resolves event ownership, doc-artifact ownership, and watch-condition triggers from declarative fields on specialists/org (with optional project overlays under .construct/specialists/); the owning persona is then validated against specialists/role-manifests.json. The gateway applies threshold + cooldown + rate-ceiling, creates a bd issue, queues a pending invocation in ~/.cx/role-pending.jsonl, and emits an SSE toast. Session-start drains the backlog (cap 5 per session) and surfaces invocations to Construct, which dispatches the persona via the existing Task path. Personas act inside a fence. allowed paths, allowed bd labels, approvalRequired list per role. Handoffs are recorded as next:cx-<role> bd labels.
  4. L2. User. Commits, pushes, novel decisions, budget overrides, kill switches. Approval-required actions per rules/common/commit-approval.md are always L2. The goal is to make L2 rare. not zero.

Kill switches throughout: CONSTRUCT_ROLES=off (global L1), CONSTRUCT_ROLE_<NAME>=off (per-persona L1). L0 watchers are individually toggleable when the doctor daemon ships.

Modes of operation

ModeDescriptionTrigger
Point-atAccept a target URI, produce analysis or artifact (PRD, RFC, ADR)Conceptual trigger — point a session at a URI in chat; no dedicated CLI command
InitBootstrap a project with .construct/, shared memory, cross-agent configsconstruct init
EmbedContinuous monitoring, snapshot production, work item managementconstruct embed start
Self-hostConstruct manages its own development (this repo)Always active in the construct repo

Footprint contract

Construct's install and init paths are governed by a per-scope opt-in contract codified in ADR 0029. Two scopes; construct install requires the caller to name one explicitly (construct-vzg2i.3) — a bare invocation hard-errors rather than silently defaulting.

Project scope — construct init (per repository)

Project scope writes are confined to the project root and the project's .git/ config. Nothing in this list ever lands on a machine without an explicit construct init invocation inside the project directory.

PathPurposeOwner
.construct/Project-local Construct runtime stateconstruct init
.construct/Observations, sessions, traces, intake, task graphs (gitignored)construct init
.claude/agents/ · .claude/commands/ · .claude/settings.json · .mcp.jsonClaude Code adapter tree (agents + hooks/permissions + slash commands + MCP servers)construct sync
.codex/, .vscode/, .opencode/, .cursor/Per-host adapters when the host is detected (gitignored — run npm run adapters or construct sync)construct sync
construct.config.jsonProject config + active profileconstruct init
plan.mdLocal-only current plan (gitignored)construct init
CLAUDE.md · AGENTS.mdMarker-block injection — Construct's section only, preserving surrounding contentconstruct sync
.gitignoreAppends Construct's runtime paths (idempotent)construct init
.beads/bd issue tracker initconstruct init
.git/config core.hooksPathProject-scoped git hooks pathconstruct init

Machine scope — construct install --footprint=user (per machine)

Machine scope writes are opt-in. A bare construct install (no --footprint) hard-errors naming the flag; --footprint=project prints footprint guidance and exits without touching the machine. --footprint=user writes the paths below; the marker-block writes to ~/.claude/* require an itemized interactive consent prompt (or --yes for non-interactive contexts). ADR-0071 renamed this flag from --scope; --scope=<value> still works as a deprecated alias.

PathPurposeConsent
~/.config/construct/config.envMachine credentials (mode 0600)--footprint=user
~/.config/construct/lib (symlink)Hook lib symlink to the active Construct checkout's lib/--footprint=user
~/.config/opencode/opencode.jsonMCP entries — Construct's section merged--footprint=user
~/.codex/config.tomlMCP entries — Construct's section merged--footprint=user
~/.claude/CLAUDE.mdMarker block — Construct's section only--footprint=user + interactive consent
~/.claude/settings.jsonGlobal safety-hook injection — Construct's section only--footprint=user + interactive consent
~/.claude.jsoncontext7 MCP in the top-level mcpServers (Claude Code's user scope; settings.json does not carry MCP server definitions) — Construct's entries only--footprint=user + interactive consent

Never touched

  • ~/.bashrc, ~/.zshrc, or any other shell rc — Construct never writes to shell init files.
  • npm global config or registries.
  • git config --global core.hooksPath — explicitly prohibited by ADR 0027 §3.
  • Any path outside the table above without first appearing in construct doctor drift output and prompting for consent.

Hook performance budgets

Every lib/hooks/*.mjs file declares a @p95ms <N> budget in its header. The benchmark harness scripts/bench-hooks.mjs measures p95 over N=20 runs against a synthetic event payload; a nightly CI gate fails when measured p95 exceeds the declared budget by more than 2×. Defaults: UserPromptSubmit 200 ms, PreToolUse / PostToolUse 500 ms, sync Stop arm 500 ms, SessionStart 1000 ms. Gate hooks (user-invoked, deliberate quality gates) declare an explicit budget — e.g. pre-push-gate.mjs is @p95ms 180000.

Embedded operating profile

Embed mode is governed by a config-backed operating profile, not just a list of watched sources. The profile is the daemon's bearing: mission, strategy, focal resources, authority boundaries, artifact responsibilities, and risk model.

Precedence is explicit: approval rules and tracker/doc ownership override profile preferences. The default profile is assistive and read-first:

  • autonomous: read sources, summarize, identify gaps, generate snapshots, draft roadmaps/status/summaries/artifacts
  • approval-queued: create or update issues, publish durable docs, post externally, write broadly to the repo
  • focal resources: plan.md, docs/architecture.md, .construct/knowledge/, .construct/roadmap.md
  • artifact obligations: roadmaps, PRDs, RFCs, ADRs, memos, status updates, summaries, wireframes, and risks

Every snapshot discloses the active operating profile and any operating gaps, such as missing focal resources, missing sources, source read failures, or missing outputs. Roadmaps include the same profile obligations so operators can tell whether Construct is only observing or also missing responsibilities.

Embedded contract layer (app-facing)

Distinct from Embed mode (the daemon above), the Embedded Contract Layer (ECL) is the stable, versioned surface another application uses to embed Construct as an orchestration layer. It lives in lib/embedded-contract/ and exposes five read/execute contracts over one shared core, surfaced identically on three transports — CLI-JSON, MCP tools, and an SDK (package.json exportslib/embedded-contract/index.mjs).

ContractQuestion it answersCoreSurfaces
Capability discoveryWhat can this Construct do?capability.mjsconstruct capability describe --json · capability_describe · describeCapabilities
Triage / planningWhat kind of work is this, who should own it?triage.mjsconstruct intake classify --json / graph recommend --json · triage_recommend · recommendPlan
Model resolutionWhich model should an embedded workflow use?model-resolve.mjsconstruct models resolve --json · model_resolve · resolveEmbeddedModel
Execution capabilityWill this run orchestrate or degrade to prompt-only, and why?execution.mjsconstruct execution resolve --json · construct_execution_resolve · resolveExecution
Workflow invocationRun a role/skill workflow non-interactivelyworkflow-invoke.mjs + workflow-defs.mjsconstruct workflow invoke --json · workflow_invoke · invokeWorkflow

Invariants that make it safe to embed, especially for teams where auditability is non-negotiable:

  • One core, three surfaces. CLI, MCP, and SDK are thin adapters; every response is built by the same core function and wrapped by envelope.mjs, so the surfaces return structurally identical envelopes (proven by tests/embedded-contract-parity.test.mjs).
  • Versioned. Every envelope carries contractVersion (contract-version.mjs, semver, independent of the package version) plus constructVersion and deploymentMode, so an embedder can negotiate compatibility.
  • No secrets, structurally. Every response passes redaction.assertNoSecrets before serialization; credential state is reported as a boolean (requiresCredential), never a value.
  • No drift. Capability discovery reads the live registries (roles, skills, workflow defs, policies, providers), so the published contract cannot diverge from what the install actually supports.
  • Approval-gated writes. Only workflow invocation can write, and only under allow-durable-write; proposal-only and requires-human-approval perform no durable writes. Team/enterprise durable writes are flagged as audited. Specialist reasoning is delegated to the host agent runtime — Construct returns the orchestration plan and provenance, never fabricated specialist output.

See Embedded contract layer for the contract model and Embedded contract API for request/response schemas.

Capability registry (alignment spine)

registry/capabilities.json is the machine-readable index linking capabilities, workflows, document types, ingest strategies, and hot-path skills to their verification tests, interaction surfaces, and human gates. Validated by construct registry:validate and surfaced in construct doctor; reference docs are generated (construct registry:generate-docsdocs/guides/reference/capabilities.md). Each entry declares ADR-0039 surfaces (mcp, cli, opencode, claude, …), optional verification.functional paths into the existing functional suite (ADR-0035 extend-not-rebuild), and lastValidated stamped by node scripts/run-capability-tests.mjs --tier=P0 --stamp. Capability-tier tests live at tests/capabilities/<registry-id>/<surface>.test.mjs. specialists/artifact-manifest.json is the parallel SSoT for registered document classes: template path, author/reviewer chains, validation, output/branding capability, tone profile, structure/visual requirements, workflow skill, and release-gate policy (construct artifact validate, PostToolUse advisory hook, orchestration reviewer chain). Invocation overrides project artifactWorkflow configuration, which overrides manifest defaults; unknown requested classes produce a classification/registration result rather than a PRD fallback. Phase 0 alignment artifacts: docs/operations/audit/alignment-scorecard-2026-06-19.md, scripts/alignment/census.mjs, and the bound-orphan consolidation proposal (maintainer approval before bind/merge/delete).

Do not confuse this with platforms/capabilities.json (ADR-0033): that file describes host platform features (Claude/Codex/Cursor hook budgets, MCP allowlists) consumed by sync and init — not product capability verification.

Living dependency matrix (change-impact spine)

Where the capability registry declares what connects to what, the dependency matrix (lib/graph/) makes the connections traceable in both directions and keeps them current from code structure, so a file change can be mapped to the workflows and tests it affects. It is a typed, directed graph hosted in the knowledge-graph layer at .construct/graph/{nodes,edges}.jsonl (gitignored, regenerated; the store is lib/graph/store.mjs). Node types: file, module, test, capability, workflow, contract, surface, skill, rule. Edge relations: imports, realizes (file→capability), validates (test→capability), embeds (capability→workflow), uses, governed_by, exposes, contains, co_changes.

Population is hybrid (construct matrix build): (1) registry ingest (build-from-registry.mjs) seeds capability/workflow/test/contract/skill/surface nodes and their edges from registry/capabilities.json, workflow-defs.mjs, and specialists/org/; (2) static import derivation (build-import-graph.mjs) walks lib/, bin/, scripts/, tests/ for relative imports, adds file/module nodes with imports edges, and derives the otherwise-missing realizes (file→capability) edges from each capability's declared-test import closure; (3) git co-change (build-co-change.mjs) adds module nodes with contains and co_changes edges (reusing captureDependencyPatterns, which also feeds the entity store).

construct graph build-targets (lib/graph/build-target-graph.mjs) extends static import derivation to every registered content-capable source target (construct.config.json sources.targets[], resolved the same way knowledge_search resolves them via lib/sources/content-roots.mjs), one graph per target, walked from the target's whole content root rather than the host's lib/bin/scripts/tests layout. Each target's graph persists independently at .cx/graph/targets/<targetId>/{nodes,edges}.jsonl and is queryable with construct graph query <id>|--type <t> --projects=<id,...>|all|self (same --projects filter semantics as knowledge_search); node attrs.origin carries targetId/provider/projectKey/kind:'code' so a target's graph and its knowledge-search hits attribute to the same source.

construct impact [files] [--stdin] [--run] [--json] (lib/graph/impact.mjs) is Test Impact Analysis: a changed file selects the tests that transitively import it plus the tests validating any capability it realizes, rolls up impacted capabilities and workflows, and flags changed files that realize no capability. Oracle is the overseercollectDependencyGraph (lib/oracle/read-model.mjs) feeds synthesizeVerdict three gaps with deterministic routes (lib/oracle/routing.mjs): matrix-coverage-gap (no impl/test edge → cx-architect/cx-qa), impact-untested (realizing files changed after lastValidatedcx-qa/cx-engineer), and dependency-graph-stale (seeds changed since last build → cx-engineer). Document-type capabilities rely on tests/certification/artifact-gates.test.mjs plus workflow-level functional tests for validation stamps. Advisory-first: gaps and the impact command surface signals; nothing blocks.

Local orchestration runtime (host-independent)

Hosts are front doors; Construct is the system. To stop the best orchestration experience from being coupled to whichever editor exposes native multi-agent execution, Construct owns a local orchestration runtime (lib/orchestration/runtime.mjs + run-store.mjs), surfaced as construct orchestrate run|status [--json]. A run intakes a request, plans a sequenced specialist chain (reusing orchestration-policy.routeRequest), resolves the execution-capability contract (resolveExecution), persists a durable, resumable run + task lifecycle, and emits lifecycle traces — returning the host-adapter metadata a thin host integration consumes (executionMode, constructCapabilitiesActive, workerBackend, hostRole, degraded, selectedProvider/selectedModel, traceId).

The runtime has two pluggable axes behind unchanged callers: a worker backend and a run store.

Worker backends. The inline backend (default) owns planning, sequencing, handoff state, persistence, and observability and prepares each specialist task (status prepared, executor='inline:prepared'); it does not itself perform specialist LLM reasoning — prompt-only/host-direct runs own no specialist sequence rather than implying orchestration (ADR-0020). The provider backend (lib/orchestration/worker.mjs, opt-in via orchestration.workerBackend or executeRun({ workerBackend })) executes each task by calling the configured provider/model (Anthropic /v1/messages for Claude-family models, OpenRouter otherwise) with the specialist persona prompt, recording real output as task.output (status done, executor='provider:<provider>:<model>'); a provider failure is recorded per-task and the run completes completed-with-failures without crashing. Because the provider backend genuinely executes the model, the prepare-only disclaimer applies only to inline tasks (ADR-0021, refining ADR-0020).

Host execution seat (ADR-0063). The host backend materializes each task's persona/user prompt (materializeTaskPrompt, shared with the provider backend so both resolve byte-identical prompts) and stops — no model call runs inside Construct. The run stands at a real, non-terminal awaiting-host status until the calling MCP host executes each prompt in its own session (its own subscription, not a separate API credential) and reports the result via the orchestration_task_result tool; submitHostTaskResult records it tagged provenanceSource: 'host-reported' (never conflated with a construct-verified provider execution) and finalizes the run through the same terminal-status computation the synchronous path uses. orchestration_run, reached only via MCP, defaults worker_backend to host when neither the call nor construct.config.json names one explicitly — the CLI's own default stays inline and fails loud on --worker-backend=host (no attached host session to execute against). A stale awaiting-host run (host disconnected mid-run) is a doctor advisory, not a silent failure. Phase 2 drives the identical loop server-side via MCP sampling when the connected client declares that capability.

Handoff context (buildUserPrompt, lib/orchestration/worker.mjs). A downstream task's user prompt folds in every prior task's real output (status:'done', non-empty task.output) under a ## Prior specialist results section, wrapped by lib/security/trust.mjs's wrapUntrusted — a provider-verified upstream task at team-authored trust, a host-reported one (provenanceSource: 'host-reported') at the lower external-authenticated level — so the receiving specialist evaluates it as data, not instructions. Budgeted at 1200 chars/task, 6000 total, nearest-preceding task prioritized. Because materializeTaskPrompt is shared, provider and host runs resolve byte-identical prompts for the same task; on the host backend, submitHostTaskResult re-materializes the next awaiting-host task's prompt immediately after recording a result, since executeRun originally materialized every task's prompt in one pass before any host result existed.

Persona resolution and the pack boundary (LMCP-E2). The provider backend resolves each persona prompt ONLY through the pack registry (lib/packs/loader.mjs + lib/packs/prompts.mjs), never by reading specialists/prompts/ directly. Pack load validates every declared prompt file and its YAML frontmatter (validatePackPrompts); in team/enterprise deployment mode a missing or malformed prompt is a hard load-time error naming the file, and a run can never execute a specialist under a silently substituted persona (PERSONA_UNAVAILABLE if a miss somehow still reaches the worker). Solo mode does not hard-fail at load time — a miss there degrades visibly: the task result and the worker.completed trace event carry personaAvailable: false and degraded: 'persona-fallback', and the run's terminal status becomes degraded rather than a bare completed. construct status surfaces a personaDegradedRuns count from .construct/runtime/orchestration/runs/.

Governed web capability (ADR-0050). A specialist that declares liveWebAccess: true (the researcher) executes with a live web tool instead of a tool-less completion, closing the seam where the orchestrator promised "web access lives with the researcher" but nothing wired it. A typed resolveWebCapability grant runs in strict priority — governed (WEB_SEARCH_URL → a client tool-use loop over Construct's own webSearch(), F08 by construction), provider-native (Anthropic web_search_20250305 / OpenRouter openrouter:web_search, every citation re-graded), host-delegated (opt-in), unavailable (typed capability-unavailable forcing an honest refusal that degrades the run). governWebResults (lib/mcp/tools/web-search-governance.mjs) is the single grader: every web result on every path — including the fail-closed re-grade on remote-service ingress — emerges trust:'untrusted' + Admiralty-graded, so a citation can never reach a specialist ungoverned. The tool is least-privilege (only the declared web role gets it) and bounded (CONSTRUCT_WORKER_TOOL_ROUNDS).

Run stores. resolveRunStore (lib/orchestration/store.mjs) returns one { saveRun, loadRun, listRuns } interface over three backends: Mode-A filesystem (zero-dependency default, .construct/runtime/orchestration/runs/, no Docker), Mode-B SQLite (run-store-sqlite.mjs, lazy node:sqlite with sqliteAvailable() — falls back on Node older than 22.5), and Mode-C Postgres (run-store-postgres.mjs, shared team/enterprise store). Selection follows orchestration.store config / CONSTRUCT_ORCHESTRATION_STORE env override, else deployment mode (solo→filesystem, team/enterprise→postgres), resolving explicit storage backends through kind:"storage" manifests and degrading to filesystem with visible requestedBackend/degradedReason metadata when unavailable. Postgres uses the optional postgres SQL client only when DATABASE_URL/CONSTRUCT_DATABASE_URL is configured; construct db status|migrate probes and applies repo-owned migrations idempotently. Filesystem + inline is the byte-for-byte-unchanged default. Decisions recorded in ADR-0020 and ADR-0021 (extending ADR-0019).

Unified build-audit record (construct-ifwhw.1). The run store, lifecycle traces, and the contract-violation log were three parallel durable records with no object linking them by runId. lib/orchestration/build-audit-record.mjs's buildAuditRecord(cwd, runId) joins a run's task chain (run-store.mjs), its lifecycle trace events (lib/worker/trace.mjs's readTraceEventsForRun, matched on metadata.runId), and any contract violations its tasks triggered (lib/contracts/violation-log.mjs's recentViolations({ runId }), now that lib/orchestration/worker.mjs threads run.runId into its logViolation calls) into one object. materializeAuditRecord/loadAuditRecord persist and read it back cross-process at <stateRoot>/runtime/orchestration/audit-records/<runId>.json, the same atomic write pattern as run-store.mjs. Per-task artifact/postcondition linkage rides along when a task's outputPacket.artifactPath names an authored doc; today no orchestration task populates that field, so the array is honestly empty rather than fabricated — it is the insertion point for a future artifact-authoring producer, not a claim that one exists yet.

author_artifact provenance (construct-ifwhw.2). runConstructArtifactLoop (lib/artifact-loop-core.mjs) always calls invokeWorkflow with approvalMode: 'proposal-only', so the embedded-contract layer itself never durably records an author_artifact invocation — before this, the authored file (when a draft existed to materialize one) was the only trace the call happened. runConstructArtifactLoop now writes one observation through the existing addObservation (lib/observation-store.mjs) unconditionally, ahead of the draft-presence branch that decides whether the artifact FILE gets written: the two concerns stay independently gated, so the no-draft branch is unchanged (still writes nothing to the artifact path) while a .cx/observations/<id>.json record — traceId, workflowId, workflowType, artifactType, the resolved selectedRoles recruited-specialist set, and the target relPath — exists either way. lib/mcp/tools/artifact-author.mjs's toResult surfaces the outcome as provenance: {ok, id, store}.

Postgres queue provider. PostgresIntakeQueue (lib/queue/pg-queue.mjs) is the team/enterprise default kind:"queue" provider and can also be selected explicitly with CONSTRUCT_INTAKE_QUEUE_BACKEND=postgres or an equivalent manifest override. It stores queue items and claim history in migration-owned tables, claims with SELECT ... FOR UPDATE SKIP LOCKED, increments attempts on each claim, renews leases through heartbeat(), and reclaims expired claims without double-claiming live work. fail() applies retry/backoff policy and dead-letters maxed attempts, requestCancellation() makes cancellation visible at heartbeat boundaries, markProcessed(...executionKey) is idempotent for re-delivery, and queueStats() is the operator read model for pending/claimed/dead-letter depth. A missing SQL client in team/enterprise mode is a hard configuration error unless CONSTRUCT_DEGRADED_OK=postgres-queue is set; that override returns a visibly degraded git-backed queue.

Worker heartbeat registry. Team and enterprise workers register liveness in the migration-owned construct_workers table through WorkerRegistry (lib/orchestration/worker-runtime.mjs). register() creates or refreshes a worker identity with capabilities and TTL, heartbeat() renews liveness, deregister() marks a worker stopped, and construct workers list [--json] reports active, stale, and stopped workers when Postgres is configured.

Team health surfaces. lib/team/health.mjs joins PostgresIntakeQueue.queueStats() with WorkerRegistry.list() so construct status and construct doctor report queue depth, dead-letter depth, active workers, and stale workers from the same read model. In team/enterprise mode, a missing Postgres client is a doctor failure; DLQ entries and stale workers are visible degraded states. dev/team-harness/verify.sh is the local verification entry point for a configured team database.

Engine-as-service. The runtime is reachable so hosts can be thin clients — the pattern Claude Code, OpenCode, and Goose all use to deliver multi-agent inside editors that have no native subagent primitive (the editor's missing primitive is immaterial when the engine owns the loop). Orchestration runs in-process by default (lib/orchestration/runtime.mjs): the orchestration_run/orchestration_status MCP tools and construct orchestrate run|status drive it directly — no daemon, no port, no token for the solo default. A remote orchestration service stays opt-in via CONSTRUCT_ORCHESTRATION_URL (bearer token from CONSTRUCT_ORCHESTRATION_TOKEN); construct orchestrate run|status --remote is the reference thin client, and a VS Code/Copilot extension, OpenCode, or CI drives the same API. Responses use the versioned, secret-free embedded-contract envelope. Decision: ADR-0022.

OpenCode-first surface

Run construct sync to install the OpenCode front-door agent, runtime plugin, and MCP wiring. OpenCode owns the user conversation; Construct owns the reusable contracts behind it: orchestration policy, workflow invocation, artifact authoring, ingestion, memory, and knowledge tools. The same MCP tools keep other public hosts functional without reintroducing a Construct terminal UI.

Project-state hierarchy

One source of truth per concern:

  1. External tracker owns the durable backlog and issue lifecycle.
  2. plan.md owns the current human-readable working plan, linked to tracker ids. It is local-only (gitignored). never the system of record.
  3. Memory (observation store via MCP) stores cross-session knowledge, not task state.
  4. Single-writer rule: one active writer per file; others review, research, or wait.

Approval model

Hybrid: autonomous for low-risk, human-gated for high-risk.

RiskExamplesBehavior
LowReading, analysis, draft generation, searchAutonomous
HighWork item creation, merge, doc publish, config changesQueued for approval (messaging provider or construct oracle)

Policy enforcement. three layers

Policy rules (comment convention, doc-update requirement, CI green before walk-away, branch hygiene) are enforced at three layers so violations cannot fall through the cracks. Quality gates fire unconditionally — no skip env vars on blocking checks. If a gate fires wrong, repair the policy at its source.

Layer 1. Real-time (write/edit time). Catches at the source.

  • comment-lint.mjs. PostToolUse Write/Edit/MultiEdit, blocking. Banned patterns and missing required headers exit 2. No bypass — fix the comment or fix the policy in lib/comment-lint.mjs.
  • artifact-release-gate.mjs. PostToolUse Write/Edit/MultiEdit, advisory. Surfaces manifest structure/visual gaps on typed docs under docs/** and .construct/research/**; full gate via construct artifact validate. Artifacts may declare cx_release_gate: bypass with cx_release_gate_reason — Oracle read model surfaces these for review.
  • doc-coupling-check.mjs. PostToolUse, advisory. Counts code-file edits per session, prints stderr nudge at 3/5/10 when no doc files touched. Soft predecessor to the commit gate.
  • ci-status-check.mjs. UserPromptSubmit. Queries gh run list (60s cache) and injects red-CI status into agent observation.

Layer 2. Gate (commit/push time). Catches at the boundary.

  • .beads/hooks/pre-commit Construct policy section. calls construct lint:comments and construct docs:verify across the current worktree. Refuses commits with banned-pattern violations or code-without-docs.
  • pre-push-gate.mjs. refuses claude/* branch pushes unconditionally; blocks re-pushing the exact SHA CI most recently rejected (SHA-aware: if HEAD has advanced past the failed commit, push goes through with a non-blocking notice); lints PR body on gh pr create / gh pr edit against template policy. Tests, build, audit, evals, and every lint:* live in .github/workflows/ci.yml, not in this hook.

Layer 3. Safety net (CI + session end). Catches escapees.

  • policy-engine.mjs Stop handler (consolidated):
    • red-CI block. exits 2 when CI is red on the current branch and the agent edited code this session. Bypass: CONSTRUCT_STOP_OK_RED_CI=1.
    • open-beads block. exits 2 when bd list --status in_progress returns any issue. Bypass: CONSTRUCT_STOP_OK_OPEN_BD=1.
    • drive criteria enforcement. blocks Stop when drive mode is active and acceptance criteria lack evidence.
    • drive-session advisory. non-blocking surface of open ~/.cx/drive-session.json.
  • construct doctor. phantom-hook drift check; refuses to pass when settings.template.json registers a hook whose file is missing. Prevents the failure mode where defenses look wired up but silently no-op.

CI is the outermost safety net. Required status checks on main and dev (retrieval evals, comment policy, docs drift check, dependency CVE audit) prevent merges when any layer's escape made it to a PR.

Context hygiene

Enforced via hooks, not advisory text:

  • bash-output-logger. persists large outputs to disk, nudges grep over re-run
  • context-watch. compaction guidance at 60%/80% of resolved context window
  • Role skills loaded on demand via get_skill

Session persistence

Distilled, not raw. Sessions store summary, decisions, files changed, open questions, and task snapshot. Full transcripts are ephemeral.

Tiered injection at session start:

TierBehaviorExamples
1Always injectedheader, branch, status, approval reminder
2When fresh and meaningfulcontext.md, skill-scope warnings, last-session resume
3Hint pointing at MCP toolprior observations → memory_recent

Learning loop

Observations (patterns, decisions, anti-patterns) are recorded per-role, vectorized for semantic search, and capped for bounded storage. Entities track recurring components, services, and dependencies. Session artifacts are captured automatically at session end.

Doc auditability stamps

Every generated .md file carries UUIDv7 front-matter:

---
cx_doc_id:   019dbb90-...          # UUIDv7, preserved across re-stamps
created_at:  2026-04-23T18:18:12Z  # Set at creation, never mutated
updated_at:  2026-04-23T19:00:00Z  # Updated on every re-stamp
generator:   construct/sync-agents # Which surface produced the file
body_hash:   sha256:<hex>          # SHA-256 of trimmed body
---

Managed artifact directories

DirectoryContentsOwner
docs/specs/prd/Product requirements documentscx-product-manager
docs/decisions/adr/Architecture decision recordscx-architect
docs/decisions/rfc/Requests for commentVaries by topic

Key invariants

  • Construct is the only public surface. Specialists are implementation details.
  • Provider implementations never leak transport details into core.
  • External tracker state is canonical for durable work when present.
  • Single-writer rule governs parallel editing.
  • Mutations are traceable via audit trail with tamper-evidence chain.
  • Domain overlays must not auto-promote into permanent capabilities.
  • Deployment mode selects backends; the agent loop is identical across solo / team / enterprise.
  • Classification runs deterministically in the daemon; the agent does the LLM analysis.
  • Task graph nodes cannot transition to done without an evidence record.
  • Every MCP tool call emits a tool.called trace event; denials are typed errors. (Brokered MCP dispatch — for team/enterprise mcp-broker routing — is staged/experimental (tracked: construct-9oi4.10); the trace contract will apply when that path is active.)

Specialist roster

12 specialists ship in the default profile. Each has a role, model tier (reasoning / standard / fast), and a prompt file in specialists/prompts/. The canonical source is specialists/org.

This 12-role roster is the applied result of ADR-0065's consolidation (construct-rf26.11): a prior 29-specialist roster folded down to 12 anchors, with 15 of the retired roles absorbed as skill bundles on an anchor and 2 (cx-oracle, cx-rd-lead) retired outright. The full 29→12 mapping is recorded in the ADR-0065 roster-mapping appendix.

Single Front Door: only construct appears in editor agent pickers. Internal specialists (cx-*) dispatch via orchestration_policy / orchestration_run — not as separate picker entries. Sync injects an orchestration micro-prompt instead of a static roster token block. Research-shaped host asks classify first, then execute via orchestration_run; workflow_invoke is the planning contract, not proof that evidence was gathered.

Both dispatch tools (orchestration_policy, orchestration_run) are part of the flat core MCP surface (CORE_TOOL_NAMES in lib/mcp/server.mjs, ADR-0048), so they are advertised on every host rather than buried behind the call gateway. Because Claude Code enforces an agent's tools: frontmatter as a hard allowlist (unlike OpenCode's allow-by-default permission map or Codex's no-per-agent-gate model), sync must name the orchestrator's dispatch tools explicitly there: ORCHESTRATOR_DISPATCH_TOOLS in scripts/sync-specialists.mjs is the single source that keeps the grant in parity across hosts. Without it the orchestrator can neither reach a capability itself nor route it to a specialist, and degrades to refusing the request.

Custom specialists and teams (user extension layer)

Users author their own specialists and teams without editing specialists/org/ (construct-rf26.13). lib/registry/assemble.mjs merges three tiers into the same runtime registry every built-in reads through — builtin (specialists/org/**) → user (~/.construct/org/**, shared across every project on the machine) → project (<project>/.construct/org/**, git-tracked, highest precedence) — the same builtin → user → project precedence ADR-0052 establishes for provider manifests. A later tier overrides a builtin entry with the same id, or adds a wholly new one; lib/registry/custom-schema.mjs validates each record's role, skill bundle references, fence, modelTier, and delegation spec (handoffCandidates) with field-named, actionable errors before anything is written. construct specialist create <id> --custom and construct team create <id> (lib/registry/custom-scaffold.mjs) scaffold into the project tier by default, or the user tier with --user. Because orgDirMtime() walks the user tier alongside the built-in and project trees, construct sync's existing clearCache() + loadRegistry() pass — no separate reload path — picks up new or edited custom records immediately, and a custom specialist resolves through getSpecialist()/getTeam() exactly like a built-in one. See Custom specialists and teams for a worked example.

Full roster with domain groupings: Agents and personas. Full reference with tier and purpose per specialist: Specialist reference.