Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b3c15ba
fix(daemon+modulation): close P0 IPC/biometric seams (C1/C2/C3) + on-…
JosephOIbrahim Jun 18, 2026
740a48b
fix(hippocampus): typed EncoderMismatch guard + persisted encoder tag
Jun 19, 2026
8af48c2
fix(healthbridge): Swift UDS exclusivity fix + activation runbook
Jun 19, 2026
6c16c40
feat(healthbridge): Phase 5B daemon pipeline (proven) + app icon
Jun 19, 2026
fdb120c
feat(healthbridge): wire the XPC relay (sandbox-safe bridge->daemon r…
Jun 19, 2026
ed88ed5
docs(readme): Phase 5B Apple Watch biometric loop (HealthKit -> XPC -…
Jun 19, 2026
b92229b
build(healthbridge): bundle HarloXPCRelay into the app (Phase 5B publ…
Jun 19, 2026
12930b3
build: make build-healthbridge target + ruff lint config (publish prep)
Jun 19, 2026
30dbd6a
merge(master): reconcile biometric_prior + HarloPulse with Phase 5B H…
Jun 19, 2026
399a42f
release(v0.2.0): unify version across core · Rust · macOS app · iOS a…
Jun 19, 2026
fe94e8e
feat(modulation): score respiratory_rate into allostatic load (DEPLET…
Jun 19, 2026
2b30913
docs(design): biometric-driven macOS ambient effectors design (brains…
Jun 19, 2026
88c0357
docs(design): finalize effector spec decisions (CTO calls on §13)
Jun 19, 2026
32efe27
docs(plan): Phase 1 implementation plan — macOS ambient effectors (TDD)
Jun 19, 2026
3e0343e
ops: rotate_anthropic_key.sh + README hero (layman pitch)
Jun 19, 2026
eea14e8
docs(readme): show respiratory rate in the biometric-loop diagrams
Jun 19, 2026
aeb2bbe
Merge remote-tracking branch 'origin/master' into review/p0-seam-fixe…
Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ files/
macos/HarloHealthBridge/HarloHealthBridge.xcodeproj/
macos/HarloHealthBridge/.build/

# session scratch / capsules
.fieldhouse/
CLAUDE-FABLE-5.md

# HarloPulse (ADR-0002) — xcodegen output (regenerated each build)
ios/HarloPulse/HarloPulse.xcodeproj/
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ pytest tests/ -v # Python unit tests
pytest tests/test_integration/ -v # Integration tests
```

> Build/env gotchas (cost a debugging cycle on 2026-06-19 — don't re-hit):
> - `maturin develop --release` RE-RESOLVES the unpinned `>=` deps in pyproject
> and silently upgrades the ML stack (numpy/transformers/xgboost) → native
> segfaults in the suite. Pin the ML stack (constraints file) or rebuild the
> ext without re-resolving (`maturin build` + `pip install --no-deps wheel`).
> - Exported `PYTORCH_MPS_HIGH_WATERMARK_RATIO` ≤ ~1.0 makes torch raise
> "invalid low watermark ratio" on model load (also breaks `harlo recall`).
> - `PYTHONOPTIMIZE=1` (`-O`) strips production `assert`s → assert-based tests
> fail "DID NOT RAISE". Unset for test runs. `ruff` must be installed for lint.

## The 33 Inviolable Rules

### Biological Constraints (v3.0)
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ UNAME_S := $(shell uname -s)
APP_PATH ?= dist/Harlo.app
DMG_PATH ?= dist/Harlo.dmg

# HarloHealthBridge (Phase 5B) build knobs. Local-dev defaults; CI / release
# overrides HB_CODESIGN_ID with the "Developer ID Application" identity.
APPLE_TEAM_ID ?= 233JSS4X69
HB_CONFIG ?= Release
HB_CODESIGN_ID ?= Apple Development

# Python resolution order:
# 1. Explicit override on the command line (PYTHON=...)
# 2. Currently-activated venv ($VIRTUAL_ENV)
Expand All @@ -33,7 +39,7 @@ endif

.PHONY: help build-rust build-macos sign notarize staple dmg release \
clean-macos test compliance-greps verify doctor signing-readiness \
regen-trajectories regen-predictor
regen-trajectories regen-predictor build-healthbridge

help:
@echo "Harlo build targets"
Expand Down Expand Up @@ -65,6 +71,24 @@ else
@echo "build-macos: skipped on $(UNAME_S) (macOS only)"
endif

# Phase 5B: build HarloHealthBridge.app with the bundled HarloXPCRelay
# (xcodegen tool target → Contents/Helpers/, app signature seals it). Notarization
# is handled separately by the sign chain. Run AFTER build-macos (which wipes build/).
build-healthbridge:
ifeq ($(UNAME_S),Darwin)
command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
xcodegen generate --spec macos/HarloHealthBridge/project.yml
xcodebuild -project macos/HarloHealthBridge/HarloHealthBridge.xcodeproj \
-scheme HarloHealthBridge -configuration $(HB_CONFIG) \
-allowProvisioningUpdates CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM=$(APPLE_TEAM_ID) CODE_SIGN_IDENTITY="$(HB_CODESIGN_ID)" \
ENABLE_DEBUG_DYLIB=NO ENABLE_PREVIEWS=NO MACOSX_DEPLOYMENT_TARGET=13.0 \
-derivedDataPath build/hb build
@echo "==> Built HarloHealthBridge.app + embedded HarloXPCRelay (build/hb/Build/Products/$(HB_CONFIG)/)"
else
@echo "build-healthbridge: skipped on $(UNAME_S) (macOS only)"
endif

sign:
ifeq ($(UNAME_S),Darwin)
@if [ -f $(HOME)/.config/harlo/notary.env ]; then \
Expand Down
65 changes: 58 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@

---

Your AI coach. Watches your patterns — and now your body — predicts your
crashes, backs off during flow, and tells you when to stop before you burn out.
Built on USD composition semantics for persistent, local-first cognitive state
management.
**Make smarter decisions with a coach who knows how you're really doing.**
Harlo reads your Apple Watch — sleep, heart, stress — learns your patterns, and
eases off when you're running low. It watches for your crashes, backs off during
flow, and helps you stop before you burn out.

Your memory, your device. Harlo stores all state locally as composable USD
layers — no cloud dependency, no data mining, no rented access to your own mind.
**Private by design.** All your data lives on your device as local, composable
state — no cloud, no data mining, no rented access to your own mind.

---

Expand All @@ -37,6 +37,8 @@ Phase 5A landed: macOS bundle · intake calibration · biometric barrier · Moto
v0.1.2: USD-proof trial — §F1 native composition · §F2 structural lossless · anchor immunity · P5 customData · P1 decision-tier — CONFIRMED on live pxr stage (verifier-first, 4 cycles)
v0.1.6: HarloPulse loop LIVE — Apple Watch → HealthKit → push-on-arrival → launchd socket → biometric barrier → coach
first organic biometrics through the barrier · Siri/Shortcuts/Spotlight surface · Xcode 27 / iOS 27 SDK
Phase 5B built: Mac-local sibling — Apple Watch → HealthBridge (sandboxed) → XPC relay → daemon biometric loop (relay→daemon proven; full live Watch test pending)
v0.2.0: unified release — biometric_prior seed + HarloPulse (iPhone) + HealthBridge/XPC (macOS) reconciled; one version across every surface (core · Rust · macOS app · iOS app · bridge)
```

| Sprint | Tests | What Shipped |
Expand All @@ -50,6 +52,7 @@ v0.1.6: HarloPulse loop LIVE — Apple Watch → HealthKit → push-on-arrival
| **Phase 5A** macOS + Operator | +51 | macOS app bundle (Harlo.app + launchd socket activation), intake calibration CLI emitting three INTAKE_CALIBRATED Merkle layers, biometric barrier per ADR-0001 (opt-in HealthKit signals, freshness window, never enter trace pipeline), Motor Cortex with Basal Ganglia inhibition-default gating, `harlo doctor --strict` operator readiness, signing-readiness pre-flight (27 checks) |
| **USD Trial** v0.1.2 | +verifier | SOLO trial-harness loop (`docs/trial-harness.md` + `docs/usd-proof-trial.md`); 4 engine cycles verifier-first against `wave1_harness.py`; native USD-composition theses CONFIRMED on the live `pxr`-backed stage: §F1 `LOCAL > VARIANT > SPECIALIZE` resolution, §F2 `reconstruct_clean` bit-identical, anchor structural immunity (adversarial probe), P5 customData state tracking; new MCP tools — `compose_demo`, `lossless_demo`, `anchor_demo`, `p5_state_demo`, `persist_stage`, `decision`. P1 closed via Path C Actor-driven motor surface. |
| **Pulse Loop** v0.1.6 | +13 | HarloPulse iPhone sidecar deployed to hardware (Xcode 27 / iOS 27 SDK): 6-word HMAC pairing, per-type opt-in HealthKit reads (ADR-0001/D65), HKObserverQuery + background delivery for push-on-arrival, 48h lookback + chunked frames; Mac side `pulse listen` adopts a launchd-held TCP socket (Rule 1 — 0W between Watch syncs); App Intents surface (sync, status snippet, type toggles with clarification, OpenIntent, onscreen awareness); field-debugged end-to-end — first organic Apple Watch biometrics through the barrier into the coach. Four WWDC26 frontier docs: App Intents adoption plan, Foundation Models provider review (+ live-verified Python SDK addendum), code-along addendum, HealthKit collaboration report. |
| **Phase 5B** macOS HealthBridge | -- | Mac-local sibling to the HarloPulse iPhone loop — same `biometric_barrier`, different transport. Sandboxed `HarloHealthBridge.app` (HealthKit observers, signed under the Apple Developer Program) + `HarloXPCRelay` launchd Mach service bridging the sandboxed app to the daemon's UNIX socket — the App Group container can't host it (macOS blocks the non-sandboxed daemon from binding there). `DaemonWriter` over NSXPC, mach-lookup entitlement; full HR/HRV → `biometric_barrier` → `AllostasisTracker` → DEPLETED/RED. Relay→daemon proven (XPC ingest of 180 bpm → `force_red: true`). |

---

Expand Down Expand Up @@ -158,7 +161,7 @@ Modulation Layer with zero resident processes on either side of the wire:

```mermaid
flowchart LR
W["Apple Watch<br/>HR · HRV · sleep · workouts"]:::runtime --> HK["iPhone HealthKit<br/>background delivery"]:::runtime
W["Apple Watch<br/>HR · HRV · RR · sleep · workouts"]:::runtime --> HK["iPhone HealthKit<br/>background delivery"]:::runtime
HK --> APP["HarloPulse app<br/>HKObserverQuery wakes it"]:::runtime
APP --> PUSH["delta push<br/>HMAC auth · 48h window · chunked frames"]:::runtime
PUSH --> LD["Mac launchd<br/>holds TCP 48653 · spawns on SYN"]:::substrate
Expand Down Expand Up @@ -502,6 +505,52 @@ flowchart LR
See `docs/SIGNING.md` and `docs/APPLE_SECRETS_SETUP.md` for the
signing/notarization chain.

### Phase 5B · Apple Watch biometric loop (HealthKit → XPC → daemon)

Phase 5B closes the sensor loop: an opt-in **Apple Watch → Mac** biometric
stream feeding the Modulation Layer's allostatic load. The bridge that owns
HealthKit must be sandboxed (Apple requires it); the daemon is not — and the
two cannot share a UNIX socket (the App Group container blocks the
non-sandboxed daemon from binding there). The rendezvous is a launchd
**Mach service** reached over XPC:

```mermaid
flowchart LR
WATCH["Apple Watch<br/>HR · HRV · RR · sleep · SpO₂"]:::runtime
BRIDGE["HarloHealthBridge.app<br/>SANDBOXED · HealthKit observers<br/>HKObserverQuery · wakes on callback"]:::substrate
RELAY["HarloXPCRelay<br/>launchd Mach service · NOT sandboxed<br/>com.harlo.xpc"]:::substrate
DAEMON["Harlo daemon<br/>socket-activated · 0W idle"]:::substrate
BARRIER["biometric_barrier<br/>jsonschema · Rule 9 / ADR-0001<br/>Modulation Layer ONLY"]:::runtime
ALLO["AllostasisTracker<br/>HR · HRV · RR → load<br/>freshness window · DEPLETED / force-RED"]:::runtime

WATCH -->|"iCloud Health sync"| BRIDGE
BRIDGE -->|"NSXPCConnection<br/>mach-lookup entitlement"| RELAY
RELAY -->|"framed JSON<br/>twind.sock"| DAEMON
DAEMON --> BARRIER --> ALLO

classDef substrate fill:#1a2332,stroke:#4a90a4,color:#e8eef2
classDef runtime fill:#d4af37,stroke:#8b7115,color:#1a2332
```

- **`HarloHealthBridge.app`** — sandboxed, signed under the Apple Developer
Program (HealthKit is a restricted entitlement → paid membership). Owns the
HealthKit observers; wakes only on `HKObserverQuery` callbacks (the sole
KeepAlive process, ADR-0001).
- **`HarloXPCRelay`** — a non-sandboxed launchd Mach service. The sandboxed
bridge reaches it via a `mach-lookup` temporary-exception entitlement; the
relay forwards length-prefixed `biometric_ingest` JSON to the daemon's
`twind.sock`, which it (being unsandboxed) can reach. On-demand + idle-exit,
so Rule 1 (0W idle) holds.
- **`biometric_barrier`** — every sample is jsonschema-validated and enters the
**Modulation Layer only**; a compliance grep forbids biometrics in `bridge/`
or `elenchus/`. Stale samples (older than the freshness window) can mark
DEPLETED but never drive RED — Apple-Watch→Mac latency must not inhibit motor.

The daemon receive-side and the relay→daemon path are proven end-to-end (an XPC
ingest of 180 bpm returned `{force_red: true, biometric_load: 1.0}`). See
[`docs/HEALTHKIT_ACTIVATION.md`](docs/HEALTHKIT_ACTIVATION.md) for the build /
provisioning / install runbook.

---

## Tech Stack
Expand All @@ -515,6 +564,8 @@ signing/notarization chain.
- **MCP** — 8 tools over stdio. Works with Claude Desktop, Claude Code, any MCP client.
- **Click 8.x CLI** — `harlo intake`, `harlo doctor`, `harlo audit` operator surfaces.
- **launchd socket activation** — 0W idle daemon per Rule 1; separate KeepAlive plist for the HealthBridge.
- **Swift + HealthKit** — `HarloHealthBridge.app` (sandboxed HealthKit observer over `HKObserverQuery`/`HKAnchoredObjectQuery`) and `HarloXPCRelay` (the non-sandboxed XPC Mach-service relay). Apple Watch → Mac biometric stream into the Modulation Layer.
- **XPC (`NSXPCConnection`)** — sandbox-safe bridge↔daemon rendezvous; the relay is a launchd Mach service the sandboxed app reaches via a `mach-lookup` entitlement (the App Group container can't host the daemon socket).
- **py2app bundling** — Harlo.app produced from the Python source tree.
- **codesign + notarytool** — Apple Developer ID signing and notarization for distributed artifacts.

Expand Down
2 changes: 1 addition & 1 deletion crates/hippocampus/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "hippocampus"
version = "0.1.6"
version = "0.2.0"
edition = "2021"

[lib]
Expand Down
5 changes: 5 additions & 0 deletions crates/hippocampus/src/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ use std::hash::{Hash, Hasher};
/// SDR width in bits. 2048 bits = 256 bytes.
pub const SDR_WIDTH: usize = 2048;

/// Identifier for the encoder that produced an SDR. The Rust hot path is the
/// lexical n-gram encoder; traces it stores are tagged with this id so a
/// cross-encoder Hamming comparison can be refused at recall time (CORE-1).
pub const ENCODER_ID: &str = "lexical";

/// Target sparsity: ~2-5% of bits active.
const TARGET_ACTIVE_BITS: usize = 80;

Expand Down
39 changes: 39 additions & 0 deletions crates/hippocampus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,37 @@ pub mod reflex;
pub mod search;
pub mod store;

use pyo3::create_exception;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use rusqlite::OptionalExtension;
use std::path::PathBuf;
use std::sync::Mutex;

create_exception!(
hippocampus,
EncoderMismatch,
pyo3::exceptions::PyValueError,
"Raised when a query SDR's encoder differs from a candidate trace's encoder.\n\nRefuses cross-encoder Hamming comparison so a trace stored under one encoder\ncan never be silently recalled under another (CORE-1)."
);

static DB_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);

/// First foreign encoder id among stored traces, or None when every candidate
/// was produced by `query_encoder`. Legacy untagged rows count as 'lexical'.
/// Used to refuse cross-encoder recall before any neighbor is returned.
fn first_foreign_encoder(
conn: &rusqlite::Connection,
query_encoder: &str,
) -> rusqlite::Result<Option<String>> {
conn.query_row(
"SELECT encoder_id FROM traces WHERE COALESCE(encoder_id, 'lexical') != ?1 LIMIT 1",
rusqlite::params![query_encoder],
|row| row.get::<_, String>(0),
)
.optional()
}

fn get_or_open_db(path: Option<&str>) -> PyResult<rusqlite::Connection> {
let db_path = if let Some(p) = path {
PathBuf::from(p)
Expand All @@ -43,6 +67,20 @@ fn py_recall<'py>(py: Python<'py>, query_str: &str, depth: &str, db_path: Option
};

let conn = get_or_open_db(db_path)?;

// CORE-1: the Rust hot path encodes the query with the lexical encoder.
// Refuse — loudly — to Hamming-compare it against any trace persisted under
// a different encoder, rather than silently returning a bogus neighbor.
if let Some(found) = first_foreign_encoder(&conn, encoder::ENCODER_ID).map_err(|e| {
pyo3::exceptions::PyRuntimeError::new_err(format!("Encoder guard error: {}", e))
})? {
return Err(EncoderMismatch::new_err(format!(
"query encoder '{}' cannot be compared against trace stored under encoder '{}'",
encoder::ENCODER_ID,
found
)));
}

let result = query::recall(&conn, query_str, depth);

let dict = PyDict::new(py);
Expand Down Expand Up @@ -185,5 +223,6 @@ fn hippocampus(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(py_lookup_reflex, m)?)?;
m.add_function(wrap_pyfunction!(py_store_reflex, m)?)?;
m.add_function(wrap_pyfunction!(py_boost, m)?)?;
m.add("EncoderMismatch", m.py().get_type::<EncoderMismatch>())?;
Ok(())
}
1 change: 1 addition & 0 deletions crates/hippocampus/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ pub fn store_new_trace(
tags_json: serde_json::to_string(tags)?,
domain: domain.map(String::from),
source: source.map(String::from),
encoder_id: crate::encoder::ENCODER_ID.to_string(),
};

crate::store::store_trace(conn, &trace)?;
Expand Down
35 changes: 31 additions & 4 deletions crates/hippocampus/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub struct TraceRecord {
pub tags_json: String,
pub domain: Option<String>,
pub source: Option<String>,
/// Identifier of the encoder that produced `sdr_blob` (CORE-1). Guards
/// against silent cross-encoder Hamming comparison at recall time.
pub encoder_id: String,
}

/// Apoptosis report after cleanup.
Expand Down Expand Up @@ -55,7 +58,8 @@ pub fn init_db(conn: &Connection) -> Result<()> {
boosts_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
domain TEXT,
source TEXT
source TEXT,
encoder_id TEXT NOT NULL DEFAULT 'lexical'
);

CREATE TABLE IF NOT EXISTS reflexes (
Expand Down Expand Up @@ -85,6 +89,26 @@ pub fn init_db(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_graph_target ON graph_edges(target_id);
",
)?;
// Migrate pre-existing databases that predate the encoder tag (CORE-1).
ensure_encoder_id_column(conn)?;
Ok(())
}

/// Add the `encoder_id` column to an existing `traces` table if absent.
/// Idempotent: new databases already have it via CREATE TABLE; legacy rows
/// default to 'lexical' (the historical Rust hot-path encoder).
fn ensure_encoder_id_column(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(traces)")?;
let has_column = stmt
.query_map([], |row| row.get::<_, String>(1))?
.filter_map(|r| r.ok())
.any(|name| name == "encoder_id");
if !has_column {
conn.execute(
"ALTER TABLE traces ADD COLUMN encoder_id TEXT NOT NULL DEFAULT 'lexical'",
[],
)?;
}
Ok(())
}

Expand Down Expand Up @@ -116,8 +140,8 @@ pub fn store_trace(conn: &Connection, trace: &TraceRecord) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO traces
(id, message, sdr_blob, initial_strength, decay_lambda,
created_at, last_accessed, boosts_json, tags_json, domain, source)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
created_at, last_accessed, boosts_json, tags_json, domain, source, encoder_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
trace.id,
trace.message,
Expand All @@ -130,6 +154,7 @@ pub fn store_trace(conn: &Connection, trace: &TraceRecord) -> Result<()> {
trace.tags_json,
trace.domain,
trace.source,
trace.encoder_id,
],
)?;
Ok(())
Expand All @@ -148,7 +173,7 @@ pub fn load_all_sdrs(conn: &Connection) -> Result<Vec<(String, Vec<u8>)>> {
pub fn load_trace(conn: &Connection, id: &str) -> Result<Option<TraceRecord>> {
let mut stmt = conn.prepare(
"SELECT id, message, sdr_blob, initial_strength, decay_lambda,
created_at, last_accessed, boosts_json, tags_json, domain, source
created_at, last_accessed, boosts_json, tags_json, domain, source, encoder_id
FROM traces WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], |row| {
Expand All @@ -164,6 +189,7 @@ pub fn load_trace(conn: &Connection, id: &str) -> Result<Option<TraceRecord>> {
tags_json: row.get(8)?,
domain: row.get(9)?,
source: row.get(10)?,
encoder_id: row.get(11)?,
})
})?;
match rows.next() {
Expand Down Expand Up @@ -313,6 +339,7 @@ mod tests {
tags_json: "[]".to_string(),
domain: None,
source: None,
encoder_id: "lexical".to_string(),
}
}

Expand Down
Loading
Loading