Skip to content

himmel architecture — the diagrams

Five views of how the harness is wired. For the narrative version (what the parts are, how one ticket moves through them and why it is built this way), start with system-design.md.

A box that names a repo path points at a file you can open and read. The one exception is scripts/jira/dist/index.js, a generated build artifact that only exists after a build (see “Keeping these accurate” below). The diagrams also contain actor and external-system boxes (Claude tool call, operator, Prometheus, Telegram, Atlassian REST, …) that deliberately name no file.

Words follow the glossary: the chief is the session that runs a shift, and each worker owns one ticket, one worktree and one pull request. Many identifiers still carry the older names until their rename slice lands (HIMMEL-5689). The chief’s command, script and directories are already /chief, chief.sh and chief-kit/, but its markers say console (HIMMEL_CONSOLE_*), and a worker’s say leg (headed-arm-worker.sh, HIMMEL_CONSOLE_LEG). A box shows the new word first and the identifier as it is today.

These are the structural views. Four behavioural chains already have diagrams in configuration.md and are not repeated here: the ticket→ship chain, the tool-call permission chain, the Telegram bridge router, and the delegation-lane routing tree. Read those for “what happens to one call”; read this file for “what the layer is made of”. The development graph (guide/dev-graph.html, drawn from guide/dev-graph/graph.json) shows every decision a change meets on its way from dispatch to merge.


1. The enforcement layers

himmel enforces structurally, at five independent stages. A rule that lives at only one stage can be forgotten; the same invariant is often checked at two. The live inventory is .claude/settings.json, the plugin hooks.json files, and .pre-commit-config.yaml; this diagram shows the shape, not the list.

