docs: reconcile plans/ status against actual code state

Audited all 10 active plan docs against the codebase (not just commit
titles). 5 were fully shipped and stale-tagged "Planned"/"In Progress" —
moved to done/ with verification notes. The other 4 got corrected
Planned→In Progress status plus concrete remaining-gap notes so the next
pass doesn't re-derive what's already done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 11:42:26 +02:00
parent 52e16e04ca
commit ef5a92269b
10 changed files with 59 additions and 16 deletions

View File

@@ -0,0 +1,266 @@
# 2026-07-08 — Nomos resident agent (renames Hermes)
**Status:** Done — 2026-07-11. N0-N3 (rename, agent loop, sessions/streaming,
UI entry point) all verified in current code. N4 (Matrix bridge, proactive
sessions) was explicitly out of scope and remains unstarted.
## Goal
Rename the gateway formerly known as **Hermes** to **Nomos** (from
*oikonomos*, the steward of the oikos — and avoiding confusion with Nous
Research's unrelated "Hermes Agent" product), and turn it from a keyword
router into a resident LLM-backed agent: a long-running service you converse
with, which reasons over the full 28-tool MCP surface, holds session context,
and is surfaced as the **main entry point of the control-room web UI**
([companion plan](2026-07-08-control-room-webui.md)). Oikos already has the
agentic substrate — policy classes, approval gating, `agent_activity`, an
OODA loop; this gives it the conversational front half.
## What exists vs what's added
Today `cmd/hermes/main.go` contains no LLM anywhere: `routeQuery` is
`strings.Contains` over ~6 phrases, `extractEntity` knows 5 hardcoded
services, and everything else silently falls back to `get_health_summary`.
But the hard plumbing already exists and is reused as-is:
- `mcpClient` (main.go:190) — working Streamable-HTTP MCP client
(initialize → session → `tools/call` with SSE frame parsing).
- `listTools()` (main.go:318) — currently dead code; extended (below) it
becomes the bridge that feeds MCP tool schemas to the LLM.
- `.agents/HERMES.md` / `hermes/SOUL.md` — the persona, becomes the system
prompt (renamed in N0).
- `agent_activity` hypertable + `correlation_id` conventions — where tool
calls get logged so the control room can show the agent working.
## Milestone N0 — the rename (Hermes → Nomos)
Do this first, as its own commit, before any agent code lands. Scope is the
**live service identity**; history (`archive/`, `plans/done/`, past
investigations) is never rewritten.
**Code & build**
- `cmd/hermes/``cmd/nomos/` (binary `nomos`; keep `serve` subcommand).
- Env vars in main.go: `HERMES_MCP_URL`, `HERMES_LISTEN`,
`HERMES_AGENT_SLUG``NOMOS_*`. Default slug `agent:nomos`;
`clientInfo.name``nomos`.
- `internal/config/config.go`: `HermesAgentSlug` field +
`OIKOS_HERMES_AGENT_SLUG``NomosAgentSlug` / `OIKOS_NOMOS_AGENT_SLUG`;
`hermesAgentID` in `internal/httpapi/server.go:140-149` renamed to match.
- `hermes/` directory → `nomos/` (`SOUL.md`, `config.yaml`,
`skills/homelab-ops/`); `tools/setup-hermes-soul.sh`
`tools/setup-nomos-soul.sh` with paths updated.
**Deploy**
- `compose/hermes/Dockerfile``compose/nomos/Dockerfile`; docker-compose
service `hermes:``nomos:` (docker-compose.yml:111, env at :63 and :121).
- Caddy: `hermes.hubris.network` vhost → `nomos.hubris.network`
(compose/caddy/Caddyfile.oikos:26); keep the old hostname as a `redir`
block for one transition window; add the DNS record. (These live in the
external caddy-conf repo / DNS — operator step.)
**Data (identity-preserving — do NOT create a new entity)**
- Live DB: `UPDATE entities SET slug='agent:nomos',
attributes = jsonb_set(attributes,'{name}','"nomos"') WHERE
slug='agent:hermes';` — relationships/audit reference the UUID, so history
survives the slug change. Ship as a migration so every environment gets it.
- `seeds/inventory.yaml:328` (`agent:hermes` entity) and `:541` (owns
relationship) → `agent:nomos`. Seed upserts by slug, so the migration must
run before seeding or the seed would mint a duplicate entity.
**Docs & persona**
- `.agents/HERMES.md` → `.agents/NOMOS.md`; update every referencing doc
(`AGENTS.md`, `CLIENTS.md`, `README.md`, `CONTRIBUTING.md`,
`.agents/operations/hermes-agent.md` → `nomos-agent.md`,
`.agents/operations/commands.md`, skills docs).
- ADR-0012 (`docs/adr/0012-hermes-oikos-interactions.md`): append a
"renamed to Nomos" note; don't rewrite the ADR.
**Explicitly out of scope**
- Matrix user `@hermes:hubris.network` (docker-compose.yml:103) — that's the
notifier's homeserver account; renaming it is an operational Matrix task,
optional and later.
- Legacy `bin/hermes` wrapper and `hermesd` on LXC 129 (referenced in
`.sops.yaml:89,110`) — separate legacy systems, untouched. Note: that
`.sops.yaml` entry shows an **OpenRouter API key path already exists** in
the secrets tree — reuse it for N1.
- Historical knowledge/investigation pages and executed plans.
**Verify N0:** `go build ./...`; `docker compose --profile full config`
resolves; `curl nomos.hubris.network/healthz` (and the old vhost redirects);
`SELECT slug FROM entities WHERE slug LIKE 'agent:%'` shows `agent:nomos`
with its original UUID; `grep -ri hermes` returns only history/ADR/legacy
hits.
## Architecture decisions
### Hand-rolled tool loop, not a hosted connector or off-the-shelf agent
Three ways to get an LLM driving the tools; decision is (3):
1. **Hosted MCP connector** (e.g. Anthropic's `mcp_servers` param): zero
loop code, but the provider's servers must reach the MCP endpoint over
the public internet. `mcp.hubris.network` is currently exposed *without
auth* — a hole the [gaps plan](2026-07-08-oikos-gaps-and-improvements.md)
says to close, not to build on. Keeping the MCP surface mesh-private is
the right posture for a homelab control plane.
2. **Off-the-shelf agent framework** (evaluated: Nous Research's
[Hermes Agent](https://hermes-agent.nousresearch.com/) — MIT, self-hostable,
multi-channel personal assistant with memory/subagents/browsing/code-exec;
also the naming-collision motivation for N0). Rejected for this role: it
is a desktop/personal-assistant framework with chat-platform frontends,
not an embeddable service — there is no clean `/chat` API for the
control-room UI to stream from; the oikos-native integration (sessions in
Postgres, `agent_activity` + correlation-id joins, approval rail) would
not exist; and its browsing/code-exec surface is far larger than a loop
confined to the 28 MCP tools. It (or any MCP client) remains usable as an
*external* agent against the MCP endpoint — orthogonal to the resident.
3. **Hand-rolled agent loop** (~200 lines): Nomos calls the LLM API with
tool definitions translated from `tools/list`, executes returned tool
calls through its *existing* `mcpClient` inside the mesh, feeds results
back, repeats until the model answers in text. Only conversation text
leaves the mesh; the tool transport stays private; the agent is confined
to the MCP surface by construction.
### LLM provider: OpenRouter, default model DeepSeek V4 Flash
- **OpenRouter** (OpenAI-compatible Chat Completions API) rather than a
single-vendor SDK: use the official `openai-go` client with
`base_url=https://openrouter.ai/api/v1` and `OPENROUTER_API_KEY` (secret
path already exists in `.sops.yaml`). Model choice becomes one env var.
- **Default model: `deepseek/deepseek-v4-flash`** — supports tool calling,
1M context, $0.09/M input / $0.18/M output. Use OpenRouter's **Exacto**
routing (highest measured tool-calling accuracy) since tool calls are this
agent's entire job; low temperature; strict system prompt.
- MCP tool `inputSchema` is already JSON Schema — exactly what
`tools[].function.parameters` expects — so translation is a field rename.
- **Privacy:** conversation text and tool results (hostnames, log excerpts)
transit OpenRouter and the upstream provider. Pin OpenRouter provider
preferences to ZDR / no-training providers in the request payload.
- A flash-class MoE will be weaker than frontier models on long multi-step
chains; mitigations are the iteration cap, Exacto routing, and bumping
`NOMOS_MODEL` per-deployment when a task warrants it.
## Design
### Agent loop (`cmd/nomos/agent.go`, new)
```
system prompt = SOUL.md content (mounted; provisioned by tools/setup-nomos-soul.sh)
tools = listToolsFull() // extend listTools() to return name, description, inputSchema
loop (max 15 iterations):
resp = chat.completions (OpenRouter, model, system, history, tools)
if resp has tool_calls:
for each: result = mcpClient.callTool(name, args) // existing code path
log to agent_activity (correlation_id = session turn id)
append assistant msg + tool-role result msgs to history
else: final text → stream to caller, persist turn
```
- Model: `deepseek/deepseek-v4-flash` default, `NOMOS_MODEL` override.
`max_tokens` and iteration cap configurable; hard per-turn budget so a
pathological loop can't burn the API bill.
- `OPENROUTER_API_KEY` from env / Infisical / SOPS (existing path).
- Mutations need no new guardrails: the agent's only write path is
`request_execution`, which flows through the existing risk-class /
approval machinery. The agent's actor identity is `agent:nomos`.
**Depends on gaps-plan bug A1** (approvals FK) — without that fix the
agent's config mutations dead-end silently, which is worse when a
conversational agent confidently reports "queued for approval".
### HTTP surface (`cmd/nomos/main.go`)
- `POST /chat` `{session_id?, message}` → **SSE stream** of typed events:
`text` (deltas), `tool_use` (name + args), `tool_result` (truncated),
`done` (session_id, usage). The web UI renders tool calls as inline chips
as they happen.
- `GET /sessions`, `GET /sessions/{id}` — history for the UI.
- `/query` kept for scripts/structured callers. The toy NLU is **removed**
(per gaps plan §C): a `query` with no `tool` returns "natural language
belongs to /chat" plus the tool list from the now-live `listTools()`.
- `/healthz` unchanged.
### Sessions (Postgres, new migration)
`agent_sessions (id, title, actor, created_at, last_active_at)` and
`agent_messages (id, session_id, role, content JSONB, created_at)` in the
shared oikos DB — Nomos gains a `DATABASE_URL` (it's in the same compose
stack). DB-backed rather than in-memory so conversations survive restarts
and the control room can list/replay them. Tool invocations additionally go
to `agent_activity` with the session's correlation_id, so the existing
agent-activity page and the ops ledger join up with zero new query paths.
### Connectivity & auth
- Compose: the `nomos` service (renamed in N0, `full` profile) gains
`OPENROUTER_API_KEY`, `NOMOS_MODEL`, `DATABASE_URL` env.
- Caddy: route `oikos.hubris.network/agent/*` → `nomos:8092` **behind the
same Authentik forward_auth** as the rest of the vhost, stripping the
`/agent` prefix. The web UI then calls same-origin `/agent/chat` — no
CORS, and EventSource/fetch-streaming work unmodified.
- Nomos enforces trusted-proxy headers (`X-Authentik-Username`) when
`NOMOS_TRUSTED_PROXY=true`, and finally implements the `mesh_only: true`
check that the config promises but `main.go` never enforces (gaps plan
B3). Direct :8092 access stays mesh-only for agents/scripts.
### Web UI entry point (amends the control-room plan)
The agent chat becomes the **home view** of the control room (`/ui/#/`):
- Center: conversation pane (streamed text, expandable tool-call chips
showing args/results, correlation-id links into the ops ledger).
- Right rail: live context — pending approvals with approve/deny buttons,
recent events, health strip. When the agent's `request_execution` needs
approval, the approval card appears in the rail *mid-conversation* (via
the `approval.created` SSE event) and can be decided without leaving chat.
That's the whole product in one screen: ask → watch it act → approve →
watch it complete.
- A persistent chat drawer is available from every other page.
- Session list in the nav; sessions resumable.
### Later (explicitly out of scope for v1)
- Matrix bridge: Nomos as a Matrix bot in the operator room, reusing the
notifier's homeserver credentials — same `/chat` loop, different frontend.
- Proactive mode: agent opens a session itself when a signal fires
(escalation-with-context instead of a bare alert).
## Milestones
- **N0 — rename:** see above; standalone commit, deployable on its own.
- **N1 — loop:** `agent.go` + openai-go (OpenRouter base URL);
`listToolsFull()`; `/chat` (non-streaming JSON first); remove toy NLU, fix
`/query` fallback + help. Verify by curl: multi-tool question ("what's
degraded and what depends on it?") produces chained
`get_health_summary` → `get_blast_radius` calls.
- **N2 — state + streaming:** sessions migration, DB persistence, SSE
streaming on `/chat`, `agent_activity` logging, budgets.
- **N3 — UI entry point:** home chat view + context rail + drawer in the
control-room SPA (needs control-room M1; rail approvals need M2's
`approval.created` event and gaps-plan A1).
- **N4 (optional):** Matrix bridge, proactive sessions.
## Files
```
cmd/nomos/{main.go, agent.go, store.go} # renamed + loop + sessions
internal/config/config.go, internal/httpapi/server.go # slug config rename
nomos/{SOUL.md, config.yaml, skills/} # renamed dir; drop query_routing
tools/setup-nomos-soul.sh
migrations/0xx_rename_agent_hermes_to_nomos.up.sql
migrations/0xx_agent_sessions.up.sql
seeds/inventory.yaml
compose/nomos/Dockerfile, docker-compose.yml, Caddyfile.oikos (/agent route, vhost)
.agents/NOMOS.md + referencing docs; docs/adr/0012 note
web/src/pages/Chat.svelte + lib/stores/chat.ts # control-room home view
go.mod # openai-go
```
## Verification
- N0: see milestone N0 verify list.
- N1: `curl -N /agent/chat` with a question requiring 2+ tools; confirm the
tool chain in the response and rows in `agent_activity`.
- N2: restart nomos mid-session, resume by session_id, history intact.
- N3: from the UI home, ask Nomos to restart a low-risk service; watch the
tool chips stream, the execution appear in the rail, and (for a gated
action) the approval card arrive and be decidable in place.

View File

@@ -0,0 +1,260 @@
# 2026-07-08 — Plan vs implementation cross-reference
**Status:** Done — 2026-07-11. Every action this audit recommended has a
corresponding follow-up commit (consolidation `7660e56`, client lifecycle
`efa66c7`/`fcd9f23`/`28ab9b8`, comprehensive audit `43aaf2a`,
DB-as-source-of-truth `a3ebd12`, MCP tool surface `7c6cffb`, apps/105 webhook
cleanup `cefeba7`). Its own Prometheus finding (0% done) still matches the
current state — see [2026-07-05-oikos-prometheus-lxc.md](2026-07-05-oikos-prometheus-lxc.md),
still Planned.
## Goal
Snapshot each active plan against the actual codebase on disk. No action taken
— this is the map from which the next round of work is drawn.
---
## 1. Consolidate Oikos on mac-mini (2026-07-06)
**Plan status:** Done (2026-07-08) — Code complete. Scripts, runbooks, safeguards in place.
**Cutover item status:**
| Item | Status |
|------|--------|
| Infisical bootstrap | **CODE COMPLETE.** `bootstrap-infisical.sh` (138 lines), Infisical Go backend, Docker service with Redis, `.env` configured, ADR-0010. `OIKOS_SECRET_BACKEND=infisical` set in `.env`. Needs operator to run bootstrap script on mac-mini. |
| Watchdog | **DONE.** `scripts/watchdog.sh` rewritten: dual-path health check (LAN `192.168.8.175:8090` + mesh `100.122.0.10:8090`). Alerts only when BOTH paths fail. Partial failure (one path down) logged but not paged. External to Docker stack (runs on apps/105). |
| Rollback drill | **DONE.** `scripts/rollback.sh` works (rehearsed 2026-07-07, recovered to SHA 7ac2521 with 20 tools). `docs/operations/rollback.md` runbook created. |
| Rollback verify + re-deploy | **DONE.** Verify is in rollback script (30-attempt health check loop). Re-deploy via separate `deploy.sh` invocation. Runbook documents the full cycle. |
| Deploy pre-dump | **DONE.** `deploy.sh` now runs `pg_dump` before every deploy → `/opt/oikos/backups/pre-deploy-<sha>.sql`. Rollback script recovers from this dump. |
| apps/105 cleanup | **ALMOST DONE.** Gitea webhooks (ids 10, 11, 14) deleted 2026-07-08. LXC archival remains: `ssh hubris pct stop 105 && pct snapshot 105 archive-$(date +%Y%m%d)`. |
**Score: 98%** (code complete; 2 operational actions require operator on Proxmox/Gitea)
---
## 2. Oikos Prometheus LXC (2026-07-05)
**Plan status:** Planned
**Reality check:**
| Claim | Reality |
|-------|---------|
| No LXC exists | True |
| "Extend oikos/scheduler.py" probes | **Stale.** `oikos/scheduler.py` was deleted. Plan references dead Python. |
| "bin/homelab" CLI for provisioning | **Stale.** `bin/homelab` directory deleted. Go binary handles operations. |
| Undocumented LXC 131 | **Unchanged.** Never investigated. |
**Score: 0%**
**Blockers:**
- Plan needs rewrite to reference Go scheduler (`internal/scheduler/`) and `check_defs` table
- LXC 131 mystery unresolved — may collide with Prometheus VMID
---
## 3. Client Lifecycle in Go (2026-07-07)
**Plan status:** Done (2026-07-08)
**Initial audit was incorrect — the API was already fully implemented.** Discovery:
| Phase | Status |
|-------|--------|
| Phase 1: enrollment API (`POST /api/v1/clients/enroll`) | **DONE.** impl.go:1091. Generates age keypair, stores pubkey in attrs, sets state→provisioning. |
| Phase 1: `GET /api/v1/clients/{slug}/secrets` | **DONE.** impl.go:1245. Lists secrets scoped to client prefix from secretsManager. |
| Phase 1: `GET /api/v1/clients/{slug}/context` | **DONE.** impl.go:1195. Returns context_version + changed file/tool/sops deltas. |
| Phase 2: `POST /api/v1/entities/provision` | **DONE.** impl.go:1271. Creates entity in planned, validates slug uniqueness, inserts provisioning_steps, creates hosts relationship, emits audit+events. |
| Phase 2: `GET /api/v1/entities/{slug}/provision/status` | **DONE.** impl.go:1380. Polls provisioning_steps table for step-by-step progress. |
| Phase 2: lifecycle transitions (activate/deprecate/destroy/fail) | **DONE.** impl.go:933. PATCH /entities/{id} validates transitions against lifecycle_defs, rejects illegal transitions with 409. |
| Phase 3: MCP tools (`whoami`, `explain`, `preflight`, etc.) | **DONE.** All 6 in mcp/server.go. |
| Tests | **DONE.** `client_lifecycle_test.go`: 324 lines, full e2e: planned→enroll→provisioning→active→migrating→deprecated→failed. Provision rejection, relationship edges, blast radius verified. |
**Score: 100%** (12/12 verified; see below)
**12-point verification (2026-07-08):**
| # | Item | File:Line |
|---|------|-----------|
| 1 | POST /clients/enroll | `impl.go:1099` — age keypair, state→provisioning, audit |
| 2 | GET /clients/{slug}/context | `impl.go:1203` — context_version + file/tool/sops deltas |
| 3 | GET /clients/{slug}/secrets | `impl.go:1253` — scoped secret key listing |
| 4 | POST /entities/provision | `impl.go:1279` — slug validation, provisioning_steps, hosts edge |
| 5 | GET /entities/{slug}/provision/status | `impl.go:1388` — step-by-step polling |
| 6 | Lifecycle transitions | `impl.go:933` — PATCH /entities/{id} with lifecycle_defs validation |
| 7 | MCP tools (6) | `mcp/server.go:602-723` — whoami, explain, preflight, history, snapshot, secrets |
| 8 | Precondition checks | `impl.go:1459-1551` + `ontology/validate.go:127-246` — dual impl |
| 9 | bootstrap.sh thin-client | No git clone; curl-fetched files; API enroll; context poller install |
| 10 | tools/context-poller.sh | 73 lines — polls /context, fetches deltas, re-runs setup scripts |
| 11 | migration 012 | provisioning_steps, context_version, context_files, enrolled_at |
| 12 | openapi.yaml endpoints | All 5 endpoints + 7 schemas defined; codegen in sync |
**Minor deviations from plan spec:**
- `internal/db/queries/clients.sql` not created — queries are inline in impl.go (same DB operations)
- `internal/secrets/infisical.go` lacks `CreateMachineIdentity` — enrollment uses synthetic IDs until Infisical is bootstrapped (consolidation plan #1)
**Transition precondition enforcement** (Phase 5):
- Hard checks: `no-inbound-edges`, `backups-verified`, `secrets-revoked`, `ingress-dns-removed`, `age-key-enrolled-if-needed`, `mesh-joined-if-needed`, `health-check-answering`, `doc-page-complete`
- Soft checks (operator intent): `inventory-entry`, `cancelled-note`, `preflight-passed`, `error-summary`, `replacement-live-or-role-retired`, `post-verify-passed`, `recovery-verified`, `written-off`, `ingress-live-if-public`, `doc-page-stub`
- Parsed from `lifecycle_defs.transitions` JSONB `{requires: [...]}` at mutation time
---
## 4. Comprehensive Audit & Next Steps (2026-07-07)
**Plan status:** Done (2026-07-08)
**Reality check:**
| Audit item | Status |
|-----------|--------|
| Remove 9 superseded `oikos/*.py` files | **DONE.** All deleted. Only `gen-topology.py` + `gen_topology_lib.py` remain. |
| `bin/homelab` audit/removal | **DONE.** `bin/` directory doesn't exist. |
| `oikos/cards/` (45 files) audit/removal | **DONE.** Directory deleted. |
| `.hermes/plans/` (7 files) → `archive/hermes-plans/` | **DONE.** All 7 files archived. |
| TRMNL plan marked done in index | **DONE.** Already in Done table. |
| Create wiki pages for seanime (133), romm (134) | **DONE.** Both documented in `seeds/knowledge.yaml`. Wiki is DB-native now. |
| Update strong.md + hubris.md guest lists | **ARCHIVED.** Host pages are in `archive/knowledge/hosts/`. DB is source of truth. |
| Regenerate topology.md | **ARCHIVED.** Topology lives in DB relationships + `seeds/inventory.yaml`. |
| Prometheus plan — update Python → Go references | **DONE.** References updated to Go scheduler, check_defs, MCP request_execution. |
| ADR-0011 (Go rewrite completion) | **COVERED.** ADR-0011 exists (client lifecycle). Consolidation plan (1,540 lines) is the authoritative record. |
| Traefik reference audit | **VALID.** VPS still runs traefik for public termination. References in seeds are accurate. |
| Infisical bootstrap | **PENDING.** Cross-plan item, belongs to consolidation plan (#1). |
| Watchdog tested | **PENDING.** Cross-plan item, belongs to consolidation plan (#1). |
| Rollback drill | **PENDING.** Cross-plan item, belongs to consolidation plan (#1). |
| apps/105 cleanup | **PENDING.** Cross-plan item, belongs to consolidation plan (#1). |
**Score: 100%** (audit-specific items complete; remaining items owned by consolidation plan)
**4 operator decisions:** already resolved — `oikos/cards/` deleted, `bin/homelab` gone.
Infisical + apps/105 decisions belong to consolidation plan.
---
## 5. DB as Source of Truth (2026-07-07)
**Plan status:** Done (2026-07-08)
**Reality check:**
| Phase | Status |
|-------|--------|
| Phase 1: `seeds/knowledge.yaml` seed format | **DONE.** 24 documents + 6 investigations + 3 runbooks. |
| Phase 1: `content_hash` column (migration 010) | **DONE.** |
| Phase 1: `search` tsvector column + GIN index (migration 011) | **DONE.** |
| Phase 1: Knowledge ingestion logic (`internal/knowledge/seed.go`) | **DONE.** |
| Phase 2: convert wiki → seeds, archive originals | **DONE.** `archive/knowledge/` contains all originals. `knowledge/` directory removed. |
| Phase 3: `search_knowledge` with PostgreSQL FTS | **DONE.** Both MCP and HTTP use `ts_rank` + `ts_headline` + `plainto_tsquery`. |
| Phase 3: `get_entity_knowledge` MCP tool | **DONE.** Walks relationships to return docs/investigations/runbooks linked to entity. |
| Phase 3: `GET /api/v1/knowledge/search` (HTTP) | **DONE.** Full FTS with ranked results and snippets. |
| Phase 3: `GET /api/v1/knowledge/{entitySlug}` (HTTP) | **DONE.** Aggregates documents, investigations, runbooks via relationship edges. |
| Phase 4: agent conventions for knowledge cycle | **DONE.** AGENTS.md documents `search_knowledge` + `get_entity_knowledge`. Export round-trip via `oikos export`. |
**Score: 100%**
---
## 6. MCP Tool Completion / bin/homelab Migration (2026-07-07)
**Plan status:** Done (2026-07-08)
**Reality check:**
| Phase | Status |
|-------|--------|
| `tail_log` — journalctl via SSH | **DONE.** `internal/mcp/server.go:466-488` |
| `get_service_status` — systemctl is-active/enabled | **DONE.** `internal/mcp/server.go:489-509` |
| `ping_service` — HTTP reachability from entity_status | **DONE.** `internal/mcp/server.go:438-465` |
| `list_lxcs` — all LXCs with ID/host/IP/state | **DONE.** `internal/mcp/server.go:425-437` |
| `get_lxc_state` — pct status from Proxmox | **DONE.** `internal/mcp/server.go:511-562` |
| `request_execution` routing: restart | **DONE.** Immediate execute via SSH. |
| `request_execution` routing: systemctl (reload/restart) | **DONE.** Immediate; enable/disable gated as config_mutation. |
| `request_execution` routing: pct_exec | **DONE.** Resolves Proxmox host via relationships. |
| `request_execution` routing: apt_upgrade (audit/upgrade) | **DONE.** Audit immediate; upgrade gated as config_mutation. |
| `get_execution_status` | **DONE.** `internal/mcp/server.go:339-365` |
| Matrix approval escalation | **DONE.** Notifier sends Matrix messages with approval tokens. Stores `matrix_event_id`. Polls for ✅/❌ reactions via `/relations/{id}/m.annotation`. Calls DecideApproval internally on reaction detection. Token verification in DecideApproval endpoint. |
| Delete `bin/homelab` | **DONE.** Directory gone. |
| Delete `bin/oikos` | **DONE.** Directory gone. |
| Update AGENTS.md | **DONE.** Full 21-tool surface documented. Stale `homelab` CLI references removed. |
**End-to-end approval flow:**
```
Hermes → request_execution (config_mutation) → creates approval record
Notifier → generates HMAC token → sends Matrix message → stores event_id
Operator → reacts ✅ on Matrix message
Notifier → polls /relations/{eventId}/m.annotation → detects ✅
Notifier → POST /api/v1/approvals/{id}/decision {decision:"approve"}
DecideApproval → verifies token (if provided) → executes gated SSH command
```
**Score: 100%**
---
## Summary matrix
| Plan | Score | Key blocker |
|------|-------|-------------|
| Consolidation | 98% | Code complete. 2 operator actions: apps/105 webhooks + LXC archive |
| Prometheus LXC | 10% | Not provisioned; plan references updated to Go |
| Client lifecycle | 100% | DONE — 12/12 verified |
| Audit & next steps | 100% | DONE — all cleanup resolved |
| DB as source of truth | 100% | DONE — wiki archived, FTS live |
| MCP tool surface | 100% | DONE — Matrix approval loop + token verification wired |
---
## Drift catalog (index vs reality)
| Issue | Detail |
|-------|--------|
| TRMNL plan still in Active | `2026-06-24-trmnl-plugins-lxc.md` is in `done/` but `index.md` Active table hasn't been updated |
| Grimmory plan internal status | File in `done/` but internal status header says `in-progress` |
| `.hermes/plans/` directory | Missing from disk. 7 executed plans lost. Recoverable from git history. |
| Prometheus plan stale refs | References `oikos/scheduler.py` (deleted) and `bin/homelab` (deleted) |
| Consolidation cutover checklist | 5 items open per `scripts/cutover-checklist.md` |
| Audit plan decisions | 4 operator decisions listed as outstanding (section 7) |
---
## Changelog
### 2026-07-08 — plan 1 completed (code)
Consolidation at 98%. Dual-path watchdog.sh, pre-deploy pg_dump in deploy.sh,
rollback runbook created. Infisical bootstrap scripts + Go backend complete.
Two operational items remain (apps/105 webhooks + LXC archive — operator on
Proxmox/Gitea). All 5 cutover checklist items now resolved or documented.
### 2026-07-08 — plan 3 fully completed
Client lifecycle at 100%. Transition precondition enforcement added: no-inbound-edges,
backups-verified, secrets-revoked, ingress-dns-removed, age-key-enrolled, mesh-joined,
health-check-answering, and doc-page-complete are checked before transitions. Soft
preconditions (inventory-entry, cancelled-note, etc.) confirmed by operator intent.
Thin-client distribution: bootstrap.sh already rewritten; standalone context-poller.sh
created in tools/.
### 2026-07-08 — plan 4 completed
Audit plan at 100%. All cleanup resolved: hermes plans archived to
archive/hermes-plans/, TRMNL in Done, seanime/romm in seeds (no wiki pages
needed), Prometheus plan references updated to Go. Remaining items (cutover,
Infisical, watchdog, rollback, apps/105) belong to consolidation plan.
### 2026-07-08 — plan 5 completed
DB as source of truth at 100%. Wiki files already archived to `archive/knowledge/`.
`seeds/knowledge.yaml` has 24 docs + 6 investigations + 3 runbooks. HTTP knowledge
endpoints already used full PostgreSQL FTS. MCP `search_knowledge` upgraded from
ILIKE to `ts_rank`/`ts_headline`. MCP `get_entity_knowledge` tool added, walks
relationship edges to return all docs/investigations/runbooks for an entity.
### 2026-07-08 — plan 6 completed
MCP tool surface at 100%. Matrix approval webhook loop implemented: notifier
sends Matrix messages, polls for ✅/❌ reactions via `/relations/{id}/m.annotation`,
calls DecideApproval API internally. Token verification added to DecideApproval.
AGENTS.md updated with full 21-tool surface and policy-gated mutation path.
Migration 013 added `matrix_event_id` + `alert_sent_at` to approvals table.
### 2026-07-08 — initial audit
Cross-referenced all 6 active plans against codebase on disk. Consolidation
infrastructure is solid; client lifecycle and Prometheus are the gap.

View File

@@ -0,0 +1,164 @@
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
**Status:** Done — 2026-07-11. All 5 findings fixed on `main`
(`49c37fe fix: chat session reliability, cost, and hygiene`): empty/refusal
retry guard in `agent.go`, bulk-tool guidance in `SOUL.md`, tool-result
truncation in `store.go`, `get_state_snapshot` filtering, and session
delete + generated titles.
## Goal
Fix concrete problems found by inspecting the *actual* production Nomos chat
data (`agent_sessions`/`agent_messages` on the `oikos` Postgres, 24 sessions /
52 messages as of 2026-07-09), not a code-review of the UI in the abstract.
Findings below are backed by real rows, not hypotheticals.
## How this was investigated
Queried `oikos-postgres-1` directly (`docker exec oikos-postgres-1 psql -U oikos
-d oikos`) since this runs on mac-mini, the same host as the production
containers. Pulled session list, per-message content sizes, tool-call
breakdowns, and cross-checked against `cmd/nomos/agent.go` to explain what was
observed.
---
## Findings
### 1. ~17% of turns come back completely empty, silently
4 of 24 sessions ("hi" ×3, "what services are healthy?") have an assistant
message with `text=""` and zero tool calls — the model returned a blank
completion. `cmd/nomos/agent.go:175-183` treats `len(msg.ToolCalls) == 0` as a
normal final answer and emits `text: ""` + `done`. No `error` event fires, so
[chat.ts](../web/src/lib/stores/chat.ts) never sets `$error`, and
[Chat.svelte](../web/src/pages/Chat.svelte) renders a permanently-blank
assistant bubble (the "…" typing dots only show while `$streaming` is true;
once `done` fires they vanish, leaving nothing). The user has no idea the turn
failed and no obvious way to retry — they have to notice the silence and
retype.
**Fix:** in `agent.chat()`, if `msg.Content == "" && len(msg.ToolCalls) == 0`,
treat it as a retryable failure: log it, retry once against the provider
before giving up, and if still empty, emit a real `error` event instead of an
empty `text`/`done` pair. On the frontend, surface a "Nomos didn't respond —
retry?" affordance on empty assistant messages rather than a silent blank
bubble.
### 2. A tool-heavy turn came back as a canned non-English refusal
The session "what are the termals of hubris?" ran 22 tool calls and then
returned, verbatim: `关于这个问题,我没有相关信息,您可以尝试问我其它问题,我会尽力为您解答~`
("I don't have relevant information on this, try asking me something else").
This is after successfully gathering data via tools — the model discarded its
own tool results and emitted a boilerplate deflection in the wrong language.
`NOMOS_MODEL` is currently a DeepSeek flash-tier model on OpenRouter, which is
consistent with this kind of degraded-tier fallback text leaking through.
**Fix:** add a response-quality guard in `agent.chat()` — if the final text
doesn't match the conversation's language/looks like a canned refusal (simple
heuristic: non-ASCII-majority reply to an ASCII-majority conversation, or
matches a small denylist of known refusal boilerplate), treat it like the
empty-response case (retry, then surface an error rather than showing it to
the operator as a real answer). Separately, reconsider whether the flash-tier
model is worth the latency/cost tradeoff given it's producing failures like
this in a small sample — worth an eval pass against a couple of alternative
OpenRouter models on the same 24 real prompts before deciding.
### 3. Simple fleet questions fan out into dozens of individual tool calls
"Are any of the proxmox hosts saturated?" (2 hosts) triggered **70 tool
calls** in one turn, 21 of them individual `get_lxc_state` calls — one per LXC
container — instead of using the already-available `list_lxcs()` bulk tool.
Similar pattern in "What should be updated with high priority?" (68 calls) and
"What needs updating?" (54 calls). Each `get_lxc_state` is a live `pct status`
SSH round-trip to the Proxmox host, so this is 21 sequential SSH round trips
to answer a question `list_lxcs()` already answers in one call. This is the
direct cause of both slow responses and the huge persisted payloads in
finding 4.
**Fix:** two angles, not mutually exclusive:
- **Prompt-level**: tighten the Nomos system prompt (`nomos/SOUL.md`) to
explicitly prefer bulk tools (`list_lxcs`, `get_state_snapshot`,
`query_metrics`) over per-entity tools when the question is fleet-wide, and
only fall back to `get_lxc_state`/`tail_log` for a specific named entity.
- **Tool-level**: `get_lxc_state` already exists per-slug; consider whether
`list_lxcs()`'s summary is actually sufficient for "saturated" (CPU/mem %
per container) — if it's missing that field, that's *why* the model loops
per-container, and the real fix is enriching `list_lxcs()` rather than
prompting around the gap.
### 4. Tool results are persisted raw and unbounded, inflating messages to 100KB+
Message content sizes in `agent_messages.content` (JSONB) range up to
**106KB** for a single assistant turn. Even a plain "hi" greeting produced a
44KB message, because `get_state_snapshot()`'s full result — every entity in
the DB, including ~15 `document:containers/*` rows that are all
`state: <nil>, health: unknown` and contribute nothing — gets embedded
verbatim in the `tool_calls[].result` field and stored as-is
(`cmd/nomos/store.go:68-76` just JSON-inserts whatever the tool returned).
This bloats the DB, and every time a session is opened via
[loadSessionMessages](../web/src/lib/stores/chat.ts#L56) or the
[SessionRail](../web/src/lib/components/SessionRail.svelte)/
[Sessions](../web/src/pages/Sessions.svelte) page loads history, the browser
downloads and parses all of it just to render a collapsed tool-call summary.
**Fix:**
- Filter `get_state_snapshot()`'s result server-side (in the MCP tool, not
the agent) to drop entities with no meaningful state/health signal, or add
a `type` filter param the agent can pass.
- In `store.saveMessage`, cap persisted tool-result size (e.g. truncate to a
few KB with a `"...truncated, N bytes"` marker) — the full result already
served its purpose informing that turn's answer; historical replay
(`agent.chat()`'s history-replay loop at `agent.go:122-137`) doesn't need
the full blob, just enough for the model to know what it already checked.
### 5. No session hygiene: duplicate/typo'd titles, no delete/archive
Session titles are the raw, unprocessed first user message
(`store.createSession`), with no dedup, normalization, or cleanup. Real
production titles include **6 sessions titled "hi"**, **2 titled "say ok"**,
and typos preserved verbatim ("what are the **termals** of hubris?", "whats
the **termans** of strong", "Are any of the **proxomox** hosts saturated?").
Neither [Sessions.svelte](../web/src/pages/Sessions.svelte) nor
[SessionRail.svelte](../web/src/lib/components/SessionRail.svelte) nor the
`store`/API layer (`cmd/nomos/store.go`, `web/src/lib/api.ts`) has any delete
or archive path — grepped the whole stack, confirmed absent. Throwaway test
sessions accumulate forever with no way to clean them from the UI.
**Fix:**
- Add `DELETE /sessions/{id}` to the nomos gateway + a matching store method
and wire a delete affordance into `SessionRail`/`Sessions` (hover trash
icon, confirm on click).
- Generate titles from the assistant's actual answer once the turn completes
(or a cheap follow-up summarization call) instead of the raw first message,
so distinct "hi" sessions become distinguishable by what was actually
discussed.
---
## Implementation order
1. **Empty-response + refusal-leak guard** (`cmd/nomos/agent.go`) — highest
user-visible impact, smallest change, no schema/API changes.
2. **Bulk-tool prompting fix** (`nomos/SOUL.md`) — cheap, directly cuts
latency and tool-call volume; re-run the same 24 real prompts against the
updated prompt to confirm `get_lxc_state` fan-out drops.
3. **Tool-result truncation on persist** (`cmd/nomos/store.go`) — bounds
future DB growth; pair with a one-off cleanup pass on the 52 existing rows
if the table needs to be shrunk immediately.
4. **`get_state_snapshot` filtering** — coordinate with whichever MCP tool
file defines it; verify with `jsonb_pretty` on a fresh "hi" session that
payload drops well below the current ~44KB.
5. **Session delete + title generation** — UI + gateway change, lowest risk,
can ship independently of 1-4.
## Verification
- Re-run the same 24 real user prompts (recorded in this plan's investigation)
against the patched agent; confirm zero empty/refusal-leak responses and
`get_lxc_state`-style fan-out drops to O(hosts) not O(containers).
- `docker exec oikos-postgres-1 psql -U oikos -d oikos -c "SELECT max(length(content::text)) FROM agent_messages;"`
before/after — expect the ceiling to move from ~106KB to low single-digit KB.
- Manually delete a test session via the new UI affordance, confirm it's gone
from both `SessionRail` and the `agent_sessions` table.

View File

@@ -0,0 +1,161 @@
# 2026-07-09 — Session execution, UX, and learning improvements
**Status:** Done — 2026-07-11. Hard blocker and all major items verified:
`pct_create` wired into `request_execution`, `ToolCallGroup` collapse +
live status, `InlineApproval` blast radius, `http_get` tool, `session-review`
skill. Two minor secondary items not implemented: `list_lxcs` CPU/mem
enrichment, and a dedicated `get_tools_summary` tool (SOUL.md has general
bulk-tool guidance instead).
## Goal
Fix the hard blocker and UX issues found in the latest production Nomos session
(`b9c5de7c-e0b5-424c-9149-9fd45b2e7011` — "deploy TypeType as an LXC on strong").
The user asked Nomos to deploy an LXC; Nomos gathered data, proposed a plan, but
at the final step ("run all yourself") said it *couldn't*`request_execution`
has no `pct_create` action. The user's objective was not achieved.
## Session analysis
10 messages (5 user / 5 assistant), 150 tool calls, zero LXC created.
| Msg | Role | Tool calls | Top tools | Summary |
|-----|------|-----------|-----------|---------|
| 0 | user | 0 | — | "deploy TypeType on strong as LXC" |
| 1 | assistant | 40 | get_entity(20), search_knowledge(12) | Fleet scan, entity detail per host/LXC |
| 2 | user | 0 | — | "it's github.com/Priveetee/TypeType, use tube.hubris.network" |
| 3 | assistant | 2 | search_knowledge(2) | MCP has no web fetch tool → couldn't read GitHub |
| 4 | user | 0 | — | "I think you can figure out those" |
| 5 | assistant | 60 | search_knowledge(32), request_execution(14) | Built the plan, tried provisioning, failed silently |
| 6 | user | 0 | — | "connect to strong media disk, no youtube login, proceed" |
| 7 | assistant | 44 | search_knowledge(10), get_entity(10), request_execution(8) | Full deployment plan laid out |
| 8 | user | 0 | — | "run all yourself" |
| 9 | assistant | 4 | request_execution(4) | **"I can't run pct create — only pct_exec, systemctl, restart, apt_upgrade"** — failure |
## Findings
### 1. HARD BLOCKER: `pct_create` missing from `request_execution`
`request_execution` (`internal/mcp/server.go:264-376`) supports `restart`,
`systemctl`, `pct_exec`, `apt_upgrade`. The actuator has `ProvisionLXC()`
(`internal/actuator/actuator.go:209`) that calls `pct create` via SSH, but it
is only wired into the auto-act signal pipeline — Nomos has no way to invoke
it through MCP.
Nomos's final response laid out the exact `pct create` command the operator
needs to run manually on the Proxmox host. That's a dead end for the user, who
expected the agent to execute from chat.
**Fix:**
- Add `pct_create` action to `request_execution` handler.
- Wire it to the existing `ProvisionLXC()` function.
- Classification: `config_mutation` — requires operator approval. Once approved
(from the Ops page or Matrix), the actuator picks it up and provisions the
LXC with the step callback reporting progress.
- Alternatively (for the "run from chat" expectation): add a chat-level approval
flow — when Nomos proposes `request_execution` with `pct_create`, the frontend
renders an inline "Approve" button in the chat bubble. Operator clicks → it runs.
This is the UX the user described: "allow the agent to run things directly from
the chat I'm in."
### 2. UI: ToolCallGroup expanded by default during streaming — no status animation
`ToolCallGroup.svelte` uses `<details open>` when `active=true`. During a
streaming turn with 40+ tool calls, the collapsed group fills the viewport
with raw JSON. The summary header shows only a static icon and "N tools" text.
**What happens now:**
- Streaming starts → group opens and stays open → all tool results visible as raw JSON.
- When streaming ends → auto-collapses. No animation.
- Header shows `WrenchIcon` pulsing OR `CheckIcon` OR `XIcon` — but no live
running count, no per-tool status in the collapsed summary bar.
**What should happen:**
- Group starts **collapsed** by default. The summary header shows a live animated
status: "⠋ Running get_lxc_state (caddy)… [2/40 done]" with the active tool
name + a progress fraction, updating in real time.
- When a tool completes, the header briefly reflects it ("✓ get_lxc_state (caddy)")
before moving to the next.
- Clicking the summary expands the group with a smooth animated open/close
(replacing native `<details>` with bits-ui `Collapsible` + CSS transition).
- On load from history (not streaming), always starts collapsed.
**Fix:**
- Replace `<details open>` with bits-ui `Collapsible` component (already in
`web/src/lib/components/ui/collapsible/`).
- Add `animate-pulse` to the chevron icon during streaming (user sees motion).
- Add a `statusText` derived that shows the in-progress tool name + count.
- CSS animation: `Collapsible.Content` supports `forceMount` with transitions.
### 3. No web-fetch tool → Nomos can't read GitHub READMEs
Msg 3: Nomos needed to inspect `github.com/Priveetee/TypeType` to understand the
stack. It used `search_knowledge` (DB FTS), which returned nothing because the
repo isn't in the DB. The agent had no way to fetch external URLs.
The MCP has 27 tools (21 listed in AGENTS.md + 6 more added since), but none
for HTTP/web fetching. Nomos can only query the DB or execute SSH commands on
existing hosts.
This forced the human to provide context that should have been machine-read.
**Fix options (non-blocking):**
- Add an `http_get` MCP tool that returns sanitized body text (strip scripts,
truncate to 8KB). Rate-limited per-turn.
- Or: add `web_fetch` as a first-class action in the MCP gateway itself, since
the gateway container already makes outbound HTTP calls to OpenRouter.
### 4. Task: Bulk-tool awareness already in SOUL.md but not enough
The SOUL.md already says "prefer `list_lxcs` over `get_lxc_state` for fleet-wide"
(line 27-28). But msg 1 still made 40 calls. The issue:
- `get_entity` was called 20 times (one per entity found by `list_entities`).
`list_entities` already returns all entities; the agent wanted per-entity
detail, which is redundant since `explain` or `get_state_snapshot` gives the
same info in one call.
**Fix (already planned in `2026-07-09-chat-sessions-improvements.md` finding 3):**
- Enrich `list_lxcs` with CPU/memory utilization so the model doesn't feel it
needs `get_lxc_state` per container.
- Add `get_tools_summary` to SOUL.md preamble that lists each tool's intended
use and warns about N+1 call patterns.
### 5. Skill gaps
| Missing | Why | Where to add |
|---------|-----|-------------|
| `http_get` / `web_fetch` | Agent can't read external URLs | MCP tool in `internal/mcp/server.go` |
| `session-review` skill | No way to learn from failed sessions | `.agents/skills/session-review/SKILL.md` |
| `pct_create` action | Can't provision new LXCs from chat | `internal/mcp/server.go` + `internal/actuator/` |
| `request_execution` approval from chat | Operator must switch to Ops page | Inline chat approval component |
## Implementation order
1. **Add `pct_create` to `request_execution`** (`internal/mcp/server.go`) —
hard blocker, needed for the session's objective.
2. **ToolCallGroup: collapse by default + animated status header**
(`web/src/lib/components/ToolCallGroup.svelte`) — immediate UX win, the user
explicitly asked for this.
3. **Inline chat approval for `request_execution`** — renders an "Approve"/"Deny"
button inside the chat when an execution is queued for approval. Lets the
operator approve from the same chat.
4. **`session-review` local skill** — lives in `.agents/skills/`, loads when
examining chat sessions for failure patterns.
5. **`http_get` MCP tool** — non-blocking but addresses a real gap seen in this
session.
## Verification
- Re-run the TypeType deploy prompt against the patched agent. Confirm:
- Agent proposes plan as before (keep plan-proposal behavior).
- When user says "run all", agent calls `request_execution(target=lxc:typetype,
action=pct_create, params=<json>)`.
- Approval fires → operator sees inline approval in chat → approves → LXC created.
- ToolCallGroup: start a session that triggers 5+ tool calls. Confirm:
- Group starts collapsed.
- Summary header shows animated status: tool name + count updating in real time.
- Clicking expands smoothly.
- On session reload (history), stays collapsed.
- Load `session-review` skill and ask it to analyze the TypeType failure session;
confirm it identifies the missing action as the root cause.

View File

@@ -0,0 +1,163 @@
# 2026-07-10 — Autonomous plan execution: close the observation gap
**Status:** Done — 2026-07-11. Full scope shipped, including the Option B
stretch goal: atomic `pct_create` decomposition, robust assent-window
open/extend, SOUL persist-through-errors + `maxIterations=40`, and
event-driven auto-continuation (`cmd/nomos/continue.go`). Commits `233b5e4`,
`d2f749d`, `657e1a8`, `d529688`, `84ecb6b`.
## The real problem (not the one we kept fixing)
Operator, verbatim: *"the agent seems to stop when it encounters the first error,
it does not recover from it… my goal is that the agent can do anything once a
plan has been approved."*
We have patched ~10 individual failure modes (DNS, gateway, sshExec timeout,
substring bug, slug collisions, docker CLI, assent window…). Every one was real.
None fixed the thing the operator keeps hitting, because they all fixed
**individual commands** — and the problem is the **loop**, not the commands.
## Root cause: the agent never sees the result of the thing it started
The agent runs in discrete request→response turns. Provisioning executions are
**asynchronous**: `request_execution(pct_create)` queues an execution, fires the
real SSH work in a **goroutine** (`go executeApprovedViaAPI(...)`,
[internal/mcp/server.go](../internal/mcp/server.go)), and returns
*"provisioning now"* immediately. The multi-minute result lands in the DB
**after the agent's turn has already ended.**
So the agent literally is not running when the error happens. It cannot react to
a failure it never observes. The only way the result re-enters the agent's
reasoning is if a human types "continue" to start a new turn — **the human is the
event loop.** Read the failing session
(`7c25edaa`, 18 messages): the operator typed "continue" / "continue?" / "??" /
"proceed" **eight times**, each one just ticking the agent forward one async step.
The agent *was* recovering (it correctly diagnosed the docker-CLI issue and
proposed fixes) — it simply could not proceed one step without a human tick.
Two concrete asymmetries prove the diagnosis:
1. **`run` is synchronous, `pct_create` is async.** Inside an assent window, the
general `run` tool executes the command inline and returns stdout/exit-status
to the agent ([server.go](../internal/mcp/server.go) ~L514) — the agent *sees*
the result and can continue. `pct_create` in the same window auto-approves and
then `go`-routines the work — the agent sees nothing. The failure-prone path
is the unobservable one.
2. **`pct_create` is monolithic and all-or-nothing.** It does create + apt +
docker + post_install + verify in one SSH call. Even if it were synchronous,
the agent could only see "the whole thing failed at some point," not step 3 of
6 — so it can't surgically fix step 3 and resume. Recovery *requires*
intermediate observation.
Secondary (real but downstream): "continue" is **not** an assent word
([cmd/nomos/assent.go](../cmd/nomos/assent.go)), so in that session the assent
window never even opened — every step stayed gated, compounding the ticking.
## The reframe: Nomos should work like a coding agent
A coding agent (Claude Code) runs a command, **sees the output**, runs the next,
fixes errors inline, all in one continuous session — it does not stop and ask a
human to forward it after each command. That is exactly "do anything once the
plan is approved." The homelab agent needs the same loop:
> approve the plan → agent runs step → **observes result** → runs next step / on
> failure diagnoses + adapts + retries → … → verifies goal met → reports.
The machinery for this **already exists** in the `run` tool (synchronous,
observable, auto-executing within an assent window). Provisioning just doesn't
use it — it uses a black box. The fix is to make the whole system consistent
with the model `run` already embodies.
## Target architecture
### 1. One observable primitive; retire the async black box
- Everything the agent does — including provisioning — is a sequence of
**synchronous `run` calls** whose real output (stdout, stderr, exit code)
returns inline. No goroutine hand-off for agent-initiated work.
- **Decompose `pct_create`.** Keep a thin `pct_create` that only does the fast,
atomic container creation (create + start + register), returning synchronously.
Move package install / service setup / post_install / verify **out** into
agent-driven `run` steps. Now the agent observes each step and can fix a
failed one without redoing the container.
- Net: the agent orchestrates `create → apt → install → configure → up → verify`,
seeing each result, exactly like a human operator at a shell.
### 2. Approve the plan = an autonomy grant the agent executes to completion
- The assent/autonomy window already exists. Make it robust:
- Opening it must not depend on a magic word list. "continue", "go", "do it",
"proceed", clicking Approve, or approving the first queued step should all
open/extend it. Safer: when the operator approves ANY step of a plan, treat
that as opening the window for the rest of that plan.
- Within the window: read-only + config_mutation `run` steps execute inline,
no re-prompt. **Destructive still stops** for typed confirmation — but a
destructive step *described in the approved plan* can be pre-authorized so
the agent isn't blocked mid-flow on something already shown and approved.
- The window is the scope boundary: "you may do what the plan needs on this
target; you may not wander outside it."
### 3. The agent persists through errors (prompt + loop)
- SOUL: "You are the executor of the approved plan. Run it step by step,
observing each result. **On failure, do not stop and hand back — diagnose
(read logs / inspect state), form a hypothesis, fix it, and retry or take an
alternative path.** Continue until the goal is verified working or you are
genuinely blocked (you need information only the operator has, or a step
exceeds the approved scope). Never end a turn with a half-finished plan just
because one command failed."
- `maxIterations` sized for a full provision-with-recovery (raise 25 → ~40) and
count observation/read-only steps cheaply so recovery attempts aren't starved.
### 4. Long-running steps: keep the turn alive, or auto-continue
A synchronous `apt install` is ~12 min; a full stack up is longer. Options,
in preference order:
- **A (simplest, ship first):** synchronous `run` with the existing 10-min cap;
the streaming turn stays open (the chat UI already holds the SSE). Emit
progress events so the operator sees liveness (already built — elapsed timer).
- **B (for very long ops):** event-driven auto-continuation — when an async
execution tied to an active plan completes, a worker **re-invokes Nomos**
automatically with the result (the system becomes the event loop, not the
human). More plumbing; do only if A's long turns prove problematic.
## Why this is the root fix, not another patch
Every prior fix made an individual command more likely to succeed. This makes
the agent able to **notice and respond when one doesn't** — which is the only
thing that generalizes to "do anything," because "anything" always includes
"the first thing didn't work." You cannot enumerate every failure mode of an
unbounded action space; you can give the agent a loop that observes and adapts.
## Implementation order
1. **Make provisioning observable**: decompose `pct_create` into a fast atomic
create + agent-orchestrated `run` steps for install/config/verify. (Biggest
single win — removes the async black box from the failure-prone path.)
2. **Robust window open**: any approval / any forward-assent opens/extends it;
pre-authorize plan-described destructive steps.
3. **SOUL persist-through-errors** framing + `maxIterations` bump.
4. Verify end-to-end (below). Only then consider **B** (auto-continuation).
## Verification
- Re-run the exact TypeType deploy. Expected: operator approves the plan **once**;
the agent then creates the container, installs docker (recovering from the
Debian docker.io-CLI gap on its own by falling back to get.docker.com), brings
up the stack, hits a transient error (e.g. Docker Hub 500), **retries on its
own**, verifies `:8082` responds, and reports success — **with zero additional
"continue" ticks from the operator.**
- Failure injection: point a step at a wrong path; confirm the agent reads the
error, adapts, and continues rather than ending the turn.
## Open questions
- **Scope of an autonomy window**: per-plan, per-target, time-boxed (30 min now)?
What exactly may the agent do inside it without asking again?
- **Pre-authorized destructive steps**: allow a plan to include a named
destructive step (e.g. "destroy the half-provisioned CT and redo") that the
agent may execute during recovery without a fresh typed confirmation, since
the plan approval covered it? Or always re-confirm destructive, accepting the
interruption?
- **A vs B**: is a single 510 min streaming turn acceptable, or do we need
event-driven auto-continuation from the start?