Architecture
Diagrams + the request lifecycle + where things live. Read once when you want to understand the bones.
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
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
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):
| Surface | Entry | Role |
|---|---|---|
| OpenCode | construct agent in OpenCode | Primary conversational authoring surface |
| Agent / MCP | @construct or MCP tools in Claude, Codex, Cursor, Copilot, VS Code | Orchestration policy, skills, workflows |
| Thin CLI | construct 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):
| Half | Entry | Engine |
|---|---|---|
| Ingest | construct ingest | Docling sidecar (high fidelity), unpdf/mammoth (fast), whisper (AV) |
| Export | construct export / construct publish | Pandoc + 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
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.
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).
Physical: lib/embed/ (daemon + supervisor), lib/service-manager.mjs, scheduler jobs under lib/embed/.
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 unlessCONSTRUCT_DEGRADED_OK=postgres-queueexplicitly 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/constructandlib/cli-commands.mjs - MCP server.
lib/mcp/server.mjs, tools split acrosslib/mcp/tools/ - Orchestration policy.
lib/orchestration-policy.mjs(intent classification, execution track selection, gate evaluation, contract-chain resolution) - Agent contracts.
specialists/org/contracts/andlib/specialist-contracts.mjs(producer→consumer service contracts with preconditions, postconditions, input/output schemas). Every postcondition is classifiedexecutableoradvisory(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 — firstconstruct ingest/observation store or search, notconstruct init(opt in early with--seed-index/construct install --with-embeddings). TTL/size eviction on the vector-indexed rows (resources.disk.observationsMaxDays/observationsMaxRowsinconstruct.config.json, default 90d/5000 rows) runs on everyconstruct ingestand is surfaced inconstruct doctor/construct prune(lib/storage/admin.mjs'spurgeExpiredData, 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.mjsruns file BM25 + native pgvector HNSW ANN (<=>, withhnsw.iterative_scanon 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.mjsacceptstags+tagMatchfor 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, orCONSTRUCT_PLUGIN_<LAYER>env. Failed overrides fall back to the default and surface inconstruct 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.jsonis 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.mjsmerges both tiers;lib/tags/lifecycle.mjsmanages the active/deprecated/archived lifecycle with Postgres-backed migration audit atdb/schema/007_tags.sql. Auto-attribution:suggestTags(triage, related, vocab)inlib/intake/classify.mjsstampsintake/<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.mjsprovides a graceful-degrade facade over@opentelemetry/api(optional dep). WhenOTEL_EXPORTER_OTLP_ENDPOINTis set, real GenAI client spans emitgen_ai.*attributes per the OpenTelemetry GenAI semconv. W3C traceparent propagates across MCP boundaries viaparams._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.mjscaptures per-skill load events with latency, agent identity, session id, and token size;construct skills usage|orphans|hot|correlate-qualitysurfaces 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.mjsrenders and creates the chain.schemas/workflow.schema.jsonvalidates 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.jsonlwith tamper-evidence chain - Doc stamps. UUIDv7 front-matter on all generated
.mdfiles 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:
| Capability | Description |
|---|---|
read | Fetch items, pages, messages, files from the external system |
write | Create or update items (work items, messages, pages, PRs) |
search | Query the external system's index |
watch | Poll or subscribe for changes |
webhook | Receive 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.yamlor environment. - A provider may support any subset of capabilities; unsupported capabilities return a typed error.
- Provider implementations live in
lib/providers/(data-source adapters) andlib/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:
| Provider | Transport | Capabilities |
|---|---|---|
| Git repo | git CLI | read, write, watch |
| Project tracker (Jira) | REST API v3 | read, write, search, webhook |
| Messaging (Slack) | Slack Web API | read, write, watch, webhook |
| Code host (GitHub) | gh CLI | read, write, search, webhook |
| Knowledge base (Confluence) | REST API v2 | read, 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 whenDATABASE_URL/CONSTRUCT_DATABASE_URLis configured) - Embed daemon. scheduled or long-running process that monitors sources through providers, produces snapshots, manages approval queue.
construct embed superviseinstalls a platform-native supervisor (launchd/systemd/Task Scheduler) for auto-restart on crash. - Scheduler abstraction.
lib/scheduler/index.mjs—registerJob,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.mjsselects stale lifecycle-tagged documents for review:lifecycle/approvedolder than 30 days,lifecycle/draftolder than 7 days, any doc missinglast_verified_at.stampVerifiedwrites the field to frontmatter. Integrated into thedoc-hygiene-scanscheduler job. - Resource bootstrap.
lib/bootstrap/resources.mjsprobes optional resources (Postgres, ONNX model, Docker, git);construct initruns the full wizard. - Backup.
lib/storage/backup.mjscreates 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.createauto-prunes toCONSTRUCT_BACKUP_RETAIN(default 10) unless--no-pruneis passed. Optional embed-daemon jobauto-backupruns everyCONSTRUCT_AUTO_BACKUP_DAYSdays (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-onlyrootfs with/datavolume. 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 loadsshared_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--compiledistribution path, not yet wired intorelease.yml. LanceDB's N-API binding and the MCP SDK both verified working under a Bun-compiled binary;bin/constructitself does not yet run standalone because it resolves its data directories (skills/,specialists/,templates/, etc.) viaimport.meta.dirname, which resolves to Bun's virtual bundle root rather than a real path. See the Unreleased section ofCHANGELOG.mdfor the full verification record. - Webhook ingestion endpoint for provider events
Operating model: gates + contracts + specialists
Every request flows through three structural layers:
- Gates (
routeRequestreturnsframingChallenge,externalResearch,docAuthoring, and for research-shaped asksresearchExecutionPolicy): 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. - Contract chain (
routeRequest.contractChain): ordered typed handoffs fromspecialists/org/contracts/. Each contract names a producer→consumer pair, required input fields, preconditions, expected output shape, and postconditions. Postconditions are classifiedexecutable(mechanically checked inlib/contracts/validate.mjs— output-shape, nested-schema, or a binary-postcondition rule) oradvisory(irreducible prose kept verbatim); 39 of 111 postconditions across the 40 contracts are executable today (construct-rf26.12), reported as apostcondition-coveragecategory inconstruct 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. - 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:
- L0. Deterministic agents (non-LLM, always-on). The
construct-doctordaemon (lib/doctor/) holds watchers for process-pressure (extendslib/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), andmcp-protocol(performs a real MCP handshake against every configured MCP server in Claude Code and OpenCode configs, surfaces servers that failinitializeortools/list). Recoverable actions are auto-taken and audit-logged to~/.cx/doctor-log.jsonl. L0 escalates aservice.downrole event when deterministic rules can't resolve the problem (e.g., 2 failed restarts or repeated pressure churn). - L0.5. Oracle meta-review (deterministic synthesis + bounded auto). The
construct oracledaemon (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 (persistentgapIddedup, open-bead check) whenCONSTRUCT_ORACLE_AUTO_RAISEis not off — hygiene/meta gaps (beads-hygiene,workflow-misaligned, …) are verdict-only viaconstruct oracle gapsand never file duplicate tracker beads;detectBeadsDriftexcludes[oracle/*]meta beads from stuck/stale counts. Orchestration queues to.construct/oracle/pending.jsonlfor 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 withconstruct devunlessCONSTRUCT_ORACLE=off. See ADR 0043. - L1. LLM personas (event-driven, fenced, rate-limited). The role framework in
lib/roles/. Events published by hooks or L0 are routed bylib/orchestration/routing-tables.mjs, which resolves event ownership, doc-artifact ownership, and watch-condition triggers from declarative fields onspecialists/org(with optional project overlays under.construct/specialists/); the owning persona is then validated againstspecialists/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,approvalRequiredlist per role. Handoffs are recorded asnext:cx-<role>bd labels. - L2. User. Commits, pushes, novel decisions, budget overrides, kill switches. Approval-required actions per
rules/common/commit-approval.mdare 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
| Mode | Description | Trigger |
|---|---|---|
| Point-at | Accept a target URI, produce analysis or artifact (PRD, RFC, ADR) | Conceptual trigger — point a session at a URI in chat; no dedicated CLI command |
| Init | Bootstrap a project with .construct/, shared memory, cross-agent configs | construct init |
| Embed | Continuous monitoring, snapshot production, work item management | construct embed start |
| Self-host | Construct 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.
| Path | Purpose | Owner |
|---|---|---|
.construct/ | Project-local Construct runtime state | construct init |
.construct/ | Observations, sessions, traces, intake, task graphs (gitignored) | construct init |
.claude/agents/ · .claude/commands/ · .claude/settings.json · .mcp.json | Claude 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.json | Project config + active profile | construct init |
plan.md | Local-only current plan (gitignored) | construct init |
CLAUDE.md · AGENTS.md | Marker-block injection — Construct's section only, preserving surrounding content | construct sync |
.gitignore | Appends Construct's runtime paths (idempotent) | construct init |
.beads/ | bd issue tracker init | construct init |
.git/config core.hooksPath | Project-scoped git hooks path | construct 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.
| Path | Purpose | Consent |
|---|---|---|
~/.config/construct/config.env | Machine credentials (mode 0600) | --footprint=user |
~/.config/construct/lib (symlink) | Hook lib symlink to the active Construct checkout's lib/ | --footprint=user |
~/.config/opencode/opencode.json | MCP entries — Construct's section merged | --footprint=user |
~/.codex/config.toml | MCP entries — Construct's section merged | --footprint=user |
~/.claude/CLAUDE.md | Marker block — Construct's section only | --footprint=user + interactive consent |
~/.claude/settings.json | Global safety-hook injection — Construct's section only | --footprint=user + interactive consent |
~/.claude.json | context7 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.npmglobal 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 doctordrift 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 exports → lib/embedded-contract/index.mjs).
| Contract | Question it answers | Core | Surfaces |
|---|---|---|---|
| Capability discovery | What can this Construct do? | capability.mjs | construct capability describe --json · capability_describe · describeCapabilities |
| Triage / planning | What kind of work is this, who should own it? | triage.mjs | construct intake classify --json / graph recommend --json · triage_recommend · recommendPlan |
| Model resolution | Which model should an embedded workflow use? | model-resolve.mjs | construct models resolve --json · model_resolve · resolveEmbeddedModel |
| Execution capability | Will this run orchestrate or degrade to prompt-only, and why? | execution.mjs | construct execution resolve --json · construct_execution_resolve · resolveExecution |
| Workflow invocation | Run a role/skill workflow non-interactively | workflow-invoke.mjs + workflow-defs.mjs | construct 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 bytests/embedded-contract-parity.test.mjs). - Versioned. Every envelope carries
contractVersion(contract-version.mjs, semver, independent of the package version) plusconstructVersionanddeploymentMode, so an embedder can negotiate compatibility. - No secrets, structurally. Every response passes
redaction.assertNoSecretsbefore 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-onlyandrequires-human-approvalperform 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-docs → docs/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 overseer — collectDependencyGraph (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 lastValidated → cx-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:
- External tracker owns the durable backlog and issue lifecycle.
plan.mdowns the current human-readable working plan, linked to tracker ids. It is local-only (gitignored). never the system of record.- Memory (observation store via MCP) stores cross-session knowledge, not task state.
- 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.
| Risk | Examples | Behavior |
|---|---|---|
| Low | Reading, analysis, draft generation, search | Autonomous |
| High | Work item creation, merge, doc publish, config changes | Queued 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 inlib/comment-lint.mjs.artifact-release-gate.mjs. PostToolUse Write/Edit/MultiEdit, advisory. Surfaces manifest structure/visual gaps on typed docs underdocs/**and.construct/research/**; full gate viaconstruct artifact validate. Artifacts may declarecx_release_gate: bypasswithcx_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. Queriesgh 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-commitConstruct policy section. callsconstruct lint:commentsandconstruct docs:verifyacross the current worktree. Refuses commits with banned-pattern violations or code-without-docs.pre-push-gate.mjs. refusesclaude/*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 ongh pr create/gh pr editagainst template policy. Tests, build, audit, evals, and everylint:*live in.github/workflows/ci.yml, not in this hook.
Layer 3. Safety net (CI + session end). Catches escapees.
policy-engine.mjsStop 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_progressreturns 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.
- red-CI block. exits 2 when CI is red on the current branch and the agent edited code this session. Bypass:
construct doctor. phantom-hook drift check; refuses to pass whensettings.template.jsonregisters 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-runcontext-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:
| Tier | Behavior | Examples |
|---|---|---|
| 1 | Always injected | header, branch, status, approval reminder |
| 2 | When fresh and meaningful | context.md, skill-scope warnings, last-session resume |
| 3 | Hint pointing at MCP tool | prior 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
| Directory | Contents | Owner |
|---|---|---|
docs/specs/prd/ | Product requirements documents | cx-product-manager |
docs/decisions/adr/ | Architecture decision records | cx-architect |
docs/decisions/rfc/ | Requests for comment | Varies 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
donewithout an evidence record. - Every MCP tool call emits a
tool.calledtrace event; denials are typed errors. (Brokered MCP dispatch — for team/enterprisemcp-brokerrouting — 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.