flowchart TD
    subgraph S1["1 · tool call — in-session, before the call runs"]
        TC["Claude tool call<br/>Bash · Edit · Read · Agent · MCP"]
        H1[".claude/settings.json — 33 PreToolUse hook scripts,<br/>run as ONE chained dispatch per matcher; the first refusal wins<br/>block-edit-on-main · block-read-secrets · block-destructive-commands<br/>block-git-stash · block-jira-compound-write · block-tail-pipe-on-gates<br/>block-rogue-claude-schedule · block-chokepoint-env-prefix<br/>block-edit-live-settings · block-write-into-main-checkout<br/>require-quiet-run · read-clamp · guard-pr-check-literal<br/>orchestrator-inline-guard · guard-memory-capture · guard-agent-model<br/>block-backend-tier · mcp-policy · check-cr-marker-on-pr-create<br/>auto-approve-safe-bash · auto-arm-on-cap · …"]
        subgraph PLUG["plugin hook files"]
            H2["marketplace/plugins/himmel-ops/<br/>hooks/hooks.json — 15 PreToolUse guards<br/>block-docker-privesc · block-infra-destroy · block-merged-pr-commit<br/>block-unresolved-cr-merge · block-glm-external-writes<br/>block-graphify-egress · block-rogue-codex-exec<br/>block-lesson-enforcement-writes · block-agent-native-egress<br/>guard-implementor-dispatch · guard-chief-dispatch<br/>guard-subagent-model · guard-chief-foreground · guard-judge-writes · guard-rm-writes"]
            HG["marketplace/plugins/himmel-guard/<br/>hooks/hooks.json<br/>block-destructive-commands for adopter projects<br/>(on in the settings template, so adopter projects get it by default)"]
            H2 ~~~ HG
        end
        H3["scripts/hooks/guardrail-block.mjs<br/>installs 3 of them into USER scope (~/.claude/settings.json):<br/>auto-approve-safe-bash · block-edit-on-main · block-read-secrets<br/>so they still fire outside a himmel checkout"]
        H4["passive taps — record, never block<br/>scripts/trust/shadow-ledger.mjs<br/>scripts/observability/<br/>session-run-hook.ts"]
        TC --> H1
        TC --> PLUG
        TC --> H4
        PLUG ~~~ H4
        H3 -.->|"installs into user scope"| H1
    end

    subgraph S2["2 · git commit"]
        PC["pre-commit stage — 61 hook ids<br/>.pre-commit-config.yaml<br/>shellcheck · gitleaks · worktree-isolation<br/>guardrail-matrices · artifact-leakage<br/>fail-open-lint · oxlint ratchets"]
        CM["commit-msg stage — 1 hook id<br/>scripts/hooks/check-commit-msg.sh<br/>conventional commit + ticket ID"]
        PC --> CM
    end

    subgraph S3["3 · git push"]
        PP["pre-push stage — 13 hook ids<br/>no-push-to-main · no-force-push<br/>platforms-tested · security-reviewed<br/>pr-mergeable-gate · code-review-before-push<br/>npm audit / licenses / signatures"]
    end

    subgraph S4["4 · CI — .github/workflows/ci.yml, on the pushed branch"]
        CI["21 jobs, most with a fail-fast twin · plan · ci-quick<br/>secret-scan · commit-lint · plugin-version-bump · lint<br/>node-suites · bun-suites · lanes-and-trust-suites<br/>guardrail-matrices · doc-invariants · security-scan<br/>shell-unit · guard-corpus-full · leak-classes · …"]
    end

    subgraph S5["5 · pull request + merge"]
        CR["the review-owed marker (CR marker) — three scripts, three roles:<br/>WRITTEN at push by check-cr-before-push.sh<br/>gh pr create REFUSED while it exists<br/>(check-cr-marker-on-pr-create.sh)<br/>CLEARED only by a clean /pr-check<br/>(scripts/cr/clear-review-marker.sh)"]
        MG["merge gate<br/>scripts/hooks/block-unresolved-cr-merge.sh<br/>consumes the CI result AND review state:<br/>CI green AND zero unresolved threads;<br/>a worker also needs the chief's GO file"]
        MOG["scripts/handover/merge-on-green.sh<br/>the sanctioned merge: squash pinned to the checked head"]
        CR --> MG
        MG --> MOG
    end

    H1 --> S2
    CM --> S3
    PP --> S4
    PP --> CR
    CI --> MG

    H1 -.->|"exit 2"| BLOCK["BLOCKED — reason on stderr.<br/>Bypass is a session env var set in the<br/>LAUNCHING shell; a per-call prefix does not work."]
    PC -.->|"non-zero"| BLOCK
    PP -.->|"non-zero"| BLOCK

Why five stages and not one. Each stage sees something the others cannot. A PreToolUse hook sees the intent of a single call but not the diff. A pre-commit gate sees the staged diff but not the branch’s history. A pre-push gate sees the whole range and the remote. CI sees a clean machine. The merge gate sees the CI result plus review state, which is why it comes last and why the arrow runs CI → merge gate and never the reverse. An invariant that matters gets checked wherever it is cheapest to catch, and the ones that matter most get checked twice.

What a starter install carries. An adopter project gets a small subset: the three user-facing guards (auto-approve-safe-bash, block-edit-on-main, block-read-secrets) wired into its own .claude/settings.json, and — when it has no .pre-commit-config.yaml — three native git gates placed by scripts/adopt.sh (check-worktree-isolation.sh, check-commit-msg.sh, check-push-target.sh). The rest of this diagram runs from a himmel checkout.

Per-hook behaviour, the guardrail matrix, and the bypass env var for each: internals/enforcement.md. Recovery when one stops you: internals/stuck-playbook.md.


2. The handover system

Handover is how work survives a session boundary. State is plain markdown in a directory the resolver picks; nothing about it is opaque or lossy.

