Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 10 additions & 2 deletions zenith/src/zenith_harness/acp_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,18 @@ def _augment_acp_command(command: str, provider) -> str:
For codex-acp this is the no-ask, no-sandbox combo — equivalent to
`codex --dangerously-bypass-approvals-and-sandbox`, which codex-acp
does not expose as a flag but accepts via `-c` overrides.

For hermes the command is passed through unchanged.
"""
if getattr(provider, "name", None) == "codex":
name = getattr(provider, "name", None)
if name == "codex":
return (
command
+ ' -c sandbox_mode="danger-full-access"'
+ ' -c approval_policy="never"'
+ ' -c model_reasoning_effort="xhigh"'
)
# hermes: no-op
return command


Expand All @@ -113,12 +117,16 @@ def _acp_subprocess_env(provider) -> dict[str, str]:
`/usr/bin/env node`, and pass sandbox-disable hints through env. The
command line also receives `sandbox_mode="danger-full-access"` in
`_augment_acp_command`.

For hermes the env is passed through unchanged.
"""
env = os.environ.copy()
if getattr(provider, "name", None) == "codex":
name = getattr(provider, "name", None)
if name == "codex":
# Env-var hints — harmless if codex ignores them.
env["CODEX_SANDBOX"] = "danger-full-access"
env["CODEX_DISABLE_SANDBOX"] = "1"
# hermes: no special env needed
return env


Expand Down
3 changes: 3 additions & 0 deletions zenith/src/zenith_harness/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ def bundled_agents_dir(self, provider_name: str) -> Path:
"""Per-provider subagents directory (`bundled/providers/<name>/agents/`)."""
return self.config.bundled_dir / "providers" / provider_name / "agents"

def bundled_prompts_dir(self) -> Path:
return self.config.bundled_dir / "prompts"

def _iter_skill_dirs(
self, project_id: str | None
) -> list[tuple[Literal["project", "personal", "bundled"], Path]]:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Hermes Addendum — Orchestrator Recall & Session Memory

This addendum applies when Hermes is the orchestrator provider. It extends the base orchestrator prompt with Hermes-specific recall mechanisms.

## Hermes Total Recall

Hermes maintains a persistent session database (`~/.hermes/state.db` with FTS5) that survives across sessions, compressions, and restarts. Use it as a **queryable append-only log** of every zenith state read.

### Recall Protocol

**Before every `inspect_project` / `advance_project` / `decide_attention` call:**
```
session_search(query="project_id mission_id", role_filter="user,assistant,tool")
```

**After every MCP tool call that returns zenith state:**
```
memory(action="add", target="memory", content="zenith state: {project_id} {mission_id} {state} {key_findings}")
```

### Query Patterns

| Need | Query |
|------|-------|
| Last known state | `session_search(query="project_id state", limit=1, sort="newest")` |
| All attention items | `session_search(query="attention_needed project_id", limit=10)` |
| Decision history | `session_search(query="decide_attention project_id", role_filter="assistant,tool")` |
| Contract changes | `session_search(query="contract VAL- project_id", limit=20)` |
| Failed tasks | `session_search(query="failed project_id", limit=10)` |

### Memory Promotion

When Hermes session compression drops context, **promote critical findings to Zenith disk state first:**

```python
# Before compression triggers (at ~50% threshold):
# 1. Write to mission.md / MEMORY.md / decisions/
# 2. Then let Hermes compress
```

The Zenith disk state (`brief.md`, `mission.md`, `contract/`, `decisions/`, `MEMORY.md`) is the **canonical memory**. Hermes session DB is the **audit trail**.

### Skills Loading

Hermes loads skills from:
- `.hermes/skills/` (project-specific, written by `zenith init`)
- `~/.hermes/skills/` (user-installed)
- Bundled skills (copied to `.hermes/skills/` by `zenith init`)

To load a skill mid-mission: `/skill engineering-mission-playbook` or `hermes -s engineering-mission-playbook chat ...`

### Compression Awareness

Default threshold: 50% context window. Lower for zenith missions:
```bash
hermes config set compression.threshold 0.3
hermes config set compression.target_ratio 0.2
```

### Slash Commands in Mission

