0029 Install Scopes And Hook Budgets
ADR 0029: Install scopes and per-hook performance budgets
- Date: 2026-06-05
- Status: accepted
- Deciders: Construct·Architect
- Supersedes: none
- Extends: ADR 0025 (explicit activation — extends the "aware, never ambient" doctrine from activation into install-scope opt-in), ADR 0027 (host-project footprint — completes the install/init boundary by adding scope opt-in for machine-side writes that ADR-0027 left as silent defaults).
- Extended by: ADR 0071 renames this ADR's
--scope=project|user|bothinstall flag to--footprint=project|user|both(--scopekeeps working as a deprecated alias) to resolve a naming collision with the unrelatedconstruct scopeorg-profile command. Theproject | user | bothsemantics decided below are unchanged — only the flag spelling changed.
Problem
construct install and the install/sync paths today write to a user's machine and to the user's global Claude Code config without an explicit, per-scope opt-in:
scripts/sync-specialists.mjs:826–856writes a marker block into~/.claude/CLAUDE.mdand injects Construct's hooks into~/.claude/settings.jsonwheneversyncruns withtargetDir == null. The global-scope path is reachable fromconstruct install(vialib/setup.mjs), fromconstruct sync --global, and historically frombin/construct-postinstall.mjs. There is no interactive disclosure, no itemized path list, and no way to install Construct on a machine for use in one project without also mutating the user's global Claude config.lib/setup.mjs:511–523(runSetup) unconditionally writes~/.construct/config.env, the~/.construct/libsymlink,~/.config/opencode/opencode.jsonMCP entries, and optionally the docker-compose for Postgres. The flag surface is--yes/--no-docker/--reconfigure— no--scopeand no project-only mode.- Construct's hook surface is 53 hooks across 7 Claude Code event types (
platforms/claude/settings.template.json,lib/hooks/*.mjs). Per-event budgets are not declared, not measured, and not enforced. TheStopevent was opportunistically tuned in commit411d1bd(sync 150s → 15s) but no harness prevents the next hook addition from regressing it, and no harness coversUserPromptSubmit,PreToolUse, orPostToolUse.
Two specific hot-path costs went unmeasured until this audit:
lib/hooks/ci-status-check.mjsspawnsgh run listsynchronously on everyUserPromptSubmit, cached 60s. The cache miss is unbounded by Construct (depends ongh+ network).lib/hooks/pre-push-gate.mjsrunsnpm testandnpm run buildsynchronously for 180s before everygit push. This is correct as a gate but is currently undeclared in any budget table, so it cannot be distinguished from accidental hot-path cost.
The decision-forcing tension is whether to leave install scope and hook cost as undocumented operational defaults — fix-forward only — or codify them as a contract with explicit opt-in and measured budgets.
Context
- Construct's stated doctrine in ADR 0025 is "aware, never ambient": activation requires explicit user action. The install path's silent global writes violate this doctrine at the machine layer; ADR-0025 explicitly addressed it only at the per-session activation layer.
- ADR 0027 §3 made
construct installstrictly machine-scoped (never touchesprocess.cwd()) but did not introduce per-scope opt-in for the machine-side writes themselves. ADR-0027's.construct-manifestdisposition model (tracked/ignored/asked-before-modify) is the natural foundation for extending the disposition contract with ascope: project | useraxis. - Anthropic's Claude Code documentation (Hooks reference, Best practices, both fetched 2026-06-05) declares concrete budgets and an
async: trueescape valve: default timeouts are 600s (command/http/mcp_tool), 30s (prompt), 60s (agent). The hooks reference cites the 30sUserPromptSubmittimeout with the rationale "a stuck hook stalls the session." TheStopevent auto-overrides after 8 consecutive blocks. These are upstream constraints, not Construct's invention. - Community peer projects diverge on global-config policy:
claude-task-master: project-local default,~/.claude/only on user opt-in. Documentation ambiguity on this boundary was filed as a bug (issue #1180, accessed 2026-06-05).ruflo(claude-flow): project-local only. Filed a measured 18–21s/prompt vs 4.8s/prompt slowdown (~4×) caused by 11 node-spawning hooks running per turn (issue #1530, accessed 2026-06-05).- SuperClaude: explicit global install at
~/.claude/(~50MB), disclosed up-front viapipx install+superclaude install(install docs, accessed 2026-06-05). - Claudia (GUI): read-only on
~/.claude/. The pattern: the credible peers either default to project-local or disclose global writes line-by-line. Silent global writes are the outlier.
- Community write-ups converge on ~500ms per
PostToolUsehook as the perceptual sluggishness threshold (Pixelmojo, Blakecrosley, accessed 2026-06-05). The constraint is total wall time per event, not hook count — 95 fast hooks can outperform 2 slow ones. lib/host-disposition.mjsalready encodes per-artifact disposition for the project scope. Extending its enum with ascopeaxis is mechanical; building parallel infrastructure for machine-scope disposition would duplicate without payoff.
Decision
Construct's install and hook surfaces are governed by two additions to the existing footprint contract:
1. Install is explicitly scoped. construct install accepts --scope=project | user | both, defaulting to project. The default does no machine-state writes and instead prints a single line directing the user to either construct init (project artifacts) or construct install --scope=user (machine artifacts). The user scope writes the XDG user dirs (config ~/.config/construct/*, state ~/.local/state/construct/*, cache ~/.cache/construct/*; each honors $XDG_CONFIG_HOME / $XDG_STATE_HOME / $XDG_CACHE_HOME — see ADR-0045 §B and docs/guides/reference/config.md), ~/.config/opencode/opencode.json, ~/.codex/config.toml, the hook-lib symlink at ~/.config/construct/lib, and — only with an itemized interactive consent prompt (or --yes in non-interactive contexts) — the marker block in ~/.claude/CLAUDE.md and the hook injection in ~/.claude/settings.json. The both scope is project then user. bin/construct-postinstall.mjs never writes user scope on its own; it prints the same scope guidance and exits 0. construct sync --global reuses the user-scope writer and the same consent prompt.
2. Every hook declares a measured p95 budget. Each lib/hooks/*.mjs file header carries a @p95ms \<N> line. Defaults are derived from Anthropic's primary-source budgets and the 500ms community perceptual threshold:
| Event | Default @p95ms | Rationale |
|---|---|---|
UserPromptSubmit | 200 | Anthropic ceiling 30s; this is hot path — well below threshold |
PreToolUse | 500 | Community perceptual threshold for hot path |
PostToolUse | 500 | Community perceptual threshold for hot path |
Stop (sync arm) | 500 | Sync portion only; async arms have their own budgets |
SessionStart | 1000 | Run-once-per-session; less latency-sensitive |
| Gate hooks (user-invoked) | declared explicitly | e.g. pre-push-gate.mjs declares 180_000 (180s) as a deliberate quality gate |
A new harness scripts/bench-hooks.mjs measures p95 over N=20 runs per hook against a synthetic stdin matching the hook's event shape. A scheduled CI job (tests/perf/hook-budgets.test.mjs) fails when measured p95 exceeds the declared @p95ms by more than 2×. The harness is also a construct doctor lane so a developer can probe locally without waiting for CI.
Two immediate hot-path fixes ride with the budget rollout:
lib/hooks/ci-status-check.mjsmoves thegh run listspawn off the synchronousUserPromptSubmitpath. The hook serves the cached value immediately and triggers a background refresh (stale-while-revalidate). The cache file path is unchanged.lib/hooks/pre-push-gate.mjskeeps its 180s sync behavior (correct as an explicit user-invoked gate) but declares@p95ms 180000in its header so the benchmark does not flag it.
The contract is implemented under bd epic construct-7w69 with child issues construct-hvns (this ADR), and follow-up issues for: --scope flag + sync gating + tests; README + architecture footprint contract; hook budget headers + benchmark harness + ci-status-check async fix; hook budget CI gate.
Rationale
The chosen position aligns Construct with the credible community defaults and the upstream Anthropic guidance without abandoning the operational properties that make Construct useful. Three load-bearing reasons:
-
Project-local-by-default is the dominant pattern among performance-credible peers. Both task-master and ruflo default to project-local; task-master's documentation ambiguity around the global boundary was treated as a bug by the community (#1180). The peers that do write globally (SuperClaude) disclose it explicitly with byte counts before mutation. Silent global writes are the outlier — extending ADR-0027's "disposition is explicit and enumerated" doctrine from project scope to machine scope is the consistent move.
-
Hook cost is bounded by total wall time, not count — and only measured budgets keep it bounded. Ruflo's #1530 is the load-bearing peer evidence: 11 node-spawning hooks turned the session into a 4× slowdown. Construct already has 53 hooks. The existing commit
411d1bdproves the team will tune the budget when it visibly hurts; the failure mode this ADR addresses is the invisible regression — the next hook that adds 300ms toUserPromptSubmitwithout anyone noticing. Per-hook declared budgets + a scheduled measurement harness is the cheapest way to catch this class. -
The marker-block / disposition machinery already exists.
lib/host-disposition.mjsencodes per-artifact disposition for the project scope; the--scopeaxis extends an existing enum rather than introducing a parallel mechanism.tests/hooks/no-skip-vars.test.mjsis the precedent for AST-style hook-policy enforcement; the budget gate is modeled on it. The total new surface this ADR adds is small: one flag, one header convention, one harness, one nightly CI lane.
The community ~500ms perceptual threshold is cited because it represents the convergent empirical evidence across multiple independent practitioners; Anthropic's primary-source budgets are cited as the upstream ceiling (the latitude the platform itself grants). The chosen Construct budgets sit between the community threshold (the perceptual budget) and the upstream ceiling (the platform budget), giving the team a tightening dial without flagging legitimate gates.
Rejected alternatives
-
Keep silent global writes; rely on
construct uninstallto reverse them. Minimizes setup friction. Rejected because it directly contradicts the "aware, never ambient" doctrine of ADR 0025 and reproduces exactly the ambiguity that task-master's #1180 identified as a bug. Reversibility-after-the-fact is not a substitute for opt-in-before-the-fact; users discover the global writes only when they break. -
Itemized consent prompt on every install (no
--scopeflag). Match SuperClaude's pattern directly. Rejected because the SuperClaude pattern conflates two distinct decisions — what scope and which paths — into one wall-of-text prompt. A--scopeflag makes the coarse decision explicit and cheap (a single argv token), and reserves the itemized prompt for the path-level disclosure within the user scope. It also makes non-interactive contexts (CI, container builds,--yes) cleanly expressible without bypassing the consent semantics. -
Three install commands (
construct install-project,construct install-user,construct install-both). Maximally explicit at the verb level. Rejected because it triples the documentation surface for a decision that's structurally a single dimension (scope), creates command-name churn for users coming fromconstruct install(which would have to become an alias or be removed), and breaks the existing--yes/--reconfigureflag ergonomics. -
Declare hook budgets in
platforms/claude/settings.template.jsonrather than file headers. Co-locates budgets with hook registration. Rejected because the budget describes the hook's implementation cost (a property of the .mjs file), not its event binding (a property of settings.json). Co-locating with the implementation means a single file move/rename keeps the budget attached; co-locating with the registration would let budget and implementation drift apart. The header convention also reuses the existing file-header comment block per the project's comment convention — no new parsing target. -
Measure budgets on every PR. Catches regressions earlier. Rejected because hook benchmarking is variance-heavy (process spawn time, CI runner contention) and the 2× tolerance can still produce flaky PR signals. A scheduled nightly job + on-demand
construct doctor bench:hooksgives a stable signal without teaching the team to ignore red builds (the feedback_no_test_rewrite_to_hide_deviation anti-pattern). Promoting to per-PR is a one-line config change once the noise floor is characterized. -
Drop the
~/.claude/settings.jsonglobal hook injection entirely. Eliminates the most visible global write. Rejected because users who run Construct'sconstruct sync --globaldeliberately want the hooks active outside any single project (e.g. for ad-hoc work in non-Construct-init'd repos). The right answer is opt-in disclosure, not removal — taking away an existing capability that some users rely on, rather than gating it, is a sharper regression than the silent default it would replace.
Consequences
- A user running
npm install -g @anthropic-ai/constructfollowed byconstruct install(no flag) sees a one-line scope guidance and zero mutation. The "install" verb stops being a load-bearing silent action. - A user running
construct install --scope=useron a fresh machine sees an itemized list of every path Construct will create or modify with byte sizes, accepts (or declines) interactively, and gets a deterministic exit code in non-interactive contexts when--yesis set. Documentation in the README's new "Footprint contract" section mirrors the same itemization. - A developer touching
lib/hooks/*.mjsmust declare or update the@p95msheader.tests/perf/hook-budgets.test.mjsfails if the file lacks the header; a separatetests/hooks/budget-header-required.test.mjsenforces presence at unit-test speed without running the harness. construct doctorgains abench:hookslane usable for local probing. The lane is opt-in (not on by default) to keepdoctorfast.- The
bin/construct-postinstall.mjspostinstall path becomes a no-op printer. Users who had the silent global write happen atnpm installtime will, on next upgrade, not have it happen — surfaced byconstruct doctoras a drift category (per ADR-0027 §4) with the preciseconstruct install --scope=userremediation. construct sync --globalandconstruct install --scope=user|bothshare the consent prompt and the user-scope writer. There is exactly one code path that mutates user scope.- The hook budget gate adds a nightly CI job. Existing per-PR gates are unchanged. The benchmark harness produces a JSON report (
.cx/bench/hooks-\<date>.json) thatconstruct doctorcan read. - ADR-0029's
@p95msconvention extends to any future hook surface (e.g. if Construct ships hooks for other platforms). The convention is platform-agnostic by design. - Backward-repair: a user upgrading from a Construct version that wrote
~/.claude/settings.jsonhook injection silently can runconstruct doctorto see the drift. Removing the injection requires explicitconstruct uninstall --scope=user(or manual edit) — Construct never silently un-installs what it silently installed; reversal is also opt-in.
Reversibility
Two-way door for the --scope flag: the default can be flipped from project back to both (current de-facto behavior) by changing one default value in lib/setup.mjs. The consent prompt, the user-scope writer, and --scope=user|both all remain functional regardless of the default. Reversing would be justified only if telemetry shows a high rate of users hitting the no-op project default and then re-running with --scope=user|both — the cost would be a regression to the ambient-install posture, not a code-shape change.
Two-way door for the budget headers: removing the gate is a one-line change in tests/perf/hook-budgets.test.mjs. The headers themselves are informational and survive gate removal. Lowering specific budgets (e.g. @p95ms 200 → @p95ms 100 for UserPromptSubmit) is a per-hook decision that does not require ADR amendment.
One-way door for the bin/construct-postinstall.mjs behavior change: users who relied on the silent postinstall global write to "just work" will need a one-time construct install --scope=user after the change lands. Communicated via CHANGELOG entry and the first-run construct doctor drift surface. Not literally irreversible but not worth reversing — the silent-postinstall posture is the exact pattern this ADR sets out to retire.
References
- ADR 0025 — Construct activation is explicit and host-initiated.
- ADR 0027 — Host project footprint & non-destructive scaffolding.
- Anthropic — Claude Code Hooks reference (accessed 2026-06-05)
- Anthropic — Claude Code Best practices (accessed 2026-06-05)
- claude-task-master #1180 — global config ambiguity (accessed 2026-06-05)
- ruflo #1530 — 18–21s/prompt slowdown from hooks (accessed 2026-06-05)
- SuperClaude install docs (accessed 2026-06-05)
- Pixelmojo — Claude Code hooks production patterns (accessed 2026-06-05)
- bd epic
construct-7w69(Install scopes + hook budgets — audit + remediation); this ADR:construct-hvns. - Plan:
~/.claude/plans/thoroughly-review-the-configurations-silly-meadow.md.