flowchart TD
    subgraph R["resolution — scripts/lib/handover-path.sh"]
        HR["handover_root()<br/>PURE — never mkdirs, rc=2 if absent"]
        HRE["handover_root_ensure()<br/>creates the Mode A dir on demand"]
        MA["Mode A · inline<br/>&lt;repo&gt;/handovers<br/>(HANDOVER_DIR unset)"]
        MB["Mode B · external<br/>$HANDOVER_DIR<br/>fails CLOSED (rc=2) on a bad path"]
        HR --> MA
        HR --> MB
        HRE --> HR
    end

    subgraph L["layout"]
        BUCKET["&lt;root&gt;/&lt;USER_SLUG&gt;/&lt;repo-bucket&gt;/<br/>cross · himmel · luna · luna_brain (legacy)"]
        REG["~/.claude/handover/registry.json<br/>source of truth for tracked repos<br/>read/written only via /handover repos|register|init"]
    end

    subgraph W["writing state"]
        CMD["/handover — snapshot the session"]
        AC["scripts/handover/auto-commit.sh<br/>Mode B only"]
        PO["scripts/handover/pr-open.sh<br/>scripts/handover/pr-merge.sh"]
        FL["scripts/handover/flush.sh<br/>session-end consolidation sweep"]
        CMD ~~~ AC
        PO ~~~ FL
    end

    subgraph C["continuity across sessions"]
        QL["scripts/handover/queue-lock.sh<br/>ONE writer per queue.<br/>Lock is a DIRECTORY (atomic mkdir);<br/>release needs the printed release-token"]
        AR["scripts/handover/arm-resume.sh<br/>a scheduled launch (arming) through at / crontab;<br/>dedupes per handover, self-deletes its own slot"]
        RES["/handover-resume · /handover-resume-armed<br/>scripts/handover/resume*.sh"]
        NEXT["next session — reads the same markdown,<br/>resumes at the recorded stop point"]
        QL --> AR --> NEXT
        RES --> NEXT
    end

    MB --> BUCKET
    MA --> BUCKET
    REG --> BUCKET
    BUCKET --> W
    W --> C

The invariant that makes it work. One worker is one file: state and orders never split across two documents, so a resuming session cannot read half the picture. The queue lock is structural rather than advisory because prose coordination between two armed sessions failed three ways in one night. The escalation is recorded in scripts/handover/queue-lock.sh’s own header.

Full flows and the resolver contract: internals/handover-system.md.


3. The fleet: the chief and its workers

More than one Claude session runs at a time. The fleet model says who may write what, who may spawn whom, and how a revision reaches a running agent. The chief starts its workers in parallel waves; a dependency chain is a set of workers that must run in order, because each needs the one before it to merge.

flowchart TD
    OP["operator"] --> CON["the chief<br/>runs the shift; holds no worktree<br/>(chief.sh, chief-kit/)"]

    CON -->|"SendMessage / ListAgents"| P1["worker<br/>one ticket · one worktree · one PR<br/>(launched by headed-arm-worker.sh)"]
    CON -->|"SendMessage / ListAgents"| P2["worker<br/>one ticket · one worktree · one PR"]
    CON -.->|"a parallel wave: started together,<br/>collision-checked, capped by HIMMEL_FLEET_CAP"| P2

    P1 --> GD["scripts/hooks/guard-implementor-dispatch.sh<br/>refuses an implementation-shaped native Agent dispatch<br/>(haiku and read-only helpers are exempt) when Claude usage<br/>is ≥80% of the 5-hour window (IMPL_GUARD_HARD)<br/>or ≥85% of the weekly window (IMPL_GUARD_WEEKLY_HARD);<br/>with no live reset time it only warns"]
    GD --> NAT["native subagents — the default fleet<br/>haiku · sonnet · opus · fable<br/>every dispatch names its model"]

    P1 -.->|"registry"| EXT["scripts/lanes/lanes.json — 16 lanes:<br/>the 4 native tiers plus external ones<br/>(claudex · codex · hermes · openrouter · ollama · …).<br/>claudex is the only live external implementation lane;<br/>the others are dormant opt-ins, review critics<br/>or bulk lanes with no dispatch path.<br/>No defaultImplLane by operator ruling —<br/>an unqualified impl dispatch refuses to the native path.<br/>Query the live set with /lanes"]

    NAT --> RT["revision channel (RETASK)<br/>every brief carries a fresh token (nonce).<br/>EXPANSION / REDIRECT needs the echoed token;<br/>narrowing and halt are tokenless (fail-safe).<br/>An expansion or redirect arrives as a direct message, never in a tool result;<br/>a halt or narrowing is honored from any source."]

    subgraph RULES["worker rules"]
        SW["single-writer<br/>many readers, ONE writer.<br/>Never fan parallel writes at one artifact."]
        QL["queue-lock.sh<br/>one session per handover queue"]
        SW ~~~ QL
    end
    P1 --> RULES
    RULES ~~~ EXT

    NAT --> WORK["a worker commits on its own branch;<br/>the chief owns approval and merge"]
    WORK --> CRL["/pr-check → review-owed marker cleared → READY"]
    CRL --> GO["the chief's GO file<br/>&lt;handover root&gt;/.locks/go/&lt;pr&gt;.&lt;head&gt;,<br/>written by scripts/handover/chief-kit/go.sh<br/>signed for one PR and one head; a worker never writes its own"]
    GO --> MOG["scripts/handover/merge-on-green.sh<br/>merge gate → squash merge"]

