diff --git a/zenith/src/zenith_harness/acp_runner.py b/zenith/src/zenith_harness/acp_runner.py index 26bfc8d..2d09ac7 100644 --- a/zenith/src/zenith_harness/acp_runner.py +++ b/zenith/src/zenith_harness/acp_runner.py @@ -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 @@ -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 diff --git a/zenith/src/zenith_harness/assets.py b/zenith/src/zenith_harness/assets.py index a0ffe26..eb3d802 100644 --- a/zenith/src/zenith_harness/assets.py +++ b/zenith/src/zenith_harness/assets.py @@ -111,6 +111,9 @@ def bundled_agents_dir(self, provider_name: str) -> Path: """Per-provider subagents directory (`bundled/providers//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]]: diff --git a/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md b/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md new file mode 100644 index 0000000..21df599 --- /dev/null +++ b/zenith/src/zenith_harness/bundled/prompts/orchestrator/hermes_addendum.md @@ -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 ` | 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. \ No newline at end of file diff --git a/zenith/src/zenith_harness/cli.py b/zenith/src/zenith_harness/cli.py index 7e20918..ec68677 100644 --- a/zenith/src/zenith_harness/cli.py +++ b/zenith/src/zenith_harness/cli.py @@ -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}") diff --git a/zenith/src/zenith_harness/coordinator.py b/zenith/src/zenith_harness/coordinator.py index 3fa17bb..3c11bef 100644 --- a/zenith/src/zenith_harness/coordinator.py +++ b/zenith/src/zenith_harness/coordinator.py @@ -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) ) diff --git a/zenith/src/zenith_harness/providers.py b/zenith/src/zenith_harness/providers.py index 4c24d51..69043c5 100644 --- a/zenith/src/zenith_harness/providers.py +++ b/zenith/src/zenith_harness/providers.py @@ -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", ) @@ -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, + ), } diff --git a/zenith/src/zenith_harness/storage.py b/zenith/src/zenith_harness/storage.py index da1d9c0..07b514b 100644 --- a/zenith/src/zenith_harness/storage.py +++ b/zenith/src/zenith_harness/storage.py @@ -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 @@ -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,