diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 6c92265..1981098 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -19,13 +19,17 @@ The following files in `.machine_readable/6a2/` contain structured project metad
 
 ## Language Policy (Hyperpolymath Standard)
 
-> **Policy refresh 2026-05-28**: This repo has two products. (1) The **Oikos DSL** at the repo root is **Rust** (compiler crates `oikos-syntax`, `oikos-parser`, `oikos-desugar`, `oikos-check`); (2) **OikosBot** at `bot-integration-affine/` is **AffineScript** as of the 2026-05-28 legacy-shutoff (oikos#41) — the previous ReScript implementation at `bot-integration/` was removed in the same PR. Write no new ReScript or TypeScript here. MPL-1.0 / MPL-1.0-or-later are banned; rewrite to MPL-2.0 wherever encountered (DR-010 supersedes DR-002).
+> **Disambiguation (READ FIRST — see [`DISAMBIGUATION.adoc`](../DISAMBIGUATION.adoc)):** this repo is **OikosBot** and **only** OikosBot. Do not conflate it with two separate, similarly-named projects:
+> - **oikos** — the stock-flow-consistent economics *DSL* (crates `oikos-syntax`/`oikos-parser`/`oikos-desugar`/`oikos-check`). It lives in its **own** repo `hyperpolymath/oikos-economics-accounting-dsl`, **not here**. Do **not** add `oikos-*` DSL crates to this repo.
+> - **sustainabot** — a *reserved member slot* of `hyperpolymath/gitbot-fleet` (`bots/sustainabot/`). **Not here.** A misfiled copy of OikosBot once lived there; it was moved into this repo and renamed `sustainabot-*` → `oikosbot-*`. Do **not** reintroduce the `sustainabot` name here (the sole exception is the legacy `crates/oikosbot-fleet` bridge, which still targets the fleet's `BotId::Sustainabot`).
+>
+> **Policy refresh 2026-06-19**: This repo has two surfaces, both OikosBot. (1) The **Rust analysis workspace** at the repo root — crates `oikosbot-*` (`oikosbot-cli`/`-analysis`/`-metrics`/`-sarif`/`-eclexia`, plus the optional, workspace-excluded `oikosbot-fleet` bridge). The default `cargo` workspace builds **standalone** with no dependency on gitbot-fleet. (2) **OikosBot's webhook bot** at `bot-integration-affine/` is **AffineScript** as of the 2026-05-28 legacy-shutoff (oikos#41) — the previous ReScript implementation at `bot-integration/` was removed in the same PR. Write no new ReScript or TypeScript here. MPL-1.0 / MPL-1.0-or-later are banned; rewrite to MPL-2.0 wherever encountered (DR-010 supersedes DR-002).
 
 ### ALLOWED Languages & Tools
 
 | Language/Tool | Use Case | Notes |
 |---------------|----------|-------|
-| **Rust** | Oikos DSL compiler crates | Repo root; performance-critical |
+| **Rust** | OikosBot analysis workspace (`crates/oikosbot-*`) | Repo root; performance-critical. NOT the `oikos-*` DSL (separate repo). |
 | **AffineScript** (`.affine`) | OikosBot (`bot-integration-affine/`) | Affine types, dependent types, row polymorphism, extensible effects |
 | **Haskell** | OikosBot analyser backend (`analyzers/`) | Existing surface; pure analysis |
 | **Deno** | Runtime & package management | Replaces Node/npm/bun |
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d15c975..30193c7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -8,3 +8,11 @@ updates:
       actions:
         patterns:
           - "*"
+  - package-ecosystem: "cargo"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+    groups:
+      cargo:
+        patterns:
+          - "*"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 51d924f..8ee5866 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,35 @@ jobs:
       - name: Test Haskell analyzer
         working-directory: analyzers/code-haskell
         run: cabal test all
+  rust:
+    name: Rust Workspace
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+    permissions:
+      contents: read
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+      - name: Cache cargo
+        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+        with:
+          path: |
+            ~/.cargo/registry
+            ~/.cargo/git
+            target
+          key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
+      # Uses the runner's preinstalled stable toolchain (no third-party action).
+      # NB: the default workspace deliberately EXCLUDES crates/oikosbot-fleet so
+      # OikosBot builds with no dependency on hyperpolymath/gitbot-fleet.
+      - name: Format check
+        run: cargo fmt --check
+      - name: Build (standalone workspace)
+        run: cargo build --workspace --locked
+      - name: Test (standalone workspace)
+        run: cargo test --workspace --locked
+      - name: Clippy (informational)
+        run: cargo clippy --workspace
+        continue-on-error: true
   affine:
     name: AffineScript Scaffold
     runs-on: ubuntu-latest
diff --git a/.machine_readable/6a2/ECOSYSTEM.a2ml b/.machine_readable/6a2/ECOSYSTEM.a2ml
index 7788283..b8ec8da 100644
--- a/.machine_readable/6a2/ECOSYSTEM.a2ml
+++ b/.machine_readable/6a2/ECOSYSTEM.a2ml
@@ -1,20 +1,28 @@
 # SPDX-License-Identifier: MPL-2.0
 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
 #
-# ECOSYSTEM.a2ml — Oikos ecosystem position
+# ECOSYSTEM.a2ml — OikosBot ecosystem position
 [metadata]
-version = "1.0"
-last-updated = "2026-04-11"
+version = "1.1"
+last-updated = "2026-06-19"
 
 [project]
-name = "Oikos"
-purpose = ""
-role = ""
+name = "OikosBot"
+purpose = "An ecological & economic code-analysis GitHub/GitLab App that adds an eco/econ review lens to pull requests, complementing (not replacing) Dependabot/CodeQL/linters."
+role = "Standalone analysis product. Optionally plugs into the gitbot fleet via the excluded crates/oikosbot-fleet bridge."
 
 [position-in-ecosystem]
-category = ""
+category = "code-analysis-app"
+
+# Disambiguation — three similarly-named, SEPARATE projects. See DISAMBIGUATION.adoc.
+[disambiguation]
+this-project = "OikosBot (this repo, hyperpolymath/oikosbot) — crates oikosbot-*"
+not-the-dsl = "oikos — the stock-flow-consistent economics DSL — lives in hyperpolymath/oikos-economics-accounting-dsl (crates oikos-*). NOT here."
+not-the-fleet-slot = "sustainabot — a reserved member slot of hyperpolymath/gitbot-fleet (bots/sustainabot/), to be prototyped later. NOT here. A misfiled copy of OikosBot once lived there and was moved into this repo."
 
 [related-projects]
 projects = [
-  # No related projects recorded
+  { name = "oikos-economics-accounting-dsl", repo = "hyperpolymath/oikos-economics-accounting-dsl", relation = "name-sibling", note = "Separate DSL; shares the οἶκος root only." },
+  { name = "gitbot-fleet", repo = "hyperpolymath/gitbot-fleet", relation = "optional-consumer", note = "OikosBot can run as a fleet member via the excluded crates/oikosbot-fleet bridge; the bots/sustainabot/ slot is reserved and separate." },
+  { name = "affinescript", repo = "hyperpolymath/affinescript", relation = "upstream-toolchain", note = "Provides the AffineScript compiler/stdlib for bot-integration-affine/." }
 ]
diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml
index 59453eb..df9c183 100644
--- a/.machine_readable/6a2/STATE.a2ml
+++ b/.machine_readable/6a2/STATE.a2ml
@@ -3,16 +3,16 @@
 #
 # STATE.a2ml — Oikos project state
 [metadata]
-project = "oikos"
+project = "oikosbot"
 version = "0.1.0-dev"
-last-updated = "2026-05-28"
+last-updated = "2026-06-19"
 status = "active"
-session = "2026-05-28 Phase 5 scaffold + legacy shutoff; upstream RSR rewires landed in affinescript"
+session = "2026-06-19 extract Rust analysis workspace from gitbot-fleet/bots/sustainabot → crates/oikosbot-* (renamed, decoupled, standalone, 35 tests green); add DISAMBIGUATION.adoc"
 
 [project-context]
-name = "Oikos"
-purpose = "Two products: (1) the Oikos DSL — typed compiler for stock-flow-consistent macroeconomic models; (2) OikosBot — code-analysis GitHub App. See docs/disambiguation/oikos-dsl-vs-oikosbot.adoc."
-completion-percentage = 30
+name = "OikosBot"
+purpose = "OikosBot — an ecological & economic code-analysis GitHub/GitLab App. NB: the similarly-named 'oikos' DSL (separate repo hyperpolymath/oikos-economics-accounting-dsl) and the 'sustainabot' gitbot-fleet slot are SEPARATE projects — see DISAMBIGUATION.adoc."
+completion-percentage = 40
 
 [position]
 phase = "implementation"   # design | implementation | testing | maintenance | archived
@@ -22,6 +22,7 @@ maturity = "alpha"  # experimental | alpha | beta | production | lts
 milestones = [
   { id = "phase-5-scaffold", status = "landed", pr = 35, summary = "OikosBot AffineScript scaffold at bot-integration-affine/" },
   { id = "legacy-shutoff", status = "landed", pr = 41, summary = "Clean shutoff of ReScript bot-integration/ (-33,061 lines)" },
+  { id = "crates-extraction", status = "landed", summary = "Extract Rust analysis workspace from gitbot-fleet/bots/sustainabot; rename sustainabot-* → oikosbot-*; decouple from fleet (oikosbot-fleet bridge excluded from default workspace); 35 tests green" },
   { id = "stdlib-json-v03", status = "landed-upstream", repo = "affinescript", pr = 421, summary = "stdlib/json.affine RSR rewire to hpm-json-rsr Zig FFI — unblocks OikosBot webhook payload extraction" },
   { id = "stdlib-http-server", status = "landed-upstream", repo = "affinescript", pr = 425, summary = "stdlib/Http.affine RSR rewire — surfaces hpm-http-rsr Zig FFI server externs — unblocks OikosBot accept loop" },
   { id = "bot-affine-implementation", status = "pending", summary = "Implement webhook handler + HMAC verification + dispatch in bot-integration-affine/ now that both stdlib gates are closed" },
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a32d215..8430cbd 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -9,14 +9,19 @@ Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
 
 > **Note — which product is this?**
 > This document describes **OikosBot**, the code-analysis GitHub/GitLab App.
-> Its AffineScript port lives in
-> [`bot-integration-affine/`](bot-integration-affine/) (in-flight; the
+> Its Rust analysis workspace is `crates/oikosbot-*`; its AffineScript port lives
+> in [`bot-integration-affine/`](bot-integration-affine/) (in-flight; the
 > previous ReScript codebase at `bot-integration/` was retired on
 > 2026-05-28 — see git history for the prior implementation).
-> The DSL of the same root name (a typed compiler for stock-flow consistent
-> macroeconomic models) now lives in
-> [`hyperpolymath/oikos-economics-accounting-dsl`](https://github.com/hyperpolymath/oikos-economics-accounting-dsl).
-> They share the οἶκος root but are otherwise unrelated.
+> Two similarly-named-but-**separate** projects are easy to confuse with this one
+> (full breakdown in [`DISAMBIGUATION.adoc`](DISAMBIGUATION.adoc)):
+> the **oikos** DSL (a typed compiler for stock-flow consistent macroeconomic
+> models) now lives in
+> [`hyperpolymath/oikos-economics-accounting-dsl`](https://github.com/hyperpolymath/oikos-economics-accounting-dsl);
+> and **sustainabot** is a reserved member slot of
+> [`hyperpolymath/gitbot-fleet`](https://github.com/hyperpolymath/gitbot-fleet)
+> (`bots/sustainabot/`), not OikosBot. They share the οἶκος root but are
+> otherwise unrelated.
 
 ## Vision
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 221d75b..54c6b45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,10 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- feat(crates): extract the Rust analysis workspace from `gitbot-fleet/bots/sustainabot/` into this repo, renamed `sustainabot-*` → `oikosbot-*` (`oikosbot-cli`/`-analysis`/`-metrics`/`-sarif`/`-eclexia`, plus the optional `oikosbot-fleet` bridge). Adds `policies/`, `fuzz/`, `examples/`, `containers/`, and `QUICKSTART.md`. Builds and tests green (35 tests).
+- docs: `DISAMBIGUATION.adoc` — canonical breakdown of **oikos** (the DSL) vs **OikosBot** (this App) vs **sustainabot** (a reserved gitbot-fleet slot), with guardrails to prevent the misfiling recurring.
+- ci(rust): add a `rust` job (fmt-check + build + test, informational clippy) to `ci.yml` and a `cargo` dependabot ecosystem. New `just rust-build` / `rust-test` targets.
+- feat(bot): missing AffineScript modules `GitHubAPI`, `GitHubApp`, and the TEA runtime (`tea/Cmd`, `tea/Runtime`, `tea/Sub`) added to `bot-integration-affine/src/` from the former sustainabot tree.
 - feat(bot): `bot-integration-affine/` Phase 5 AffineScript scaffold (#35) — initial AS port of OikosBot; cross-module type-check, JSON payload extraction, and HTTP-server accept loop are gated on upstream `affinescript` stdlib work (Json v0.3 RSR rewire `affinescript#421` + Http server FFI `affinescript#425`).
 - feat: Oikos Bot v0.1.0-beta - TEA architecture with typed HTTP routing
 - feat: add GitHub App manifest for developer programme registration
@@ -39,6 +43,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Changed
 
+- chore(decouple): sever OikosBot's dependency on `gitbot-fleet`. The default `cargo` workspace **excludes** `crates/oikosbot-fleet` (the only fleet-aware crate), and the optional `panic-attacker` / `eclexia` path dependencies that escaped the repo were neutralised to no-op feature seams — OikosBot now builds from a clean checkout with no sibling repos present.
 - chore(repo): split OikosBot into its own repository separate from the Oikos economics accounting DSL.
 - chore(bot-integration): clean shutoff of the legacy ReScript `bot-integration/` (#41) — 208 files / -33,061 lines: removes `bot-integration/`, `containers/`, `.github/workflows/oikos.yml`, the `rescript:` job from `ci.yml`, the npm/bot-integration dependabot entry, and `.gitmodules`. README / ARCHITECTURE / ROADMAP / DEPLOY / disambiguation docs repointed at `bot-integration-affine/`. No production blast radius (`.github/app.yml` URLs were `*.example.com` placeholders).
 - chore(license): align stale SPDX headers + `Cargo.toml` manifest with `MPL-2.0` (#36) — completes the 2026-05-22 EUPL → MPL migration that had left 45 file headers + the manifest at `EUPL-1.2`.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..5d853c1
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,763 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "cc"
+version = "1.2.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db"
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "oikosbot-analysis"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "oikosbot-metrics",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "toml",
+ "tracing",
+ "tree-sitter",
+ "tree-sitter-javascript",
+ "tree-sitter-python",
+ "tree-sitter-rust",
+]
+
+[[package]]
+name = "oikosbot-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "oikosbot-analysis",
+ "oikosbot-eclexia",
+ "oikosbot-metrics",
+ "oikosbot-sarif",
+ "serde_json",
+ "tracing",
+ "tracing-subscriber",
+ "walkdir",
+]
+
+[[package]]
+name = "oikosbot-eclexia"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "oikosbot-metrics",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "oikosbot-metrics"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "oikosbot-sarif"
+version = "0.1.0"
+dependencies = [
+ "oikosbot-metrics",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca"
+dependencies = [
+ "cc",
+ "regex",
+ "regex-syntax",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-javascript"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce"
+
+[[package]]
+name = "tree-sitter-python"
+version = "0.23.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-rust"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..9bd9893
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,61 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[workspace]
+resolver = "2"
+members = [
+    "crates/oikosbot-cli",
+    "crates/oikosbot-analysis",
+    "crates/oikosbot-metrics",
+    "crates/oikosbot-sarif",
+    "crates/oikosbot-eclexia",
+]
+# `oikosbot-fleet` is the OPTIONAL gitbot-fleet bridge. It is intentionally
+# excluded from the default workspace so OikosBot builds standalone with no
+# dependency on `hyperpolymath/gitbot-fleet`. Build it explicitly (with the
+# fleet checked out as a sibling) only when wiring OikosBot into the fleet.
+# See crates/oikosbot-fleet/README.md and DISAMBIGUATION.adoc.
+exclude = [
+    "crates/oikosbot-fleet",
+    "fuzz",
+]
+
+[workspace.package]
+version = "0.1.0"
+authors = ["Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"]
+edition = "2021"
+license = "MPL-2.0"
+repository = "https://github.com/hyperpolymath/oikosbot"
+
+[workspace.dependencies]
+# AST parsing
+tree-sitter = "0.23.2"
+tree-sitter-rust = "0.23.3"
+tree-sitter-javascript = "0.23.1"
+tree-sitter-python = "0.23.6"
+
+# CLI
+clap = { version = "4.5.55", features = ["derive", "cargo"] }
+anyhow = "1.0.100"
+thiserror = "2.0.18"
+
+# Serialization
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.149"
+toml = "0.8.23"
+
+# Logging
+tracing = "0.1.44"
+tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
+
+# Process execution (for Eclexia interpreter)
+tokio = { version = "1.49.0", features = ["full"] }
+
+# Carbon APIs
+reqwest = { version = "0.12.28", features = ["json"] }
+
+
+[profile.release]
+lto = true
+codegen-units = 1
+panic = "abort"
diff --git a/DISAMBIGUATION.adoc b/DISAMBIGUATION.adoc
new file mode 100644
index 0000000..f205614
--- /dev/null
+++ b/DISAMBIGUATION.adoc
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+= Disambiguation: oikos vs OikosBot vs sustainabot
+Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+:toc: macro
+:icons: font
+
+Three similarly-named things in the hyperpolymath estate are routinely confused.
+They are **three separate projects** living in **three separate places**. This
+page is the canonical reference; link here whenever the distinction matters.
+
+toc::[]
+
+== TL;DR
+
+[cols="1,2,3,2",options="header"]
+|===
+| Name | What it is | Where it lives | Language
+
+| **oikos**
+| A DSL — a typed compiler for stock-flow consistent (SFC) macroeconomic /
+  accounting models.
+| Its own top-level repo: `hyperpolymath/oikos-economics-accounting-dsl`.
+| Rust (compiler crates `oikos-syntax`, `oikos-parser`, `oikos-desugar`,
+  `oikos-check`).
+
+| **OikosBot** (this repo)
+| A code-analysis GitHub/GitLab App that adds an *ecological & economic* review
+  lens to pull requests.
+| Its own top-level repo: `hyperpolymath/oikosbot`.
+| Rust workspace (`crates/oikosbot-*`) + Haskell analyzer + AffineScript bot.
+
+| **sustainabot**
+| A *reserved* member slot of the gitbot fleet — a future, lightweight quality
+  bot, kept for later prototyping. **Currently empty.**
+| A directory inside another repo: `hyperpolymath/gitbot-fleet` →
+  `bots/sustainabot/`.
+| TBD (not yet built).
+|===
+
+They share the Greek root _οἶκος_ ("household / habitat") but are otherwise
+unrelated.
+
+== The three projects in detail
+
+=== oikos — the DSL (NOT this repo)
+
+`oikos` is a programming language / compiler for stock-flow consistent economic
+models. It is **not a bot** and has **nothing to do** with code analysis. It
+lives in its own repository, `hyperpolymath/oikos-economics-accounting-dsl`, and
+its crates are namespaced `oikos-*` (e.g. `oikos-parser`).
+
+=== OikosBot — this repository
+
+`OikosBot` is the eco/econ code-analysis App. Its Rust crates are namespaced
+`oikosbot-*` (`oikosbot-cli`, `oikosbot-analysis`, `oikosbot-metrics`,
+`oikosbot-sarif`, `oikosbot-eclexia`, and the optional `oikosbot-fleet` bridge).
+
+[IMPORTANT]
+====
+OikosBot crates use the `oikosbot-` prefix, **never** `oikos-` (that namespace
+belongs to the DSL) and **never** `sustainabot-` (that belongs to the fleet
+slot). Do not reintroduce either prefix here.
+====
+
+=== sustainabot — a gitbot-fleet member (NOT this repo)
+
+`sustainabot` is one of the bots coordinated by `hyperpolymath/gitbot-fleet`. It
+is a **reserved slot**, intended to be prototyped later as a lightweight quality
+helper. It is a *peer* of the other fleet bots (rhodibot, echidnabot, …), not a
+home for full standalone products.
+
+== What went wrong (and how this was fixed)
+
+A full incarnation of **OikosBot** was once committed *inside* gitbot-fleet at
+`bots/sustainabot/` — a Rust workspace named `sustainabot-*`, a Haskell
+analyzer, an AffineScript bot, containers, policies, and fuzz targets. That was
+a misfiling: a whole standalone product had been vendored into one fleet bot's
+slot, and it had even grown a hard `path` dependency back into
+`gitbot-fleet/shared-context`.
+
+The fix:
+
+. The implementation was **moved here** (`hyperpolymath/oikosbot`) and renamed
+  `sustainabot-*` → `oikosbot-*`.
+. The fleet coupling was severed: the default OikosBot workspace builds with
+  **no** dependency on gitbot-fleet. The only bridge,
+  link:crates/oikosbot-fleet/README.md[`oikosbot-fleet`], is *excluded* from the
+  default workspace and built only on demand.
+. `gitbot-fleet/bots/sustainabot/` was emptied back to a **placeholder** that
+  reserves the slot and points here.
+
+== Guardrails — how to avoid this happening again
+
+* **Naming:** keep the prefixes distinct — `oikos-*` (DSL),
+  `oikosbot-*` (this App), `sustainabot-*` (the fleet slot, if/when built). A CI
+  language-policy/antipattern check can assert no `sustainabot` strings appear
+  in this repo outside the legacy `oikosbot-fleet` bridge.
+* **No vendoring of standalone products into `gitbot-fleet/bots/<name>/`.** A
+  fleet bot directory is a *thin adapter*, not a home for an entire project. See
+  `gitbot-fleet/bots/sustainabot/README.md`.
+* **No path dependencies escaping the repository.** OikosBot must build from a
+  clean checkout with no sibling repos present (the `oikosbot-fleet` bridge is
+  the sole, explicitly-excluded exception).
+* When in doubt about which "oikos" thing is meant, **link to this file**.
diff --git a/Justfile b/Justfile
index 599b579..e022fb6 100644
--- a/Justfile
+++ b/Justfile
@@ -7,7 +7,18 @@ haskell_dir := "analyzers/code-haskell"
 
 all: check
 
-check: haskell-build haskell-test
+check: rust-build rust-test haskell-build haskell-test
+
+# Rust analysis workspace. The default workspace excludes crates/oikosbot-fleet
+# (the optional gitbot-fleet bridge) so OikosBot builds standalone.
+rust-build:
+    cargo build --workspace
+
+rust-test:
+    cargo test --workspace
+
+rust-fmt-check:
+    cargo fmt --check
 
 haskell-build:
     cd {{ haskell_dir }} && cabal build all
@@ -22,4 +33,5 @@ shell-check:
     bash -n bot-integration-affine/check.sh
 
 clean:
+    cargo clean
     cd {{ haskell_dir }} && cabal clean
diff --git a/QUICKSTART.md b/QUICKSTART.md
new file mode 100644
index 0000000..c0cc605
--- /dev/null
+++ b/QUICKSTART.md
@@ -0,0 +1,75 @@
+# OikosBot Quickstart
+
+## Build
+
+```bash
+cargo build --release
+```
+
+## Usage
+
+### Analyze a file
+
+```bash
+./target/release/oikosbot analyze path/to/file.rs
+```
+
+Supports: Rust (.rs), JavaScript (.js, .mjs), TypeScript (.ts)
+
+### Analyze oikosbot itself (dogfooding!)
+
+```bash
+./target/release/oikosbot self-analyze
+```
+
+This proves we practice what we preach - the analyzer itself is efficient.
+
+### JSON output
+
+```bash
+./target/release/oikosbot analyze file.rs --format json
+```
+
+## What It Does
+
+OikosBot analyzes code for:
+
+- **Energy consumption** (Joules)
+- **Carbon footprint** (gCO2e)
+- **Execution time** (milliseconds)
+- **Memory usage** (bytes)
+
+And provides:
+- **Eco score** (0-100) - environmental impact
+- **Econ score** (0-100) - algorithmic efficiency
+- **Quality score** (0-100) - code quality
+- **Overall health** (weighted combination)
+
+## Example Output
+
+```
+📍 Function: nested_loops_function
+   Resources:
+     Energy:   7.10 J
+     Carbon:   0.0009 gCO2e
+     Time:     35.50 ms
+
+   Health Index:
+     Eco:      80.4/100
+     Overall:  75.1/100
+
+   Recommendations:
+     • Consider algorithm optimization to reduce nested iterations
+```
+
+## The Eclexia Connection
+
+- **Phase 1** (DONE): Analyzer built with Eclexia principles (explicit resource types)
+- **Phase 2** (Next): Policy engine runs Eclexia code from `policies/*.ecl`
+- **Phase 3+**: Migrate core to Eclexia as language matures
+
+## Philosophy
+
+> "The best ecological code analyzer is one that's ecological itself."
+
+OikosBot demonstrates that efficient, resource-aware code is not just theory - it works in practice.
diff --git a/README.adoc b/README.adoc
index 0a6abab..8db74a7 100644
--- a/README.adoc
+++ b/README.adoc
@@ -15,17 +15,30 @@ image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[License: MPL-2.0,lin
 
 [NOTE]
 ====
-This repository is only OikosBot. The Oikos stock-flow consistent economics DSL
-now lives in
-https://github.com/hyperpolymath/oikos-economics-accounting-dsl[`hyperpolymath/oikos-economics-accounting-dsl`].
-Canonical home for OikosBot is this repo: `https://github.com/hyperpolymath/oikosbot`.
+This repository is only **OikosBot**, and it is easy to confuse with two
+similarly-named-but-separate things:
+
+* **oikos** — the stock-flow consistent economics *DSL*, now in
+  https://github.com/hyperpolymath/oikos-economics-accounting-dsl[`hyperpolymath/oikos-economics-accounting-dsl`].
+* **sustainabot** — a *reserved member slot* of
+  https://github.com/hyperpolymath/gitbot-fleet[`hyperpolymath/gitbot-fleet`]
+  (`bots/sustainabot/`), to be prototyped later. OikosBot is **not** sustainabot;
+  the implementation that briefly lived there was a misfiled copy of OikosBot and
+  has been moved into this repo.
+
+See link:DISAMBIGUATION.adoc[`DISAMBIGUATION.adoc`] for the full breakdown.
 ====
 
 toc::[]
 
 == Status
 
-`0.1.0-dev` - scaffold only.
+`0.1.0-dev`.
+
+The Rust analysis workspace (`crates/oikosbot-*`: CLI, analysis engine,
+metrics, SARIF output, and Eclexia policy evaluation) builds and tests cleanly
+and is the most complete surface — it was extracted from the former
+`gitbot-fleet/bots/sustainabot/` and renamed.
 
 The AffineScript webhook receiver is in flight. Cross-module type-checking,
 JSON payload extraction, and the HTTP server accept loop are still gated on
@@ -39,15 +52,28 @@ OikosBot is not currently deployed in production.
 [source,text]
 ----
 oikosbot/
+|-- crates/                  # Rust workspace (oikosbot-* analysis engine + CLI)
+|   |-- oikosbot-cli/        #   `oikosbot` binary (analyze / check / report)
+|   |-- oikosbot-analysis/   #   tree-sitter analysis + eco/econ heuristics
+|   |-- oikosbot-metrics/    #   shared metric/result types
+|   |-- oikosbot-sarif/      #   SARIF report generation
+|   |-- oikosbot-eclexia/    #   Eclexia (.ecl) policy evaluation
+|   `-- oikosbot-fleet/      #   OPTIONAL gitbot-fleet bridge (excluded by default)
 |-- bot-integration-affine/  # AffineScript webhook receiver
 |-- analyzers/code-haskell/  # Haskell eco/econ/quality analysis engine
 |-- policy-engine/           # Datalog and DeepProbLog policy rules
+|-- policies/                # Eclexia (.ecl) policy definitions
 |-- config/                  # Bot configuration
 |-- prompts/                 # Generated assistant prompt templates
+|-- containers/              # Container build + compose for deployment
+|-- examples/                # Example CI integration
+|-- fuzz/                    # cargo-fuzz targets (excluded from the workspace)
 |-- database/                # Knowledge-graph schema and query assets
 |-- databases/               # Legacy database assets retained for migration
 |-- docs/GITHUB_APP_SETUP.md # GitHub App registration notes
 |-- ARCHITECTURE.md          # Platform architecture
+|-- DISAMBIGUATION.adoc      # oikos vs OikosBot vs sustainabot
+|-- QUICKSTART.md            # CLI quickstart
 `-- DEPLOY.md                # Deployment status and runbook notes
 ----
 
@@ -55,11 +81,17 @@ oikosbot/
 
 [source,shell]
 ----
+cargo build --workspace      # or: just rust-build
+cargo test  --workspace      # or: just rust-test
 just haskell-build
 just haskell-test
 just affine-check
 ----
 
+The default `cargo` workspace deliberately excludes `crates/oikosbot-fleet`
+(the optional gitbot-fleet bridge) so OikosBot builds standalone. See
+link:DISAMBIGUATION.adoc[`DISAMBIGUATION.adoc`].
+
 `just affine-check` expects an AffineScript compiler. Override the compiler path
 with `AS_BIN=/path/to/main.exe` when needed.
 
diff --git a/bot-integration-affine/src/GitHubAPI.affine b/bot-integration-affine/src/GitHubAPI.affine
new file mode 100644
index 0000000..abc5767
--- /dev/null
+++ b/bot-integration-affine/src/GitHubAPI.affine
@@ -0,0 +1,153 @@
+// face: affinescript
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2024-2026 hyperpolymath
+//
+// MIGRATED FROM GitHubAPI.res by hand-port per issue #148 (policy override 2026-05-24).
+// Uses Http::fetch (affinescript#160) and canonical json::*.
+
+module GitHubAPI;
+
+use prelude::{Option, Some, None, Result, Ok, Err};
+use json::{Json};
+
+const user_agent: String = "oikos-bot/0.1.0-beta";
+const api_version: String = "2022-11-28";
+
+pub fn api_request(
+  token: ref String,
+  method: ref String,
+  endpoint: ref String,
+  body: Option<Json>,
+) -{IO}-> Result<Json, String> {
+  let url = "https://api.github.com" ++ endpoint;
+
+  let headers = [
+    ("Authorization", "Bearer " ++ token),
+    ("Accept", "application/vnd.github+json"),
+    ("X-GitHub-Api-Version", api_version),
+    ("User-Agent", user_agent),
+    ("Content-Type", "application/json"),
+  ];
+
+  try {
+    let body_str = match body {
+      Some(b) => Some(json::stringify(b)),
+      None => None
+    };
+    let response = Http::http_request(url, method, headers, body_str);
+
+    if response.status >= 200 && response.status < 300 {
+      // TODO(mechanical-migrator): parse response.body to Json once
+      // json::parse (#161 follow-up) is bound.
+      Err("response decoder not yet bound (awaits json::parse)")
+    } else {
+      Err("GitHub API error " ++ Int::to_string(response.status) ++ ": " ++ response.body)
+    }
+  } catch {
+    err => Err("API request failed: " ++ Exn::message(err))
+  }
+}
+
+pub fn post_pr_comment(
+  token: ref String,
+  owner: ref String,
+  repo: ref String,
+  pr_number: Int,
+  body: ref String,
+) -{IO}-> Result<Int, String> {
+  let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/issues/" ++ Int::to_string(pr_number) ++ "/comments";
+  let payload = json::encode_object([("body", json::encode_string(body))]);
+
+  let result = api_request(token, "POST", endpoint, Some(payload));
+
+  match result {
+    Ok(j) => match json::decode_object(j) {
+      Some(obj) => match dict::get(obj, "id") {
+        Some(id) => match json::decode_int(id) {
+          Some(n) => Ok(n),
+          None => Err("Invalid comment ID in response")
+        },
+        None => Err("No comment ID in response")
+      },
+      None => Err("Invalid JSON response")
+    },
+    Err(e) => Err(e)
+  }
+}
+
+pub fn update_comment(
+  token: ref String,
+  owner: ref String,
+  repo: ref String,
+  comment_id: Int,
+  body: ref String,
+) -{IO}-> Result<(), String> {
+  let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/issues/comments/" ++ Int::to_string(comment_id);
+  let payload = json::encode_object([("body", json::encode_string(body))]);
+
+  let result = api_request(token, "PATCH", endpoint, Some(payload));
+
+  match result {
+    Ok(_) => Ok(()),
+    Err(e) => Err(e)
+  }
+}
+
+// conclusion: "success" | "failure" | "neutral" | "cancelled" | "skipped" | "timed_out" | "action_required"
+pub fn create_check_run(
+  token: ref String,
+  owner: ref String,
+  repo: ref String,
+  head_sha: ref String,
+  name: ref String,
+  conclusion: ref String,
+  title: ref String,
+  summary: ref String,
+) -{IO}-> Result<Int, String> {
+  let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/check-runs";
+  let payload = json::encode_object([
+    ("name", json::encode_string(name)),
+    ("head_sha", json::encode_string(head_sha)),
+    ("status", json::encode_string("completed")),
+    ("conclusion", json::encode_string(conclusion)),
+    ("output", json::encode_object([
+      ("title", json::encode_string(title)),
+      ("summary", json::encode_string(summary)),
+    ])),
+  ]);
+
+  let result = api_request(token, "POST", endpoint, Some(payload));
+
+  match result {
+    Ok(j) => match json::decode_object(j) {
+      Some(obj) => match dict::get(obj, "id") {
+        Some(id) => match json::decode_int(id) {
+          Some(n) => Ok(n),
+          None => Err("Invalid check run ID in response")
+        },
+        None => Err("No check run ID in response")
+      },
+      None => Err("Invalid JSON response")
+    },
+    Err(e) => Err(e)
+  }
+}
+
+pub fn get_pull_request(
+  token: ref String,
+  owner: ref String,
+  repo: ref String,
+  pr_number: Int,
+) -{IO}-> Result<Json, String> {
+  let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/pulls/" ++ Int::to_string(pr_number);
+  api_request(token, "GET", endpoint, None)
+}
+
+pub fn get_repository(
+  token: ref String,
+  owner: ref String,
+  repo: ref String,
+) -{IO}-> Result<Json, String> {
+  let endpoint = "/repos/" ++ owner ++ "/" ++ repo;
+  api_request(token, "GET", endpoint, None)
+}
diff --git a/bot-integration-affine/src/GitHubApp.affine b/bot-integration-affine/src/GitHubApp.affine
new file mode 100644
index 0000000..c16aa3e
--- /dev/null
+++ b/bot-integration-affine/src/GitHubApp.affine
@@ -0,0 +1,191 @@
+// face: affinescript
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2024-2026 hyperpolymath
+//
+// MIGRATED FROM GitHubApp.res by hand-port per issue #148 (policy override 2026-05-24).
+// RS256 JWT signing + installation-token caching.
+// Crypto / Base64 / Bytes paths assume the Web Crypto-derived stdlib
+// surface; will need re-validation against the canonical AffineScript
+// stdlib once #103 (Async-extern ABI) is fully wired through handlers.
+// See MISSING-EXTERNS.md for the unbound surfaces called below.
+
+module GitHubApp;
+
+use prelude::{Option, Some, None, Result, Ok, Err};
+use json::{Json};
+use option::{unwrap_or};
+use Types::{Config, InstallationToken};
+
+// Module-level token cache. The original ReScript used a mutable
+// process-local Dict; AffineScript does not permit `mut` on a `const`,
+// so the cache has been moved behind get/set helpers that the
+// mechanical migrator should re-back with a proper handler-managed
+// state cell (Cmd-style, or a `state` effect once #59 lands).
+
+fn cache_get(_key: ref String) -{IO}-> Option<InstallationToken> {
+  // TODO(mechanical-migrator): wire to actual state-effect cache.
+  None
+}
+
+fn cache_set(_key: ref String, _value: ref InstallationToken) -{IO}-> () {
+  // TODO(mechanical-migrator): wire to actual state-effect cache.
+  ()
+}
+
+fn pem_to_array_buffer(pem: ref String) -{IO}-> [Int] {
+  let stripped = String::replace_regex(pem, "-----BEGIN (?:RSA )?PRIVATE KEY-----", "");
+  let stripped2 = String::replace_regex(stripped, "-----END (?:RSA )?PRIVATE KEY-----", "");
+  let stripped3 = String::replace_regex(stripped2, "\\s", "");
+
+  let binary = Base64::decode(stripped3);
+  Bytes::from_string(binary)
+}
+
+fn base64_url_encode(data: ref [Int]) -> String {
+  let raw = Base64::encode(data);
+  let r1 = String::replace_all(raw, "+", "-");
+  let r2 = String::replace_all(r1, "/", "_");
+  String::replace_regex(r2, "=+$", "")
+}
+
+fn base64_url_encode_string(s: ref String) -> String {
+  let raw = Base64::encode(Bytes::from_utf8(s));
+  let r1 = String::replace_all(raw, "+", "-");
+  let r2 = String::replace_all(r1, "/", "_");
+  String::replace_regex(r2, "=+$", "")
+}
+
+// JWT valid for 10 minutes (GitHub requirement). `iat` is set 60 s in the
+// past to account for clock drift.
+pub fn generate_jwt(
+  app_id: ref String,
+  private_key_pem: ref String,
+) -{IO}-> Result<String, String> {
+  let now_seconds = Time::now_millis() / 1000.0;
+
+  let header = json::encode_object([
+    ("alg", json::encode_string("RS256")),
+    ("typ", json::encode_string("JWT")),
+  ]);
+  let header_b64 = base64_url_encode_string(json::stringify(header));
+
+  let payload = json::encode_object([
+    ("iss", json::encode_string(app_id)),
+    ("iat", json::encode_float(now_seconds - 60.0)),
+    ("exp", json::encode_float(now_seconds + 600.0)),
+  ]);
+  let payload_b64 = base64_url_encode_string(json::stringify(payload));
+
+  let message = header_b64 ++ "." ++ payload_b64;
+
+  try {
+    let key_buffer = pem_to_array_buffer(private_key_pem);
+
+    let key = Crypto::import_key_pkcs8(
+      key_buffer,
+      #{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
+      false,
+      ["sign"],
+    );
+
+    let message_bytes = Bytes::from_utf8(message);
+    let signature_buffer = Crypto::sign_with_algorithm(
+      #{ name: "RSASSA-PKCS1-v1_5" },
+      key,
+      message_bytes,
+    );
+
+    let signature_b64 = base64_url_encode(signature_buffer);
+    Ok(message ++ "." ++ signature_b64)
+  } catch {
+    err => Err("Failed to generate JWT: " ++ Exn::message(err))
+  }
+}
+
+// GitHub installation tokens are valid for 1 hour.
+pub fn get_installation_token(
+  jwt: ref String,
+  installation_id: Int,
+) -{IO}-> Result<InstallationToken, String> {
+  let cache_key = Int::to_string(installation_id);
+
+  match cache_get(cache_key) {
+    Some(cached) => {
+      if cached.expiresAt > Time::now_millis() + 60000.0 {
+        Ok(cached)
+      } else {
+        fetch_installation_token(jwt, cache_key)
+      }
+    },
+    None => fetch_installation_token(jwt, cache_key)
+  }
+}
+
+fn fetch_installation_token(
+  jwt: ref String,
+  cache_key: ref String,
+) -{IO}-> Result<InstallationToken, String> {
+  try {
+    let response = Http::http_request(
+      "https://api.github.com/app/installations/" ++ cache_key ++ "/access_tokens",
+      "POST",
+      [
+        ("Authorization", "Bearer " ++ jwt),
+        ("Accept", "application/vnd.github+json"),
+        ("X-GitHub-Api-Version", "2022-11-28"),
+        ("User-Agent", "oikos-bot")
+      ],
+      None,
+    );
+
+    if response.status < 200 || response.status >= 300 {
+      Err("GitHub API error " ++ Int::to_string(response.status) ++ ": " ++ response.body)
+    } else {
+      // TODO(mechanical-migrator): parse response.body once json::parse lands.
+      Err("installation-token decoder not yet bound (awaits json::parse)")
+    }
+  } catch {
+    err => Err("Failed to get installation token: " ++ Exn::message(err))
+  }
+}
+
+pub fn extract_installation_id(payload: ref Json) -> Option<Int> {
+  match json::decode_object(payload) {
+    Some(obj) => match dict::get(obj, "installation") {
+      Some(inst) => match json::decode_object(inst) {
+        Some(inst_obj) => match dict::get(inst_obj, "id") {
+          Some(id) => json::decode_int(id),
+          None => None
+        },
+        None => None
+      },
+      None => None
+    },
+    None => None
+  }
+}
+
+pub fn get_auth_token(
+  config: ref Config,
+  payload: ref Json,
+) -{IO}-> Result<String, String> {
+  match (config.githubAppId, config.githubPrivateKey) {
+    (Some(app_id), Some(private_key)) => match extract_installation_id(payload) {
+      Some(installation_id) => {
+        let jwt_result = generate_jwt(app_id, private_key);
+        match jwt_result {
+          Ok(jwt) => {
+            let token_result = get_installation_token(jwt, installation_id);
+            match token_result {
+              Ok(install_token) => Ok(install_token.token),
+              Err(e) => Err(e)
+            }
+          },
+          Err(e) => Err(e)
+        }
+      },
+      None => Err("No installation ID in payload")
+    },
+    _ => Err("GitHub App credentials not configured")
+  }
+}
diff --git a/bot-integration-affine/src/tea/Cmd.affine b/bot-integration-affine/src/tea/Cmd.affine
new file mode 100644
index 0000000..966f10f
--- /dev/null
+++ b/bot-integration-affine/src/tea/Cmd.affine
@@ -0,0 +1,52 @@
+// face: affinescript
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell
+//
+// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24).
+// TEA Cmd module — describes IO actions that produce messages.
+//
+// Split out of the original single-file ServerTea hand-port: the parser
+// has no inline `module Name { ... }` form, so each TEA submodule is its
+// own file.
+
+module Cmd;
+
+use prelude::{Option, Some, None, Result, Ok, Err};
+
+pub type T<M> =
+    None
+  | Batch([T<M>])
+  | Perform(fn() -{IO}-> M)
+  | PerformWithDispatch(fn(fn(M) -> ()) -{IO}-> ())
+
+pub fn none<M>() -> T<M> {
+  None
+}
+
+pub fn batch<M>(cmds: [T<M>]) -> T<M> {
+  Batch(cmds)
+}
+
+pub fn perform<A, M>(task: fn() -{IO}-> A, to_msg: fn(A) -> M) -> T<M> {
+  Perform(fn() -{IO}-> M { to_msg(task()) })
+}
+
+// `attempt` lifts a fallible task into a Result-carrying message.
+// The E parameter is unconstrained here; the mechanical migrator should
+// re-narrow against the actual Exn[E] effect surface (#59).
+pub fn attempt<A, E, M>(
+  task: fn() -{IO}-> A,
+  to_msg: fn(Result<A, E>) -> M,
+) -> T<M> {
+  Perform(fn() -{IO}-> M {
+    try {
+      to_msg(Ok(task()))
+    } catch {
+      e => to_msg(Err(e))
+    }
+  })
+}
+
+pub fn with_dispatch<M>(f: fn(fn(M) -> ()) -{IO}-> ()) -> T<M> {
+  PerformWithDispatch(f)
+}
diff --git a/bot-integration-affine/src/tea/Runtime.affine b/bot-integration-affine/src/tea/Runtime.affine
new file mode 100644
index 0000000..80e72fd
--- /dev/null
+++ b/bot-integration-affine/src/tea/Runtime.affine
@@ -0,0 +1,92 @@
+// face: affinescript
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell
+//
+// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24).
+// TEA Runtime module — the dispatch loop.
+//
+// Structural caveat (must be re-resolved by the mechanical migrator):
+//   The original ReScript used `let rec dispatch = ... and update_subs =
+//   ... and start_subscription = ...` mutual recursion over shared `mut`
+//   cells. The AffineScript parser has no `let rec ... and ...` form, so
+//   the closures here are hoisted to top-level `fn`s that take the
+//   state record explicitly. The borrow checker will likely require
+//   the State record to be `own`-passed through these functions, or
+//   the whole loop to be re-expressed as a single handler. See
+//   MIGRATION-NOTES.md "Runtime structural rework".
+
+module Runtime;
+
+use prelude::{Option, Some, None};
+use Cmd;
+use Sub;
+
+pub type State<Model, Msg> = {
+  model: mut Model,
+  subscriptions: mut [Sub::T<Msg>],
+  running: mut Bool,
+  // `http_server` / `intervals` carry host handles whose canonical types
+  // (Http::Server, Time::IntervalId) are not yet in stdlib; modelled as
+  // opaque Int handles until the externs land.
+  http_server: mut Option<Int>,
+  intervals: mut [Int],
+}
+
+pub type Handle<Model, Msg> = {
+  dispatch: fn(Msg) -> (),
+  get_model: fn() -> Model,
+  stop: fn() -{IO}-> (),
+}
+
+// dispatch / update_subscriptions / start_subscription were
+// mutually-recursive closures in the original; mechanical re-port should
+// re-express them under whatever final closure-form the borrow checker
+// admits. Stubs preserved here for traceability.
+
+pub fn execute_cmd<Model, Msg>(
+  cmd: ref Cmd::T<Msg>,
+  dispatch: fn(Msg) -> (),
+) -{IO}-> () {
+  match cmd {
+    Cmd::None => (),
+    Cmd::Batch(cmds) => {
+      // TODO(mechanical-migrator): iterate `cmds` once Array/iter externs
+      // are bound; for now this is a no-op stub so the file parses.
+      ()
+    },
+    Cmd::Perform(task) => {
+      let msg = task();
+      dispatch(msg)
+    },
+    Cmd::PerformWithDispatch(f) => f(dispatch),
+  }
+}
+
+// `make` is the public entry point. The original wired up dispatch,
+// update_subscriptions and start_subscription as a `let rec ... and`
+// cluster sharing the `state` record. That form is not in the parser;
+// the implementation body has been replaced with a TODO stub so the
+// file parses cleanly. The mechanical migrator must rebuild the loop
+// against the final effect-row + borrow shape (#59 / #103).
+pub fn make<Model, Msg, Flags>(
+  init: fn(Flags) -> (Model, Cmd::T<Msg>),
+  update: fn(Msg, Model) -> (Model, Cmd::T<Msg>),
+  subscriptions: fn(Model) -> Sub::T<Msg>,
+  flags: Flags,
+) -{IO}-> Handle<Model, Msg> {
+  // TODO(mechanical-migrator): rebuild the dispatch loop. The shape was
+  //   1. let (initial_model, initial_cmd) = init(flags);
+  //   2. create State {model, subscriptions=[], running=true, ...}
+  //   3. dispatch = msg => if running { (m, c) = update(msg, state.model);
+  //                       state.model := m; execute_cmd(c, dispatch); ... }
+  //   4. start_subscription on initial subs
+  //   5. return Handle { dispatch, get_model, stop }
+  // The hand-port has been stubbed so the file parses; do NOT trust the
+  // returned Handle to actually drive a TEA loop.
+  let (initial_model, _initial_cmd) = init(flags);
+  Handle #{
+    dispatch: fn(_msg) -> () { () },
+    get_model: fn() -> Model { initial_model },
+    stop: fn() -{IO}-> () { () },
+  }
+}
diff --git a/bot-integration-affine/src/tea/Sub.affine b/bot-integration-affine/src/tea/Sub.affine
new file mode 100644
index 0000000..2405429
--- /dev/null
+++ b/bot-integration-affine/src/tea/Sub.affine
@@ -0,0 +1,33 @@
+// face: affinescript
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell
+//
+// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24).
+// TEA Sub module — long-lived subscription descriptors.
+
+module Sub;
+
+use prelude::{Option, Some, None};
+use json::{Json};
+
+pub type T<M> =
+    None
+  | Batch([T<M>])
+  | HttpServer(Int, fn(Json) -> Option<M>)
+  | Interval(Int, fn() -> M)
+
+pub fn none<M>() -> T<M> {
+  None
+}
+
+pub fn batch<M>(subs: [T<M>]) -> T<M> {
+  Batch(subs)
+}
+
+pub fn http_server<M>(port: Int, handler: fn(Json) -> Option<M>) -> T<M> {
+  HttpServer(port, handler)
+}
+
+pub fn every<M>(ms: Int, to_msg: fn() -> M) -> T<M> {
+  Interval(ms, to_msg)
+}
diff --git a/containers/Containerfile b/containers/Containerfile
new file mode 100644
index 0000000..2945629
--- /dev/null
+++ b/containers/Containerfile
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: MPL-2.0
+# Oikos Bot Container Image
+#
+# Build: vordr build -t oikos:latest -f containers/Containerfile .
+# Run:   vordr run -p 3000:3000 oikos:latest
+
+# =============================================================================
+# Stage 1: Placeholder for Haskell analyzer (skipped for MVP)
+# =============================================================================
+# The Haskell analyzer will be added when fully implemented.
+# For now, the bot uses mock analysis or calls external analyzer endpoint.
+FROM docker.io/debian:bookworm-slim AS haskell-builder
+RUN echo "Haskell analyzer skipped for MVP" > /build-status.txt
+
+# =============================================================================
+# Stage 2: Placeholder for OCaml analyzer (skipped for MVP)
+# =============================================================================
+# The OCaml doc analyzer will be added when implemented.
+FROM docker.io/debian:bookworm-slim AS ocaml-builder
+RUN echo "OCaml analyzer skipped for MVP" > /build-status.txt
+
+# =============================================================================
+# Stage 3: ReScript bot integration (pre-compiled, Deno runtime)
+# =============================================================================
+# NOTE: ReScript must be pre-compiled locally before building container.
+# Run `deno task build:rescript` in bot-integration/ to generate .res.js files.
+# We do NOT use npm in the container - Deno only.
+FROM docker.io/denoland/deno:2.1.4 AS rescript-stage
+
+WORKDIR /build/bot
+
+# Just copy pre-compiled ReScript output and Deno config
+COPY bot-integration/src/*.res.js ./src/
+COPY bot-integration/deno.json .
+COPY bot-integration/bindings/ ./bindings/
+COPY bot-integration/rescript-runtime/ ./rescript-runtime/
+
+# Cache Deno dependencies
+RUN deno cache src/Oikos.res.js || true
+
+# =============================================================================
+# Stage 4: Final runtime image
+# =============================================================================
+FROM docker.io/denoland/deno:2.1.4 AS runtime
+
+LABEL maintainer="hyperpolymath"
+LABEL org.opencontainers.image.title="Oikos Bot"
+LABEL org.opencontainers.image.description="Ecological & Economic Code Analysis Platform"
+LABEL org.opencontainers.image.version="0.1.0-beta"
+LABEL org.opencontainers.image.source="https://github.com/hyperpolymath/oikos-bot"
+LABEL org.opencontainers.image.licenses="MPL-2.0"
+
+WORKDIR /app
+
+USER root
+
+# Install runtime dependencies
+RUN apt-get update && apt-get install -y \
+    git \
+    jq \
+    curl \
+    ca-certificates \
+    && rm -rf /var/lib/apt/lists/*
+
+# Copy built artifacts from previous stages
+# NOTE: Haskell and OCaml analyzers are optional - if they didn't build, we skip them
+# The bot will fall back to mock analysis if the analyzer is unavailable
+
+# ReScript bot (pre-compiled, Deno runtime)
+COPY --from=rescript-stage /build/bot/src/*.res.js /app/bot-integration/src/
+COPY --from=rescript-stage /build/bot/deno.json /app/bot-integration/
+COPY --from=rescript-stage /build/bot/rescript-runtime/ /app/bot-integration/rescript-runtime/
+
+# Copy policy engine
+COPY policy-engine/ /app/policy-engine/
+
+# Copy configuration
+COPY config/ /app/config/
+
+# Environment
+ENV PORT=3000
+ENV BOT_MODE=advisor
+ENV ANALYSIS_ENDPOINT=http://localhost:8080/analyze
+ENV DENO_DIR=/app/.deno
+
+# Create deno cache directory
+RUN mkdir -p /app/.deno && chown -R deno:deno /app
+
+# Switch to non-root user
+USER deno
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+    CMD deno eval "const r = await fetch('http://localhost:3000/health'); if (!r.ok) Deno.exit(1);" || exit 1
+
+EXPOSE 3000
+
+# Default command
+CMD ["run", "--allow-net", "--allow-env", "--allow-read", "/app/bot-integration/src/Oikos.res.js"]
diff --git a/containers/Containerfile.policy b/containers/Containerfile.policy
new file mode 100644
index 0000000..db95b32
--- /dev/null
+++ b/containers/Containerfile.policy
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2024-2025 hyperpolymath
+#
+# Oikos Bot Policy Engine Container
+# Datalog + DeepProbLog based policy engine
+#
+# Build: podman build -t oikos-policy:latest -f containers/Containerfile.policy .
+
+FROM /cerro-torre AS builder
+
+WORKDIR /build
+
+# Install build dependencies
+RUN guix install \
+    python \
+    python-pip \
+    souffle \
+    swi-prolog \
+    git
+
+# Copy policy engine source
+COPY policy-engine/ .
+
+# Create virtual environment and install dependencies
+RUN python -m venv /opt/venv && \
+    /opt/venv/bin/pip install --upgrade pip && \
+    /opt/venv/bin/pip install \
+        numpy \
+        torch \
+        networkx \
+        pyyaml \
+        aiohttp \
+        pyarango \
+        SPARQLWrapper
+
+# Install DeepProbLog
+RUN /opt/venv/bin/pip install deepproblog
+
+# =============================================================================
+# Runtime
+# =============================================================================
+FROM /cerro-torre AS runtime
+
+WORKDIR /app
+
+# Install runtime dependencies
+RUN guix install \
+    python \
+    souffle \
+    swi-prolog \
+    jq
+
+# Copy virtual environment
+COPY --from=builder /opt/venv /opt/venv
+
+# Copy policy engine
+COPY --from=builder /build /app/policy-engine
+
+# Copy Datalog rules
+COPY policy-engine/datalog/ /app/datalog/
+
+# Copy DeepProbLog rules
+COPY policy-engine/deepproblog/ /app/deepproblog/
+
+# Environment
+ENV PATH="/opt/venv/bin:$PATH"
+ENV PYTHONUNBUFFERED=1
+ENV PORT=8081
+
+# Create non-root user
+RUN useradd -m -s /bin/bash policyengine
+USER policyengine
+
+EXPOSE 8081
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
+    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')"
+
+CMD ["python", "-m", "policy_engine.server", "--port", "8081"]
diff --git a/containers/compose.yaml b/containers/compose.yaml
new file mode 100644
index 0000000..6bc64c9
--- /dev/null
+++ b/containers/compose.yaml
@@ -0,0 +1,188 @@
+# SPDX-License-Identifier: MPL-2.0
+# Oikos Bot Compose Configuration
+#
+# Usage with Vörðr/Podman:
+#   podman-compose up -d
+#   podman-compose logs -f oikos
+#   podman-compose down
+
+name: oikos
+
+services:
+  # ==========================================================================
+  # Main Oikos Bot Service
+  # ==========================================================================
+  oikos:
+    build:
+      context: ..
+      dockerfile: containers/Containerfile
+    image: oikos:latest
+    container_name: oikos
+    restart: unless-stopped
+    ports:
+      - "3000:3000"
+    environment:
+      - PORT=3000
+      - BOT_MODE=advisor
+      - ANALYSIS_ENDPOINT=https://analyzer:8080/analyze
+      - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET:-}
+      - GITLAB_WEBHOOK_SECRET=${GITLAB_WEBHOOK_SECRET:-}
+      - ARANGO_URL=https://arangodb:8529
+      - VIRTUOSO_URL=https://virtuoso:8890/sparql
+    depends_on:
+      - arangodb
+      - virtuoso
+      - analyzer
+    networks:
+      - oikos-network
+    healthcheck:
+      test: ["CMD", "deno", "eval", "const r = await fetch('http://localhost:3000/health'); if (!r.ok) Deno.exit(1);"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+
+  # ==========================================================================
+  # Analysis Service (Haskell + OCaml analyzers)
+  # ==========================================================================
+  analyzer:
+    build:
+      context: ..
+      dockerfile: containers/Containerfile
+      target: runtime
+    image: oikos-analyzer:latest
+    container_name: oikos-analyzer
+    restart: unless-stopped
+    ports:
+      - "8080:8080"
+    environment:
+      - PORT=8080
+    networks:
+      - oikos-network
+    command: ["oikos-analyzer", "serve", "--port", "8080"]
+
+  # ==========================================================================
+  # Policy Engine (Datalog + DeepProbLog)
+  # ==========================================================================
+  policy-engine:
+    build:
+      context: ..
+      dockerfile: containers/Containerfile.policy
+    image: oikos-policy:latest
+    container_name: oikos-policy
+    restart: unless-stopped
+    ports:
+      - "8081:8081"
+    environment:
+      - PORT=8081
+      - ARANGO_URL=https://arangodb:8529
+      - VIRTUOSO_URL=https://virtuoso:8890/sparql
+    depends_on:
+      - arangodb
+      - virtuoso
+    networks:
+      - oikos-network
+    volumes:
+      - policy-data:/app/data
+
+  # ==========================================================================
+  # ArangoDB (Graph + Document Database)
+  # ==========================================================================
+  arangodb:
+    image: arangodb:3.11
+    container_name: oikos-arangodb
+    restart: unless-stopped
+    ports:
+      - "8529:8529"
+    environment:
+      - ARANGO_ROOT_PASSWORD=${ARANGO_ROOT_PASSWORD:-oikos}
+      - ARANGO_NO_AUTH=${ARANGO_NO_AUTH:-0}
+    volumes:
+      - arangodb-data:/var/lib/arangodb3
+      - ../databases/arangodb/schema.js:/docker-entrypoint-initdb.d/schema.js:ro
+    networks:
+      - oikos-network
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:8529/_api/version"]
+      interval: 30s
+      timeout: 10s
+      retries: 5
+
+  # ==========================================================================
+  # Virtuoso (RDF Triple Store + SPARQL)
+  # ==========================================================================
+  virtuoso:
+    image: openlink/virtuoso-opensource-7:latest
+    container_name: oikos-virtuoso
+    restart: unless-stopped
+    ports:
+      - "8890:8890"
+      - "1111:1111"
+    environment:
+      - DBA_PASSWORD=${VIRTUOSO_DBA_PASSWORD:-oikos}
+      - SPARQL_UPDATE=true
+      - DEFAULT_GRAPH=https://oikos.dev/knowledge
+    volumes:
+      - virtuoso-data:/database
+      - ../databases/virtuoso/ontology.ttl:/opt/virtuoso-opensource/share/virtuoso/vad/ontology.ttl:ro
+    networks:
+      - oikos-network
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:8890/sparql?query=ASK%20%7B%7D"]
+      interval: 30s
+      timeout: 10s
+      retries: 5
+
+  # ==========================================================================
+  # Prometheus (Metrics)
+  # ==========================================================================
+  prometheus:
+    image: prom/prometheus:latest
+    container_name: oikos-prometheus
+    restart: unless-stopped
+    ports:
+      - "9090:9090"
+    volumes:
+      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+      - prometheus-data:/prometheus
+    networks:
+      - oikos-network
+    command:
+      - '--config.file=/etc/prometheus/prometheus.yml'
+      - '--storage.tsdb.path=/prometheus'
+
+  # ==========================================================================
+  # Grafana (Dashboard)
+  # ==========================================================================
+  grafana:
+    image: grafana/grafana:latest
+    container_name: oikos-grafana
+    restart: unless-stopped
+    ports:
+      - "3001:3000"
+    environment:
+      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-oikos}
+      - GF_USERS_ALLOW_SIGN_UP=false
+    volumes:
+      - grafana-data:/var/lib/grafana
+      - ./grafana/provisioning:/etc/grafana/provisioning:ro
+    depends_on:
+      - prometheus
+    networks:
+      - oikos-network
+
+# =============================================================================
+# Networks
+# =============================================================================
+networks:
+  oikos-network:
+    driver: bridge
+
+# =============================================================================
+# Volumes
+# =============================================================================
+volumes:
+  arangodb-data:
+  virtuoso-data:
+  policy-data:
+  prometheus-data:
+  grafana-data:
diff --git a/containers/prometheus.yml b/containers/prometheus.yml
new file mode 100644
index 0000000..595b9d6
--- /dev/null
+++ b/containers/prometheus.yml
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: MPL-2.0
+# Prometheus configuration for Oikos Bot metrics
+
+global:
+  scrape_interval: 15s
+  evaluation_interval: 15s
+
+alerting:
+  alertmanagers:
+    - static_configs:
+        - targets: []
+
+rule_files: []
+
+scrape_configs:
+  # Prometheus self-monitoring
+  - job_name: 'prometheus'
+    static_configs:
+      - targets: ['localhost:9090']
+
+  # Oikos main service
+  - job_name: 'oikos'
+    static_configs:
+      - targets: ['oikos:3000']
+    metrics_path: '/metrics'
+
+  # Analysis service
+  - job_name: 'oikos-analyzer'
+    static_configs:
+      - targets: ['analyzer:8080']
+    metrics_path: '/metrics'
+
+  # Policy engine
+  - job_name: 'oikos-policy'
+    static_configs:
+      - targets: ['policy-engine:8081']
+    metrics_path: '/metrics'
+
+  # ArangoDB
+  - job_name: 'arangodb'
+    static_configs:
+      - targets: ['arangodb:8529']
+    metrics_path: '/_admin/metrics/v2'
+    basic_auth:
+      username: 'root'
+      password_file: '/etc/prometheus/arango_password'
diff --git a/containers/vordr-build.sh b/containers/vordr-build.sh
new file mode 100755
index 0000000..10347bc
--- /dev/null
+++ b/containers/vordr-build.sh
@@ -0,0 +1,113 @@
+#!/bin/bash
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+#
+# Oikos Container Build Script
+#
+# Builds all container images using Vörðr (Svalinn OCI runtime)
+# https://github.com/hyperpolymath/svalinn
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+
+log_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+log_step() {
+    echo -e "${CYAN}[STEP]${NC} $1"
+}
+
+# Check for container runtime (prefer vordr, fallback to podman)
+detect_runtime() {
+    if command -v vordr &> /dev/null; then
+        echo "vordr"
+    elif command -v podman &> /dev/null; then
+        echo "podman"
+    else
+        log_error "No container runtime found."
+        log_info "Install Vörðr: https://github.com/hyperpolymath/svalinn"
+        log_info "Or fallback: sudo dnf install podman"
+        exit 1
+    fi
+}
+
+RUNTIME=$(detect_runtime)
+log_info "Using container runtime: ${RUNTIME}"
+
+# Display Vörðr version if available
+if [[ "$RUNTIME" == "vordr" ]]; then
+    log_info "Vörðr version: $(vordr --version 2>/dev/null || echo 'unknown')"
+fi
+
+cd "$PROJECT_ROOT"
+
+# Ensure AffineScript is pre-compiled
+if [[ ! -f "bot-integration/src/Oikos.affine.js" ]]; then
+    log_warn "AffineScript not compiled. Running build..."
+    cd bot-integration
+    if command -v deno &> /dev/null; then
+        deno task build:affinescript
+    else
+        log_error "Deno not found. Please install Deno and run: deno task build:affinescript"
+        exit 1
+    fi
+    cd "$PROJECT_ROOT"
+fi
+
+# Build main Oikos image
+log_step "Building oikos:latest..."
+$RUNTIME build \
+    --tag oikos:latest \
+    --file containers/Containerfile \
+    --progress=plain \
+    .
+
+# Build policy engine image
+log_step "Building oikos-policy:latest..."
+$RUNTIME build \
+    --tag oikos-policy:latest \
+    --file containers/Containerfile.policy \
+    --progress=plain \
+    .
+
+# Tag images with version
+VERSION=$(git describe --tags --always 2>/dev/null || echo "0.1.0-beta")
+log_info "Tagging images with version: $VERSION"
+
+$RUNTIME tag oikos:latest "oikos:$VERSION"
+$RUNTIME tag oikos-policy:latest "oikos-policy:$VERSION"
+
+# List built images
+log_info "Built images:"
+$RUNTIME images | grep oikos || true
+
+log_info "Build complete!"
+echo ""
+echo "To run the stack:"
+if [[ "$RUNTIME" == "vordr" ]]; then
+    echo "  vordr run -d -p 3000:3000 --name oikos oikos:latest"
+    echo ""
+    echo "To run with compose (requires podman-compose or docker-compose):"
+fi
+echo "  cd containers && podman-compose up -d"
+echo ""
+echo "To push to registry:"
+echo "  $RUNTIME push oikos:$VERSION"
diff --git a/crates/oikosbot-analysis/Cargo.toml b/crates/oikosbot-analysis/Cargo.toml
new file mode 100644
index 0000000..cf9b7c3
--- /dev/null
+++ b/crates/oikosbot-analysis/Cargo.toml
@@ -0,0 +1,34 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "oikosbot-analysis"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[features]
+default = []
+# Security-sustainability correlation. The optional `panic-attacker` integration
+# (a separate hyperpolymath repo) was decoupled when OikosBot was extracted from
+# gitbot-fleet so this crate builds standalone; the feature is retained as a
+# no-op seam to be re-wired (path or git dep) when that integration is needed.
+panic-attack = []
+
+[dependencies]
+oikosbot-metrics = { path = "../oikosbot-metrics" }
+tree-sitter.workspace = true
+tree-sitter-rust.workspace = true
+tree-sitter-javascript.workspace = true
+tree-sitter-python.workspace = true
+anyhow.workspace = true
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tracing.workspace = true
+toml.workspace = true
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/oikosbot-analysis/src/analyzer.rs b/crates/oikosbot-analysis/src/analyzer.rs
new file mode 100644
index 0000000..27742da
--- /dev/null
+++ b/crates/oikosbot-analysis/src/analyzer.rs
@@ -0,0 +1,265 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Core analysis engine using tree-sitter AST
+
+use crate::carbon::estimate_carbon;
+use crate::language::Language;
+use crate::patterns::detect_patterns;
+use anyhow::{Context, Result};
+use oikosbot_metrics::*;
+use std::fs;
+use std::path::Path;
+use tree_sitter::{Parser, Tree};
+
+pub struct Analyzer {
+    _language: Language,
+    parser: Parser,
+    file_path: Option<String>,
+}
+
+impl Analyzer {
+    pub fn new(language: Language) -> Result<Self> {
+        let mut parser = Parser::new();
+        parser
+            .set_language(&language.parser())
+            .context("Failed to set parser language")?;
+
+        Ok(Analyzer {
+            _language: language,
+            parser,
+            file_path: None,
+        })
+    }
+
+    pub fn analyze_file(&mut self, path: &Path) -> Result<Vec<AnalysisResult>> {
+        let source = fs::read_to_string(path)
+            .with_context(|| format!("Failed to read file: {}", path.display()))?;
+
+        self.file_path = Some(path.display().to_string());
+        let results = self.analyze_source(&source);
+        self.file_path = None;
+        results
+    }
+
+    pub fn analyze_source(&mut self, source: &str) -> Result<Vec<AnalysisResult>> {
+        let tree = self
+            .parser
+            .parse(source, None)
+            .context("Failed to parse source")?;
+
+        self.analyze_tree(source, &tree)
+    }
+
+    fn analyze_tree(&self, source: &str, tree: &Tree) -> Result<Vec<AnalysisResult>> {
+        let mut results = Vec::new();
+
+        // Walk the AST and analyze each function
+        let root = tree.root_node();
+        let mut cursor = root.walk();
+
+        self.visit_node(source, &root, &mut cursor, &mut results);
+
+        Ok(results)
+    }
+
+    fn visit_node(
+        &self,
+        source: &str,
+        node: &tree_sitter::Node,
+        cursor: &mut tree_sitter::TreeCursor,
+        results: &mut Vec<AnalysisResult>,
+    ) {
+        // Analyze functions
+        if self.is_function_node(node) {
+            if let Some(result) = self.analyze_function(source, node) {
+                results.push(result);
+            }
+        }
+
+        // Recurse to children
+        if cursor.goto_first_child() {
+            loop {
+                let child = cursor.node();
+                self.visit_node(source, &child, cursor, results);
+
+                if !cursor.goto_next_sibling() {
+                    break;
+                }
+            }
+            cursor.goto_parent();
+        }
+    }
+
+    fn is_function_node(&self, node: &tree_sitter::Node) -> bool {
+        matches!(
+            node.kind(),
+            "function_item"          // Rust
+            | "function_declaration" // JS
+            | "arrow_function"       // JS
+            | "method_declaration"   // JS
+            | "function_definition" // Python
+        )
+    }
+
+    fn analyze_function(&self, source: &str, node: &tree_sitter::Node) -> Option<AnalysisResult> {
+        let location = self.node_location(source, node)?;
+
+        // Estimate resources based on code patterns
+        let complexity = self.estimate_complexity(node);
+        let resources = self.estimate_resources(complexity);
+
+        // Detect problematic patterns
+        let pattern_matches = detect_patterns(source, node);
+        let patterns: Vec<String> = pattern_matches.iter().map(|p| p.name.clone()).collect();
+        let recommendations = self.generate_recommendations(&patterns);
+
+        // Derive rule_id and suggestion from most significant pattern
+        let (rule_id, suggestion) = if let Some(pm) = pattern_matches.first() {
+            (format!("oikosbot/{}", pm.name), pm.suggestion.clone())
+        } else {
+            ("oikosbot/general".to_string(), None)
+        };
+
+        // Calculate scores
+        let eco_score = self.calculate_eco_score(&resources);
+        let econ_score = self.calculate_econ_score(complexity);
+        let quality_score = self.calculate_quality_score(complexity);
+
+        let health = HealthIndex::compute(eco_score, econ_score, quality_score);
+
+        let end = node.end_position();
+
+        Some(AnalysisResult {
+            location,
+            resources,
+            health,
+            recommendations,
+            rule_id,
+            suggestion,
+            end_location: Some((end.row + 1, end.column + 1)),
+            confidence: oikosbot_metrics::Confidence::Estimated,
+        })
+    }
+
+    fn node_location(&self, source: &str, node: &tree_sitter::Node) -> Option<CodeLocation> {
+        let start = node.start_position();
+        let end = node.end_position();
+        let name = self.extract_function_name(source, node);
+
+        Some(CodeLocation {
+            file: self
+                .file_path
+                .clone()
+                .unwrap_or_else(|| String::from("<source>")),
+            line: start.row + 1,
+            column: start.column + 1,
+            end_line: Some(end.row + 1),
+            end_column: Some(end.column + 1),
+            name,
+        })
+    }
+
+    fn extract_function_name(&self, source: &str, node: &tree_sitter::Node) -> Option<String> {
+        // Try to find name node (language-specific)
+        let mut cursor = node.walk();
+        if cursor.goto_first_child() {
+            loop {
+                let child = cursor.node();
+                if child.kind() == "identifier" {
+                    return Some(child.utf8_text(source.as_bytes()).ok()?.to_string());
+                }
+                if !cursor.goto_next_sibling() {
+                    break;
+                }
+            }
+        }
+        None
+    }
+
+    fn estimate_complexity(&self, node: &tree_sitter::Node) -> usize {
+        // Simple complexity: count nodes
+        let mut count = 1;
+        let mut cursor = node.walk();
+
+        if cursor.goto_first_child() {
+            loop {
+                count += self.estimate_complexity(&cursor.node());
+                if !cursor.goto_next_sibling() {
+                    break;
+                }
+            }
+            cursor.goto_parent();
+        }
+
+        count
+    }
+
+    fn estimate_resources(&self, complexity: usize) -> ResourceProfile {
+        // Baseline estimates (will be improved with profiling data)
+        let energy = Energy::joules(complexity as f64 * 0.1);
+        let duration = Duration::milliseconds(complexity as f64 * 0.5);
+        let carbon = estimate_carbon(energy);
+        let memory = Memory::kilobytes(complexity * 2);
+
+        ResourceProfile {
+            energy,
+            duration,
+            carbon,
+            memory,
+        }
+    }
+
+    fn calculate_eco_score(&self, resources: &ResourceProfile) -> EcoScore {
+        // Lower resource usage = higher score
+        // Baseline: 100J = 50 score, scale logarithmically
+        let energy_score = (100.0 - (resources.energy.0.ln() * 10.0)).max(0.0);
+        EcoScore::new(energy_score)
+    }
+
+    fn calculate_econ_score(&self, complexity: usize) -> EconScore {
+        // Lower complexity = higher efficiency
+        let score = (100.0 - (complexity as f64 * 0.5)).max(0.0);
+        EconScore::new(score)
+    }
+
+    fn calculate_quality_score(&self, complexity: usize) -> f64 {
+        // Simple quality metric based on complexity
+        (100.0 - (complexity as f64 * 0.3)).max(0.0)
+    }
+
+    fn generate_recommendations(&self, patterns: &[String]) -> Vec<String> {
+        let mut recs = Vec::new();
+
+        for pattern in patterns {
+            match pattern.as_str() {
+                "busy-wait" => recs
+                    .push("Replace busy-wait loop with async/await or blocking sleep".to_string()),
+                "nested-loops" => recs.push(
+                    "Consider algorithm optimization to reduce nested iterations".to_string(),
+                ),
+                "large-allocation" => recs
+                    .push("Review memory allocation - consider streaming or chunking".to_string()),
+                "string-concat-in-loop" => recs.push(
+                    "Use String::with_capacity + push_str or collect with iterators".to_string(),
+                ),
+                "clone-in-loop" => {
+                    recs.push("Consider borrowing instead of cloning inside loop body".to_string())
+                }
+                "unbuffered-io" => recs.push(
+                    "Wrap File with BufReader/BufWriter to reduce syscall overhead".to_string(),
+                ),
+                "redundant-allocation" => {
+                    recs.push("Accept &str instead of String where borrow suffices".to_string())
+                }
+                _ => {}
+            }
+        }
+
+        if recs.is_empty() {
+            recs.push("Code looks efficient - keep up the good work!".to_string());
+        }
+
+        recs
+    }
+}
diff --git a/crates/oikosbot-analysis/src/calibration.rs b/crates/oikosbot-analysis/src/calibration.rs
new file mode 100644
index 0000000..70699dd
--- /dev/null
+++ b/crates/oikosbot-analysis/src/calibration.rs
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Calibration framework for resource estimation.
+//!
+//! Replaces naive `complexity * 0.1 J` with pattern-based resource profiles
+//! producing ranges (min, typical, max) instead of single numbers.
+
+use crate::carbon::estimate_carbon;
+use oikosbot_metrics::{Confidence, Duration, Energy, Memory, ResourceProfile};
+
+/// Resource estimate with min/typical/max range
+#[derive(Debug, Clone)]
+pub struct ResourceRange {
+    pub min: ResourceProfile,
+    pub typical: ResourceProfile,
+    pub max: ResourceProfile,
+    pub confidence: Confidence,
+}
+
+impl ResourceRange {
+    /// Return the typical estimate as a single profile
+    pub fn as_typical(&self) -> &ResourceProfile {
+        &self.typical
+    }
+}
+
+/// Operation categories for calibrated estimates
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OperationKind {
+    /// HashMap/BTreeMap lookup
+    HashLookup,
+    /// Sorting (comparison-based)
+    Sort,
+    /// File I/O (read or write)
+    FileIO,
+    /// Network call (HTTP request)
+    NetworkCall,
+    /// Heap allocation
+    Allocation,
+    /// String operations (format, concat)
+    StringOp,
+    /// Mathematical computation
+    MathCompute,
+    /// Generic/unknown operation
+    Generic,
+}
+
+/// Calibrated resource profiles for known operation patterns.
+///
+/// These are expert estimates that will be refined with profiling data.
+/// All values assume a modern x86_64 system at ~50W TDP.
+pub fn estimate_operation(kind: OperationKind, n: usize) -> ResourceRange {
+    match kind {
+        OperationKind::HashLookup => {
+            // O(1) amortized, ~50ns per lookup
+            let count = n.max(1) as f64;
+            ResourceRange {
+                min: profile(0.000001 * count, 0.00005 * count, 64 * n.max(1)),
+                typical: profile(0.000005 * count, 0.0001 * count, 128 * n.max(1)),
+                max: profile(0.00005 * count, 0.001 * count, 256 * n.max(1)),
+                confidence: Confidence::Calibrated,
+            }
+        }
+        OperationKind::Sort => {
+            // O(n log n), ~100ns per comparison
+            let nf = n.max(1) as f64;
+            let nlogn = nf * nf.log2().max(1.0);
+            ResourceRange {
+                min: profile(0.00001 * nlogn, 0.0001 * nlogn, 8 * n),
+                typical: profile(0.00005 * nlogn, 0.0005 * nlogn, 16 * n),
+                max: profile(0.0005 * nlogn, 0.005 * nlogn, 32 * n),
+                confidence: Confidence::Calibrated,
+            }
+        }
+        OperationKind::FileIO => {
+            // ~1ms per syscall, ~10μJ per 4KB page
+            let pages = (n / 4096).max(1) as f64;
+            ResourceRange {
+                min: profile(0.01 * pages, 0.5 * pages, n),
+                typical: profile(0.05 * pages, 2.0 * pages, n + 4096),
+                max: profile(0.5 * pages, 20.0 * pages, n + 65536),
+                confidence: Confidence::Estimated,
+            }
+        }
+        OperationKind::NetworkCall => {
+            // ~50ms per request, ~0.5J for a typical HTTPS request
+            ResourceRange {
+                min: profile(0.05, 10.0, 4096),
+                typical: profile(0.5, 50.0, 65536),
+                max: profile(5.0, 500.0, 1_048_576),
+                confidence: Confidence::Estimated,
+            }
+        }
+        OperationKind::Allocation => {
+            // ~10ns per allocation, ~1nJ per byte
+            let bytes = n.max(1) as f64;
+            ResourceRange {
+                min: profile(0.000001 * bytes / 1000.0, 0.00001, n),
+                typical: profile(0.00001 * bytes / 1000.0, 0.0001, n + 64),
+                max: profile(0.0001 * bytes / 1000.0, 0.001, n + 4096),
+                confidence: Confidence::Calibrated,
+            }
+        }
+        OperationKind::StringOp => {
+            // ~100ns per string operation, proportional to length
+            let len = n.max(1) as f64;
+            ResourceRange {
+                min: profile(0.000005 * len, 0.0001 * len, n + 64),
+                typical: profile(0.00005 * len, 0.001 * len, 2 * n + 128),
+                max: profile(0.0005 * len, 0.01 * len, 4 * n + 256),
+                confidence: Confidence::Estimated,
+            }
+        }
+        OperationKind::MathCompute => {
+            // ~5ns per FP operation
+            let ops = n.max(1) as f64;
+            ResourceRange {
+                min: profile(0.0000005 * ops, 0.000005 * ops, 0),
+                typical: profile(0.000005 * ops, 0.00005 * ops, 0),
+                max: profile(0.00005 * ops, 0.0005 * ops, 0),
+                confidence: Confidence::Calibrated,
+            }
+        }
+        OperationKind::Generic => {
+            // Fallback: linear in complexity
+            let c = n.max(1) as f64;
+            ResourceRange {
+                min: profile(0.01 * c, 0.05 * c, 512 * n.max(1)),
+                typical: profile(0.1 * c, 0.5 * c, 2048 * n.max(1)),
+                max: profile(1.0 * c, 5.0 * c, 8192 * n.max(1)),
+                confidence: Confidence::Unknown,
+            }
+        }
+    }
+}
+
+/// Estimate resources for a function based on its complexity and detected patterns.
+pub fn calibrated_estimate(complexity: usize, patterns: &[String]) -> ResourceProfile {
+    // Start with generic estimate based on complexity
+    let base = estimate_operation(OperationKind::Generic, complexity);
+    let mut result = base.typical.clone();
+
+    // Apply pattern-based adjustments
+    for pattern in patterns {
+        let multiplier = match pattern.as_str() {
+            "nested-loops" => 3.0,
+            "busy-wait" => 5.0,
+            "string-concat-in-loop" => 2.0,
+            "clone-in-loop" => 1.5,
+            "unbuffered-io" => 3.0,
+            "large-allocation" => 2.0,
+            "redundant-allocation" => 1.2,
+            _ => 1.0,
+        };
+
+        result.energy = Energy::joules(result.energy.0 * multiplier);
+        result.duration = Duration::milliseconds(result.duration.0 * multiplier);
+        result.carbon = estimate_carbon(result.energy);
+    }
+
+    result
+}
+
+fn profile(energy_j: f64, duration_ms: f64, memory_bytes: usize) -> ResourceProfile {
+    let energy = Energy::joules(energy_j);
+    ResourceProfile {
+        energy,
+        duration: Duration::milliseconds(duration_ms),
+        carbon: estimate_carbon(energy),
+        memory: Memory::bytes(memory_bytes),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_hash_lookup_range() {
+        let range = estimate_operation(OperationKind::HashLookup, 1000);
+        assert!(range.min.energy.0 < range.typical.energy.0);
+        assert!(range.typical.energy.0 < range.max.energy.0);
+        assert_eq!(range.confidence, Confidence::Calibrated);
+    }
+
+    #[test]
+    fn test_calibrated_estimate_with_patterns() {
+        let base = calibrated_estimate(10, &[]);
+        let with_pattern = calibrated_estimate(10, &["nested-loops".to_string()]);
+        // Pattern should increase energy
+        assert!(with_pattern.energy.0 > base.energy.0);
+    }
+
+    #[test]
+    fn test_network_call_energy() {
+        let range = estimate_operation(OperationKind::NetworkCall, 1);
+        // Network calls should be significant energy users
+        assert!(range.typical.energy.0 >= 0.1);
+    }
+}
diff --git a/crates/oikosbot-analysis/src/carbon.rs b/crates/oikosbot-analysis/src/carbon.rs
new file mode 100644
index 0000000..53689a9
--- /dev/null
+++ b/crates/oikosbot-analysis/src/carbon.rs
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Carbon emission estimation
+
+use oikosbot_metrics::{Carbon, Energy};
+
+/// Estimate carbon emissions from energy consumption
+///
+/// Uses average grid carbon intensity. In future, this will integrate
+/// with real-time APIs (ElectricityMaps, WattTime, etc.)
+pub fn estimate_carbon(energy: Energy) -> Carbon {
+    // Average grid intensity: ~475 gCO2e/kWh globally
+    // 1 kWh = 3,600,000 J
+    // So: gCO2e = J * (475 / 3,600,000)
+    const CARBON_INTENSITY: f64 = 475.0 / 3_600_000.0;
+
+    Carbon::grams_co2e(energy.0 * CARBON_INTENSITY)
+}
+
+/// Get real-time carbon intensity for a location (stub for now)
+pub async fn get_carbon_intensity(_location: &str) -> f64 {
+    // TODO: Integrate with ElectricityMaps or WattTime API
+    // For now, return global average
+    475.0 // gCO2e/kWh
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_carbon_estimation() {
+        // 100 J should produce ~0.0132 gCO2e with 475 intensity
+        let energy = Energy::joules(100.0);
+        let carbon = estimate_carbon(energy);
+
+        assert!(carbon.0 > 0.01 && carbon.0 < 0.02);
+    }
+}
diff --git a/crates/oikosbot-analysis/src/dependencies.rs b/crates/oikosbot-analysis/src/dependencies.rs
new file mode 100644
index 0000000..bc10f02
--- /dev/null
+++ b/crates/oikosbot-analysis/src/dependencies.rs
@@ -0,0 +1,301 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Dependency analysis for sustainability.
+//!
+//! Parses Cargo.toml and package.json to flag heavy dependencies
+//! and suggest minimal feature sets.
+
+use anyhow::{Context, Result};
+use oikosbot_metrics::*;
+use std::path::Path;
+
+/// Known heavy Rust dependencies and their lighter alternatives/fixes
+const HEAVY_RUST_DEPS: &[(&str, &str)] = &[
+    (
+        "reqwest",
+        "Consider using ureq for simple HTTP (no async runtime needed)",
+    ),
+    (
+        "tokio",
+        "If only using basic features, specify minimal feature set instead of 'full'",
+    ),
+    (
+        "serde_yaml",
+        "Consider using serde_yml or minimal YAML parser if only reading configs",
+    ),
+    (
+        "openssl",
+        "Consider using rustls for TLS (pure Rust, smaller footprint)",
+    ),
+    (
+        "diesel",
+        "For simple queries, consider sqlx with compile-time checked queries",
+    ),
+    (
+        "chrono",
+        "Consider using time crate (smaller, no C dependency)",
+    ),
+    (
+        "num-bigint",
+        "If only using basic math, std library may suffice",
+    ),
+    (
+        "regex",
+        "For simple patterns, consider using glob or manual matching",
+    ),
+    ("hyper", "For simple HTTP servers, consider using tiny_http"),
+    (
+        "image",
+        "Specify only needed format features (e.g., features = [\"png\"])",
+    ),
+];
+
+/// Known heavy npm dependencies
+const HEAVY_NPM_DEPS: &[(&str, &str)] = &[
+    (
+        "moment",
+        "Replace with dayjs or date-fns (tree-shakeable, much smaller)",
+    ),
+    (
+        "lodash",
+        "Use native JS methods or lodash-es for tree-shaking",
+    ),
+    (
+        "axios",
+        "Use native fetch API (available in modern browsers and Deno)",
+    ),
+    ("express", "Consider Hono or Fastify for better performance"),
+    ("webpack", "Consider Vite or esbuild for faster builds"),
+    ("jquery", "Use native DOM APIs"),
+    ("underscore", "Use native JS methods"),
+    ("request", "Deprecated — use native fetch or undici"),
+    ("bluebird", "Use native Promise"),
+    ("chalk", "Use picocolors (much smaller)"),
+];
+
+/// Dependency finding
+#[derive(Debug, Clone)]
+pub struct DepFinding {
+    pub dep_name: String,
+    pub manifest: String,
+    pub suggestion: String,
+    pub uses_all_features: bool,
+}
+
+/// Analyze dependencies in a project directory.
+pub fn analyze_dependencies(repo_path: &Path) -> Vec<AnalysisResult> {
+    let mut results = Vec::new();
+
+    // Check Cargo.toml
+    let cargo_toml = repo_path.join("Cargo.toml");
+    if cargo_toml.exists() {
+        if let Ok(findings) = analyze_cargo_toml(&cargo_toml) {
+            for finding in findings {
+                results.push(dep_finding_to_result(finding));
+            }
+        }
+    }
+
+    // Check package.json
+    let package_json = repo_path.join("package.json");
+    if package_json.exists() {
+        if let Ok(findings) = analyze_package_json(&package_json) {
+            for finding in findings {
+                results.push(dep_finding_to_result(finding));
+            }
+        }
+    }
+
+    results
+}
+
+fn analyze_cargo_toml(path: &Path) -> Result<Vec<DepFinding>> {
+    let content = std::fs::read_to_string(path)
+        .with_context(|| format!("Failed to read {}", path.display()))?;
+
+    let table: toml::Value =
+        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
+
+    let mut findings = Vec::new();
+    let manifest = path.display().to_string();
+
+    // Check [dependencies]
+    if let Some(deps) = table.get("dependencies").and_then(|d| d.as_table()) {
+        for (name, value) in deps {
+            check_rust_dep(name, value, &manifest, &mut findings);
+        }
+    }
+
+    // Check [dev-dependencies]
+    if let Some(deps) = table.get("dev-dependencies").and_then(|d| d.as_table()) {
+        for (name, value) in deps {
+            check_rust_dep(name, value, &manifest, &mut findings);
+        }
+    }
+
+    Ok(findings)
+}
+
+fn check_rust_dep(name: &str, value: &toml::Value, manifest: &str, findings: &mut Vec<DepFinding>) {
+    // Check if it's a known heavy dep
+    if let Some((_, suggestion)) = HEAVY_RUST_DEPS.iter().find(|(dep, _)| *dep == name) {
+        let uses_all = check_uses_all_features(value);
+        findings.push(DepFinding {
+            dep_name: name.to_string(),
+            manifest: manifest.to_string(),
+            suggestion: suggestion.to_string(),
+            uses_all_features: uses_all,
+        });
+    }
+
+    // Flag deps using features = ["full"] or default-features = true with many features
+    if check_uses_all_features(value) {
+        let already_flagged = findings.iter().any(|f| f.dep_name == name);
+        if !already_flagged {
+            findings.push(DepFinding {
+                dep_name: name.to_string(),
+                manifest: manifest.to_string(),
+                suggestion: format!(
+                    "Dependency '{}' uses all features. Consider specifying only needed features.",
+                    name
+                ),
+                uses_all_features: true,
+            });
+        }
+    }
+}
+
+fn check_uses_all_features(value: &toml::Value) -> bool {
+    if let Some(table) = value.as_table() {
+        if let Some(features) = table.get("features").and_then(|f| f.as_array()) {
+            return features.iter().any(|f| f.as_str() == Some("full"));
+        }
+    }
+    false
+}
+
+fn analyze_package_json(path: &Path) -> Result<Vec<DepFinding>> {
+    let content = std::fs::read_to_string(path)
+        .with_context(|| format!("Failed to read {}", path.display()))?;
+
+    let json: serde_json::Value = serde_json::from_str(&content)
+        .with_context(|| format!("Failed to parse {}", path.display()))?;
+
+    let mut findings = Vec::new();
+    let manifest = path.display().to_string();
+
+    // Check dependencies
+    if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) {
+        for name in deps.keys() {
+            if let Some((_, suggestion)) =
+                HEAVY_NPM_DEPS.iter().find(|(dep, _)| *dep == name.as_str())
+            {
+                findings.push(DepFinding {
+                    dep_name: name.clone(),
+                    manifest: manifest.clone(),
+                    suggestion: suggestion.to_string(),
+                    uses_all_features: false,
+                });
+            }
+        }
+    }
+
+    // Check devDependencies too
+    if let Some(deps) = json.get("devDependencies").and_then(|d| d.as_object()) {
+        for name in deps.keys() {
+            if let Some((_, suggestion)) =
+                HEAVY_NPM_DEPS.iter().find(|(dep, _)| *dep == name.as_str())
+            {
+                findings.push(DepFinding {
+                    dep_name: name.clone(),
+                    manifest: manifest.clone(),
+                    suggestion: suggestion.to_string(),
+                    uses_all_features: false,
+                });
+            }
+        }
+    }
+
+    Ok(findings)
+}
+
+fn dep_finding_to_result(finding: DepFinding) -> AnalysisResult {
+    let severity_boost = if finding.uses_all_features { 2.0 } else { 1.0 };
+
+    AnalysisResult {
+        location: CodeLocation {
+            file: finding.manifest,
+            line: 0,
+            column: 0,
+            end_line: None,
+            end_column: None,
+            name: Some(format!("dep:{}", finding.dep_name)),
+        },
+        resources: ResourceProfile {
+            energy: Energy::joules(10.0 * severity_boost),
+            duration: Duration::milliseconds(50.0 * severity_boost),
+            carbon: crate::carbon::estimate_carbon(Energy::joules(10.0 * severity_boost)),
+            memory: Memory::kilobytes(500),
+        },
+        health: HealthIndex::compute(
+            EcoScore::new(if finding.uses_all_features {
+                40.0
+            } else {
+                60.0
+            }),
+            EconScore::new(50.0),
+            50.0,
+        ),
+        recommendations: vec![finding.suggestion.clone()],
+        rule_id: "oikosbot/heavy-dependency".to_string(),
+        suggestion: Some(finding.suggestion),
+        end_location: None,
+        confidence: Confidence::Estimated,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_heavy_dep_detection() {
+        let value: toml::Value = toml::from_str(r#"version = "1.0""#).unwrap();
+        let mut findings = Vec::new();
+        check_rust_dep("reqwest", &value, "Cargo.toml", &mut findings);
+        assert_eq!(findings.len(), 1);
+        assert_eq!(findings[0].dep_name, "reqwest");
+    }
+
+    #[test]
+    fn test_all_features_detection() {
+        // Build the TOML value as a table directly
+        let mut table = toml::map::Map::new();
+        table.insert(
+            "version".to_string(),
+            toml::Value::String("1.0".to_string()),
+        );
+        table.insert(
+            "features".to_string(),
+            toml::Value::Array(vec![toml::Value::String("full".to_string())]),
+        );
+        let value = toml::Value::Table(table);
+        assert!(check_uses_all_features(&value));
+    }
+
+    #[test]
+    fn test_no_all_features() {
+        let mut table = toml::map::Map::new();
+        table.insert(
+            "version".to_string(),
+            toml::Value::String("1.0".to_string()),
+        );
+        table.insert(
+            "features".to_string(),
+            toml::Value::Array(vec![toml::Value::String("json".to_string())]),
+        );
+        let value = toml::Value::Table(table);
+        assert!(!check_uses_all_features(&value));
+    }
+}
diff --git a/crates/oikosbot-analysis/src/directives.rs b/crates/oikosbot-analysis/src/directives.rs
new file mode 100644
index 0000000..b39716f
--- /dev/null
+++ b/crates/oikosbot-analysis/src/directives.rs
@@ -0,0 +1,259 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Parser for `.machine_readable/bot_directives/*.a2ml` files.
+//!
+//! These files control what bots are allowed to do in a given repository.
+//! The format is TOML-shaped A2ML; the SCM form was retired 2026-04-17.
+
+use anyhow::{Context, Result};
+use serde::Deserialize;
+use std::path::Path;
+
+/// A parsed bot directive
+#[derive(Debug, Clone)]
+pub struct BotDirective {
+    /// Bot name this directive applies to
+    pub bot: String,
+    /// Whether this bot is allowed to run
+    pub allow: bool,
+    /// Scopes the bot is allowed to operate in
+    pub scopes: Vec<String>,
+    /// Scopes explicitly denied
+    pub deny: Vec<String>,
+    /// Freeform notes
+    pub notes: Option<String>,
+    /// Custom threshold overrides
+    pub thresholds: Vec<(String, f64)>,
+}
+
+/// Raw A2ML shape (TOML deserialization target). Fields here mirror the
+/// migration-script output at `.machine_readable/bot_directives/<bot>.a2ml`.
+#[derive(Debug, Deserialize)]
+struct DirectiveFile {
+    #[serde(default)]
+    bot: Option<String>,
+    /// Either a single scope string or a list of scopes.
+    #[serde(default)]
+    scope: Option<ScopeField>,
+    #[serde(default)]
+    scopes: Option<Vec<String>>,
+    #[serde(default)]
+    allow: Option<AllowField>,
+    #[serde(default)]
+    deny: Option<Vec<String>>,
+    #[serde(default)]
+    notes: Option<String>,
+    #[serde(default)]
+    thresholds: Option<toml::value::Table>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum ScopeField {
+    One(String),
+    Many(Vec<String>),
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+enum AllowField {
+    Bool(bool),
+    Scopes(Vec<String>),
+}
+
+/// Check if a specific bot has a directive in the given repo.
+///
+/// Looks for `.machine_readable/bot_directives/{bot_name}.a2ml`. Returns
+/// `None` if the file does not exist or fails to parse.
+pub fn check_directive(repo_path: &Path, bot_name: &str) -> Option<BotDirective> {
+    let path = repo_path
+        .join(".machine_readable")
+        .join("bot_directives")
+        .join(format!("{}.a2ml", bot_name));
+
+    if !path.exists() {
+        return None;
+    }
+
+    match parse_directive(&path, bot_name) {
+        Ok(d) => Some(d),
+        Err(e) => {
+            tracing::warn!("Failed to parse directive {}: {}", path.display(), e);
+            None
+        }
+    }
+}
+
+/// Parse a bot directive A2ML file.
+fn parse_directive(path: &Path, bot_name: &str) -> Result<BotDirective> {
+    let content = std::fs::read_to_string(path)
+        .with_context(|| format!("Failed to read directive: {}", path.display()))?;
+
+    let file: DirectiveFile = toml::from_str(&content)
+        .with_context(|| format!("Failed to parse A2ML: {}", path.display()))?;
+
+    let mut scopes: Vec<String> = match file.scope {
+        Some(ScopeField::One(s)) => vec![s],
+        Some(ScopeField::Many(v)) => v,
+        None => Vec::new(),
+    };
+    if let Some(mut extra) = file.scopes {
+        scopes.append(&mut extra);
+    }
+
+    let allow = match file.allow {
+        // Plain boolean: honour as-is.
+        Some(AllowField::Bool(b)) => b,
+        // List-of-scopes: treat as allow = true + union the list into scopes.
+        Some(AllowField::Scopes(list)) => {
+            scopes.extend(list);
+            true
+        }
+        // No allow field → default to allowed (conservative parse).
+        None => true,
+    };
+
+    let thresholds = file
+        .thresholds
+        .unwrap_or_default()
+        .into_iter()
+        .filter_map(|(k, v)| v.as_float().map(|f| (k, f)))
+        .collect();
+
+    Ok(BotDirective {
+        bot: file.bot.unwrap_or_else(|| bot_name.to_string()),
+        allow,
+        scopes,
+        deny: file.deny.unwrap_or_default(),
+        notes: file.notes,
+        thresholds,
+    })
+}
+
+/// Check if the directive allows a specific scope
+pub fn is_scope_allowed(directive: &BotDirective, scope: &str) -> bool {
+    if !directive.allow {
+        return false;
+    }
+
+    // If deny list contains this scope, it's denied
+    if directive.deny.iter().any(|d| d == scope) {
+        return false;
+    }
+
+    // If scopes list is empty, all scopes are allowed
+    if directive.scopes.is_empty() {
+        return true;
+    }
+
+    // Otherwise, scope must be in the allow list
+    directive.scopes.iter().any(|s| s == scope)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use tempfile::TempDir;
+
+    fn write_directive(dir: &Path, bot: &str, body: &str) {
+        let d = dir.join(".machine_readable").join("bot_directives");
+        fs::create_dir_all(&d).unwrap();
+        fs::write(d.join(format!("{}.a2ml", bot)), body).unwrap();
+    }
+
+    #[test]
+    fn test_default_directive() {
+        let d = BotDirective {
+            bot: "test".to_string(),
+            allow: true,
+            scopes: vec![],
+            deny: vec![],
+            notes: None,
+            thresholds: vec![],
+        };
+        assert!(is_scope_allowed(&d, "anything"));
+    }
+
+    #[test]
+    fn test_denied_scope() {
+        let d = BotDirective {
+            bot: "test".to_string(),
+            allow: true,
+            scopes: vec![],
+            deny: vec!["security".to_string()],
+            notes: None,
+            thresholds: vec![],
+        };
+        assert!(!is_scope_allowed(&d, "security"));
+        assert!(is_scope_allowed(&d, "eco"));
+    }
+
+    #[test]
+    fn test_fully_denied() {
+        let d = BotDirective {
+            bot: "test".to_string(),
+            allow: false,
+            scopes: vec![],
+            deny: vec![],
+            notes: None,
+            thresholds: vec![],
+        };
+        assert!(!is_scope_allowed(&d, "anything"));
+    }
+
+    #[test]
+    fn test_parse_typical_bot_directive() {
+        let dir = TempDir::new().unwrap();
+        write_directive(
+            dir.path(),
+            "echidnabot",
+            r#"
+schema_version = "1.0"
+directive_type = "bot-directive"
+bot = "echidnabot"
+scope = "formal verification and fuzzing"
+allow = ["analysis", "fuzzing", "proof checks"]
+deny = ["write to core modules", "write to bindings"]
+notes = "May open findings; code changes require explicit approval"
+"#,
+        );
+
+        let directive = check_directive(dir.path(), "echidnabot").expect("should parse");
+        assert_eq!(directive.bot, "echidnabot");
+        assert!(directive.allow);
+        assert!(directive.scopes.contains(&"fuzzing".to_string()));
+        assert!(directive
+            .scopes
+            .contains(&"formal verification and fuzzing".to_string()));
+        assert!(directive
+            .deny
+            .contains(&"write to core modules".to_string()));
+        assert!(directive.notes.is_some());
+    }
+
+    #[test]
+    fn test_parse_allow_false() {
+        let dir = TempDir::new().unwrap();
+        write_directive(
+            dir.path(),
+            "rhodibot",
+            r#"
+schema_version = "1.0"
+bot = "rhodibot"
+allow = false
+"#,
+        );
+
+        let directive = check_directive(dir.path(), "rhodibot").expect("should parse");
+        assert!(!directive.allow);
+        assert!(!is_scope_allowed(&directive, "anything"));
+    }
+
+    #[test]
+    fn test_missing_file_returns_none() {
+        let dir = TempDir::new().unwrap();
+        assert!(check_directive(dir.path(), "nonexistent").is_none());
+    }
+}
diff --git a/crates/oikosbot-analysis/src/language.rs b/crates/oikosbot-analysis/src/language.rs
new file mode 100644
index 0000000..3fb8cd5
--- /dev/null
+++ b/crates/oikosbot-analysis/src/language.rs
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Language detection and support
+
+use anyhow::{bail, Result};
+use std::path::Path;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Language {
+    Rust,
+    JavaScript,
+    TypeScript,
+    Python,
+}
+
+impl Language {
+    /// Detect language from file extension
+    pub fn detect(path: &Path) -> Result<Self> {
+        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
+
+        match ext {
+            "rs" => Ok(Language::Rust),
+            "js" | "mjs" | "cjs" => Ok(Language::JavaScript),
+            "ts" | "mts" | "cts" => Ok(Language::TypeScript),
+            "py" | "pyw" => Ok(Language::Python),
+            _ => bail!("Unsupported file extension: {}", ext),
+        }
+    }
+
+    /// Get tree-sitter parser for this language
+    pub fn parser(&self) -> tree_sitter::Language {
+        match self {
+            Language::Rust => tree_sitter_rust::LANGUAGE.into(),
+            Language::JavaScript | Language::TypeScript => tree_sitter_javascript::LANGUAGE.into(),
+            Language::Python => tree_sitter_python::LANGUAGE.into(),
+        }
+    }
+
+    /// Human-readable name
+    pub fn name(&self) -> &'static str {
+        match self {
+            Language::Rust => "Rust",
+            Language::JavaScript => "JavaScript",
+            Language::TypeScript => "TypeScript",
+            Language::Python => "Python",
+        }
+    }
+}
diff --git a/crates/oikosbot-analysis/src/lib.rs b/crates/oikosbot-analysis/src/lib.rs
new file mode 100644
index 0000000..3048d4f
--- /dev/null
+++ b/crates/oikosbot-analysis/src/lib.rs
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! # OikosBot Analysis Engine
+//!
+//! AST-based code analysis for ecological and economic metrics.
+//! Built with Eclexia principles: explicit resource tracking.
+
+pub mod analyzer;
+pub mod calibration;
+pub mod carbon;
+pub mod dependencies;
+pub mod directives;
+pub mod language;
+pub mod migration;
+pub mod patterns;
+pub mod security;
+
+use anyhow::Result;
+use oikosbot_metrics::AnalysisResult;
+use std::path::Path;
+
+pub use analyzer::Analyzer;
+pub use language::Language;
+
+/// Main entry point for analyzing a file
+pub fn analyze_file(path: &Path) -> Result<Vec<AnalysisResult>> {
+    let language = Language::detect(path)?;
+    let mut analyzer = Analyzer::new(language)?;
+    analyzer.analyze_file(path)
+}
+
+/// Analyze source code directly
+pub fn analyze_source(source: &str, language: Language) -> Result<Vec<AnalysisResult>> {
+    let mut analyzer = Analyzer::new(language)?;
+    analyzer.analyze_source(source)
+}
diff --git a/crates/oikosbot-analysis/src/migration.rs b/crates/oikosbot-analysis/src/migration.rs
new file mode 100644
index 0000000..0730bab
--- /dev/null
+++ b/crates/oikosbot-analysis/src/migration.rs
@@ -0,0 +1,373 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
+//! Migration Health Tracking for OikosBot
+//!
+//! Extends oikosbot's analysis with ReScript migration health monitoring.
+//! Tracks migration health scores over time and alerts on regressions.
+//!
+//! Integration points:
+//! - Reads panic-attack migration-snapshot JSON outputs
+//! - Produces SARIF-compatible findings for migration regressions
+//! - Feeds health deltas into the fleet dispatch pipeline
+
+use anyhow::Context;
+use serde::{Deserialize, Serialize};
+use std::path::{Path, PathBuf};
+
+/// Migration health snapshot (parsed from panic-attack output)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MigrationHealthSnapshot {
+    /// Repository or target path
+    pub target: PathBuf,
+    /// Snapshot label
+    pub label: String,
+    /// ISO 8601 timestamp
+    pub timestamp: String,
+    /// Overall health score (0.0 - 1.0)
+    pub health_score: f64,
+    /// Count of deprecated API usages
+    pub deprecated_count: usize,
+    /// Count of modern API usages
+    pub modern_count: usize,
+    /// Config format: bsconfig, rescript_json, both, none
+    pub config_format: String,
+    /// Detected version bracket
+    pub version_bracket: String,
+}
+
+/// A migration health regression finding
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MigrationRegression {
+    /// Repository affected
+    pub target: PathBuf,
+    /// Previous health score
+    pub previous_score: f64,
+    /// Current health score
+    pub current_score: f64,
+    /// Health score delta (negative = regression)
+    pub delta: f64,
+    /// What regressed
+    pub reason: RegressionReason,
+    /// SARIF-compatible rule ID
+    pub rule_id: String,
+    /// Human-readable description
+    pub description: String,
+}
+
+/// Reason for a migration regression
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RegressionReason {
+    /// Health score dropped
+    HealthScoreDrop,
+    /// Deprecated API count increased
+    DeprecatedCountIncrease,
+    /// Config format reverted (e.g., rescript.json → bsconfig.json)
+    ConfigFormatRevert,
+    /// Version bracket downgrade
+    VersionBracketDowngrade,
+}
+
+impl std::fmt::Display for RegressionReason {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::HealthScoreDrop => write!(f, "Migration health score dropped"),
+            Self::DeprecatedCountIncrease => write!(f, "Deprecated API usage increased"),
+            Self::ConfigFormatRevert => write!(f, "Config format reverted"),
+            Self::VersionBracketDowngrade => write!(f, "Version bracket downgraded"),
+        }
+    }
+}
+
+/// Migration health tracker
+///
+/// Compares successive snapshots to detect regressions and track
+/// migration progress over time.
+pub struct MigrationHealthTracker;
+
+impl MigrationHealthTracker {
+    /// Compare two snapshots and detect regressions.
+    ///
+    /// Returns a list of regression findings. An empty list means
+    /// migration health is stable or improving.
+    pub fn detect_regressions(
+        previous: &MigrationHealthSnapshot,
+        current: &MigrationHealthSnapshot,
+    ) -> Vec<MigrationRegression> {
+        let mut regressions = Vec::new();
+
+        // Health score regression (threshold: -0.05 = 5% drop)
+        let health_delta = current.health_score - previous.health_score;
+        if health_delta < -0.05 {
+            regressions.push(MigrationRegression {
+                target: current.target.clone(),
+                previous_score: previous.health_score,
+                current_score: current.health_score,
+                delta: health_delta,
+                reason: RegressionReason::HealthScoreDrop,
+                rule_id: "oikosbot/migration-health-regression".to_string(),
+                description: format!(
+                    "Migration health score dropped from {:.2} to {:.2} ({:+.2})",
+                    previous.health_score, current.health_score, health_delta
+                ),
+            });
+        }
+
+        // Deprecated count regression
+        if current.deprecated_count > previous.deprecated_count {
+            let increase = current.deprecated_count - previous.deprecated_count;
+            regressions.push(MigrationRegression {
+                target: current.target.clone(),
+                previous_score: previous.health_score,
+                current_score: current.health_score,
+                delta: -(increase as f64),
+                reason: RegressionReason::DeprecatedCountIncrease,
+                rule_id: "oikosbot/migration-deprecated-increase".to_string(),
+                description: format!(
+                    "Deprecated API usage increased by {} (from {} to {})",
+                    increase, previous.deprecated_count, current.deprecated_count
+                ),
+            });
+        }
+
+        // Config format regression (rescript_json → bsconfig is a downgrade)
+        if previous.config_format == "rescript_json" && current.config_format == "bsconfig" {
+            regressions.push(MigrationRegression {
+                target: current.target.clone(),
+                previous_score: previous.health_score,
+                current_score: current.health_score,
+                delta: -0.2, // Config revert is a significant regression
+                reason: RegressionReason::ConfigFormatRevert,
+                rule_id: "oikosbot/migration-config-revert".to_string(),
+                description: "Config format reverted from rescript.json to bsconfig.json"
+                    .to_string(),
+            });
+        }
+
+        // Version bracket downgrade
+        let prev_rank = version_bracket_rank(&previous.version_bracket);
+        let curr_rank = version_bracket_rank(&current.version_bracket);
+        if curr_rank < prev_rank {
+            regressions.push(MigrationRegression {
+                target: current.target.clone(),
+                previous_score: previous.health_score,
+                current_score: current.health_score,
+                delta: (curr_rank as f64 - prev_rank as f64) * 0.1,
+                reason: RegressionReason::VersionBracketDowngrade,
+                rule_id: "oikosbot/migration-version-downgrade".to_string(),
+                description: format!(
+                    "Version bracket downgraded from {} to {}",
+                    previous.version_bracket, current.version_bracket
+                ),
+            });
+        }
+
+        regressions
+    }
+
+    /// Compute a migration velocity metric.
+    ///
+    /// Given a series of snapshots ordered by time, computes the average
+    /// health improvement per snapshot interval.
+    pub fn compute_velocity(snapshots: &[MigrationHealthSnapshot]) -> f64 {
+        if snapshots.len() < 2 {
+            return 0.0;
+        }
+
+        let total_delta = snapshots
+            .last()
+            .expect("snapshots non-empty: len >= 2 checked above")
+            .health_score
+            - snapshots
+                .first()
+                .expect("snapshots non-empty: len >= 2 checked above")
+                .health_score;
+        total_delta / (snapshots.len() - 1) as f64
+    }
+
+    /// Load a migration health snapshot from a JSON file.
+    ///
+    /// Reads the panic-attack migration-snapshot output format.
+    pub fn load_snapshot(path: &Path) -> anyhow::Result<MigrationHealthSnapshot> {
+        let content = std::fs::read_to_string(path)
+            .with_context(|| format!("reading migration snapshot from {}", path.display()))?;
+        let raw: serde_json::Value = serde_json::from_str(&content)
+            .with_context(|| format!("parsing migration snapshot JSON from {}", path.display()))?;
+
+        // Correctness-critical fields: a missing/malformed value here must NOT
+        // be silently defaulted. Defaulting `health_score` to 0.0 or
+        // `deprecated_api_count` to 0 would fabricate or mask regressions that
+        // feed the fleet dispatch pipeline, so propagate a hard error instead.
+        let health_score = raw["health_score"].as_f64().with_context(|| {
+            format!(
+                "migration snapshot {} is missing a numeric `health_score`",
+                path.display()
+            )
+        })?;
+        let deprecated_count = raw["deprecated_api_count"].as_u64().with_context(|| {
+            format!(
+                "migration snapshot {} is missing an integer `deprecated_api_count`",
+                path.display()
+            )
+        })? as usize;
+        let modern_count = raw["modern_api_count"].as_u64().with_context(|| {
+            format!(
+                "migration snapshot {} is missing an integer `modern_api_count`",
+                path.display()
+            )
+        })? as usize;
+        let target = raw["target_path"].as_str().with_context(|| {
+            format!(
+                "migration snapshot {} is missing a string `target_path`",
+                path.display()
+            )
+        })?;
+        let config_format = raw["config_format"].as_str().with_context(|| {
+            format!(
+                "migration snapshot {} is missing a string `config_format`",
+                path.display()
+            )
+        })?;
+        let version_bracket = raw["version_bracket"].as_str().with_context(|| {
+            format!(
+                "migration snapshot {} is missing a string `version_bracket`",
+                path.display()
+            )
+        })?;
+
+        Ok(MigrationHealthSnapshot {
+            target: PathBuf::from(target),
+            // `label`/`timestamp` are descriptive only and do not affect
+            // regression detection, so a missing value falls back benignly.
+            label: raw["label"].as_str().unwrap_or("unknown").to_string(),
+            timestamp: raw["timestamp"].as_str().unwrap_or("unknown").to_string(),
+            health_score,
+            deprecated_count,
+            modern_count,
+            config_format: config_format.to_string(),
+            version_bracket: version_bracket.to_string(),
+        })
+    }
+}
+
+/// Rank version brackets for comparison (higher = more modern)
+fn version_bracket_rank(bracket: &str) -> u8 {
+    match bracket {
+        "BuckleScript" => 1,
+        "V11" => 2,
+        "V12Alpha" => 3,
+        "V12Stable" => 4,
+        "V12Current" => 5,
+        "V13PreRelease" => 6,
+        _ => 0,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn make_snapshot(
+        health: f64,
+        deprecated: usize,
+        config: &str,
+        version: &str,
+    ) -> MigrationHealthSnapshot {
+        MigrationHealthSnapshot {
+            target: PathBuf::from("/tmp/test-repo"),
+            label: "test".to_string(),
+            timestamp: "2026-03-01T10:00:00Z".to_string(),
+            health_score: health,
+            deprecated_count: deprecated,
+            modern_count: 50,
+            config_format: config.to_string(),
+            version_bracket: version.to_string(),
+        }
+    }
+
+    #[test]
+    fn test_no_regression_on_improvement() {
+        let prev = make_snapshot(0.5, 40, "bsconfig", "V11");
+        let curr = make_snapshot(0.7, 20, "rescript_json", "V12Stable");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(regressions.is_empty());
+    }
+
+    #[test]
+    fn test_health_score_regression() {
+        let prev = make_snapshot(0.8, 10, "rescript_json", "V12Current");
+        let curr = make_snapshot(0.6, 10, "rescript_json", "V12Current");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(regressions
+            .iter()
+            .any(|r| r.reason == RegressionReason::HealthScoreDrop));
+    }
+
+    #[test]
+    fn test_deprecated_count_regression() {
+        let prev = make_snapshot(0.7, 10, "rescript_json", "V12Stable");
+        let curr = make_snapshot(0.7, 25, "rescript_json", "V12Stable");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(regressions
+            .iter()
+            .any(|r| r.reason == RegressionReason::DeprecatedCountIncrease));
+    }
+
+    #[test]
+    fn test_config_format_regression() {
+        let prev = make_snapshot(0.8, 5, "rescript_json", "V12Current");
+        let curr = make_snapshot(0.8, 5, "bsconfig", "V12Current");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(regressions
+            .iter()
+            .any(|r| r.reason == RegressionReason::ConfigFormatRevert));
+    }
+
+    #[test]
+    fn test_version_bracket_regression() {
+        let prev = make_snapshot(0.8, 5, "rescript_json", "V12Stable");
+        let curr = make_snapshot(0.8, 5, "rescript_json", "V11");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(regressions
+            .iter()
+            .any(|r| r.reason == RegressionReason::VersionBracketDowngrade));
+    }
+
+    #[test]
+    fn test_small_health_drop_not_flagged() {
+        // 3% drop should not trigger (threshold is 5%)
+        let prev = make_snapshot(0.8, 10, "rescript_json", "V12Current");
+        let curr = make_snapshot(0.77, 10, "rescript_json", "V12Current");
+        let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr);
+        assert!(!regressions
+            .iter()
+            .any(|r| r.reason == RegressionReason::HealthScoreDrop));
+    }
+
+    #[test]
+    fn test_migration_velocity() {
+        let snapshots = vec![
+            make_snapshot(0.3, 50, "bsconfig", "V11"),
+            make_snapshot(0.5, 30, "bsconfig", "V11"),
+            make_snapshot(0.7, 15, "rescript_json", "V12Stable"),
+            make_snapshot(0.85, 5, "rescript_json", "V12Current"),
+        ];
+        let velocity = MigrationHealthTracker::compute_velocity(&snapshots);
+        // (0.85 - 0.3) / 3 = 0.183...
+        assert!(velocity > 0.15);
+        assert!(velocity < 0.25);
+    }
+
+    #[test]
+    fn test_migration_velocity_single_snapshot() {
+        let snapshots = vec![make_snapshot(0.5, 20, "bsconfig", "V11")];
+        let velocity = MigrationHealthTracker::compute_velocity(&snapshots);
+        assert_eq!(velocity, 0.0);
+    }
+
+    #[test]
+    fn test_version_bracket_rank() {
+        assert!(version_bracket_rank("V12Current") > version_bracket_rank("V11"));
+        assert!(version_bracket_rank("V13PreRelease") > version_bracket_rank("V12Current"));
+        assert!(version_bracket_rank("BuckleScript") < version_bracket_rank("V11"));
+    }
+}
diff --git a/crates/oikosbot-analysis/src/patterns.rs b/crates/oikosbot-analysis/src/patterns.rs
new file mode 100644
index 0000000..d00bc5f
--- /dev/null
+++ b/crates/oikosbot-analysis/src/patterns.rs
@@ -0,0 +1,351 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Pattern detection for inefficient code
+
+/// A detected inefficiency pattern with metadata
+#[derive(Debug, Clone)]
+pub struct PatternMatch {
+    /// Machine-readable pattern name (e.g. "nested-loops")
+    pub name: String,
+    /// Human-readable description
+    pub description: String,
+    /// Concrete fix suggestion for SARIF output
+    pub suggestion: Option<String>,
+    /// Estimated energy impact multiplier (1.0 = normal, >1 = worse)
+    pub impact_multiplier: f64,
+}
+
+/// Detect problematic patterns in code
+pub fn detect_patterns(_source: &str, node: &tree_sitter::Node) -> Vec<PatternMatch> {
+    let mut patterns = Vec::new();
+
+    // Check nested loops
+    let loop_depth = count_loop_depth(node);
+    if loop_depth >= 3 {
+        patterns.push(PatternMatch {
+            name: "nested-loops".to_string(),
+            description: format!(
+                "Deeply nested loops (depth {}): O(n^{}) complexity",
+                loop_depth, loop_depth
+            ),
+            suggestion: Some(
+                "Consider algorithm optimization to reduce nested iterations. \
+                Use hash maps for lookups, sort + binary search, or restructure as flat iteration."
+                    .to_string(),
+            ),
+            impact_multiplier: loop_depth as f64,
+        });
+    }
+
+    // Check for busy-wait loops (loop/while with no sleep/await/yield)
+    if has_busy_wait(_source, node) {
+        patterns.push(PatternMatch {
+            name: "busy-wait".to_string(),
+            description: "Loop without sleep, await, or yield burns CPU continuously".to_string(),
+            suggestion: Some(
+                "Replace busy-wait with async/await, tokio::time::sleep, \
+                std::thread::sleep, or a channel recv."
+                    .to_string(),
+            ),
+            impact_multiplier: 5.0,
+        });
+    }
+
+    // Check for string concatenation in loops
+    if has_string_concat_in_loop(_source, node) {
+        patterns.push(PatternMatch {
+            name: "string-concat-in-loop".to_string(),
+            description: "String concatenation inside loop causes repeated allocation".to_string(),
+            suggestion: Some(
+                "Use String::with_capacity() and push_str(), or collect with \
+                iterators instead of concatenating in a loop."
+                    .to_string(),
+            ),
+            impact_multiplier: 2.0,
+        });
+    }
+
+    // Check for clone in loops
+    if has_clone_in_loop(_source, node) {
+        patterns.push(PatternMatch {
+            name: "clone-in-loop".to_string(),
+            description: ".clone() inside loop body causes repeated deep copies".to_string(),
+            suggestion: Some(
+                "Consider borrowing instead of cloning, or move the clone \
+                outside the loop if the value doesn't change per iteration."
+                    .to_string(),
+            ),
+            impact_multiplier: 1.5,
+        });
+    }
+
+    // Check for unbuffered I/O
+    if has_unbuffered_io(_source, node) {
+        patterns.push(PatternMatch {
+            name: "unbuffered-io".to_string(),
+            description: "Read/write without BufReader/BufWriter causes excessive syscalls"
+                .to_string(),
+            suggestion: Some(
+                "Wrap the reader/writer with BufReader/BufWriter for \
+                buffered I/O to reduce system call overhead."
+                    .to_string(),
+            ),
+            impact_multiplier: 3.0,
+        });
+    }
+
+    // Check for large allocations
+    if has_large_allocation(_source, node) {
+        patterns.push(PatternMatch {
+            name: "large-allocation".to_string(),
+            description: "Large heap allocation (>1MB) detected".to_string(),
+            suggestion: Some(
+                "Review memory allocation size. Consider streaming, \
+                chunked processing, or memory-mapped files for large data."
+                    .to_string(),
+            ),
+            impact_multiplier: 2.0,
+        });
+    }
+
+    // Check for redundant allocations (.to_string()/.to_owned() where borrow suffices)
+    if has_redundant_to_string(_source, node) {
+        patterns.push(PatternMatch {
+            name: "redundant-allocation".to_string(),
+            description: "Unnecessary .to_string()/.to_owned() where a borrow would suffice"
+                .to_string(),
+            suggestion: Some(
+                "Accept &str instead of String where possible to avoid \
+                unnecessary heap allocation."
+                    .to_string(),
+            ),
+            impact_multiplier: 1.2,
+        });
+    }
+
+    patterns
+}
+
+fn count_loop_depth(node: &tree_sitter::Node) -> usize {
+    let is_loop = matches!(
+        node.kind(),
+        "for_expression"
+            | "while_expression"
+            | "loop_expression"
+            | "for_statement"
+            | "while_statement"
+    );
+
+    let mut max_child_depth = 0;
+    let mut cursor = node.walk();
+
+    if cursor.goto_first_child() {
+        loop {
+            let child_depth = count_loop_depth(&cursor.node());
+            max_child_depth = max_child_depth.max(child_depth);
+
+            if !cursor.goto_next_sibling() {
+                break;
+            }
+        }
+    }
+
+    if is_loop {
+        1 + max_child_depth
+    } else {
+        max_child_depth
+    }
+}
+
+/// Check if a loop body contains no sleep/await/yield/recv — indicating a busy wait
+fn has_busy_wait(source: &str, node: &tree_sitter::Node) -> bool {
+    let is_loop = matches!(node.kind(), "loop_expression" | "while_expression");
+
+    if !is_loop {
+        // Recurse into children to find loops
+        let mut cursor = node.walk();
+        if cursor.goto_first_child() {
+            loop {
+                if has_busy_wait(source, &cursor.node()) {
+                    return true;
+                }
+                if !cursor.goto_next_sibling() {
+                    break;
+                }
+            }
+        }
+        return false;
+    }
+
+    // Found a loop — check if its body text mentions sleep/await/yield/recv
+    let text = match node.utf8_text(source.as_bytes()) {
+        Ok(t) => t,
+        Err(_) => return false,
+    };
+
+    // If the loop body contains any blocking/yielding call, it's not a busy wait
+    let has_yield = text.contains("sleep")
+        || text.contains("await")
+        || text.contains("yield")
+        || text.contains("recv")
+        || text.contains("park")
+        || text.contains("wait")
+        || text.contains(".await");
+
+    !has_yield
+}
+
+/// Check if any loop body contains string concatenation
+fn has_string_concat_in_loop(source: &str, node: &tree_sitter::Node) -> bool {
+    find_in_loop_body(
+        node,
+        |child, _src| {
+            // Look for binary_expression with "+" operator on strings
+            // or format! macro calls
+            child.kind() == "binary_expression" || child.kind() == "macro_invocation"
+        },
+        source,
+    )
+}
+
+/// Check if any loop body contains .clone() calls
+fn has_clone_in_loop(source: &str, node: &tree_sitter::Node) -> bool {
+    find_in_loop_body(
+        node,
+        |child, src| {
+            if child.kind() == "call_expression" || child.kind() == "method_call_expression" {
+                if let Ok(text) = child.utf8_text(src.as_bytes()) {
+                    return text.contains(".clone()");
+                }
+            }
+            false
+        },
+        source,
+    )
+}
+
+/// Check for File::open/create without BufReader/BufWriter nearby
+fn has_unbuffered_io(source: &str, node: &tree_sitter::Node) -> bool {
+    let text = match node.utf8_text(source.as_bytes()) {
+        Ok(t) => t,
+        Err(_) => return false,
+    };
+
+    let has_file_io = text.contains("File::open") || text.contains("File::create");
+    let has_buffering = text.contains("BufReader") || text.contains("BufWriter");
+
+    has_file_io && !has_buffering
+}
+
+/// Check for large allocations: Vec::with_capacity(>1_000_000) or vec![0; large]
+fn has_large_allocation(source: &str, node: &tree_sitter::Node) -> bool {
+    let text = match node.utf8_text(source.as_bytes()) {
+        Ok(t) => t,
+        Err(_) => return false,
+    };
+
+    // Simple heuristic: look for large numeric literals near allocation calls
+    if text.contains("with_capacity") || text.contains("vec![") {
+        // Check for numbers > 1_000_000
+        for word in text.split(|c: char| !c.is_ascii_digit() && c != '_') {
+            let clean: String = word.chars().filter(|c| c.is_ascii_digit()).collect();
+            if let Ok(n) = clean.parse::<u64>() {
+                if n > 1_000_000 {
+                    return true;
+                }
+            }
+        }
+    }
+    false
+}
+
+/// Check for .to_string() or .to_owned() calls that may be redundant
+fn has_redundant_to_string(source: &str, node: &tree_sitter::Node) -> bool {
+    let text = match node.utf8_text(source.as_bytes()) {
+        Ok(t) => t,
+        Err(_) => return false,
+    };
+
+    // Count occurrences as a heuristic — many .to_string() in one function is suspicious
+    let to_string_count = text.matches(".to_string()").count();
+    let to_owned_count = text.matches(".to_owned()").count();
+
+    (to_string_count + to_owned_count) >= 5
+}
+
+/// Find a pattern match inside loop bodies
+fn find_in_loop_body(
+    node: &tree_sitter::Node,
+    predicate: impl Fn(&tree_sitter::Node, &str) -> bool + Copy,
+    source: &str,
+) -> bool {
+    let is_loop = matches!(
+        node.kind(),
+        "for_expression"
+            | "while_expression"
+            | "loop_expression"
+            | "for_statement"
+            | "while_statement"
+    );
+
+    if is_loop {
+        // Search inside loop body for the pattern
+        return subtree_matches(node, predicate, source);
+    }
+
+    // Recurse to find loops
+    let mut cursor = node.walk();
+    if cursor.goto_first_child() {
+        loop {
+            if find_in_loop_body(&cursor.node(), predicate, source) {
+                return true;
+            }
+            if !cursor.goto_next_sibling() {
+                break;
+            }
+        }
+    }
+    false
+}
+
+/// Check if any node in the subtree matches the predicate
+fn subtree_matches(
+    node: &tree_sitter::Node,
+    predicate: impl Fn(&tree_sitter::Node, &str) -> bool + Copy,
+    source: &str,
+) -> bool {
+    if predicate(node, source) {
+        return true;
+    }
+    let mut cursor = node.walk();
+    if cursor.goto_first_child() {
+        loop {
+            if subtree_matches(&cursor.node(), predicate, source) {
+                return true;
+            }
+            if !cursor.goto_next_sibling() {
+                break;
+            }
+        }
+    }
+    false
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_pattern_match_fields() {
+        let pm = PatternMatch {
+            name: "test".to_string(),
+            description: "Test pattern".to_string(),
+            suggestion: Some("Fix it".to_string()),
+            impact_multiplier: 1.5,
+        };
+        assert_eq!(pm.name, "test");
+        assert!(pm.suggestion.is_some());
+        assert!(pm.impact_multiplier > 1.0);
+    }
+}
diff --git a/crates/oikosbot-analysis/src/security.rs b/crates/oikosbot-analysis/src/security.rs
new file mode 100644
index 0000000..a29d12c
--- /dev/null
+++ b/crates/oikosbot-analysis/src/security.rs
@@ -0,0 +1,166 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! Security-sustainability correlation engine.
+//!
+//! Maps panic-attack weak points to sustainability impact, producing
+//! findings that correlate security risk with ecological cost.
+//!
+//! This module is only available when the `panic-attack` feature is enabled.
+
+#[cfg(feature = "panic-attack")]
+mod inner {
+    use anyhow::Result;
+    use oikosbot_metrics::*;
+    use std::path::Path;
+
+    /// Security-sustainability correlation result
+    #[derive(Debug, Clone)]
+    pub struct SecurityCorrelation {
+        /// The original analysis results for the repo
+        pub eco_results: Vec<AnalysisResult>,
+        /// Security-derived sustainability findings
+        pub security_findings: Vec<AnalysisResult>,
+        /// Composite score (0-100) combining security and eco scores
+        pub composite_score: f64,
+    }
+
+    /// Run panic-attack scan and correlate with sustainability analysis.
+    pub fn correlate(
+        repo_path: &Path,
+        eco_results: &[AnalysisResult],
+    ) -> Result<SecurityCorrelation> {
+        // Run panic-attack scan
+        let report = panic_attack::xray::analyze(repo_path)?;
+
+        let mut security_findings = Vec::new();
+
+        for wp in &report.weak_points {
+            let (impact_desc, energy_multiplier) = match wp.category {
+                panic_attack::types::WeakPointCategory::UnsafeCode => (
+                    "Crash risk from unsafe code: all prior computation wasted on crash",
+                    3.0,
+                ),
+                panic_attack::types::WeakPointCategory::PanicPath => (
+                    "Panic/abort path: energy and carbon invested in computation becomes sunk cost",
+                    2.5,
+                ),
+                panic_attack::types::WeakPointCategory::UnboundedLoop => {
+                    ("Unbounded loop: potential CPU waste and carbon spike", 4.0)
+                }
+                panic_attack::types::WeakPointCategory::UncheckedAllocation => (
+                    "Unchecked allocation: memory waste from potential OOM or oversized alloc",
+                    2.0,
+                ),
+                panic_attack::types::WeakPointCategory::ResourceLeak => (
+                    "Resource leak: ongoing waste of handles, goroutines, or memory",
+                    3.5,
+                ),
+                panic_attack::types::WeakPointCategory::RaceCondition => (
+                    "Race condition: unpredictable resource usage, potential for wasted retries",
+                    2.0,
+                ),
+                panic_attack::types::WeakPointCategory::BlockingIO => (
+                    "Blocking I/O: thread held idle, wasting CPU time and energy",
+                    1.5,
+                ),
+                panic_attack::types::WeakPointCategory::DeadlockPotential => (
+                    "Deadlock potential: complete CPU waste when threads stall",
+                    4.0,
+                ),
+            };
+
+            let severity_str = format!("{}", wp.severity);
+
+            // Estimate energy impact based on severity and category
+            let base_energy = match wp.severity {
+                panic_attack::types::Severity::Critical => 100.0,
+                panic_attack::types::Severity::High => 50.0,
+                panic_attack::types::Severity::Medium => 20.0,
+                panic_attack::types::Severity::Low => 5.0,
+            };
+
+            let energy = Energy::joules(base_energy * energy_multiplier);
+            let carbon = crate::carbon::estimate_carbon(energy);
+
+            let file = wp
+                .location
+                .clone()
+                .unwrap_or_else(|| "<unknown>".to_string());
+
+            // Check if any eco result overlaps this location (boost severity)
+            let has_eco_overlap = eco_results.iter().any(|r| r.location.file == file);
+            let boost = if has_eco_overlap { 1.5 } else { 1.0 };
+
+            let eco_score = EcoScore::new((100.0 - (energy.0 * boost).ln() * 10.0).max(0.0));
+            let econ_score = EconScore::new(50.0); // neutral economic impact
+
+            let health = HealthIndex::compute(eco_score, econ_score, 50.0);
+
+            let finding = AnalysisResult {
+                location: CodeLocation {
+                    file,
+                    line: 0, // panic-attack doesn't provide line numbers in WeakPoint
+                    column: 0,
+                    end_line: None,
+                    end_column: None,
+                    name: Some(format!("{:?}", wp.category)),
+                },
+                resources: ResourceProfile {
+                    energy,
+                    duration: Duration::milliseconds(base_energy * 0.5),
+                    carbon,
+                    memory: Memory::kilobytes((base_energy * 10.0) as usize),
+                },
+                health,
+                recommendations: vec![
+                    format!("[{}] {}", severity_str, impact_desc),
+                    wp.description.clone(),
+                ],
+                rule_id: "oikosbot/security-sustainability".to_string(),
+                suggestion: Some(format!(
+                    "Address {:?} weak point to prevent {} energy waste",
+                    wp.category, impact_desc
+                )),
+                end_location: None,
+                confidence: Confidence::Estimated,
+            };
+
+            security_findings.push(finding);
+        }
+
+        // Calculate composite score
+        let all_eco: Vec<f64> = eco_results
+            .iter()
+            .chain(security_findings.iter())
+            .map(|r| r.health.eco_score.0)
+            .collect();
+
+        let composite_score = if all_eco.is_empty() {
+            100.0
+        } else {
+            all_eco.iter().sum::<f64>() / all_eco.len() as f64
+        };
+
+        Ok(SecurityCorrelation {
+            eco_results: eco_results.to_vec(),
+            security_findings,
+            composite_score,
+        })
+    }
+}
+
+#[cfg(feature = "panic-attack")]
+pub use inner::*;
+
+/// Stub types available even without the feature, for API compatibility
+#[cfg(not(feature = "panic-attack"))]
+pub mod stub {
+    /// Security correlation is unavailable without the `panic-attack` feature.
+    pub fn is_available() -> bool {
+        false
+    }
+}
+
+#[cfg(not(feature = "panic-attack"))]
+pub use stub::*;
diff --git a/crates/oikosbot-cli/Cargo.toml b/crates/oikosbot-cli/Cargo.toml
new file mode 100644
index 0000000..4c840be
--- /dev/null
+++ b/crates/oikosbot-cli/Cargo.toml
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "oikosbot-cli"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[[bin]]
+name = "oikosbot"
+path = "src/main.rs"
+
+[features]
+default = []
+security = ["oikosbot-analysis/panic-attack"]
+
+[dependencies]
+oikosbot-analysis = { path = "../oikosbot-analysis" }
+oikosbot-metrics = { path = "../oikosbot-metrics" }
+oikosbot-sarif = { path = "../oikosbot-sarif" }
+oikosbot-eclexia = { path = "../oikosbot-eclexia" }
+# NB: `oikosbot-fleet` (the gitbot-fleet bridge) is deliberately NOT a dependency
+# here. It is excluded from the default workspace so OikosBot stays standalone.
+# The `fleet` subcommand prints how to run the bridge; see crates/oikosbot-fleet.
+clap.workspace = true
+anyhow.workspace = true
+serde_json.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+walkdir = "2"
diff --git a/crates/oikosbot-cli/src/main.rs b/crates/oikosbot-cli/src/main.rs
new file mode 100644
index 0000000..a4a6958
--- /dev/null
+++ b/crates/oikosbot-cli/src/main.rs
@@ -0,0 +1,509 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! # OikosBot CLI
+//!
+//! Ecological and economic code analysis tool.
+//! Built with Eclexia principles - proving resource-aware design works.
+
+#![forbid(unsafe_code)]
+use anyhow::Result;
+use clap::{Parser, Subcommand};
+use oikosbot_analysis::analyze_file;
+use std::fs;
+use std::path::PathBuf;
+use tracing::info;
+use walkdir::WalkDir;
+
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+
+#[derive(Parser)]
+#[command(name = "oikosbot")]
+#[command(about = "Ecological & Economic Code Analysis", long_about = None)]
+#[command(version)]
+struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+
+    /// Enable verbose logging
+    #[arg(short, long, global = true)]
+    verbose: bool,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+    /// Analyze a single file
+    Analyze {
+        /// File to analyze
+        file: PathBuf,
+
+        /// Output format (text, json, sarif)
+        #[arg(short, long, default_value = "text")]
+        format: String,
+
+        /// Write output to file instead of stdout
+        #[arg(short, long)]
+        output: Option<PathBuf>,
+    },
+
+    /// Analyze a directory recursively
+    Check {
+        /// Directory to check
+        path: PathBuf,
+
+        /// Minimum eco score threshold (0-100)
+        #[arg(long, default_value = "50")]
+        eco_threshold: f64,
+
+        /// Output format (text, json, sarif)
+        #[arg(short, long, default_value = "text")]
+        format: String,
+
+        /// Write output to file instead of stdout
+        #[arg(short, long)]
+        output: Option<PathBuf>,
+
+        /// Include security-sustainability correlation (requires panic-attack feature)
+        #[arg(long)]
+        security: bool,
+
+        /// Directory containing Eclexia policy files (.ecl)
+        #[arg(long)]
+        policy_dir: Option<PathBuf>,
+    },
+
+    /// Generate a full report for a directory (alias for check with defaults)
+    Report {
+        /// Directory to analyze
+        path: PathBuf,
+
+        /// Output format (text, json, sarif)
+        #[arg(short, long, default_value = "sarif")]
+        format: String,
+
+        /// Write output to file instead of stdout
+        #[arg(short, long)]
+        output: Option<PathBuf>,
+
+        /// Minimum eco score threshold (0-100)
+        #[arg(long, default_value = "50")]
+        eco_threshold: f64,
+
+        /// Include security-sustainability correlation (requires panic-attack feature)
+        #[arg(long)]
+        security: bool,
+
+        /// Directory containing Eclexia policy files (.ecl)
+        #[arg(long)]
+        policy_dir: Option<PathBuf>,
+    },
+
+    /// Run as a gitbot-fleet member
+    Fleet {
+        /// Repository path to analyze
+        path: PathBuf,
+
+        /// Path to shared context JSON file
+        #[arg(short, long)]
+        context: Option<PathBuf>,
+    },
+
+    /// Show analysis of oikosbot itself (dogfooding!)
+    SelfAnalyze,
+}
+
+fn main() -> Result<()> {
+    let cli = Cli::parse();
+
+    // Set up logging
+    let log_level = if cli.verbose { "debug" } else { "info" };
+    tracing_subscriber::fmt().with_env_filter(log_level).init();
+
+    match cli.command {
+        Commands::Analyze {
+            file,
+            format,
+            output,
+        } => {
+            info!("Analyzing file: {}", file.display());
+            let results = analyze_file(&file)?;
+            emit_output(&results, &format, output.as_deref())?;
+        }
+
+        Commands::Check {
+            path,
+            eco_threshold,
+            format,
+            output,
+            security,
+            policy_dir,
+        }
+        | Commands::Report {
+            path,
+            format,
+            output,
+            eco_threshold,
+            security,
+            policy_dir,
+        } => {
+            info!("Checking directory: {}", path.display());
+
+            let mut all_results = collect_directory_results(&path)?;
+
+            // Security-sustainability correlation
+            if security {
+                run_security_correlation(&path, &mut all_results);
+            }
+
+            // Policy evaluation
+            if let Some(ref pdir) = policy_dir {
+                run_policy_evaluation(pdir, &mut all_results);
+            }
+
+            // Emit formatted output
+            match format.as_str() {
+                "sarif" | "json" => {
+                    emit_output(&all_results, &format, output.as_deref())?;
+                }
+                "text" => {
+                    println!(
+                        "Checking directory: {} (eco threshold: {})\n",
+                        path.display(),
+                        eco_threshold
+                    );
+
+                    let mut files_below_threshold = 0u32;
+                    for result in &all_results {
+                        if result.health.eco_score.0 < eco_threshold {
+                            files_below_threshold += 1;
+                            println!(
+                                "  BELOW THRESHOLD: {} :: {} (eco: {:.1}, threshold: {})",
+                                result.location.file,
+                                result.location.name.as_deref().unwrap_or("<anon>"),
+                                result.health.eco_score.0,
+                                eco_threshold
+                            );
+                        }
+                    }
+
+                    print_summary(&all_results, eco_threshold, files_below_threshold);
+
+                    if let Some(ref out_path) = output {
+                        // Also write text summary to file
+                        let text = format_results_text(&all_results);
+                        fs::write(out_path, text)?;
+                        println!("\nOutput written to: {}", out_path.display());
+                    }
+
+                    if files_below_threshold > 0 {
+                        std::process::exit(1);
+                    }
+                }
+                _ => {
+                    eprintln!("Unsupported format: {}", format);
+                }
+            }
+        }
+
+        Commands::Fleet { path, context } => {
+            // The gitbot-fleet bridge (`oikosbot-fleet`) is intentionally NOT a
+            // dependency of the standalone CLI — OikosBot and gitbot-fleet are
+            // separate projects. The bridge crate is excluded from the default
+            // workspace; build it explicitly when wiring OikosBot into the fleet.
+            let ctx = context
+                .as_deref()
+                .map(|p| p.display().to_string())
+                .unwrap_or_default();
+            eprintln!(
+                "Fleet integration lives in the optional `oikosbot-fleet` crate, which is\n\
+                 excluded from the default workspace so OikosBot builds standalone.\n\n\
+                 To run it (with hyperpolymath/gitbot-fleet checked out as a sibling):\n  \
+                 cargo run --manifest-path crates/oikosbot-fleet/Cargo.toml -- {} {}\n\n\
+                 See crates/oikosbot-fleet/README.md and DISAMBIGUATION.adoc.",
+                path.display(),
+                ctx,
+            );
+        }
+
+        Commands::SelfAnalyze => {
+            println!("OikosBot Self-Analysis (Dogfooding!)");
+            println!("==========================================\n");
+            println!("Analyzing oikosbot's own resource usage...\n");
+
+            let analyzer_src = PathBuf::from("crates/oikosbot-analysis/src/analyzer.rs");
+            if analyzer_src.exists() {
+                let results = analyze_file(&analyzer_src)?;
+                print_results_text(&results);
+
+                println!("\nMeta-Analysis:");
+                println!("This analyzer used minimal resources to analyze itself.");
+                println!("Eclexia-inspired design: explicit resource tracking from day 1.");
+            } else {
+                println!("Run from oikosbot repository root.");
+            }
+        }
+    }
+
+    Ok(())
+}
+
+/// Collect analysis results from all supported files in a directory
+fn collect_directory_results(
+    path: &std::path::Path,
+) -> Result<Vec<oikosbot_metrics::AnalysisResult>> {
+    let mut all_results = Vec::new();
+
+    for entry in WalkDir::new(path)
+        .follow_links(false)
+        .into_iter()
+        .filter_entry(|e| {
+            let name = e.file_name().to_str().unwrap_or("");
+            !matches!(
+                name,
+                "target" | "node_modules" | ".git" | "dist" | "build" | ".cache"
+            )
+        })
+        .filter_map(|e| e.ok())
+    {
+        let entry_path = entry.path();
+        if !entry_path.is_file() {
+            continue;
+        }
+
+        let ext = entry_path
+            .extension()
+            .and_then(|e| e.to_str())
+            .unwrap_or("");
+        if !matches!(ext, "rs" | "js" | "py") {
+            continue;
+        }
+
+        match analyze_file(entry_path) {
+            Ok(results) => {
+                all_results.extend(results);
+            }
+            Err(e) => {
+                info!("Skipping {}: {}", entry_path.display(), e);
+            }
+        }
+    }
+
+    Ok(all_results)
+}
+
+/// Emit analysis results in the requested format
+fn emit_output(
+    results: &[oikosbot_metrics::AnalysisResult],
+    format: &str,
+    output: Option<&std::path::Path>,
+) -> Result<()> {
+    let text = match format {
+        "sarif" => oikosbot_sarif::to_sarif_json(results, VERSION)?,
+        "json" => serde_json::to_string_pretty(results)?,
+        "text" => {
+            print_results_text(results);
+            return Ok(());
+        }
+        other => {
+            eprintln!("Unsupported format: {}", other);
+            return Ok(());
+        }
+    };
+
+    match output {
+        Some(path) => {
+            fs::write(path, &text)?;
+            eprintln!("Output written to: {}", path.display());
+        }
+        None => {
+            println!("{}", text);
+        }
+    }
+
+    Ok(())
+}
+
+fn print_summary(
+    all_results: &[oikosbot_metrics::AnalysisResult],
+    eco_threshold: f64,
+    files_below_threshold: u32,
+) {
+    let total_files = all_results
+        .iter()
+        .map(|r| r.location.file.as_str())
+        .collect::<std::collections::HashSet<_>>()
+        .len();
+
+    println!("\n--- Summary ---");
+    println!("Files analyzed:        {}", total_files);
+    println!("Functions found:       {}", all_results.len());
+    println!("Below threshold:       {}", files_below_threshold);
+
+    if !all_results.is_empty() {
+        let avg_eco: f64 = all_results
+            .iter()
+            .map(|r| r.health.eco_score.0)
+            .sum::<f64>()
+            / all_results.len() as f64;
+        let avg_overall: f64 =
+            all_results.iter().map(|r| r.health.overall).sum::<f64>() / all_results.len() as f64;
+        let total_energy: f64 = all_results.iter().map(|r| r.resources.energy.0).sum();
+        let total_carbon: f64 = all_results.iter().map(|r| r.resources.carbon.0).sum();
+
+        println!("Avg eco score:         {:.1}/100", avg_eco);
+        println!("Avg overall health:    {:.1}/100", avg_overall);
+        println!("Total est. energy:     {:.2} J", total_energy);
+        println!("Total est. carbon:     {:.4} gCO2e", total_carbon);
+    }
+
+    if files_below_threshold > 0 {
+        println!(
+            "\nResult: FAIL ({} functions below eco threshold {})",
+            files_below_threshold, eco_threshold
+        );
+    } else {
+        println!(
+            "\nResult: PASS (all functions meet eco threshold {})",
+            eco_threshold
+        );
+    }
+}
+
+fn print_results_text(results: &[oikosbot_metrics::AnalysisResult]) {
+    for result in results {
+        println!(
+            "\nFunction: {}",
+            result.location.name.as_deref().unwrap_or("<anonymous>")
+        );
+        println!(
+            "   Location: {}:{}:{}",
+            result.location.file, result.location.line, result.location.column
+        );
+        println!("\n   Resources:");
+        println!("     Energy:   {:.2} J", result.resources.energy.0);
+        println!("     Time:     {:.2} ms", result.resources.duration.0);
+        println!("     Carbon:   {:.4} gCO2e", result.resources.carbon.0);
+        println!("     Memory:   {} bytes", result.resources.memory.0);
+
+        println!("\n   Health Index:");
+        println!("     Eco:      {:.1}/100", result.health.eco_score.0);
+        println!("     Econ:     {:.1}/100", result.health.econ_score.0);
+        println!("     Quality:  {:.1}/100", result.health.quality_score);
+        println!("     Overall:  {:.1}/100", result.health.overall);
+
+        if !result.recommendations.is_empty() {
+            println!("\n   Recommendations:");
+            for rec in &result.recommendations {
+                println!("     - {}", rec);
+            }
+        }
+    }
+
+    println!("\nAnalysis complete");
+}
+
+/// Run Eclexia policy evaluation
+fn run_policy_evaluation(
+    policy_dir: &std::path::Path,
+    results: &mut Vec<oikosbot_metrics::AnalysisResult>,
+) {
+    match oikosbot_eclexia::evaluate_policies(policy_dir, results) {
+        Ok(decisions) => {
+            let warns = decisions
+                .iter()
+                .filter(|d| d.outcome != oikosbot_eclexia::PolicyOutcome::Pass)
+                .count();
+            eprintln!(
+                "Policy evaluation: {} policies, {} warnings/failures",
+                decisions.len(),
+                warns
+            );
+            for d in &decisions {
+                if d.outcome != oikosbot_eclexia::PolicyOutcome::Pass {
+                    eprintln!("  {:?}: {} - {}", d.outcome, d.policy_name, d.message);
+                }
+            }
+            // Convert policy decisions to analysis results for SARIF output
+            let policy_results = oikosbot_eclexia::decisions_to_results(&decisions);
+            results.extend(policy_results);
+        }
+        Err(e) => {
+            eprintln!("Policy evaluation failed: {}", e);
+        }
+    }
+}
+
+/// Run security-sustainability correlation if the feature is available
+fn run_security_correlation(
+    path: &std::path::Path,
+    _results: &mut Vec<oikosbot_metrics::AnalysisResult>,
+) {
+    // Check for .machine_readable/bot_directives/panic-attack.scm
+    let directive = oikosbot_analysis::directives::check_directive(path, "panic-attack");
+
+    match directive {
+        Some(ref d) if !d.allow => {
+            eprintln!(
+                "Security scan denied by .machine_readable/bot_directives/panic-attack.scm: {}",
+                d.notes.as_deref().unwrap_or("no reason given")
+            );
+            return;
+        }
+        None => {
+            eprintln!(
+                "Warning: No .machine_readable/bot_directives/panic-attack.scm found in {}. \
+                 Running security scan anyway.",
+                path.display()
+            );
+        }
+        _ => {}
+    }
+
+    #[cfg(feature = "security")]
+    {
+        match oikosbot_analysis::security::correlate(path, results) {
+            Ok(correlation) => {
+                eprintln!(
+                    "Security scan: {} findings, composite score: {:.1}",
+                    correlation.security_findings.len(),
+                    correlation.composite_score
+                );
+                results.extend(correlation.security_findings);
+            }
+            Err(e) => {
+                eprintln!("Security scan failed: {}", e);
+            }
+        }
+    }
+
+    #[cfg(not(feature = "security"))]
+    {
+        eprintln!("Security correlation unavailable: build with --features security");
+    }
+}
+
+fn format_results_text(results: &[oikosbot_metrics::AnalysisResult]) -> String {
+    let mut out = String::new();
+
+    for result in results {
+        out.push_str(&format!(
+            "\nFunction: {}\n",
+            result.location.name.as_deref().unwrap_or("<anonymous>")
+        ));
+        out.push_str(&format!(
+            "   Location: {}:{}:{}\n",
+            result.location.file, result.location.line, result.location.column
+        ));
+        out.push_str(&format!("   Energy: {:.2} J\n", result.resources.energy.0));
+        out.push_str(&format!(
+            "   Carbon: {:.4} gCO2e\n",
+            result.resources.carbon.0
+        ));
+        out.push_str(&format!(
+            "   Eco: {:.1}/100  Overall: {:.1}/100\n",
+            result.health.eco_score.0, result.health.overall
+        ));
+    }
+
+    out
+}
diff --git a/crates/oikosbot-eclexia/Cargo.toml b/crates/oikosbot-eclexia/Cargo.toml
new file mode 100644
index 0000000..24d41ee
--- /dev/null
+++ b/crates/oikosbot-eclexia/Cargo.toml
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "oikosbot-eclexia"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "Eclexia policy engine integration for oikosbot"
+
+[features]
+default = []
+# Native Eclexia compiler integration. The upstream `eclexia` compiler crates
+# (a separate hyperpolymath repo) were decoupled when OikosBot was extracted
+# from gitbot-fleet so this crate builds standalone; the feature is retained as
+# a no-op seam to be re-wired (path or git dep) when native evaluation is added.
+# Until then the crate ships its lightweight built-in `.ecl` policy evaluator.
+eclexia-native = []
+
+[dependencies]
+oikosbot-metrics = { path = "../oikosbot-metrics" }
+anyhow.workspace = true
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tracing.workspace = true
diff --git a/crates/oikosbot-eclexia/src/lib.rs b/crates/oikosbot-eclexia/src/lib.rs
new file mode 100644
index 0000000..79c54f1
--- /dev/null
+++ b/crates/oikosbot-eclexia/src/lib.rs
@@ -0,0 +1,529 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! # OikosBot-Eclexia Integration
+//!
+//! Policy engine integration with two backends:
+//! - **Default**: shells out to `eclexia` binary (works without eclexia repo on disk)
+//! - **Native** (`eclexia-native` feature): direct library integration via eclexia-interp
+//!
+//! Both backends implement the same `PolicyEngine` trait.
+
+#![forbid(unsafe_code)]
+use anyhow::{Context, Result};
+use oikosbot_metrics::{AnalysisResult, ResourceProfile};
+use std::path::Path;
+
+/// Policy evaluation outcome
+#[derive(Debug, Clone)]
+pub struct PolicyDecision {
+    /// The verdict
+    pub outcome: PolicyOutcome,
+    /// Human-readable explanation
+    pub message: String,
+    /// Suggestion for fixing a policy violation
+    pub suggestion: Option<String>,
+    /// Resource cost of evaluating this policy (dogfooding!)
+    pub evaluation_cost: Option<ResourceProfile>,
+    /// Name of the policy that produced this decision
+    pub policy_name: String,
+}
+
+/// Policy verdict
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PolicyOutcome {
+    /// Code meets all policy criteria
+    Pass,
+    /// Code has issues worth noting but not blocking
+    Warn,
+    /// Code violates a hard policy requirement
+    Fail,
+}
+
+/// Evaluate policies against analysis results.
+///
+/// Uses the binary backend by default, or native eclexia-interp
+/// when the `eclexia-native` feature is enabled.
+pub fn evaluate_policies(
+    policy_dir: &Path,
+    results: &[AnalysisResult],
+) -> Result<Vec<PolicyDecision>> {
+    let mut decisions = Vec::new();
+
+    // Find all .ecl files in the policy directory
+    if !policy_dir.exists() {
+        return Ok(decisions);
+    }
+
+    let entries = std::fs::read_dir(policy_dir)
+        .with_context(|| format!("Failed to read policy dir: {}", policy_dir.display()))?;
+
+    for entry in entries.flatten() {
+        let path = entry.path();
+        if path.extension().and_then(|e| e.to_str()) == Some("ecl") {
+            let policy_name = path
+                .file_stem()
+                .and_then(|s| s.to_str())
+                .unwrap_or("unknown")
+                .to_string();
+
+            match evaluate_single_policy(&path, results) {
+                Ok(decision) => decisions.push(decision),
+                Err(e) => {
+                    tracing::warn!("Policy {} failed: {}", policy_name, e);
+                    decisions.push(PolicyDecision {
+                        outcome: PolicyOutcome::Warn,
+                        message: format!("Policy evaluation error: {}", e),
+                        suggestion: None,
+                        evaluation_cost: None,
+                        policy_name,
+                    });
+                }
+            }
+        }
+    }
+
+    Ok(decisions)
+}
+
+/// Evaluate a single policy file against analysis results.
+fn evaluate_single_policy(
+    policy_path: &Path,
+    results: &[AnalysisResult],
+) -> Result<PolicyDecision> {
+    let policy_name = policy_path
+        .file_stem()
+        .and_then(|s| s.to_str())
+        .unwrap_or("unknown")
+        .to_string();
+
+    #[cfg(feature = "eclexia-native")]
+    {
+        evaluate_native(policy_path, results, &policy_name)
+    }
+
+    #[cfg(not(feature = "eclexia-native"))]
+    {
+        evaluate_binary(policy_path, results, &policy_name)
+    }
+}
+
+/// Binary backend: shells out to `eclexia` CLI
+#[cfg(not(feature = "eclexia-native"))]
+fn evaluate_binary(
+    policy_path: &Path,
+    results: &[AnalysisResult],
+    policy_name: &str,
+) -> Result<PolicyDecision> {
+    use std::process::Command;
+
+    // Serialize results summary as JSON input
+    let input = serde_json::json!({
+        "function_count": results.len(),
+        "total_energy": results.iter().map(|r| r.resources.energy.0).sum::<f64>(),
+        "total_carbon": results.iter().map(|r| r.resources.carbon.0).sum::<f64>(),
+        "avg_eco_score": if results.is_empty() { 100.0 } else {
+            results.iter().map(|r| r.health.eco_score.0).sum::<f64>() / results.len() as f64
+        },
+        "below_threshold_count": results.iter()
+            .filter(|r| r.health.eco_score.0 < 50.0)
+            .count(),
+    });
+
+    // Try to find eclexia binary
+    let eclexia = which_eclexia();
+
+    match eclexia {
+        Some(binary) => {
+            let output = Command::new(&binary)
+                .arg("run")
+                .arg(policy_path)
+                .arg("--input")
+                .arg(input.to_string())
+                .output()
+                .with_context(|| format!("Failed to execute eclexia at {}", binary))?;
+
+            if output.status.success() {
+                let stdout = String::from_utf8_lossy(&output.stdout);
+                let result: bool = serde_json::from_str(stdout.trim()).unwrap_or(false);
+
+                Ok(PolicyDecision {
+                    outcome: if result {
+                        PolicyOutcome::Warn
+                    } else {
+                        PolicyOutcome::Pass
+                    },
+                    message: format!("Policy '{}' evaluated via eclexia binary", policy_name),
+                    suggestion: None,
+                    evaluation_cost: Some(placeholder_cost()),
+                    policy_name: policy_name.to_string(),
+                })
+            } else {
+                let stderr = String::from_utf8_lossy(&output.stderr);
+                anyhow::bail!("Eclexia policy failed: {}", stderr);
+            }
+        }
+        None => {
+            // No eclexia binary found — use built-in policy evaluation
+            evaluate_builtin(results, policy_name)
+        }
+    }
+}
+
+/// Native backend: direct eclexia-interp library integration
+#[cfg(feature = "eclexia-native")]
+fn evaluate_native(
+    policy_path: &Path,
+    results: &[AnalysisResult],
+    policy_name: &str,
+) -> Result<PolicyDecision> {
+    let source = std::fs::read_to_string(policy_path)
+        .with_context(|| format!("Failed to read policy: {}", policy_path.display()))?;
+
+    let (ast, errors) = eclexia_parser::parse(&source);
+
+    if !errors.is_empty() {
+        let error_msgs: Vec<String> = errors.iter().map(|e| format!("{:?}", e)).collect();
+        anyhow::bail!(
+            "Policy parse errors in {}: {}",
+            policy_name,
+            error_msgs.join("; ")
+        );
+    }
+
+    // Run the policy through the interpreter
+    let mut interp = eclexia_interp::Interpreter::new();
+    // Set a tight budget for policy evaluation itself (dogfooding!)
+    interp.set_energy_budget(1.0); // 1 Joule max for policy eval
+    interp.set_carbon_budget(0.001); // 0.001g CO2e max
+
+    match eclexia_interp::run(&ast) {
+        Ok(value) => {
+            let should_warn = value.is_truthy();
+
+            Ok(PolicyDecision {
+                outcome: if should_warn {
+                    PolicyOutcome::Warn
+                } else {
+                    PolicyOutcome::Pass
+                },
+                message: format!(
+                    "Policy '{}' evaluated natively: result={:?}",
+                    policy_name, value
+                ),
+                suggestion: None,
+                evaluation_cost: Some(placeholder_cost()),
+                policy_name: policy_name.to_string(),
+            })
+        }
+        Err(e) => {
+            // Runtime error — treat as warning
+            Ok(PolicyDecision {
+                outcome: PolicyOutcome::Warn,
+                message: format!("Policy '{}' runtime error: {:?}", policy_name, e),
+                suggestion: Some("Check policy syntax and logic".to_string()),
+                evaluation_cost: None,
+                policy_name: policy_name.to_string(),
+            })
+        }
+    }
+}
+
+/// Built-in policy evaluation when eclexia binary is not available.
+///
+/// Implements the core policies in Rust as a fallback.
+fn evaluate_builtin(results: &[AnalysisResult], policy_name: &str) -> Result<PolicyDecision> {
+    let total_energy: f64 = results.iter().map(|r| r.resources.energy.0).sum();
+    let total_carbon: f64 = results.iter().map(|r| r.resources.carbon.0).sum();
+    let avg_eco = if results.is_empty() {
+        100.0
+    } else {
+        results.iter().map(|r| r.health.eco_score.0).sum::<f64>() / results.len() as f64
+    };
+
+    let (outcome, message, suggestion) = match policy_name {
+        "energy_threshold" => {
+            if total_energy > 1000.0 {
+                (
+                    PolicyOutcome::Fail,
+                    format!("Total energy {:.2}J exceeds 1000J budget", total_energy),
+                    Some("Optimize hot functions to reduce energy consumption".to_string()),
+                )
+            } else if total_energy > 500.0 {
+                (
+                    PolicyOutcome::Warn,
+                    format!("Total energy {:.2}J approaching 1000J budget", total_energy),
+                    Some("Consider optimizing highest-energy functions".to_string()),
+                )
+            } else {
+                (
+                    PolicyOutcome::Pass,
+                    format!("Total energy {:.2}J within budget", total_energy),
+                    None,
+                )
+            }
+        }
+        "carbon_budget" => {
+            if total_carbon > 1.0 {
+                (
+                    PolicyOutcome::Fail,
+                    format!(
+                        "Carbon footprint {:.4}gCO2e exceeds 1g budget",
+                        total_carbon
+                    ),
+                    Some("Reduce computation intensity to lower carbon emissions".to_string()),
+                )
+            } else {
+                (
+                    PolicyOutcome::Pass,
+                    format!("Carbon footprint {:.4}gCO2e within budget", total_carbon),
+                    None,
+                )
+            }
+        }
+        "memory_efficiency" => {
+            let large_allocs: Vec<_> = results
+                .iter()
+                .filter(|r| r.resources.memory.0 > 1_048_576) // >1MB
+                .collect();
+            if !large_allocs.is_empty() {
+                (
+                    PolicyOutcome::Warn,
+                    format!("{} functions have >1MB allocations", large_allocs.len()),
+                    Some(
+                        "Review large allocations; consider streaming or chunked processing"
+                            .to_string(),
+                    ),
+                )
+            } else {
+                (
+                    PolicyOutcome::Pass,
+                    "All allocations within bounds".to_string(),
+                    None,
+                )
+            }
+        }
+        _ => {
+            // Unknown policy — apply generic eco threshold check
+            if avg_eco < 50.0 {
+                (
+                    PolicyOutcome::Warn,
+                    format!("Average eco score {:.1} below 50.0 threshold", avg_eco),
+                    Some("Improve code efficiency in low-scoring functions".to_string()),
+                )
+            } else {
+                (
+                    PolicyOutcome::Pass,
+                    format!("Average eco score {:.1} meets threshold", avg_eco),
+                    None,
+                )
+            }
+        }
+    };
+
+    Ok(PolicyDecision {
+        outcome,
+        message,
+        suggestion,
+        evaluation_cost: Some(placeholder_cost()),
+        policy_name: format!("{} (builtin)", policy_name),
+    })
+}
+
+/// Try to find the eclexia binary
+fn which_eclexia() -> Option<String> {
+    // Check common locations
+    let candidates = [
+        "eclexia",
+        "~/.asdf/installs/rust/nightly/bin/eclexia",
+        "../eclexia/target/release/eclexia",
+    ];
+
+    for candidate in &candidates {
+        let expanded = shellexpand(candidate);
+        if std::path::Path::new(&expanded).exists() {
+            return Some(expanded);
+        }
+    }
+
+    // Try PATH
+    if std::process::Command::new("which")
+        .arg("eclexia")
+        .output()
+        .map(|o| o.status.success())
+        .unwrap_or(false)
+    {
+        return Some("eclexia".to_string());
+    }
+
+    None
+}
+
+fn shellexpand(path: &str) -> String {
+    if path.starts_with('~') {
+        if let Ok(home) = std::env::var("HOME") {
+            return path.replacen('~', &home, 1);
+        }
+    }
+    path.to_string()
+}
+
+fn placeholder_cost() -> ResourceProfile {
+    ResourceProfile {
+        energy: oikosbot_metrics::Energy::joules(0.05),
+        duration: oikosbot_metrics::Duration::milliseconds(1.0),
+        carbon: oikosbot_metrics::Carbon::grams_co2e(0.000007),
+        memory: oikosbot_metrics::Memory::kilobytes(50),
+    }
+}
+
+/// Example policy in Eclexia (to be written to policies/ directory)
+pub const EXAMPLE_POLICY: &str = r#"
+// SPDX-License-Identifier: MPL-2.0
+// Example OikosBot policy in Eclexia
+
+// This policy runs IN Eclexia, analyzing code's resource usage.
+// Meta-level: The analyzer itself has provable resource bounds!
+
+def should_warn_high_energy(energy_joules: Float) -> Bool {
+    energy_joules > 100.0
+}
+
+def should_warn_high_carbon(carbon_grams: Float) -> Bool {
+    carbon_grams > 10.0
+}
+
+def evaluate_policy(energy: Float, carbon: Float) -> Bool
+    @requires: energy < 1J, carbon < 0.001gCO2e  // Policy itself is cheap!
+{
+    should_warn_high_energy(energy) || should_warn_high_carbon(carbon)
+}
+"#;
+
+/// Convert policy decisions to analysis results for SARIF output
+pub fn decisions_to_results(decisions: &[PolicyDecision]) -> Vec<AnalysisResult> {
+    decisions
+        .iter()
+        .map(|d| {
+            let eco_score = match d.outcome {
+                PolicyOutcome::Pass => 90.0,
+                PolicyOutcome::Warn => 50.0,
+                PolicyOutcome::Fail => 20.0,
+            };
+
+            let cost = d.evaluation_cost.clone().unwrap_or(ResourceProfile {
+                energy: oikosbot_metrics::Energy::ZERO,
+                duration: oikosbot_metrics::Duration::ZERO,
+                carbon: oikosbot_metrics::Carbon::ZERO,
+                memory: oikosbot_metrics::Memory::ZERO,
+            });
+
+            AnalysisResult {
+                location: oikosbot_metrics::CodeLocation {
+                    file: "policies/".to_string(),
+                    line: 0,
+                    column: 0,
+                    end_line: None,
+                    end_column: None,
+                    name: Some(d.policy_name.clone()),
+                },
+                resources: cost,
+                health: oikosbot_metrics::HealthIndex::compute(
+                    oikosbot_metrics::EcoScore::new(eco_score),
+                    oikosbot_metrics::EconScore::new(80.0),
+                    70.0,
+                ),
+                recommendations: {
+                    let mut recs = vec![d.message.clone()];
+                    if let Some(ref s) = d.suggestion {
+                        recs.push(s.clone());
+                    }
+                    recs
+                },
+                rule_id: format!("oikosbot/policy-{}", d.policy_name),
+                suggestion: d.suggestion.clone(),
+                end_location: None,
+                confidence: oikosbot_metrics::Confidence::Estimated,
+            }
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_example_policy_syntax() {
+        assert!(EXAMPLE_POLICY.contains("def evaluate_policy"));
+        assert!(EXAMPLE_POLICY.contains("@requires"));
+    }
+
+    #[test]
+    fn test_builtin_energy_policy_pass() {
+        let results = vec![sample_result(10.0)];
+        let decision = evaluate_builtin(&results, "energy_threshold").unwrap();
+        assert_eq!(decision.outcome, PolicyOutcome::Pass);
+    }
+
+    #[test]
+    fn test_builtin_energy_policy_warn() {
+        // Create many results to exceed 500J total
+        let results: Vec<AnalysisResult> = (0..60).map(|_| sample_result(10.0)).collect();
+        let decision = evaluate_builtin(&results, "energy_threshold").unwrap();
+        assert_eq!(decision.outcome, PolicyOutcome::Warn);
+    }
+
+    #[test]
+    fn test_builtin_energy_policy_fail() {
+        // Exceed 1000J
+        let results: Vec<AnalysisResult> = (0..200).map(|_| sample_result(10.0)).collect();
+        let decision = evaluate_builtin(&results, "energy_threshold").unwrap();
+        assert_eq!(decision.outcome, PolicyOutcome::Fail);
+    }
+
+    #[test]
+    fn test_decisions_to_results() {
+        let decisions = vec![PolicyDecision {
+            outcome: PolicyOutcome::Warn,
+            message: "Test warning".to_string(),
+            suggestion: Some("Fix it".to_string()),
+            evaluation_cost: None,
+            policy_name: "test".to_string(),
+        }];
+
+        let results = decisions_to_results(&decisions);
+        assert_eq!(results.len(), 1);
+        assert_eq!(results[0].rule_id, "oikosbot/policy-test");
+        assert!(results[0].suggestion.is_some());
+    }
+
+    fn sample_result(energy_j: f64) -> AnalysisResult {
+        AnalysisResult {
+            location: oikosbot_metrics::CodeLocation {
+                file: "test.rs".to_string(),
+                line: 1,
+                column: 1,
+                end_line: None,
+                end_column: None,
+                name: Some("test_fn".to_string()),
+            },
+            resources: ResourceProfile {
+                energy: oikosbot_metrics::Energy::joules(energy_j),
+                duration: oikosbot_metrics::Duration::milliseconds(5.0),
+                carbon: oikosbot_metrics::Carbon::grams_co2e(0.001),
+                memory: oikosbot_metrics::Memory::kilobytes(100),
+            },
+            health: oikosbot_metrics::HealthIndex::compute(
+                oikosbot_metrics::EcoScore::new(80.0),
+                oikosbot_metrics::EconScore::new(70.0),
+                75.0,
+            ),
+            recommendations: vec![],
+            rule_id: "oikosbot/general".to_string(),
+            suggestion: None,
+            end_location: None,
+            confidence: oikosbot_metrics::Confidence::Estimated,
+        }
+    }
+}
diff --git a/crates/oikosbot-fleet/Cargo.toml b/crates/oikosbot-fleet/Cargo.toml
new file mode 100644
index 0000000..8254fb4
--- /dev/null
+++ b/crates/oikosbot-fleet/Cargo.toml
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+# OPTIONAL gitbot-fleet bridge — excluded from the default OikosBot workspace.
+#
+# This crate is the ONLY part of OikosBot that depends on
+# `hyperpolymath/gitbot-fleet`. It is intentionally kept out of the default
+# workspace (see ../../Cargo.toml `exclude`) so that OikosBot builds with no
+# coupling to the fleet. Build it explicitly, with gitbot-fleet checked out as
+# a sibling of this repository:
+#
+#     cargo build --manifest-path crates/oikosbot-fleet/Cargo.toml
+#
+# It currently publishes findings under the fleet's existing `BotId::Sustainabot`
+# slot; a dedicated `BotId::Oikosbot` upstream in gitbot-shared-context is the
+# follow-up. See README.md in this directory and DISAMBIGUATION.adoc.
+
+# NB: concrete package metadata (not `*.workspace = true`) because this crate is
+# EXCLUDED from the root workspace and must resolve standalone.
+[package]
+name = "oikosbot-fleet"
+version = "0.1.0"
+authors = ["Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"]
+edition = "2021"
+license = "MPL-2.0"
+repository = "https://github.com/hyperpolymath/oikosbot"
+
+[dependencies]
+gitbot-shared-context = { path = "../../../gitbot-fleet/shared-context" }
+oikosbot-analysis = { path = "../oikosbot-analysis" }
+oikosbot-metrics = { path = "../oikosbot-metrics" }
+anyhow = "1"
+serde_json = "1"
diff --git a/crates/oikosbot-fleet/README.md b/crates/oikosbot-fleet/README.md
new file mode 100644
index 0000000..6598e41
--- /dev/null
+++ b/crates/oikosbot-fleet/README.md
@@ -0,0 +1,46 @@
+<!--
+SPDX-License-Identifier: MPL-2.0
+SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
+-->
+# `oikosbot-fleet` — optional gitbot-fleet bridge
+
+This crate is the **only** part of OikosBot that knows about
+[`hyperpolymath/gitbot-fleet`](https://github.com/hyperpolymath/gitbot-fleet).
+It converts OikosBot's `AnalysisResult`s into the fleet's shared-context
+`Finding`s so OikosBot can run as a fleet member.
+
+## Why it is excluded from the default workspace
+
+OikosBot and gitbot-fleet are **separate projects** (see
+[`../../DISAMBIGUATION.adoc`](../../DISAMBIGUATION.adoc)). To keep that boundary
+honest, the root `Cargo.toml` lists this crate under `exclude`, **not**
+`members`. As a result:
+
+- `cargo build` / `cargo test` at the repo root build OikosBot with **zero**
+  dependency on gitbot-fleet.
+- This bridge is compiled only when you ask for it explicitly.
+
+## Building / running the bridge
+
+The bridge has a `path` dependency on `gitbot-fleet/shared-context`, so check
+out gitbot-fleet as a **sibling** of this repository:
+
+```text
+parent/
+├── oikosbot/        # this repo
+└── gitbot-fleet/    # hyperpolymath/gitbot-fleet
+```
+
+Then:
+
+```bash
+cargo build --manifest-path crates/oikosbot-fleet/Cargo.toml
+cargo run   --manifest-path crates/oikosbot-fleet/Cargo.toml -- <repo-path> [--context ctx.json]
+```
+
+## Known follow-up
+
+The bridge currently publishes under the fleet's existing `BotId::Sustainabot`
+slot. A dedicated `BotId::Oikosbot` in `gitbot-shared-context` is the intended
+follow-up so the fleet can distinguish OikosBot from the (separate, reserved)
+`sustainabot` slot.
diff --git a/crates/oikosbot-fleet/src/lib.rs b/crates/oikosbot-fleet/src/lib.rs
new file mode 100644
index 0000000..0c822cc
--- /dev/null
+++ b/crates/oikosbot-fleet/src/lib.rs
@@ -0,0 +1,437 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell
+
+//! Gitbot-fleet bridge for OikosBot (optional; excluded from the default workspace).
+//!
+//! Publishes ecological and economic analysis findings to the shared context
+//! layer for consumption by other bots in the fleet.
+
+#![forbid(unsafe_code)]
+use anyhow::Result;
+use gitbot_shared_context::{BotId, Context, Finding, Severity};
+use oikosbot_analysis::directives;
+use oikosbot_metrics::AnalysisResult;
+use std::path::{Path, PathBuf};
+
+/// Ecological thresholds for reporting
+pub struct EcologicalThresholds {
+    /// Total energy threshold in kilojoules
+    pub total_energy_threshold_kj: f64,
+    /// Total carbon threshold in grams
+    pub total_carbon_threshold_grams: f64,
+    /// Per-function energy threshold in joules
+    pub energy_per_function_joules: f64,
+}
+
+impl Default for EcologicalThresholds {
+    fn default() -> Self {
+        Self {
+            total_energy_threshold_kj: 10.0,
+            total_carbon_threshold_grams: 2.0,
+            energy_per_function_joules: 100.0,
+        }
+    }
+}
+
+/// Publish sustainabot analysis findings to the fleet shared context.
+///
+/// This is the primary integration point: converts `AnalysisResult` items
+/// from `sustainabot-metrics` into `Finding` objects for the fleet.
+pub fn publish_findings(
+    ctx: &mut Context,
+    results: &[AnalysisResult],
+    thresholds: &EcologicalThresholds,
+) -> Result<()> {
+    let mut total_energy_j = 0.0f64;
+    let mut total_carbon_g = 0.0f64;
+    let mut high_impact_functions = Vec::new();
+
+    for result in results {
+        total_energy_j += result.resources.energy.0;
+        total_carbon_g += result.resources.carbon.0;
+
+        // Flag high-impact functions
+        if result.resources.energy.0 > thresholds.energy_per_function_joules {
+            high_impact_functions.push((
+                result
+                    .location
+                    .name
+                    .clone()
+                    .unwrap_or_else(|| "<anon>".to_string()),
+                result.resources.energy.0,
+            ));
+        }
+
+        // Convert each result to a rich Finding
+        let finding = convert_to_finding(result);
+        ctx.add_finding(finding);
+    }
+
+    // Report overall resource usage
+    let total_energy_kj = total_energy_j / 1000.0;
+    if total_energy_kj > thresholds.total_energy_threshold_kj {
+        ctx.add_finding(
+            Finding::new(
+                BotId::Sustainabot,
+                "SUSTAIN-HIGH-ENERGY",
+                Severity::Warning,
+                &format!(
+                    "High total energy consumption: {:.2} kJ (threshold: {:.2} kJ)",
+                    total_energy_kj, thresholds.total_energy_threshold_kj
+                ),
+            )
+            .with_category("sustainability"),
+        );
+    }
+
+    if total_carbon_g > thresholds.total_carbon_threshold_grams {
+        ctx.add_finding(
+            Finding::new(
+                BotId::Sustainabot,
+                "SUSTAIN-HIGH-CARBON",
+                Severity::Warning,
+                &format!(
+                    "High carbon footprint: {:.4}g CO2e (threshold: {:.2}g)",
+                    total_carbon_g, thresholds.total_carbon_threshold_grams
+                ),
+            )
+            .with_category("sustainability"),
+        );
+    }
+
+    // Report high-impact functions
+    if !high_impact_functions.is_empty() {
+        let function_list = high_impact_functions
+            .iter()
+            .map(|(name, energy)| format!("{} ({:.2}J)", name, energy))
+            .collect::<Vec<_>>()
+            .join(", ");
+
+        ctx.add_finding(
+            Finding::new(
+                BotId::Sustainabot,
+                "SUSTAIN-HIGH-IMPACT-FUNCTIONS",
+                Severity::Info,
+                &format!(
+                    "{} function(s) exceed per-function energy threshold: {}",
+                    high_impact_functions.len(),
+                    function_list
+                ),
+            )
+            .with_category("sustainability"),
+        );
+    }
+
+    // Efficiency rating
+    let rating = calculate_efficiency_rating(results);
+    ctx.add_finding(
+        Finding::new(
+            BotId::Sustainabot,
+            "SUSTAIN-EFFICIENCY-RATING",
+            Severity::Info,
+            &format!("Ecological efficiency rating: {}", rating),
+        )
+        .with_category("sustainability")
+        .with_metadata(serde_json::json!({
+            "rating": rating,
+            "total_energy_kj": total_energy_kj,
+            "total_carbon_g": total_carbon_g,
+            "functions_analyzed": results.len(),
+        })),
+    );
+
+    Ok(())
+}
+
+/// Convert a sustainabot AnalysisResult to a gitbot-fleet Finding
+/// using ALL builder fields for rich integration.
+fn convert_to_finding(result: &AnalysisResult) -> Finding {
+    let func_name = result.location.name.as_deref().unwrap_or("<anonymous>");
+
+    let severity = if result.health.eco_score.0 < 30.0 {
+        Severity::Error
+    } else if result.health.eco_score.0 < 60.0 {
+        Severity::Warning
+    } else if result.health.eco_score.0 < 80.0 {
+        Severity::Info
+    } else {
+        Severity::Suggestion
+    };
+
+    let message = format!(
+        "{}: eco={:.0}/100 energy={:.2}J carbon={:.4}gCO2e. {}",
+        func_name,
+        result.health.eco_score.0,
+        result.resources.energy.0,
+        result.resources.carbon.0,
+        result.recommendations.join("; "),
+    );
+
+    let mut finding = Finding::new(BotId::Sustainabot, &result.rule_id, severity, &message)
+        .with_rule_name(&format!("Sustainability: {}", result.rule_id))
+        .with_category("sustainability")
+        .with_file(PathBuf::from(&result.location.file))
+        .with_location(result.location.line, result.location.column)
+        .with_metadata(serde_json::json!({
+            "eco_score": result.health.eco_score.0,
+            "econ_score": result.health.econ_score.0,
+            "quality_score": result.health.quality_score,
+            "overall_health": result.health.overall,
+            "energy_joules": result.resources.energy.0,
+            "carbon_gco2e": result.resources.carbon.0,
+            "duration_ms": result.resources.duration.0,
+            "memory_bytes": result.resources.memory.0,
+            "confidence": format!("{:?}", result.confidence),
+        }));
+
+    if let Some(ref suggestion) = result.suggestion {
+        finding = finding.with_suggestion(suggestion);
+    }
+
+    // Mark as fixable if there's a concrete suggestion
+    if result.suggestion.is_some() {
+        finding = finding.fixable();
+    }
+
+    finding
+}
+
+/// Run sustainabot as a fleet member with directive awareness.
+///
+/// Reads `.machine_readable/bot_directives/sustainabot.scm` (legacy fallback
+/// supported) to determine allowed scopes,
+/// then runs analysis respecting the directive.
+pub fn run_fleet_analysis(repo_path: &Path, context_path: Option<&Path>) -> Result<()> {
+    // Check for sustainabot directive
+    let directive = directives::check_directive(repo_path, "sustainabot");
+
+    if let Some(ref d) = directive {
+        if !d.allow {
+            eprintln!(
+                "Sustainabot denied by .machine_readable/bot_directives/sustainabot.scm: {}",
+                d.notes.as_deref().unwrap_or("no reason given")
+            );
+            return Ok(());
+        }
+    }
+
+    // Collect analysis results
+    let results = collect_results(repo_path)?;
+
+    // Build thresholds from directive if available
+    let thresholds = if let Some(ref d) = directive {
+        let mut t = EcologicalThresholds::default();
+        for (key, val) in &d.thresholds {
+            match key.as_str() {
+                "energy" => t.energy_per_function_joules = *val,
+                "carbon" => t.total_carbon_threshold_grams = *val,
+                _ => {}
+            }
+        }
+        t
+    } else {
+        EcologicalThresholds::default()
+    };
+
+    // If context file provided, publish to shared context
+    if let Some(ctx_path) = context_path {
+        let content = std::fs::read_to_string(ctx_path)?;
+        let mut ctx: Context = serde_json::from_str(&content)?;
+
+        ctx.start_bot(BotId::Sustainabot)?;
+        publish_findings(&mut ctx, &results, &thresholds)?;
+        ctx.complete_bot(BotId::Sustainabot, results.len(), 0, 0)?;
+
+        let output = serde_json::to_string_pretty(&ctx)?;
+        std::fs::write(ctx_path, output)?;
+    } else {
+        // Standalone mode: just print findings
+        let thresholds = EcologicalThresholds::default();
+        let rating = calculate_efficiency_rating(&results);
+
+        eprintln!("Sustainabot fleet analysis: {} functions", results.len());
+        eprintln!("Efficiency rating: {}", rating);
+
+        let below: Vec<_> = results
+            .iter()
+            .filter(|r| r.health.eco_score.0 < 60.0)
+            .collect();
+
+        if !below.is_empty() {
+            eprintln!("{} functions below eco threshold:", below.len());
+            for r in &below {
+                eprintln!(
+                    "  {} (eco: {:.0}, energy: {:.2}J)",
+                    r.location.name.as_deref().unwrap_or("<anon>"),
+                    r.health.eco_score.0,
+                    r.resources.energy.0,
+                );
+            }
+        }
+
+        let total_energy: f64 = results.iter().map(|r| r.resources.energy.0).sum();
+        if total_energy / 1000.0 > thresholds.total_energy_threshold_kj {
+            eprintln!(
+                "WARNING: Total energy {:.2} kJ exceeds threshold {:.2} kJ",
+                total_energy / 1000.0,
+                thresholds.total_energy_threshold_kj
+            );
+        }
+    }
+
+    Ok(())
+}
+
+fn collect_results(repo_path: &Path) -> Result<Vec<AnalysisResult>> {
+    let mut results = Vec::new();
+
+    for entry in walkdir(repo_path) {
+        match oikosbot_analysis::analyze_file(&entry) {
+            Ok(file_results) => results.extend(file_results),
+            Err(_) => continue,
+        }
+    }
+
+    Ok(results)
+}
+
+/// Simple directory walker for supported files
+fn walkdir(path: &Path) -> Vec<PathBuf> {
+    let mut files = Vec::new();
+    walk_recursive(path, &mut files);
+    files
+}
+
+fn walk_recursive(path: &Path, files: &mut Vec<PathBuf>) {
+    let entries = match std::fs::read_dir(path) {
+        Ok(e) => e,
+        Err(_) => return,
+    };
+
+    for entry in entries.flatten() {
+        let p = entry.path();
+        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
+
+        // Skip common non-source directories
+        if p.is_dir() {
+            if !matches!(
+                name,
+                "target" | "node_modules" | ".git" | "dist" | "build" | ".cache" | "__pycache__"
+            ) {
+                walk_recursive(&p, files);
+            }
+            continue;
+        }
+
+        // Only include supported source files
+        if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
+            if matches!(ext, "rs" | "js" | "py") {
+                files.push(p);
+            }
+        }
+    }
+}
+
+/// Calculate efficiency rating (A-F scale)
+fn calculate_efficiency_rating(results: &[AnalysisResult]) -> String {
+    if results.is_empty() {
+        return "N/A".to_string();
+    }
+
+    let avg_energy: f64 =
+        results.iter().map(|r| r.resources.energy.0).sum::<f64>() / results.len() as f64;
+
+    if avg_energy < 10.0 {
+        "A (Excellent)".to_string()
+    } else if avg_energy < 50.0 {
+        "B (Good)".to_string()
+    } else if avg_energy < 100.0 {
+        "C (Average)".to_string()
+    } else if avg_energy < 200.0 {
+        "D (Below Average)".to_string()
+    } else if avg_energy < 500.0 {
+        "E (Poor)".to_string()
+    } else {
+        "F (Very Poor)".to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use oikosbot_metrics::*;
+
+    fn sample_result(energy_j: f64) -> AnalysisResult {
+        AnalysisResult {
+            location: CodeLocation {
+                file: "test.rs".to_string(),
+                line: 1,
+                column: 1,
+                end_line: Some(10),
+                end_column: Some(2),
+                name: Some("test_fn".to_string()),
+            },
+            resources: ResourceProfile {
+                energy: Energy::joules(energy_j),
+                duration: Duration::milliseconds(energy_j * 0.5),
+                carbon: Carbon::grams_co2e(energy_j * 0.0001),
+                memory: Memory::kilobytes(100),
+            },
+            health: HealthIndex::compute(EcoScore::new(80.0), EconScore::new(70.0), 75.0),
+            recommendations: vec!["Code looks efficient".to_string()],
+            rule_id: "sustainabot/general".to_string(),
+            suggestion: None,
+            end_location: Some((10, 2)),
+            confidence: Confidence::Estimated,
+        }
+    }
+
+    #[test]
+    fn test_efficiency_rating() {
+        let results = vec![sample_result(5.0)];
+        let rating = calculate_efficiency_rating(&results);
+        assert!(rating.starts_with('A'));
+    }
+
+    #[test]
+    fn test_convert_to_finding() {
+        let result = sample_result(5.0);
+        let finding = convert_to_finding(&result);
+
+        assert_eq!(finding.source, BotId::Sustainabot);
+        assert_eq!(finding.rule_id, "sustainabot/general");
+        assert_eq!(finding.category, "sustainability");
+        assert!(finding.file.is_some());
+        assert!(finding.line.is_some());
+    }
+
+    #[test]
+    fn test_convert_finding_with_suggestion() {
+        let mut result = sample_result(5.0);
+        result.suggestion = Some("Use hash map for O(1) lookup".to_string());
+        result.rule_id = "sustainabot/nested-loops".to_string();
+
+        let finding = convert_to_finding(&result);
+        assert!(finding.fixable);
+        assert!(finding.suggestion.is_some());
+    }
+
+    #[test]
+    fn test_severity_mapping() {
+        // Low eco score → Error
+        let mut result = sample_result(5.0);
+        result.health = HealthIndex::compute(EcoScore::new(20.0), EconScore::new(70.0), 75.0);
+        let finding = convert_to_finding(&result);
+        assert_eq!(finding.severity, Severity::Error);
+
+        // Medium eco score → Warning
+        result.health = HealthIndex::compute(EcoScore::new(50.0), EconScore::new(70.0), 75.0);
+        let finding = convert_to_finding(&result);
+        assert_eq!(finding.severity, Severity::Warning);
+
+        // High eco score → Info
+        result.health = HealthIndex::compute(EcoScore::new(75.0), EconScore::new(70.0), 75.0);
+        let finding = convert_to_finding(&result);
+        assert_eq!(finding.severity, Severity::Info);
+    }
+}
diff --git a/crates/oikosbot-metrics/Cargo.toml b/crates/oikosbot-metrics/Cargo.toml
new file mode 100644
index 0000000..bd3543e
--- /dev/null
+++ b/crates/oikosbot-metrics/Cargo.toml
@@ -0,0 +1,15 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "oikosbot-metrics"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
diff --git a/crates/oikosbot-metrics/src/lib.rs b/crates/oikosbot-metrics/src/lib.rs
new file mode 100644
index 0000000..5d3d17b
--- /dev/null
+++ b/crates/oikosbot-metrics/src/lib.rs
@@ -0,0 +1,320 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! # OikosBot Metrics
+//!
+//! Core data types for ecological and economic code analysis.
+//! Inspired by Eclexia's resource-aware design principles.
+
+#![forbid(unsafe_code)]
+use serde::{Deserialize, Serialize};
+use std::ops::{Add, Mul};
+
+/// Energy measurement in Joules
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct Energy(pub f64);
+
+impl Energy {
+    pub const ZERO: Self = Energy(0.0);
+
+    pub fn joules(j: f64) -> Self {
+        Energy(j)
+    }
+
+    pub fn kilojoules(kj: f64) -> Self {
+        Energy(kj * 1000.0)
+    }
+}
+
+impl Add for Energy {
+    type Output = Self;
+    fn add(self, rhs: Self) -> Self::Output {
+        Energy(self.0 + rhs.0)
+    }
+}
+
+impl Mul<f64> for Energy {
+    type Output = Self;
+    fn mul(self, rhs: f64) -> Self::Output {
+        Energy(self.0 * rhs)
+    }
+}
+
+/// Time duration in milliseconds
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct Duration(pub f64);
+
+impl Duration {
+    pub const ZERO: Self = Duration(0.0);
+
+    pub fn milliseconds(ms: f64) -> Self {
+        Duration(ms)
+    }
+
+    pub fn seconds(s: f64) -> Self {
+        Duration(s * 1000.0)
+    }
+}
+
+impl Add for Duration {
+    type Output = Self;
+    fn add(self, rhs: Self) -> Self::Output {
+        Duration(self.0 + rhs.0)
+    }
+}
+
+/// Carbon emissions in grams of CO2 equivalent
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct Carbon(pub f64);
+
+impl Carbon {
+    pub const ZERO: Self = Carbon(0.0);
+
+    pub fn grams_co2e(g: f64) -> Self {
+        Carbon(g)
+    }
+
+    pub fn kilograms_co2e(kg: f64) -> Self {
+        Carbon(kg * 1000.0)
+    }
+}
+
+impl Add for Carbon {
+    type Output = Self;
+    fn add(self, rhs: Self) -> Self::Output {
+        Carbon(self.0 + rhs.0)
+    }
+}
+
+impl Mul<f64> for Carbon {
+    type Output = Self;
+    fn mul(self, rhs: f64) -> Self::Output {
+        Carbon(self.0 * rhs)
+    }
+}
+
+/// Memory usage in bytes
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct Memory(pub usize);
+
+impl Memory {
+    pub const ZERO: Self = Memory(0);
+
+    pub fn bytes(b: usize) -> Self {
+        Memory(b)
+    }
+
+    pub fn kilobytes(kb: usize) -> Self {
+        Memory(kb * 1024)
+    }
+
+    pub fn megabytes(mb: usize) -> Self {
+        Memory(mb * 1024 * 1024)
+    }
+}
+
+impl Add for Memory {
+    type Output = Self;
+    fn add(self, rhs: Self) -> Self::Output {
+        Memory(self.0 + rhs.0)
+    }
+}
+
+/// Complete resource profile for a code unit
+///
+/// This is inspired by Eclexia's `@provides` annotations but tracked
+/// at runtime during analysis rather than compile-time.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ResourceProfile {
+    pub energy: Energy,
+    pub duration: Duration,
+    pub carbon: Carbon,
+    pub memory: Memory,
+}
+
+impl ResourceProfile {
+    pub fn zero() -> Self {
+        ResourceProfile {
+            energy: Energy::ZERO,
+            duration: Duration::ZERO,
+            carbon: Carbon::ZERO,
+            memory: Memory::ZERO,
+        }
+    }
+
+    /// Calculate cost using shadow prices (Eclexia-inspired)
+    ///
+    /// Cost = λ_energy * energy + λ_time * time + λ_carbon * carbon
+    pub fn cost(&self, shadow_prices: &ShadowPrices) -> f64 {
+        shadow_prices.energy * self.energy.0
+            + shadow_prices.time * self.duration.0
+            + shadow_prices.carbon * self.carbon.0
+    }
+}
+
+impl Add for ResourceProfile {
+    type Output = Self;
+    fn add(self, rhs: Self) -> Self::Output {
+        ResourceProfile {
+            energy: self.energy + rhs.energy,
+            duration: self.duration + rhs.duration,
+            carbon: self.carbon + rhs.carbon,
+            memory: self.memory + rhs.memory,
+        }
+    }
+}
+
+/// Shadow prices for resources (economic optimization)
+///
+/// These represent the marginal value of each resource, guiding
+/// trade-off decisions. Inspired by Eclexia's shadow price system.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ShadowPrices {
+    /// Value per Joule of energy
+    pub energy: f64,
+    /// Value per millisecond of time
+    pub time: f64,
+    /// Value per gram of CO2e
+    pub carbon: f64,
+}
+
+impl Default for ShadowPrices {
+    fn default() -> Self {
+        // Default weights favoring carbon reduction
+        ShadowPrices {
+            energy: 1.0,
+            time: 0.5,
+            carbon: 2.0, // Carbon twice as important as energy
+        }
+    }
+}
+
+/// Ecological score (0-100)
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct EcoScore(pub f64);
+
+impl EcoScore {
+    pub fn new(score: f64) -> Self {
+        EcoScore(score.clamp(0.0, 100.0))
+    }
+}
+
+/// Economic score (0-100) - measures Pareto efficiency
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
+pub struct EconScore(pub f64);
+
+impl EconScore {
+    pub fn new(score: f64) -> Self {
+        EconScore(score.clamp(0.0, 100.0))
+    }
+}
+
+/// Confidence level for an estimate
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub enum Confidence {
+    /// Derived from actual measurements or profiling data
+    Measured,
+    /// Calibrated against known baselines
+    Calibrated,
+    /// Heuristic estimate with reasonable basis
+    Estimated,
+    /// No strong basis; placeholder value
+    #[default]
+    Unknown,
+}
+
+/// Overall health index combining eco and econ scores
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HealthIndex {
+    pub eco_score: EcoScore,
+    pub econ_score: EconScore,
+    pub quality_score: f64,
+    pub overall: f64,
+}
+
+impl HealthIndex {
+    pub fn compute(eco: EcoScore, econ: EconScore, quality: f64) -> Self {
+        // Formula from README: 0.4 × Eco + 0.3 × Econ + 0.3 × Quality
+        let overall = 0.4 * eco.0 + 0.3 * econ.0 + 0.3 * quality;
+
+        HealthIndex {
+            eco_score: eco,
+            econ_score: econ,
+            quality_score: quality,
+            overall,
+        }
+    }
+}
+
+/// Analysis result for a single code unit (function, file, module)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AnalysisResult {
+    pub location: CodeLocation,
+    pub resources: ResourceProfile,
+    pub health: HealthIndex,
+    pub recommendations: Vec<String>,
+    /// Machine-readable rule identifier (e.g. "oikosbot/nested-loops")
+    #[serde(default)]
+    pub rule_id: String,
+    /// Concrete suggestion for fixing the finding
+    #[serde(default)]
+    pub suggestion: Option<String>,
+    /// End location for range-based annotations
+    #[serde(default)]
+    pub end_location: Option<(usize, usize)>,
+    /// How confident is this estimate?
+    #[serde(default)]
+    pub confidence: Confidence,
+}
+
+/// Source code location
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CodeLocation {
+    pub file: String,
+    pub line: usize,
+    pub column: usize,
+    /// End line (1-indexed)
+    #[serde(default)]
+    pub end_line: Option<usize>,
+    /// End column (1-indexed)
+    #[serde(default)]
+    pub end_column: Option<usize>,
+    pub name: Option<String>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_energy_arithmetic() {
+        let e1 = Energy::joules(10.0);
+        let e2 = Energy::joules(5.0);
+        assert_eq!(e1 + e2, Energy::joules(15.0));
+        assert_eq!(e1 * 2.0, Energy::joules(20.0));
+    }
+
+    #[test]
+    fn test_resource_cost() {
+        let profile = ResourceProfile {
+            energy: Energy::joules(10.0),
+            duration: Duration::milliseconds(100.0),
+            carbon: Carbon::grams_co2e(5.0),
+            memory: Memory::bytes(1024),
+        };
+
+        let prices = ShadowPrices::default();
+        let cost = profile.cost(&prices);
+
+        // cost = 1.0*10 + 0.5*100 + 2.0*5 = 10 + 50 + 10 = 70
+        assert_eq!(cost, 70.0);
+    }
+
+    #[test]
+    fn test_health_index() {
+        let health = HealthIndex::compute(EcoScore::new(80.0), EconScore::new(70.0), 60.0);
+
+        // 0.4*80 + 0.3*70 + 0.3*60 = 32 + 21 + 18 = 71
+        assert_eq!(health.overall, 71.0);
+    }
+}
diff --git a/crates/oikosbot-sarif/Cargo.toml b/crates/oikosbot-sarif/Cargo.toml
new file mode 100644
index 0000000..ce00f5b
--- /dev/null
+++ b/crates/oikosbot-sarif/Cargo.toml
@@ -0,0 +1,16 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+[package]
+name = "oikosbot-sarif"
+version.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "SARIF 2.1.0 output for oikosbot analysis results"
+
+[dependencies]
+oikosbot-metrics = { path = "../oikosbot-metrics" }
+serde.workspace = true
+serde_json.workspace = true
diff --git a/crates/oikosbot-sarif/src/lib.rs b/crates/oikosbot-sarif/src/lib.rs
new file mode 100644
index 0000000..2105717
--- /dev/null
+++ b/crates/oikosbot-sarif/src/lib.rs
@@ -0,0 +1,501 @@
+// SPDX-License-Identifier: MPL-2.0
+// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell
+
+//! SARIF 2.1.0 output for oikosbot analysis results.
+//!
+//! Produces machine-readable SARIF that GitHub/IDEs render as inline annotations.
+
+#![forbid(unsafe_code)]
+use oikosbot_metrics::AnalysisResult;
+use serde::{Deserialize, Serialize};
+
+/// SARIF schema version
+const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json";
+const SARIF_VERSION: &str = "2.1.0";
+const TOOL_NAME: &str = "oikosbot";
+const TOOL_INFO_URI: &str = "https://github.com/hyperpolymath/oikosbot";
+
+/// Top-level SARIF log
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SarifLog {
+    #[serde(rename = "$schema")]
+    pub schema: String,
+    pub version: String,
+    pub runs: Vec<Run>,
+}
+
+/// A single SARIF run
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Run {
+    pub tool: Tool,
+    pub results: Vec<SarifResult>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub invocations: Option<Vec<Invocation>>,
+}
+
+/// Tool description
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Tool {
+    pub driver: ToolComponent,
+}
+
+/// Tool component with rules
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ToolComponent {
+    pub name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub semantic_version: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub information_uri: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub rules: Vec<ReportingDescriptor>,
+}
+
+/// Rule definition
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReportingDescriptor {
+    pub id: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub name: Option<String>,
+    pub short_description: Message,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub full_description: Option<Message>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub help: Option<MultiformatMessage>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub default_configuration: Option<ReportingConfiguration>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub properties: Option<serde_json::Value>,
+}
+
+/// Reporting configuration for severity
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ReportingConfiguration {
+    pub level: SarifLevel,
+}
+
+/// A single finding result
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SarifResult {
+    pub rule_id: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub rule_index: Option<usize>,
+    pub level: SarifLevel,
+    pub message: Message,
+    pub locations: Vec<Location>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub fixes: Vec<Fix>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub properties: Option<serde_json::Value>,
+}
+
+/// SARIF severity levels
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum SarifLevel {
+    Error,
+    Warning,
+    Note,
+    None,
+}
+
+/// Simple message
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Message {
+    pub text: String,
+}
+
+/// Multiformat message (text + markdown)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MultiformatMessage {
+    pub text: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub markdown: Option<String>,
+}
+
+/// Code location
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Location {
+    pub physical_location: PhysicalLocation,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub logical_locations: Option<Vec<LogicalLocation>>,
+}
+
+/// Physical file location
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PhysicalLocation {
+    pub artifact_location: ArtifactLocation,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub region: Option<Region>,
+}
+
+/// File path reference
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ArtifactLocation {
+    pub uri: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub uri_base_id: Option<String>,
+}
+
+/// Line/column region
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Region {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub start_line: Option<usize>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub start_column: Option<usize>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub end_line: Option<usize>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub end_column: Option<usize>,
+}
+
+/// Logical location (function name, etc.)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LogicalLocation {
+    pub name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub kind: Option<String>,
+}
+
+/// Suggested fix
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Fix {
+    pub description: Message,
+}
+
+/// Invocation metadata
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Invocation {
+    pub execution_successful: bool,
+}
+
+/// Known oikosbot rules
+fn builtin_rules() -> Vec<ReportingDescriptor> {
+    vec![
+        rule(
+            "oikosbot/general",
+            "General sustainability finding",
+            "Code unit analyzed for ecological and economic efficiency.",
+            SarifLevel::Note,
+        ),
+        rule(
+            "oikosbot/nested-loops",
+            "Deeply nested loops",
+            "Deeply nested loops create O(n^k) complexity, wasting CPU and energy.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/busy-wait",
+            "Busy-wait loop",
+            "Loop without sleep/await/yield burns CPU continuously.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/string-concat-in-loop",
+            "String concatenation in loop",
+            "String concatenation inside loops causes repeated heap allocation.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/clone-in-loop",
+            "Clone in loop",
+            ".clone() inside loop body causes repeated deep copies.",
+            SarifLevel::Note,
+        ),
+        rule(
+            "oikosbot/unbuffered-io",
+            "Unbuffered I/O",
+            "File I/O without buffering causes excessive system calls.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/large-allocation",
+            "Large allocation",
+            "Heap allocation exceeding 1MB detected.",
+            SarifLevel::Note,
+        ),
+        rule(
+            "oikosbot/redundant-allocation",
+            "Redundant allocation",
+            "Excessive .to_string()/.to_owned() where borrows suffice.",
+            SarifLevel::Note,
+        ),
+        rule(
+            "oikosbot/eco-threshold",
+            "Below eco threshold",
+            "Function's ecological score is below the configured threshold.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/carbon-intensity",
+            "High carbon intensity",
+            "Estimated carbon emissions exceed sustainable baseline.",
+            SarifLevel::Warning,
+        ),
+        rule(
+            "oikosbot/security-sustainability",
+            "Security-sustainability correlation",
+            "Security weak point with sustainability impact detected.",
+            SarifLevel::Warning,
+        ),
+    ]
+}
+
+fn rule(id: &str, name: &str, desc: &str, level: SarifLevel) -> ReportingDescriptor {
+    ReportingDescriptor {
+        id: id.to_string(),
+        name: Some(name.to_string()),
+        short_description: Message {
+            text: desc.to_string(),
+        },
+        full_description: None,
+        help: None,
+        default_configuration: Some(ReportingConfiguration { level }),
+        properties: None,
+    }
+}
+
+/// Convert oikosbot analysis results into a SARIF log.
+pub fn to_sarif(results: &[AnalysisResult], version: &str) -> SarifLog {
+    let rules = builtin_rules();
+    let rule_ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect();
+
+    let sarif_results: Vec<SarifResult> = results
+        .iter()
+        .map(|r| convert_result(r, &rule_ids))
+        .collect();
+
+    SarifLog {
+        schema: SARIF_SCHEMA.to_string(),
+        version: SARIF_VERSION.to_string(),
+        runs: vec![Run {
+            tool: Tool {
+                driver: ToolComponent {
+                    name: TOOL_NAME.to_string(),
+                    semantic_version: Some(version.to_string()),
+                    information_uri: Some(TOOL_INFO_URI.to_string()),
+                    rules,
+                },
+            },
+            results: sarif_results,
+            invocations: Some(vec![Invocation {
+                execution_successful: true,
+            }]),
+        }],
+    }
+}
+
+/// Convert oikosbot analysis results to SARIF JSON string.
+pub fn to_sarif_json(
+    results: &[AnalysisResult],
+    version: &str,
+) -> Result<String, serde_json::Error> {
+    let log = to_sarif(results, version);
+    serde_json::to_string_pretty(&log)
+}
+
+fn convert_result(result: &AnalysisResult, rule_ids: &[&str]) -> SarifResult {
+    let rule_id = &result.rule_id;
+    let rule_index = rule_ids.iter().position(|&id| id == rule_id);
+
+    // Determine severity from health scores
+    let level = if result.health.eco_score.0 < 30.0 {
+        SarifLevel::Error
+    } else if result.health.eco_score.0 < 60.0 {
+        SarifLevel::Warning
+    } else {
+        SarifLevel::Note
+    };
+
+    // Build message
+    let func_name = result.location.name.as_deref().unwrap_or("<anonymous>");
+    let message_text = if result.recommendations.is_empty() {
+        format!(
+            "{}: eco={:.0}/100, energy={:.2}J, carbon={:.4}gCO2e",
+            func_name,
+            result.health.eco_score.0,
+            result.resources.energy.0,
+            result.resources.carbon.0,
+        )
+    } else {
+        format!(
+            "{}: eco={:.0}/100, energy={:.2}J, carbon={:.4}gCO2e. {}",
+            func_name,
+            result.health.eco_score.0,
+            result.resources.energy.0,
+            result.resources.carbon.0,
+            result.recommendations.join("; "),
+        )
+    };
+
+    // Build location
+    let location = Location {
+        physical_location: PhysicalLocation {
+            artifact_location: ArtifactLocation {
+                uri: result.location.file.clone(),
+                uri_base_id: Some("%SRCROOT%".to_string()),
+            },
+            region: Some(Region {
+                start_line: Some(result.location.line),
+                start_column: Some(result.location.column),
+                end_line: result.location.end_line,
+                end_column: result.location.end_column,
+            }),
+        },
+        logical_locations: result.location.name.as_ref().map(|name| {
+            vec![LogicalLocation {
+                name: name.clone(),
+                kind: Some("function".to_string()),
+            }]
+        }),
+    };
+
+    // Build fixes from suggestion
+    let fixes = result
+        .suggestion
+        .as_ref()
+        .map(|s| {
+            vec![Fix {
+                description: Message { text: s.clone() },
+            }]
+        })
+        .unwrap_or_default();
+
+    // Custom properties with eco/econ scores and resource profile
+    let properties = serde_json::json!({
+        "eco_score": result.health.eco_score.0,
+        "econ_score": result.health.econ_score.0,
+        "quality_score": result.health.quality_score,
+        "overall_health": result.health.overall,
+        "energy_joules": result.resources.energy.0,
+        "carbon_gco2e": result.resources.carbon.0,
+        "duration_ms": result.resources.duration.0,
+        "memory_bytes": result.resources.memory.0,
+        "confidence": format!("{:?}", result.confidence),
+    });
+
+    SarifResult {
+        rule_id: rule_id.clone(),
+        rule_index,
+        level,
+        message: Message { text: message_text },
+        locations: vec![location],
+        fixes,
+        properties: Some(properties),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use oikosbot_metrics::*;
+
+    fn sample_result() -> AnalysisResult {
+        AnalysisResult {
+            location: CodeLocation {
+                file: "src/main.rs".to_string(),
+                line: 10,
+                column: 1,
+                end_line: Some(25),
+                end_column: Some(2),
+                name: Some("process_data".to_string()),
+            },
+            resources: ResourceProfile {
+                energy: Energy::joules(5.0),
+                duration: Duration::milliseconds(25.0),
+                carbon: Carbon::grams_co2e(0.0007),
+                memory: Memory::kilobytes(100),
+            },
+            health: HealthIndex::compute(EcoScore::new(75.0), EconScore::new(80.0), 85.0),
+            recommendations: vec!["Code looks efficient".to_string()],
+            rule_id: "oikosbot/general".to_string(),
+            suggestion: None,
+            end_location: Some((25, 2)),
+            confidence: Confidence::Estimated,
+        }
+    }
+
+    #[test]
+    fn test_sarif_structure() {
+        let results = vec![sample_result()];
+        let log = to_sarif(&results, "0.1.0");
+
+        assert_eq!(log.version, "2.1.0");
+        assert_eq!(log.runs.len(), 1);
+        assert_eq!(log.runs[0].tool.driver.name, "oikosbot");
+        assert!(!log.runs[0].tool.driver.rules.is_empty());
+        assert_eq!(log.runs[0].results.len(), 1);
+    }
+
+    #[test]
+    fn test_sarif_json_valid() {
+        let results = vec![sample_result()];
+        let json = to_sarif_json(&results, "0.1.0").unwrap();
+
+        // Should be valid JSON
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(parsed["version"], "2.1.0");
+        assert!(
+            parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]
+                ["startLine"]
+                .as_u64()
+                .unwrap()
+                == 10
+        );
+    }
+
+    #[test]
+    fn test_sarif_with_suggestion() {
+        let mut result = sample_result();
+        result.rule_id = "oikosbot/nested-loops".to_string();
+        result.suggestion = Some("Use hash map for O(1) lookup".to_string());
+
+        let log = to_sarif(&[result], "0.1.0");
+        let sarif_result = &log.runs[0].results[0];
+
+        assert_eq!(sarif_result.rule_id, "oikosbot/nested-loops");
+        assert_eq!(sarif_result.fixes.len(), 1);
+        assert_eq!(
+            sarif_result.fixes[0].description.text,
+            "Use hash map for O(1) lookup"
+        );
+    }
+
+    #[test]
+    fn test_sarif_severity_mapping() {
+        // Low eco score → Error
+        let mut result = sample_result();
+        result.health = HealthIndex::compute(EcoScore::new(20.0), EconScore::new(80.0), 85.0);
+        let log = to_sarif(&[result], "0.1.0");
+        assert!(matches!(log.runs[0].results[0].level, SarifLevel::Error));
+
+        // Medium eco score → Warning
+        let mut result = sample_result();
+        result.health = HealthIndex::compute(EcoScore::new(45.0), EconScore::new(80.0), 85.0);
+        let log = to_sarif(&[result], "0.1.0");
+        assert!(matches!(log.runs[0].results[0].level, SarifLevel::Warning));
+
+        // High eco score → Note
+        let mut result = sample_result();
+        result.health = HealthIndex::compute(EcoScore::new(85.0), EconScore::new(80.0), 85.0);
+        let log = to_sarif(&[result], "0.1.0");
+        assert!(matches!(log.runs[0].results[0].level, SarifLevel::Note));
+    }
+}
diff --git a/examples/oikosbot-ci.yml b/examples/oikosbot-ci.yml
new file mode 100644
index 0000000..c8f7fcd
--- /dev/null
+++ b/examples/oikosbot-ci.yml
@@ -0,0 +1,54 @@
+# SPDX-License-Identifier: MPL-2.0
+# Example GitHub Actions workflow for oikosbot SARIF integration
+#
+# This workflow runs oikosbot on your codebase and uploads the SARIF
+# results to GitHub's Security tab, giving you inline PR annotations.
+#
+# Usage: Copy this file to .github/workflows/oikosbot.yml in your repo.
+
+name: Sustainability Analysis
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+permissions:
+  security-events: write
+  contents: read
+
+jobs:
+  oikosbot:
+    name: Eco & Economic Analysis
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+      - name: Install oikosbot
+        run: cargo install --path . --locked
+        # Or, if published to crates.io:
+        # run: cargo install oikosbot
+
+      - name: Run sustainability analysis
+        run: |
+          oikosbot report ./src \
+            --format sarif \
+            --output results.sarif \
+            --eco-threshold 50
+
+      - name: Upload SARIF to GitHub Security
+        if: always()
+        uses: github/codeql-action/upload-sarif@6624720a57d4c312633c7b953db2f2da5bcb4c3a # v3
+        with:
+          sarif_file: results.sarif
+          category: oikosbot
+
+      # Optional: Run with policy evaluation
+      - name: Run policy evaluation
+        if: hashFiles('policies/') != ''
+        run: |
+          oikosbot check ./src \
+            --format text \
+            --policy-dir policies/ \
+            --eco-threshold 50
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..160e062
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "fuzz"
+version = "0.0.0"
+publish = false
+edition = "2021"
+
+[package.metadata]
+cargo-fuzz = true
+
+[dependencies]
+libfuzzer-sys = "0.4"
+
+# Fuzz the analysis engine. (The workspace root is virtual — there is no crate
+# named `oikosbot` at `..`; target the actual library crate instead.)
+[dependencies.oikosbot-analysis]
+path = "../crates/oikosbot-analysis"
+
+[[bin]]
+name = "fuzz_main"
+path = "fuzz_targets/fuzz_main.rs"
+test = false
+doc = false
diff --git a/fuzz/fuzz_targets/fuzz_main.rs b/fuzz/fuzz_targets/fuzz_main.rs
new file mode 100644
index 0000000..1f71ba3
--- /dev/null
+++ b/fuzz/fuzz_targets/fuzz_main.rs
@@ -0,0 +1,22 @@
+#![no_main]
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+    // Generic fuzzing for memory safety and crash detection
+    if data.is_empty() || data.len() > 100000 {
+        return;
+    }
+
+    // Test UTF-8 validity
+    if let Ok(text) = std::str::from_utf8(data) {
+        // Test string operations
+        let _ = text.to_lowercase();
+        let _ = text.chars().count();
+        let _ = text.split_whitespace().collect::<Vec<_>>();
+    }
+
+    // Test binary data handling
+    if data.len() >= 8 {
+        let _chunk = &data[..8];
+    }
+});
diff --git a/policies/carbon_budget.ecl b/policies/carbon_budget.ecl
new file mode 100644
index 0000000..9ca7dc8
--- /dev/null
+++ b/policies/carbon_budget.ecl
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MPL-2.0
+// OikosBot Policy: Carbon Budget
+//
+// Per-function and per-project carbon budgets.
+// Enforces sustainable computation practices.
+
+def exceeds_function_carbon(carbon_grams: Float) -> Bool
+    @requires: energy < 0.1J
+{
+    carbon_grams > 0.5
+}
+
+def exceeds_project_carbon(total_carbon_grams: Float) -> Bool
+    @requires: energy < 0.1J
+{
+    total_carbon_grams > 5.0
+}
+
+def evaluate_carbon_budget(function_carbon: Float, total_carbon: Float) -> Bool
+    @requires: energy < 1J, carbon < 0.001gCO2e
+{
+    exceeds_function_carbon(function_carbon) || exceeds_project_carbon(total_carbon)
+}
diff --git a/policies/energy_threshold.ecl b/policies/energy_threshold.ecl
new file mode 100644
index 0000000..0840a67
--- /dev/null
+++ b/policies/energy_threshold.ecl
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: MPL-2.0
+// OikosBot Policy: Energy Threshold Check
+//
+// This policy is written in ECLEXIA - proving dogfooding works!
+// The policy engine itself has provable resource bounds.
+
+// Check if a function uses too much energy
+def exceeds_energy_threshold(energy_joules: Float) -> Bool
+    @requires: energy < 0.1J, carbon < 0.001gCO2e  // This policy is CHEAP to run
+    @optimize: minimize latency
+{
+    energy_joules > 50.0
+}
+
+// Check if carbon footprint is too high
+def exceeds_carbon_threshold(carbon_grams: Float) -> Bool
+    @requires: energy < 0.1J
+{
+    carbon_grams > 5.0
+}
+
+// Main policy evaluation function
+// Returns true if the code should trigger a warning
+adaptive def should_warn(energy: Float, carbon: Float) -> Bool
+    @requires: energy < 1J, latency < 5ms
+    @optimize: minimize energy, minimize latency
+{
+    @solution "fast_check":
+        @when: energy < 10.0 && carbon < 1.0
+        @provides: energy: 0.05J, latency: 1ms
+    {
+        false  // Obviously fine, skip detailed check
+    }
+
+    @solution "detailed_check":
+        @provides: energy: 0.2J, latency: 3ms
+    {
+        exceeds_energy_threshold(energy) || exceeds_carbon_threshold(carbon)
+    }
+}
+
+// Example: This policy used < 1J to decide if your code uses > 50J
+// Meta-level efficiency: The analyzer is more efficient than what it analyzes!
diff --git a/policies/memory_efficiency.ecl b/policies/memory_efficiency.ecl
new file mode 100644
index 0000000..ac784ba
--- /dev/null
+++ b/policies/memory_efficiency.ecl
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: MPL-2.0
+// OikosBot Policy: Memory Efficiency
+//
+// Allocation pattern rules to catch wasteful memory usage.
+
+def exceeds_allocation_limit(bytes: Float) -> Bool
+    @requires: energy < 0.05J
+{
+    bytes > 1048576.0  // 1MB
+}
+
+def high_allocation_count(count: Float) -> Bool
+    @requires: energy < 0.05J
+{
+    count > 100.0
+}
+
+def evaluate_memory(allocation_bytes: Float, allocation_count: Float) -> Bool
+    @requires: energy < 0.5J
+{
+    exceeds_allocation_limit(allocation_bytes) || high_allocation_count(allocation_count)
+}
diff --git a/policies/security_sustainability.ecl b/policies/security_sustainability.ecl
new file mode 100644
index 0000000..9c8375a
--- /dev/null
+++ b/policies/security_sustainability.ecl
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: MPL-2.0
+// OikosBot Policy: Security-Sustainability Correlation
+//
+// Correlates panic-attack findings with eco scores.
+// Functions that are BOTH high-energy AND have security weak points
+// get elevated severity.
+
+def is_high_energy(energy_joules: Float) -> Bool
+    @requires: energy < 0.05J
+{
+    energy_joules > 50.0
+}
+
+def has_security_issues(weak_point_count: Float) -> Bool
+    @requires: energy < 0.05J
+{
+    weak_point_count > 0.0
+}
+
+def correlate(energy: Float, weak_points: Float) -> Bool
+    @requires: energy < 0.5J
+{
+    is_high_energy(energy) && has_security_issues(weak_points)
+}