Caps that are not model-tuned. Spawn depth and concurrency come from CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH and CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, so read the environment; unset means the harness default applies. Haiku does not spawn.

Every decision a change meets between dispatch and merge — the usage check, the fleet cap, the review-round cap, the ready-check, the judge, the GO file — is drawn in the development graph (guide/dev-graph.html); the local dashboard shows the same graph on its Workflow page. Tier semantics, effort-before-tier, and cost: internals/lane-calibration.md. The revision channel’s threat model: internals/retask-channel.md. Native subagents vs. external lane workers: internals/worker-spawn-matrix.md.


4. The Jira / backend seam

An MCP server costs context on every session that loads it. A local CLI costs nothing until it is called. himmel routes issue-tracker work to the CLI and keeps MCP for the individual ops a backend’s CLI does not implement. A hook enforces that routing, so Claude does not have to remember it.

flowchart TD
    INT["agent needs a Jira op"] --> REG["scripts/backends.json<br/>per-service chain: [cli, api, mcp]<br/>jira · confluence · bitbucket · github"]

    REG --> BT{"scripts/hooks/block-backend-tier.sh<br/>PreToolUse on mcp__plugin_atlassian_atlassian__.*<br/>and mcp__claude_ai_Atlassian_MCP__.*"}
    BT -->|"an equivalent CLI verb exists"| DENY["DENIED — use the CLI"]
    BT -->|"that op has no CLI equivalent"| MCP["Atlassian MCP server"]

    DENY --> CLI["scripts/jira/dist/index.js<br/>45 verbs: get create list transition comment edit<br/>link assign worklog sprint versions fix-version<br/>mirror roadmap rank resolution …<br/>(--list-commands prints them all)"]

    CLI --> API["Atlassian REST<br/>JIRA_BASE_URL · JIRA_EMAIL · JIRA_API_TOKEN · JIRA_PROJECT_KEY"]
    MCP --> API

    CLI -.->|"--list-commands<br/>one verb per line"| BT

    CLI -.->|"jira mcp"| SELF["the same verbs re-exposed over MCP on stdio,<br/>lazily imported so the SDK stays off the hot path"]

The gate maps, then asks the CLI. block-backend-tier.sh keeps a hard-coded verb-to-method map for Jira and for Confluence; an MCP method with no map entry is allowed. A mapped method is blocked only while the CLI still lists that verb, so removing a verb loosens the gate on its own, but a new verb tightens it only once its MCP methods are added to the map. It generalised the older Jira-only block-mcp-when-plugin-exists.sh to the services in scripts/backends.json that have an MCP prefix: Jira and Confluence. GitHub and Bitbucket are registered there with no MCP prefix, so this gate does not route them. The older script is no longer wired as a hook.

