Architecture
Hack is a local CLI that turns a small committed .hack/ folder into a running
project: services, environment, secrets, trusted HTTPS, logs and host-side helpers.
Everything runs on the developer's machine. There is no hosted component.
Two kinds of state exist. Global state under ~/.hack/ (overridable with
HACK_HOME) holds the shared infrastructure every project uses: the proxy, DNS,
the logging stack, the project registry and the optional daemon. Project state
under <repo>/.hack/ holds that project's definition and is committed, apart from
machine-local generated files that Hack keeps ignored.
System overview
---
title: Hack on one developer machine
---
flowchart LR
Agent["Coding agent"] -->|"same CLI, --json"| CLI["hack CLI"]
subgraph Project["Project (.hack/ in the repo)"]
direction TB
Config["Compose file,<br/>config, env YAML"]
Services["Service containers"]
Helpers["Host helpers<br/>(tunnels, hooks)"]
Config -->|"Compose backend"| Services
Config -->|"lifecycle"| Helpers
end
subgraph Global["Global infrastructure (~/.hack)"]
direction TB
Caddy["Caddy proxy<br/>TLS + routing"]
CoreDNS["CoreDNS"]
Logs["Alloy → Loki → Grafana"]
Hackd["hackd (optional)"]
end
CLI -->|"hack up"| Config
CLI -->|"hack global install"| Global
Services -->|"labels"| Caddy
Services -->|"stdout/stderr"| Logs
CoreDNS -.->|"*.hack.local → Caddy"| Services
Hackd -.->|"docker events"| Services
Caddy -->|"https://myapp.hack.local"| Browser["Browser"]
Logs -->|"https://logs.hack.local"| Browser
classDef idle stroke-width:1.6px
class Hackd idle- Caddy terminates TLS with a locally trusted CA and routes
https://<service>.<project>.hack.localto containers by their Docker labels. Existing.hacknames,.hack.gyOAuth aliases and custom domains route the same way. - CoreDNS gives containers the same
*.hack.localnames as the host, so a service can call another service by its public local URL. - Alloy, Loki and Grafana collect container logs and keep searchable history.
- hackd is an optional daemon that caches Docker state for fast
--jsonstatus. The CLI works without it. - Schemas for the project config files are served by Caddy at
https://schemas.hack.localfor editor validation.
Backends
The CLI reads the same .hack/ project on either backend. Docker Compose is the
default and runs everywhere Hack runs. The native backend is opt-in and
experimental: on Apple Silicon macOS it runs an explicitly prepared project inside
a provider-managed Linux VM pool without Docker, with its own routing and a bounded
command subset.
---
title: One project, two backends
---
flowchart LR
Repo[".hack/ project"]
CLI["hack CLI"]
Repo --> CLI
CLI -->|"default"| Compose
CLI -->|"HACK_RUNTIME_BACKEND=native"| Native
subgraph Compose["Compose backend"]
direction TB
DC["docker compose"] --> C1["Service containers"]
C1 --> GlobalCaddy["Global Caddy + CoreDNS"]
end
subgraph Native["Native backend (opt-in, macOS arm64)"]
direction TB
Plan["Config compiler<br/>validate / plan"] --> Exec["hack-native executor"]
Exec --> Pool["Provider VM pool"]
Pool --> N1["Workloads + relay"]
N1 --> NativeHttps["Shared HTTPS owner"]
end
classDef live stroke-width:1.6px
classDef wait stroke-width:1.6px
class Compose live
class Native waitThe native backend selects a verified executor binary and a private native home,
plans the project through the config compiler, and runs workloads under a
foreground owner process that holds the run until it is stopped. Its supported
commands are whole-project up and restart, owner-mediated down, saved-run
ps, finite logs, and noninteractive exec and run. See
the native backend guide and
native authored commands. A Compose flag
in these docs does not imply native support.
Global vs project scope
- Global scope (
~/.hack)- Caddy proxy on 80/443, routing by Docker labels
- CoreDNS for container DNS (
*.hack.localand legacy*.hack→ Caddy) - macOS DNS helper: dnsmasq plus
/etc/resolverentries pointing at Caddy - Logging stack (Alloy → Loki → Grafana)
- Global config
hack.config.jsonand the project registryprojects.json - Networks
hack-dev(ingress) andhack-logging
- Project scope (
.hack)docker-compose.ymldefines services and Caddy labelshack.config.jsonstores the project name, dev host, log preferences, OAuth alias and browser launch preferencehack.env.default.yamland optional overlays hold committed env, with per-value encryption for secretshack.branches.jsonis an optional committed branch registry.internal/and.branch/hold generated, machine-local files. Hack rewrites them on every run; keep them out of Git.hack doctorreports tracked copies andhack doctor --fixuntracks them.
Project lifecycle
hack up and hack down run more than Compose. Hooks and managed host processes
declared in .hack/hack.config.json run around the services, inside a dedicated
tmux or zellij session that Hack owns and can tear down. The exact ordering is in
Lifecycle.
---
title: hack up and hack down
---
stateDiagram-v2
direction TB
[*] --> ResolveEnv : hack up
ResolveEnv --> UpBefore : env contract ok
UpBefore --> Processes : hooks done
Processes --> ComposeUp : session owned
ComposeUp --> UpAfter : services started
UpAfter --> Running
state failure <<choice>>
UpBefore --> failure : hook failed
ComposeUp --> failure : compose failed
UpAfter --> failure : hook failed
failure --> Failed : own session removed
Running --> DownBefore : hack down
DownBefore --> ComposeDown : hooks done
DownBefore --> Running : hook failed, abort
ComposeDown --> StopProcesses : services stopped
StopProcesses --> DownAfter : session killed
DownAfter --> [*]
note right of Processes
Adopts a session only when its
ownership token, definition hash
and live windows all match.
end note
classDef live stroke-width:1.6px
classDef fail stroke-width:1.6px
class Running live
class Failed failLifecycle state is checkout-local at .hack/.internal/lifecycle/state.json, one
entry per instance. A random token mirrored in the mux session and in that state
proves ownership. Teardown never trusts a session name or a reused process group
on its own: hack down stops the saved process groups only while a matching
session still proves ownership, and hack doctor --fix removes a stale session
only when its instance is absent and a second check agrees. Fixed-port helpers
such as database or SSM tunnels can declare a singleton listener set so Hack
reuses an equivalent helper instead of starting a competing one; see
shared host helper lifecycle.
Environment and secrets
Env lives in committed YAML. Secrets are encrypted per value with a key that stays
out of Git. At runtime Hack merges the layers below and injects the result into
Compose, host commands and lifecycle processes. Nothing decrypted rests on disk:
the generated Compose override holds only ${HACK_ENV_...} references and the
values travel through the docker compose process environment.
---
title: Env resolution order
---
flowchart LR
subgraph Committed["Committed with the repo"]
direction TB
D["hack.env.default.yaml"] --> O["hack.env.<overlay>.yaml<br/>(--env)"]
end
subgraph Local["Machine-local overrides"]
direction TB
PL["primary checkout<br/>hack.env.local.yaml"] --> POL["primary checkout<br/>hack.env.<overlay>.local.yaml"]
POL --> L["this checkout<br/>hack.env.local.yaml"]
L --> OL["this checkout<br/>hack.env.<overlay>.local.yaml"]
end
subgraph Scope["Scope"]
direction TB
G["values.global"] --> S["values.<service>"]
S --> H["host values<br/>(host exec, hooks)"]
end
O --> PL
OL --> G
H --> R["Effective env"]
classDef idle stroke-width:1.6px
class PL,POL idleLater layers win. The primary-checkout layers apply only in a linked git worktree,
which also inherits the primary checkout's secret key through the shared git
directory. HACK_ENV_SECRET_KEY covers CI and other detached environments.
Environment and secrets covers the file format, delivery matrix,
rotation and the external secret-store backends.
Internal DNS and TLS
When internal.dns and internal.tls are enabled, hack up writes a Compose
override that points each service at CoreDNS, mounts Caddy's local CA and the
combined trust bundle, sets the usual SSL_CERT_FILE-style variables, and adds
extra_hosts entries for runtimes that ignore custom DNS. Containers then use the
same https://*.hack.local names as the host. If Caddy's IP changes,
hack status and hack doctor warn and hack restart refreshes the mapping.
---
title: A request from one service to another
---
sequenceDiagram
autonumber
participant Web as web container
participant DNS as CoreDNS
participant Caddy
participant Api as api container
Web->>DNS: resolve api.myapp.hack.local
DNS-->>Web: A record = Caddy IP
Web->>Caddy: HTTPS request (local CA trusted)
Caddy->>Api: route by container labels
Api-->>Web: responseWorkflow: init, up, logs
---
title: First run of a project
---
sequenceDiagram
autonumber
participant User
participant CLI as hack
participant Docker
participant Caddy
participant Loki
User->>CLI: hack init
CLI-->>User: wrote .hack/ files
User->>CLI: hack up --detach
CLI->>CLI: resolve env, run up.before hooks
CLI->>Docker: docker compose up -d
Docker->>Caddy: labels → routes
Docker->>Loki: logs via Alloy
CLI-->>User: https://myapp.hack.local
User->>CLI: hack logs
alt compose backend
CLI->>Docker: docker compose logs
else loki backend
CLI->>Loki: LogQL query or tail
endBranch instances
--branch <name> generates a per-branch Compose override that prefixes hostnames
(api.myapp.hack.local → api.<branch>.myapp.hack.local) and the Compose project
name, so parallel checkouts never collide on ports or names. In a linked git
worktree, hack up selects a branch instance named after the branch automatically.
A detached worktree has no branch to derive a name from, so Hack refuses to target
the base instance silently; pass --branch, or set worktree.auto_branch=false.
---
title: Checkouts and instances
---
flowchart LR
P["Primary checkout<br/>(main)"] -->|"hack up"| B["Base instance<br/>myapp.hack.local"]
W["Linked worktree<br/>(feature-x)"] -->|"hack up"| F["Branch instance<br/>feature-x.myapp.hack.local"]
D["Detached worktree"] -->|"hack up"| R{"branch name?"}
R -->|"--branch review"| V["Branch instance<br/>review.myapp.hack.local"]
R -->|"none"| X["Refused"]
classDef ok stroke-width:1.6px
classDef fail stroke-width:1.6px
class B,F,V ok
class X failhack doctor checks linked checkouts for divergent secret keys and dev_host
collisions. See Worktrees and watchers.
Logging pipeline
hack logs has two backends. compose streams docker compose logs directly
and is the default for following output. loki adds searchable history and
LogQL filters and is the default for snapshots (--no-follow), falling back to
Compose with a one-line notice when Loki has nothing for the selection.
hack logs --json emits NDJSON start, log, error and end events for
agents and the TUI.
---
title: Where a log line goes
---
flowchart LR
C["Service containers"] -->|"stdout/stderr"| A["Alloy"]
A -->|"push"| L["Loki"]
L -->|"LogQL"| Q["hack logs --loki"]
C -->|"docker compose logs"| F["hack logs"]
L -->|"Explore"| G["Grafana<br/>logs.hack.local"]
Q --> T["Terminal / NDJSON"]
F --> TAlloy discovers containers by polling Docker every five seconds and includes exited
containers, so a one-shot service such as a migration is shipped after it stops as
long as Docker keeps the container. A container removed between two polls never
reaches Loki; use hack logs --compose or the command's own output for those.
Alloy keeps its read positions in a named volume across hack global down and
up; hack global logs-reset clears positions and Loki data together. Loki
retention defaults to one week in the global template; per-project
logs.retention_period applies when hack down prunes logs.
Daemon (hackd)
hackd is an optional local daemon that watches Docker events and keeps a cached
view of running containers behind a Unix socket at ~/.hack/daemon/hackd.sock.
It powers hack projects --json and streaming consumers such as the TUI and MCP.
hack ps queries Compose directly so failed startups are visible immediately. If
the daemon is missing or version-mismatched, the CLI falls back to direct Docker
calls.
---
title: Cached status
---
flowchart LR
CLI["hack projects --json"] -->|"GET /v1/projects"| H["hackd"]
TUI["hack tui / MCP"] -->|"stream"| H
H -->|"docker events"| D["Docker Engine"]
H -->|"full inspect every 30s"| D
H --- Cache["In-memory cache"]
classDef idle stroke-width:1.6px
class H idleThe daemon treats the container runtime as ephemeral: it fingerprints the engine,
detects resets, performs one bounded refresh, and marks cached state stale with
runtime_* metadata when the runtime is unavailable. Scripted and noninteractive
commands never autostart it. v5 prerelease launchers (hack-next, hack-v5)
never autostart it either, because autostart would run the stable daemon on PATH;
hack doctor --fix under those launchers skips its hackd repair and names the
command to run yourself. On macOS the launchd service belongs to the default
~/.hack root only; an isolated HACK_HOME runs hackd directly.
Native backend internals
The native backend is built around explicit ownership: every run has one owner,
every resource it creates is recorded before it can outlive the frontend, and
cleanup is confirmed rather than assumed. These rules are checked against finite
TLA+ models under tests/models/tla/; the charts below are drawn from those
specifications, and the model coverage map
records what each model does and does not prove.
Foreground startup owner
A foreground hack up on the native backend serves the run until it is ready or
fails. Readiness publishes the run mapping. A failure after resources were created
must leave them owned: either cleanup is confirmed, or the frontend's final
read-only inspection authenticates the owner receipt and records it.
---
title: Owner · native foreground startup
---
stateDiagram-v2
direction TB
[*] --> idle
idle --> serving : Serve
serving --> serving : StartResources
serving --> ready : Ready [resources present]
serving --> failed : Fail
ready --> failed : Fail
state inspect <<choice>>
failed --> inspect : Inspect
inspect --> exited : ExitConfirmed [cleanup confirmed]
inspect --> exited : ExitUnconfirmed [receipt authenticated, owner recorded]
inspect --> exited : ExitUnconfirmed [receipt refused]
exited --> [*]
note right of ready
INV NoUnprovenOwner: a mapping is
published only by readiness or an
authenticated receipt.
end note
note right of exited
INV NoUnownedResources: resources that
outlive the frontend under an
authenticated receipt are recorded.
end note
classDef live stroke-width:1.6px
classDef ok stroke-width:1.6px
classDef fail stroke-width:1.6px
class serving live
class ready ok
class failed failSource: Owner.tla. A refused
inspection is a known coverage gap: nothing can be authenticated, so only the
runtime journal retains the graph.
Interrupted restart
hack restart saves a prepared intent bound to the exact old run, asks the old
run's owner to stop, and marks the intent cleaned only after that stop is
confirmed. hack up refuses while an intent is pending, so an interrupted restart
must always leave a state that hack restart itself accepts.
---
title: RestartIntent · hack restart under interruption
---
stateDiagram-v2
direction TB
[*] --> idle
idle --> stopping : BeginRestart [intent prepared]
stopping --> stopping : Request [owner asked to stop]
stopping --> starting : StopConfirmed [intent cleaned]
starting --> done : Ready [intent none]
state interrupted <<choice>>
stopping --> interrupted : Interrupt or Crash
starting --> interrupted : Interrupt or Crash
interrupted --> canceled : [own process group, stop finished]
interrupted --> failed : [intent kept]
canceled --> [*]
failed --> idle : BeginRestart [resumes prepared or cleaned intent]
done --> [*]
note right of failed
INV NoRecoveryLoop: when no restart
is running, either hack up or
hack restart accepts the state.
end note
classDef live stroke-width:1.6px
classDef ok stroke-width:1.6px
classDef fail stroke-width:1.6px
class stopping,starting live
class done,canceled ok
class failed failSource: RestartIntent.tla.
The restart's stop request runs in its own process group so a terminal Ctrl-C
reaches only the restart frontend and lets the stop finish.
Shared HTTPS owner
Native runs on one machine share a single HTTPS owner. Clients lease it, prove cleanup, and release; the owner may begin closing only once no lease remains.
---
title: SharedHttps · owner lifetime
---
stateDiagram-v2
direction LR
[*] --> active
active --> active : Acquire / Release
active --> closing : BeginClose [no leases]
closing --> closed : FinishClose
active --> failed : OwnerCrash
closing --> failed : OwnerCrash
closed --> [*]
note right of closing
INV NoPrematureShutdown: closing
with a live lease is unsafe.
end note
classDef live stroke-width:1.6px
classDef ok stroke-width:1.6px
classDef fail stroke-width:1.6px
class active live
class closed ok
class failed failSource: SharedHttps.tla.
Other models cover registry writers and lock recovery, publication journals, persistent data enrollment, dependency rebinding, engine restart policies and MCP startup. A finite model checks a property over a bounded domain; it does not qualify the installed binary, real filesystems or an application.
Files and directories
~/.hack/(default; override the whole root with an absoluteHACK_HOME)hack.config.json(global config and extension settings)projects.json(project registry;hack projects pruneremoves stale entries)daemon/hackd.sock,daemon/hackd.pid,daemon/hackd.log(capped at 5 MiB)caddy/docker-compose.yml,caddy/Corefilelogging/docker-compose.yml,logging/alloy.alloy,logging/loki.yaml,logging/grafana/schemas/hack.config.schema.json,schemas/hack.branches.schema.jsoncerts/(mkcert output for non-Caddy services)
<repo>/.hack/docker-compose.yml,hack.config.json, optionalhack.branches.json.gitignore(committed; created byinitand repaired bydoctor --fix)hack.env.default.yamland optionalhack.env.<overlay>.yaml(committed env)hack.env.local.yamlandhack.env.<overlay>.local.yaml(worktree-local).env(only when explicitly materialized) and.env.state.json.internal/compose.override.yml,compose.env.override.yml,compose.runtime.override.yml.internal/extra-hosts.json,.internal/branch-usage.json.internal/lifecycle/state.jsonand.internal/lifecycle/*.log.branch/compose.<branch>.override.ymland.branch/compose.<branch>.runtime.override.ymlhack.project.jsonandnative/adaptation.json(native backend only)
Key design choices
- Docker Compose is the default execution substrate for predictability and portability.
- Caddy routes by container label, so no project carries reverse-proxy config.
- CoreDNS gives containers the same
*.hack.localnamespace as the host. - Logs default to
docker compose logsfor speed; Loki holds history and filtering. - The daemon keeps JSON queries fast without becoming a dependency.
- Config lives beside each repo in
.hack/so projects stay isolated and portable. - The native backend records ownership before effects and confirms cleanup, and those rules are model-checked.
Unsupported retained surfaces
Remote, gateway, node and dispatch code, the extension dispatcher and the macOS app remain in the repository for explicit maintenance. They are outside the supported local CLI and are hidden from default help. See unsupported retained surfaces.