0027 Host Project Footprint And Non Destructive Scaffolding
ADR 0027: Host project footprint & non-destructive scaffolding
Date: 2026-06-04 Status: Accepted Deciders: Construct·Architect Supersedes: none Extends: ADR 0013 (skills on-disk layout — clarifies the dual-target claim against current code), ADR 0025 (explicit activation — extends the "good citizen" doctrine from activation into scaffolding).
Problem
A user should be able to construct init in any project — fresh or established — and know exactly what was installed, what is correctly tracked, what is correctly ignored, what was preserved, and how to undo it. Empirical isolation testing (2026-06-04, two runs under a throwaway HOME and project dir at /tmp/cx-iso-test) showed that this is not currently true:
- The host
.gitignorewritten byconstruct init(lib/init-unified.mjs~L958-978) ignores only.cx/, leaving six adapter directories —.claude/,.codex/,.cursor/,.vscode/,.opencode/,.github/— to be committed. Construct's own repo.gitignoreignores all six as machine-specific generated output. The host setup contradicts the upstream stance. construct initinvokesbd init(lib/init-unified.mjs:1029-1043), which produces a 13-file / 1,145-insertion commit on the user's repo without consent. The commit is referentially broken:.claude/settings.jsonis committed but the.claude/agents/,.claude/commands/,.claude/skills/it references are left untracked.construct installis documented as machine-scoped but writes toprocess.cwd()— the first test run rebuiltlib/server/static/index.htmlin the real repo and dropped.cx/project-profile.jsonthere.construct.config.json(the central project config, loader atlib/config/project-config.mjs) is never scaffolded by init, so the configuration surface is invisible.AGENTS.mdandCLAUDE.mdare scaffolded viawriteStampedIfMissing(lib/init-unified.mjs:858-866) — skip-if-exists, but with no mechanism to add or update Construct-specific guidance idempotently in a file the user owns.docs/guides/concepts/knowledge-layout.md:18claims.cx/knowledge/is "version-controlled," whileREADME.md:106says.cx/"must never be committed." Empirically,.cx/is fully gitignored and.cx/knowledge/does not even exist at init time.construct uninstall(lib/uninstall/uninstall.mjs) leaves thedev.construct.pressure-releaseLaunchAgent registered, leavesgit config core.hooksPathset, and leaves thepgvector/pgvector:pg16docker image.construct synchistorically wrote SKILL.md files to~/.agents/skills/per ADR 0013; current code targets only\<project>/.claude/skills/, but the older files at~/.agents/skills/remain on disk with stale doc-stamp-only frontmatter, causing 141 startup warnings in Codex (missing field 'description') on every session — verified by direct inspection.- MCP entries Construct registers (e.g.
githubat~/.codex/config.toml:63and~/.config/opencode/opencode.json:431) are not reconciled when their referenced env vars become unresolvable; Codex emits "MCP startup failed: Environment variableGITHUB_TOKENfor MCP server 'github' is not set" every session.
The pattern is consistent: the existing design treats the install/init/sync surface as fix-forward only — each new version writes what it now considers correct, but never reconciles what prior versions wrote and never asks before mutating files the user owns. The decision-forcing tension is whether to leave this fragmented or codify a single contract for the entire host footprint.
Context
- Construct embeds into projects that already have history, conventions,
.gitignores, agent-instruction files, and CI directories. It runs in a real git repo, on a developer machine, alongside other tools. - Construct's own repo
.gitignoreis the de facto specification of what's machine-specific vs durable: it ignores.claude/,.codex/,.cursor/,.vscode/,.opencode/,.github/prompts/,.github/copilot-instructions.md,plan.md,config.env,embed.yaml,.cx/. The host-side.gitignoreConstruct generates today is a strict subset. - The existing Beads integration in
lib/beads-automation.mjsalready implements a marker-block pattern (``) for non-destructive injection intoCLAUDE.md. It is the only place this pattern lives; AGENTS.md useswriteStampedIfMissingwhich can't update. - The uninstall surface (
lib/uninstall/uninstall.mjs) already uses.construct-manifestfiles to surgically remove only files Construct authored, preserving user additions. The same manifest model is the natural foundation for sync-time reconciliation. - The empirical test under HOME override confirmed:
os.homedir()honors$HOME, so machine-state writes can be fully isolated for testing. Two resources do not isolate (thedev.construct.pressure-releaseLaunchAgent label is global per user; theconstruct-postgres:54329 container name/port are hardcoded) — but these are out of scope for the disposition contract itself.
Decision
Construct's host-project footprint is governed by a single contract with four parts:
1. Disposition is explicit and enumerated. Every artifact Construct creates in a host project carries a documented intended disposition — tracked, ignored, or asked-before-modify — recorded in .construct-manifest. The generated host .gitignore mirrors ignored artifacts 1:1 with Construct's own repo .gitignore. No artifact has an implicit disposition.
2. Mutation of files Construct does not own is marker-managed. Any file authored by the user (AGENTS.md, CLAUDE.md, README.md, .gitignore, construct.config.json, .github/*) is mutated exclusively through versioned, hash-stamped marker blocks of the form …. The injector preserves byte-for-byte everything outside its markers, deduplicates against sibling marker blocks (e.g. the Beads block), and is idempotent: same hash = no-op, different hash = replace block content only. Init creates files only when missing; sync updates only the marker block.
3. The install / init / sync boundary is strict. construct install writes only machine state (~/.construct/, ~/.claude/ front-door, LaunchAgent registration). It never reads or writes process.cwd(). construct init writes project state into the given target directory, exactly once per file (skip-if-exists for user-editable scaffolds; idempotent inject-or-update for marker-managed files). construct sync reconciles project adapters and prunes orphans. None of the three invokes git commit on the host repo without an explicit --commit-bootstrap opt-in.
4. Forward-fix AND backward-repair. Every change to what Construct installs ships with a migration that repairs state left by prior versions. The repair runs automatically on construct sync where it is safe (cleaning stale machine-scope state, reconciling orphan MCP entries, pruning unused adapter dirs, extending the host .gitignore). Where automatic repair would touch user data or commit history, construct doctor surfaces the drift with a precise one-line remediation command, and construct migrate \<id> runs the destructive repair only on explicit consent. Migrations are stamped to ~/.construct/migrations.json so subsequent syncs no-op.
The contract is implemented across the 12 work items tracked under epic construct-jsut (children construct-jlql, construct-dhfz, construct-e13x, construct-81dk, construct-7e2o, construct-4xy6, construct-6xo0, construct-73su, construct-lb7b, construct-r9u3, construct-n6h7, construct-w9pp). The migration framework (construct-w9pp) lands first; every other item's repair plugs into it.
Rationale
The current behavior is not the result of intent — it is the cumulative residue of features added one at a time without a contract. Skip-if-exists protects user files in some places but auto-commit overrides them in others; the .gitignore writer guards .cx/ but forgets the six adapter directories; sync changes its target paths between versions but leaves the old paths populated. Each piece behaves reasonably in isolation; the combination breaks the citizenship promise.
A single contract resolves this by making three things first-class: the disposition table (so .gitignore generation, uninstall coverage, and adapter pruning all read from the same source of truth), the marker-block injector (so updates to agent-instruction files are safe by construction), and the migration framework (so a user upgrading from any prior Construct version can self-heal via one construct sync).
The marker-block pattern was chosen over the alternatives below because it preserves the property the user explicitly asked for: Construct can drop in the minimum guidance an agent needs to work seamlessly with .cx/, intake, specialists, and beads — without overwriting user content, without duplicating content already present from a sibling integration, and with a versioning model that lets the guidance evolve.
The migration framework was chosen over per-item ad-hoc repair because the same repair shapes (orphan-config reconcile, stale-dir cleanup, gitignore extension, manifest-driven prune) recur across items 1, 5, 6, 10, 11. Folding them into a registry with detect() / apply() / safety gives one auditable list, one place to stamp success, and one user-facing surface (construct migrate list / construct doctor).
Rejected alternatives
- Skip-if-exists everywhere. The current default for AGENTS.md / CLAUDE.md. Preserves user content but cannot deliver updated guidance — once the file exists, Construct goes silent. Forces users to manually copy in new Beads commands, new
.cx/paths, new specialist references whenever Construct evolves. Rejected because it makes the guidance surface useless after first init. - Rewrite files freely (overwrite mode). Maximally simple for Construct; user edits are lost. Already used for
.claude/settings.json(which the test showed produces inconsistent partial state in git when bd-init then sweeps it up). Rejected because it violates the user's explicit constraint: never overwrite content the user may already have. - Ship a one-off cleanup script with each release. Solves backward-repair without the framework. Each release would carry a
scripts/migrate-vN.shusers run manually. Rejected because users don't run release-note scripts, the scripts diverge from current code over time, andconstruct doctoralready exists as the natural surface for surfacing drift. - Treat
~/.agents/skills/and~/.cx/as opaque machine state that the user manages. Avoids the migration cost. Rejected because the empirical evidence is that prior-version Construct populated those paths and current-version Construct cannot reconcile them — telling the user "runrm -rfto fix our warnings" is the antithesis of low-friction integration. - Defer
.gitignoreand adapter-dir disposition to per-user configuration. Each user picks whether to commit adapters. Rejected because the empirical test showed adapter content carries machine-specific paths (MCP server absolute paths, env-resolved tokens) that should never be committed regardless of preference — disposition is a property of the artifact, not a user choice.
Consequences
construct initon a fresh repo produces a cleangit status(zero untracked Construct-generated files) and zero unsolicited commits. On an established repo it preserves pre-existingCLAUDE.md/AGENTS.md/README.md/.gitignorebyte-for-byte outside the marker blocks.construct syncbecomes the single self-healing entry point: extends the host.gitignore, prunes orphan adapter dirs, reconciles MCP entries, cleans up legacy~/.agents/skills/, and surfaces a one-line summary of what changed. Idempotent on a fully reconciled machine.construct doctorgains a "drift" category surfacing every detected legacy condition with its preciseconstruct migrate \<id>orgit rm --cachedremediation. Already-committed adapter files (from the legacy bd-init commit) are reported but never auto-removed.construct uninstallcovers the LaunchAgent andcore.hooksPathreversal that the empirical test had to do manually. Docker images stay (other projects may share them) unless--with-images.- Updates to Construct's host-side guidance — new specialist hints, new
.cx/paths, new commands — flow into existing host files via marker-block hash bumps. User content above and below the block is preserved by construction; tests undertests/functional/agent-instructions-injection.functional.test.mjs(new) assert this. - ADR 0013's dual-target claim is reconciled with code: either reinstate the
~/.agents/skills/write (with correct frontmatter from the current helper) or formally retract the dual target and remove the legacy directory via migration. The decision is recorded inconstruct-r9u3and the ADR will be amended once that decision lands. - A user upgrading from any prior Construct version can run
construct synconce and reach a clean state. No release-note script, no manual cleanup, no stale warnings on next session. - The doctrine extends from the project footprint to the user's global OpenCode config (
~/.config/opencode/opencode.json): the ownership boundary — which keys Construct manages versus which belong to the user, and the non-destructive merge rules — is documented in docs/guides/concepts/opencode-config-ownership.md. The github-MCP token reconciliation named in problem #9 lands as an env-ref (Bearer {env:GITHUB_TOKEN}) emitted bysyncOpencode.
Implementation tracking
Plan: ~/.claude/plans/when-contrite-he-knits-wondrous-pumpkin.md. Epic: construct-jsut. Migration framework: construct-w9pp (lands first). All work items carry a "Repair (prior versions)" section that names the migration plug-in.
§2 conformance note (construct-83ik)
The marker-block injector (lib/agent-instructions/inject.mjs) satisfied §2 for new guidance, but construct init still pre-wrote a full operating-doctrine body into AGENTS.md (via buildAgentsGuide) outside any marker — so a host repo carried un-fenced Construct content §2 forbids, and the same doctrine leaked into docs/README.md, the greenfield README.md, and a root-level construct_guide.md. The pre-write is removed: bd init creates the project skeleton and injectIntoAgentFile adds the fenced block as the sole Construct-owned region of AGENTS.md/CLAUDE.md (symmetric handling). construct_guide.md moves to the ignored .cx/ tree; docs/README.md is lane-accurate and tool-command-free; inbox/ is ignored via host-disposition.mjs. Backward-repair for repos written by the non-conformant path: reconcile tasks legacy-doctrine-strip and legacy-guide-decommit (both ask), surfaced by construct doctor.