Invocation shape (absolute path from the primary checkout, because dist/ is an untracked build artifact and a worktree lacks it) and the full op ↔ MCP mapping: internals/jira-plugin.md.


5. The observability chain

The stack is local, passive, and read-only. The exporter never writes a ledger, never mutates the vault, never starts or kills a process. Missing data stays missing rather than being invented.

flowchart TD
    subgraph WRITE["writers — hooks, riding existing chokepoints"]
        HK["scripts/observability/session-run-hook.ts<br/>SessionStart · SessionEnd · PreToolUse/PostToolUse on Agent"]
        SL["session-run-ledger.ts<br/>fixed field order, single O_APPEND write,<br/>rotates at 10MB, never blocks the hook"]
        LED["~/.himmel/session-runs.jsonl"]
        FLOW["~/.himmel/flow-runs.jsonl<br/>flow runs: pipeline and other cadences,<br/>codex and hermes dispatch, lane verify-return"]
        HK --> SL --> LED
    end

    subgraph READ["reader — pure"]
        EXP["scripts/observability/flow-exporter.ts<br/>Bun HTTP server on 127.0.0.1:9877"]
        LED --> EXP
        FLOW --> EXP
    end

    subgraph SCRAPE["prometheus.yml as install-grafana.sh renders it"]
        PROM["Prometheus · scrape_interval 60s<br/>flow-exporter scrape_timeout 30s"]
        EXP --> PROM
    end

    subgraph VIEW["Grafana"]
        DASH["dashboards/himmel-health.json<br/>dashboards/war-room-system.json"]
        ALERT["provisioning/alerting/rules.yaml<br/>unified alerting — the ONLY delivery path"]
        PROM --> DASH
        PROM --> ALERT
    end

    ALERT --> TG["contact point: a webhook to<br/>grafana-cadence-hook.ts →<br/>scripts/luna/cadence-alert.sh → Telegram"]

    EXP -.-> M["49 metric families, for example:<br/>flow_run_in_flight · flow_run_outcome_total · flow_run_stalled_current<br/>session_active_total · session_dead_total · subagent_active_total<br/>himmel_tool_calls_total · himmel_tool_denials_total · himmel_tool_errors_total<br/>hook_chain_budget_events_recent · lane_quota_used_pct<br/>himmel_eval_metric_value · graphify_shipped_graph_age_seconds<br/>scheduled_task_next_run_timestamp · luna_git_unpushed_commits"]

The record that never gets written is the important one. SessionEnd is a graceful-exit hook: a crash, an OOM, or a window the operator eventually closes emits session_start and no session_end, ever. So the reader decides liveness, from transcript mtime; the writer does not assert it. The same reasoning splits an expired unpaired row into abandoned (its pid is confirmed dead: real hygiene information, but nothing is hung and nobody can act, so it must not page) and stalled (everything else).

alerts.rules.yml is loaded into Prometheus for self-visibility and promtool testability only; no Alertmanager is installed, so nothing there is ever delivered. Details and the passivity invariant: ../scripts/observability/README.md.


Keeping these accurate

When you move or rename a file a box above names, this file is part of the change, the same rule that governs commands-catalog.md. Every count here is derivable: the hook scripts per surface from the PreToolUse arrays of .claude/settings.json (33 unique scripts) and the himmel-ops hooks.json (16 commands: 15 blocking guards plus the advisory inject-minerva-critic.sh); the gate ids and their split across the three git stages (62 pre-commit, 14 pre-push, 1 commit-msg) from .pre-commit-config.yaml; the job count from .github/workflows/ci.yml (21 jobs, not counting the fail-fast-* twins); the lane count from scripts/lanes/lanes.json; the verb count from node scripts/jira/dist/index.js --list-commands (45 verbs plus mcp); the metric families from the addFamily calls in scripts/observability/flow-exporter.ts. Re-derive rather than trust them if a diagram and the code disagree.