| Command | Use |
|---------|-----|
| `/inspect` | Show current session info (tokens, tools, skills) |
| `/memory` | Search/add memory entries |
| `/session_search` | Query past zenith sessions |
| `/skill <name>` | Load a skill |
| `/reload-skills` | Re-scan skill directories |

---

**Rule:** Every zenith state transition (start_project → submit_plan → advance_project → attention → decide → advance → end_mission) should leave a trace in both Hermes session DB *and* Zenith disk state. The disk state is the contract; the session DB is the witness.
5 changes: 5 additions & 0 deletions zenith/src/zenith_harness/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,11 @@ def _setup_provider_assets(
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
body = loader.load_prompt_file("orchestrator", "system_prompt.md")
# Append Hermes-specific addendum for Hermes provider
if provider.name == "hermes":
addendum_path = loader.bundled_prompts_dir() / "orchestrator" / "hermes_addendum.md"
if addendum_path.exists():
body += "\n\n---\n\n" + addendum_path.read_text(encoding="utf-8")
path.write_text(body, encoding="utf-8")
click.echo(f"Created {path}")

Expand Down
17 changes: 10 additions & 7 deletions zenith/src/zenith_harness/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,18 @@ def _dispatch_batch(
handoffs = self._dispatch_requests(requests)

attention: list[AttentionItemInternal] = []
# Batch write all attempts to disk concurrently
self.store.save_attempts(
self.project_id,
mid,
[
(attempt.spawn_ts, attempt.task.id, handoffs[attempt.task.id])
for attempt in sorted(batch_attempts, key=lambda item: item.task.id)
]
)

for attempt in sorted(batch_attempts, key=lambda item: item.task.id):
handoff = handoffs[attempt.task.id]
self.store.save_attempt(
self.project_id,
mid,
attempt.spawn_ts,
attempt.task.id,
handoff,
)
attention.extend(
self._apply_handoff_collect(mid, attempt.task, handoff, attempt.spawn_ts)
)
Expand Down
15 changes: 14 additions & 1 deletion zenith/src/zenith_harness/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
from dataclasses import dataclass
from typing import Literal

ProviderName = Literal["claude", "codex"]
ProviderName = Literal["claude", "codex", "hermes"]
ConfigFormat = Literal["mcp_json", "codex_config"]

ORCHESTRATOR_PROVIDER_NAMES: tuple[ProviderName, ...] = (
"claude",
"codex",
"hermes",
)
WORKER_PROVIDER_NAMES: tuple[ProviderName, ...] = (
"claude",
"codex",
"hermes",
)


Expand Down Expand Up @@ -138,6 +140,17 @@ def _dedupe_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
orchestrator_prompt_output_path=".codex/orchestrator_prompt.md",
acp_supports_system_prompt=False,
),
"hermes": ProviderDefinition(
name="hermes",
skill_dirs=(".hermes/skills", ".agents/skills"),
skill_alias_dirs=(".hermes/skills", ".agents/skills"),
config_format="mcp_json",
default_worker_acp_command="hermes acp",
agent_output_dir=".hermes/agents",
orchestrator_prompt_output_path=".hermes/orchestrator_prompt.md",
acp_supports_system_prompt=True,
acp_runtime_mode=None,
),
}


Expand Down
18 changes: 18 additions & 0 deletions zenith/src/zenith_harness/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import os
import re
import shutil
import concurrent.futures

from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
Expand Down Expand Up @@ -443,6 +445,22 @@ def save_attempt(
atomic_write_text(md_path, attempt_to_markdown(handoff))
return json_path


def save_attempts(
self,
project_id: str,
mission_id: str,
items: list[tuple[str, str, WorkHandoff | ValidateHandoff]],
) -> None:
def save_one(item: tuple[str, str, WorkHandoff | ValidateHandoff]) -> None:
spawn_ts, node_id, handoff = item
self.save_attempt(project_id, mission_id, spawn_ts, node_id, handoff)

workers = min(32, (os.cpu_count() or 1) + 4)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# list() to force evaluation and wait for completion/errors
list(executor.map(save_one, items))

def read_attempt(
self,
project_id: str,
Expand Down
Loading