# 2026-07-14 — Post-fix session audit: empty responses & plan drift remainders

**Status:** Done — 2026-07-14. All 18 fixes shipped, e2e-validated via the
golden eval harness (4/4 passed), committed (`337d577` + `3de359b` +
`dd3076a`), pushed to `main`, and deployed to `oikos-nomos-1` (v0.5.3). The
knowledge loop is structurally closed, the plan-duplication chain is broken,
and the eval harness catches regressions on future changes.

**PM addition — OIDC token-refresh fix** (lines 9-23 below) also shipped:
committed as `3b98097` ("fix(web): refresh expired OIDC tokens before API
calls"). The root cause of the empty-graph symptom is fixed and deployed.

**2026-07-14 (PM) — OIDC token-refresh fix (unplanned, root-cause for the
empty graph symptom):** the overview background graph and the Knowledge Base
graph both rendered empty because the SPA's OIDC access token expired
(~5 min TTL) and was never refreshed. `fetchWithAuth` called `getToken()`
synchronously (no refresh); `ensureToken` returned the stale token without
refreshing; `storeTokens` discarded `expires_in`; and the resulting 401
made `fetchGraph` return `null` → both graphs drew nothing, with no error
surfaced. Fixed structurally in `web/src/lib/oidc.ts` +
`web/src/lib/config.ts` + `web/src/lib/stores/events.ts`: tokens now carry
`expiresAt`, `getToken()` returns null within 30s of expiry, `fetchWithAuth`
awaits `ensureToken()` (refreshes on demand), `sseUrl` is async + refreshes
before constructing the EventSource, and a 401 flushes the OIDC session so
the static token fallback takes over. Build passes. Not yet committed or
deployed (pending operator verification). Not part of any numbered phase
above — filed here because it was the highest-impact surface symptom.

## Shipped (2026-07-14, v0.5.0–v0.5.3 — commits 337d577 + 3de359b + dd3076a, deployed)

| Fix | File(s) | Validation |
|---|---|---|
| **A.1** `proposePlan` sets `generation` on INSERT | `cmd/nomos/store.go` | eval: plan steps carry `generation: 1` |
| **A.2** `proposePlan` refuses re-proposal when in flight (drops append-mode) | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `propose_plan` called exactly once on "proceed" |
| **A.3** `propose_plan` tool description restated as a crisp contract | `cmd/nomos/tasks.go` | agent self-described the contract |
| **F.3** Approval vocabulary expanded + directive result strings | `cmd/nomos/tasks.go` | eval: "proceed" and "go ahead" both recognized as approval |
| **B.1** `chatWith` emits `done` after `error` on every terminal path | `cmd/nomos/agent.go` | eval: no reconnect/resume entries in nomos logs |
| **B.2** Reconnect/resume note carries last user msg + plan-in-flight directive | `cmd/nomos/store.go`, `cmd/nomos/main.go`, `cmd/nomos/continue.go` | wired into all 4 resume entry points |
| **B.3** `resumeSession` escalates the recovery note across 3 attempts | `cmd/nomos/continue.go` | e2e: escalated retry produced a real response |
| **D.1** `complete_task` refused when discovery ran without writeback | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | e2e: agent REFUSED → wrote back → retried → succeeded |
| **D.2** `propose_plan` auto-appends a writeback step if missing | `cmd/nomos/tasks.go` | e2e: appended step 4 when agent omitted writeback |
| **F.1** Consolidated SOUL.md's three overlapping task-flow sections to one | `nomos/SOUL.md` | eval: agent follows the consolidated flow (4/4 evals pass) |
| **F.2** Tightened set_goal/update_plan_step result strings to imperatives | `cmd/nomos/tasks.go` | eval: tool results are now directive |
| **C.1** `completeTask` rejects re-completion of a terminal session | `cmd/nomos/store.go`, `cmd/nomos/tasks.go` | eval: `complete_task` called exactly once |
| **C.2** SOUL.md: don't re-execute on UI-clarification complaints | `nomos/SOUL.md` | eval: no re-execution on followup |
| **B.4** Surface real model error text (finish_reason + refusal) | `cmd/nomos/agent.go` | error event now carries `finish_reason=length` etc. |
| **B.5** Back off between resume retries (4s, 8s) | `cmd/nomos/continue.go` | exponential backoff between attempts |
| **B.6** Don't persist empty placeholder as a visible bubble | `cmd/nomos/main.go`, `cmd/nomos/store.go` | empty rows deleted, not persisted |
| **E.1** SOUL.md: prefer knowledge over re-execution for fleet-wide facts | `nomos/SOUL.md` | eval: `search_knowledge` called first, 0 `run` calls on fleet audit |
| **E.2** `list_lxcs` last-audited hint in the result | `internal/mcp/server.go` | `last_audited_at` column via `about` edge subquery |
| **Bonus** Fixed pre-existing tool-call doubling bug in persistence | `cmd/nomos/main.go`, `cmd/nomos/continue.go` | eval: tool-call counts now accurate (was 2× in every session since v0.3.x) |

Tests: `TestProposePlan_RefuseInFlight` + `TestHadDiscoveryAndWriteback` in
`cmd/nomos/store_test.go`. Golden eval harness: `cmd/nomos/eval/` with 4
conversations in `cmd/nomos/eval/evals/golden.yaml` — all 4 pass.

## Golden eval results (v0.5.3, 4/4 passed)

| Eval | Tool calls | Key assertions |
|---|---|---|
| trivial_readonly | 2 | no plan, no run, completes |
| plan_advances_on_proceed | 13 | propose_plan ×1, writes back, complete_task ×1 |
| ui_complaint_no_rerun | 12 | propose_plan ×1, writes back |
| knowledge_preferred_over_rerun | 7 | search_knowledge ×1, 0 run calls |

Run: `go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest cmd/nomos/eval/evals/*.yaml` (~$0.10/run).

## Remaining (not yet shipped)

None. All 18 fixes + the OIDC token-refresh fix (PM addition, `3b98097`)
are shipped, committed, and deployed.

## Commit-history context (the 20-commit iteration)

Reviewing `git log` since the agent-task phases landed (be3ce76 → 5caf49b),
the same problems recur because we keep fixing them with **SOUL.md prose +
safety-net append logic** instead of structural gates:

- `5384499` (Jul 11) — "plan panel showed only the latest step" → fixed by
  making `proposePlan` APPEND when a step is in flight, so history is
  preserved even if the model re-proposes per step. **This is the source of
  the duplication the operator saw today.** The fix traded "lost progress"
  for "duplicate progress" — and the duplication is what's visible to the
  operator now.
- `e30813a` / `532310b` (Jul 11) — "research-first / knowledge-write-back-
  last explicit steps" → added the FIRST/LAST step language to SOUL.md.
  Three commits later the warnings are still being ignored in production.
- `5caf49b` (Jul 14, today) — "mandatory pre-plan flow" → another SOUL.md
  section at the top of the file, overlapping the existing "Every chat is a
  task" / "AFTER EVERY TASK: WRITE BACK" sections. The agent now has three
  overlapping sections telling it the same thing.
- `60effcb` (Jul 14) — Phase 5 of the prior plan added the `generation`
  column, the `replaced` status, the frontend grouping, and the writeback
  warnings. The migration landed; the INSERT in `proposePlan` did not.

**The pattern:** every iteration adds another paragraph to SOUL.md and a
safety net in the store layer. The agent still does the wrong thing
because prose instructions are unreliable and the safety nets paper over
the symptom instead of refusing the bad action. **This plan pivots to
structural gates** — `proposePlan` and `completeTask` should refuse the
calls that produce drift, not accommodate them.

## Sessions under audit

| Session | Time | Goal | Messages | Outcome | Real tool calls |
|---|---|---|---|---|---|
| `722d8878` (failure) | 10:45 | Fleet update audit | 3 | **failed** — empty response during auto-resume | 41 in turn 1 |
| `d9cdcee1` (success w/ friction) | 11:44 | Same prompt (user retried) | 11 | success | 38 across 5 turns |

Both sessions are the same operator request: "Check all the services on the
homelab and give me an overview of what needs updating, categorize by
criticality." Cross-referencing them shows **where the prior fixes held vs.
where they didn't.**

---

## What worked (preserve)

- **`upsert_knowledge` `about` array** (5.2 from prior plan) — the agent
  linked the audit to all affected LXCs in one call:
  `about: ["lxc:nextcloud","lxc:jellyfin","host:hubris", ...]`.
- **`complete_task` writeback warning** (5.5) — fired correctly (the session
  has no `update_entity_attributes` calls and the warning text appears in the
  tool result).
- **`propose_plan` writeback nudge** (5.4) — fired (last step title was
  "Write back: upsert_knowledge if anything changed", which contains neither
  required tool name).
- **Seq-order completion enforcement** (5.6) — no out-of-order completions
  observed.
- **Replaced-status mechanism** (3.3) — pending steps from the prior
  generation were correctly marked `replaced` on re-propose.

## What didn't (the findings below)

---

## Findings

### 1. Empty response still ends the session — operator had to start over

**Where:** Session `722d8878` msg 2: `[System: auto-resume failed after
retrying: Nomos returned an empty or unusable response — please retry. The
task is paused — send another message to continue.]`

**What happened:** Turn 1 ran 41 tool calls (set_goal + list_lxcs +
get_health_summary + get_state_snapshot + search_knowledge + 4× get_relations
+ 4× get_entity + 20× `run` for `apt-get update` across the fleet). The model
returned that successfully. Auto-continuation then ran `resumeSession`, which
retried `chatWith` **3 times** (continue.go:229) — all three came back empty.
The session ended with the system note above. The operator abandoned it and
opened `d9cdcee1` with the same prompt.

**Root cause:** Three identical retries with the same injected `note` produce
three identical empty responses (the model isn't randomly failing — it's
responding to the prompt the same way each time). The retry loop never varies
the prompt, never backs off, and never escalates to a more aggressive
recovery (e.g. a fresh continuation prompt that summarizes what just happened
and asks explicitly for the next single step).

**Severity:** Blocker — a 41-tool-call turn costs real money and time, and the
operator gets nothing for it.

### 2. `generation` column exists but `proposePlan` never sets it — frontend grouping is dead code

**Where:** `cmd/nomos/store.go:458-461` (INSERT statement) vs.
`migrations/020_session_reliability.up.sql:7` (the column) and
`web/src/lib/components/PlanProgress.svelte:17-22` (the grouping logic).

**What happened:** Migration 020 added `generation INTEGER NOT NULL DEFAULT 1`
and PlanProgress groups steps by `s.generation ?? 1`. But the INSERT in
`proposePlan` is:

```sql
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
VALUES ($1, $2, $3, $4, $5) RETURNING id
```

No `generation` column. Every step, in every plan revision, lands with
`generation = 1`. PlanProgress always sees one group ("Current plan") and
the collapse-old-generations behavior never triggers.

**Concrete impact in `d9cdcee1`:**
- Turn 1 (msg 1): `propose_plan` creates steps seq 1-5 (all generation 1).
- User: "why is the plan not updated accordingly? the steps in the sidebar."
- Turn 3 (msg 5): `update_plan_step seq=1, status=done`. Steps 2-5 still
  pending, all generation 1.
- User: "proceed with the rest."
- Turn 4 (msg 7): **empty assistant response** (text="", no tools).
- Turn 5 (msg 8): Agent calls `propose_plan` **again** with the same 5 steps.
  `proposePlan` sees `anyStarted=true` (seq 1 is done), so it goes into
  append mode: marks the 4 still-pending steps (2-5) as `replaced`, then
  inserts 5 new steps at seq 6-10. **All inserted with generation=1.**
- The frontend now sees 10 steps, all `generation: 1`, grouped together.
  Four are marked `replaced` (visible as "skipped/replaced" — dimmed but
  still in the list); six are the new active steps.
- User: "btw the plan here and the one in the sidebar differ." → Confirmed:
  the chat text describes a 5-step plan ("Step 1 done, refreshing 2-4");
  the sidebar shows 10 steps with a confusing mix of done/replaced/running.

**Severity:** Blocker — this is the direct, observable cause of the user's
two complaints in `d9cdcee1`. The prior plan (3.4) shipped the column and
the frontend code but never wired the backend INSERT.

### 3. Agent re-proposes the plan on "proceed" instead of continuing

**Where:** `d9cdcee1` msg 8 — `propose_plan` called again after user said
"proceed with the rest".

**What happened:** The agent had a perfectly good plan in flight (step 1 done,
2-5 pending). On the next operator turn ("proceed"), it should have called
`update_plan_step(seq=2, status=running)` and `run` against the targets.
Instead it called `propose_plan` with the same 5 steps, triggering the
append-mode behavior in #2.

**Root cause:** SOUL.md doesn't explicitly say "do NOT call propose_plan
again once you've already proposed — call update_plan_step + run instead."
The agent treated "proceed" as a cue to re-state the plan, not to advance
it.

**Severity:** Friction (compounds #2 into a blocker).

### 3a. WHY the agent re-proposed instead of advancing — the three-bug chain

Finding #3's surface description ("agent re-proposed on proceed") is real but
doesn't explain the *mechanism*. Tracing the message timestamps and the
`auto: true` flag on msg 8 reveals that the re-proposal wasn't the agent's
direct response to "proceed with the rest" at all — it was the agent's
response to a **generic system reconnect note**, fired by a chain of three
compounding bugs:

**The chain (all confirmed from code + session data):**

| Step | What happened | Where |
|---|---|---|
| 1. **Trigger** — model returned empty on the approval | User sent "procceed with the rest." `handleChat` → `a.chat()` → `chatWith()`. The model returned an empty completion 3× (all `maxLLMRetries=2` attempts exhausted). SOUL.md's approval vocabulary was "approved/yes/go ahead" — "proceed" wasn't listed, so the model likely wasn't certain it was approved and no-op'd. | `agent.go:362-371` |
| 2. **Amplifier** — empty response misclassified as network disconnect | On empty response, `chatWith` emits `error` and `return`s **without emitting `done`** (agent.go:370-371 — the `done` event only fires on the success path at line 382). The frontend's `onComplete` callback sees `!receivedDone` and treats it as a severed connection, calling `handleDisconnect()`. A *model* empty-response gets handled by the *network* disconnect path. | `agent.go:370-371` (missing `done`) + `chat.ts:349-356` (`!receivedDone → handleDisconnect`) |
| 3. **Divergence** — generic reconnect note triggers re-proposal | `handleDisconnect` waits 1s, then sends an empty message (`streamChat('', sessionId, …)`). The backend's reconnect path (main.go:177-188) calls `resumeSession` with: `"[System: the operator's connection was re-established. The task may have progressed in the background — report your current state and progress.]"`. The agent re-read the transcript (plan proposed, step 1 done, user said "proceed"), saw this generic note, and interpreted "report your current state and progress" as "redo the work and report it" → re-proposed + re-executed + `complete_task`. | `chat.ts:386-419` (reconnect) + `main.go:180` (note) + `continue.go:189` (resumeSession) |

**Timestamps confirm this:** msg 7 (empty) at `11:49:01.949`, msg 8 (re-propose, `auto: true`) at `11:49:16.200` — 15 seconds later, matching the 1s reconnect delay + the LLM call latency. The user never sent a second message; the frontend's reconnect logic did.

**The user's actual approval ("procceed with the rest") was in the transcript** but the agent wasn't responding to it — it was responding to the *system reconnect note*, which didn't mention approval, the plan, or the user's words. The propose_plan result had said "STOP and wait for approval," and the generic reconnect note didn't say "you're approved" — so the agent re-proposed to get a fresh approval cycle.

**Why this matters for the fix:** Phase A.2 (refuse re-proposal when in flight) would have *prevented the duplication* but not *fixed the cause*. The agent would have hit the refusal and then… what? With the generic reconnect note, it still doesn't know it's approved. The three bugs need three targeted fixes (Phase B below). This is the answer to "why didn't the agent update the original plan": **it never received a clear signal to advance, because the approval signal was lost in an empty response that got misclassified as a network drop.**

**Severity:** Blocker — this is the root cause of the plan divergence the
operator observed.

### 4. Operator clarification was interpreted as "redo the whole task"

**Where:** `d9cdcee1` msg 9 → msg 10. User said "btw the plan here and the
one in the sidebar differ." Agent's response (msg 10): re-ran all 6 `run`
calls (`apt-get update` + `apt list --upgradable` on nextcloud, jellyfin,
hubris), re-called `upsert_knowledge`, and **called `complete_task` a
second time**.

**What happened:** The operator wanted the sidebar aligned with the chat.
The agent re-executed the actual audit work and re-completed the task.

**Root cause:** No prompt-level instruction about how to handle "the UI
seems inconsistent" complaints — the agent defaulted to "do the work again,
maybe it'll line up this time."

**Severity:** Friction — wasted 6 `run` calls and a duplicate knowledge
entry; user gets a noisier transcript.

### 5. `complete_task` called twice on the same session

**Where:** `d9cdcee1` msg 8 and msg 10 both call `complete_task` with
`outcome=success`.

**What happened:** After msg 8, `agent_sessions.status` is `done`. The user
complained about the plan drift; the agent re-ran the audit and called
`complete_task` again. There's no guard in `completeTask` against re-completing
an already-terminal session.

**Severity:** Cosmetic, but it produces duplicate knowledge entries and
erodes audit-log clarity.

### 6. Turn 1 of `722d8878`: 41 tool calls including `run` against every LXC

**Where:** Session `722d8878` msg 1.

**What happened:** Despite a same-day knowledge entry
(`investigation:nomos/fleet-wide-apt-update-audit-2026-07-14` — the agent even
called `get_knowledge_content` for it), the agent ran `apt-get update` on
every LXC in turn 1 instead of presenting the prior audit and proposing a
small refresh plan. The agent already had the answer in the DB; it re-ran
the fleet audit anyway.

**Severity:** Friction — wasted ~20 `run` calls (each is a queued execution).
The successful retry session (`d9cdcee1`) only re-ran 3 (the critical trio),
which is the right pattern — but it had to learn that from the failure
session's example.

### 7. Agent ignores its own writeback warnings

**Where:** `d9cdcee1` — `propose_plan` returned the nudge from tasks.go:213
("⚠️ The final step doesn't mention update_entity_attributes…") and
`complete_task` returned the warning from tasks.go:280 ("⚠️ No entity
attributes or relationships were updated in this session…"). The agent saw
both, did nothing about either, and ended the task.

**What happened:** The warnings are surfaced in the tool result text, but
the model treats tool results as ephemeral context — it doesn't act on a
warning that appears after the work it already decided is done. The session
recorded zero `update_entity_attributes` calls and zero
`create_relationship` calls.

**Severity:** Blocker — the knowledge-loop drift problem the prior plan was
supposed to fix is still happening. The graph accumulates nothing structured
from this session; the next fleet audit will rediscover every fact from
scratch.

### 8. Empty assistant bubble persisted in the transcript

**Where:** `d9cdcee1` msg 7: `{"role":"assistant","text":"","tool_calls":[]}`.

**What happened:** On the "proceed with the rest" turn, the model returned an
empty completion. The inner `chatWith` retry (agent.go:331) eventually
succeeded and produced msg 8 — but the empty msg 7 was already persisted to
the transcript and stays there. The UI shows an empty assistant bubble between
the user's "proceed" and the agent's actual response.

**Severity:** Cosmetic, but visible to the operator and erodes trust ("is
the agent broken?").

---

## Improvement plan

### Phase A — Make `propose_plan` refuse duplication (addresses #2, #3)

The operator's "plan was added twice" complaint is the visible output of
the append-mode safety net added in `5384499`. The safety net was the wrong
default: it preserved history but produced a confusing 10-step sidebar. The
right default is to **refuse** a re-proposal when a plan is already in
flight — the agent must use `update_plan_step` + `run` to advance.

#### A.1 — `proposePlan`: set `generation` on insert (still needed for history)

**File:** `cmd/nomos/store.go:415-491`

**How:**
1. Resolve the next generation number at the top of `proposePlan`, in the
   same transaction:
   ```go
   var nextGen int
   if !anyStarted {
       // fresh/revise: reset to 1 (and the DELETE already wiped old rows)
       nextGen = 1
   } else {
       if err := tx.QueryRow(ctx, `
           SELECT COALESCE(MAX(generation), 0) + 1
           FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil {
           return nil, err
       }
   }
   ```
2. Add `generation` to the INSERT:
   ```sql
   INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation)
   VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
   ```
   Pass `nextGen` as `$6`.
3. Include `"generation": nextGen` in the `out` map so the tool result and
   the `plan.proposed` event carry it (the frontend already reads it via
   `api.ts:66`).
4. Backfill is unnecessary — existing rows default to generation 1.

#### A.2 — `proposePlan`: refuse re-proposal once any step has started

**File:** `cmd/nomos/store.go:415-491` + `cmd/nomos/tasks.go:206-219`

**How:**
1. In `proposePlan`, when `anyStarted == true`, return a sentinel error
   instead of appending:
   ```go
   if anyStarted {
       return nil, errPlanInFlight
   }
   ```
2. In `handleTaskTool`'s `propose_plan` case, detect the sentinel and return
   a directive tool result:
   ```
   Plan already in flight — refusing duplicate proposal. Steps 1..N exist;
   at least one is running or done. To advance the plan, call
   update_plan_step(seq=K, status=running) followed by run(...) for step K's
   target. Do NOT call propose_plan again. Call it again only if the
   operator explicitly asks you to revise the whole plan, and if so, say
   that in your reply before calling it.
   ```
3. Drop the append-mode code path (store.go:437-448) — it's the duplication
   source. Keep the destructive-replace path (store.go:432-436) for the
   `!anyStarted` case (genuine pre-execution revision).
4. The `replaced` status becomes unreachable through normal flow but stays
   in the schema for any future "explicit revise" path that uses it.

This is the single highest-impact fix in this plan. It directly removes
the "plan added twice" behavior the operator reported, and forces the
agent to use the correct advancement tools. Combined with the directive
tool result, even a model that ignores SOUL.md will get the right behavior
because the bad action is refused.

#### A.3 — `propose_plan` tool description: state the contract crisply

**File:** `internal/mcp/server.go` (the `propose_plan` tool schema)

**How:** Replace the current description with a one-paragraph contract:
```
Propose the full ordered plan for this task. Call ONCE per task, before
any execution. After this call: STOP and wait for operator approval.
Once a step has started (status=running/done/...), this tool REFUSES
further calls — use update_plan_step + run to advance. The LAST step
MUST be "Write back: update_entity_attributes + create_relationship
+ upsert_knowledge".
```
This puts the contract where the model reads it (in the tool schema that
gets serialized into the system prompt), not just in SOUL.md where it
competes with three overlapping sections.

#### A.4 — PlanProgress: verify grouping renders with the wired-up column

**File:** `web/src/lib/components/PlanProgress.svelte:17-90`

Once A.1 lands, the grouping code that already exists should work. Verify:
- Latest generation (`Math.max(...generations)`) → expanded, labeled
  "Current plan".
- Older generations → collapsed by default, labeled "Plan v1 (replaced)",
  with a count badge.
- A future explicit-revise path (not in this plan) would land generation 2
  as the new "Current plan" and the old steps collapse.

This is verification, not new code — the structure is there, it just
never received varied generation numbers to group on.

### Phase B — Close the three-bug chain that caused the divergence (addresses #3a, #1, #8)

Phase A.2 (refuse re-proposal) prevents the *symptom* (duplicate plan in
sidebar). This phase fixes the *cause* — the three bugs in finding #3a that
made the agent re-propose in the first place. Each fix targets one link in
the chain.

#### B.1 — Emit `done` after `error` so the frontend doesn't misclassify (fixes bug 2 — the amplifier)

**File:** `cmd/nomos/agent.go:370-371` (+ the other early-return error paths
at lines 348, 356)

**What:** On empty response, `chatWith` emits `error` and returns **without
emitting `done`**. The `done` event only fires on the success path
(agent.go:382). The frontend's `onComplete` (chat.ts:349-356) sees
`!receivedDone` and routes into `handleDisconnect` — treating a *model*
failure as a *network* drop, which triggers an unwanted auto-reconnect →
`resumeSession` → re-proposal.

**How:**
1. After the `error` emit at line 370, also emit `done` before returning:
   ```go
   emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
   emit(agentEvent{Type: "done", Data: map[string]any{
       "session_id": sessionID, "correlation_id": correlationID,
       "iterations": i + 1, "error": true,
   }, SessionID: sessionID})
   return
   ```
2. Do the same for the other early-return error paths (agent.go:348 stream
   error, agent.go:356 no choices) so every terminal path emits `done`.
3. On the frontend, `onComplete` (chat.ts:349-356) now sees
   `receivedDone === true` and sets `streaming.set(false)` instead of
   calling `handleDisconnect`. The error is still shown via the `error`
   event handler (chat.ts:329-331).
4. Add `"error": true` to the done payload so the frontend can distinguish
   "ended cleanly" from "ended with error" (e.g. to show a retry button
   instead of loading dots).

**Impact:** This alone prevents the unwanted `resumeSession` call after a
model empty-response. The error becomes a visible chat error (with the
retry button from prior Phase 1.6), not a silent trigger for re-execution.
This is the single highest-leverage fix in this phase — it breaks the chain
at the amplifier.

#### B.2 — Reconnect note: reference the user's last message and plan state (fixes bug 3 — the divergence)

**File:** `cmd/nomos/main.go:180` (reconnect note) + the other resume entry
points at `main.go:341` (`/resume` endpoint) and `continue.go:83-86`
(idle-sweep note)

**What:** Even with B.1, genuine network disconnects will still happen. When
they do, the reconnect note (`"report your current state and progress"`) is
too generic — it doesn't tell the agent what the operator actually wanted,
so the agent guesses (badly). The note should carry the operator's last
message and whether a plan is in flight.

**How:**
1. Add two helpers to `store.go`:
   ```go
   func (s *store) lastUserMessage(ctx, sessionID) string  // SELECT text FROM messages WHERE session_id=$1 AND role='user' ORDER BY created_at DESC LIMIT 1
   func (s *store) hasPlanInFlight(ctx, sessionID) bool    // SELECT EXISTS(... WHERE session_id=$1 AND status IN ('pending','running'))
   ```
2. In `handleChat`'s reconnect path (main.go:177-188), build a specific note:
   ```go
   lastUserMsg := st.lastUserMessage(pctx, req.SessionID)
   planInFlight := st.hasPlanInFlight(pctx, req.SessionID)
   note := fmt.Sprintf("[System: the operator's connection was re-established. "+
       "The operator's last message was: \"%s\". ", lastUserMsg)
   if planInFlight {
       note += "A plan is in flight — advance it with update_plan_step + run. Do NOT call propose_plan again."
   } else {
       note += "Report your current state and progress."
   }
   note += "]"
   ```
3. Apply the same enrichment to the `/resume` endpoint note (main.go:341)
   and the idle-sweep note (continue.go:83-86) — all three resume entry
   points should carry the same context.

**Impact:** Even if B.1 is bypassed (genuine disconnect mid-plan), the agent
gets "advance the plan" instead of "report state." No more re-proposal from
reconnect.

#### B.3 — `resumeSession`: escalate the recovery note across attempts (fixes bug 1 — the trigger)

**File:** `cmd/nomos/continue.go:229-253`

**What:** The current loop retries 3 times with the same note. A transient
model issue (or a prompt causing the model to no-op) gets three identical
empty responses.

**How:**
1. Build a different `note` per attempt:
   ```go
   notes := []string{
       note, // attempt 0: the original (now enriched per B.2) note
       fmt.Sprintf("[System: your previous turn produced no response. %s. "+
           "Produce a response now — call the next tool or report progress in one sentence.]", note),
       fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. "+
           "The next action is: pick the lowest-pending plan step, mark it running with "+
           "update_plan_step, and call run for its target. Do that now.]"),
   }
   ```
2. Pass `notes[attempt]` to `chatWith` so each retry gets a progressively
   more directive prompt.
3. Keep the 3-attempt cap.

**Impact:** A model that's transiently flaking or confused gets a real
second chance with an increasingly specific directive, instead of three
identical prompts.

#### B.4 — Surface the real model error text (addresses finding #1's observability)

**File:** `cmd/nomos/agent.go:370` + `cmd/nomos/continue.go:255-274`

**What:** The operator-facing message is "Nomos returned an empty or
unusable response — please retry." The actual error (OpenRouter 503,
content filter, token limit) is logged but not shown.

**How:**
1. In `chatWith`'s error emit (agent.go:370), include `errText`:
   ```go
   emit(agentEvent{Type: "error", Data: fmt.Sprintf("Nomos returned an empty or unusable response: %s", errText), SessionID: sessionID})
   ```
2. In `resumeSession`'s failure path (continue.go:262):
   ```go
   resumeFailedNote := fmt.Sprintf(
       "[System: auto-resume failed after 3 attempts. Last error: %s. "+
        "The task is paused — send another message to continue.]", errText)
   ```
3. The operator can now tell "model overloaded, just retry" from "content
   filter — I need to rephrase."

#### B.5 — Back off between resume retries

**File:** `cmd/nomos/continue.go:229`

**How:** Add a small sleep before attempts 1 and 2:
```go
for attempt := 0; attempt < 3; attempt++ {
    if attempt > 0 {
        select {
        case <-cctx.Done(): return
        case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
        }
    }
    // ... existing body, using notes[attempt] from B.3
}
```

#### B.6 — Don't persist the empty placeholder as a visible bubble

**File:** `cmd/nomos/main.go:251-266` (handleChat placeholder) +
`cmd/nomos/continue.go:190-218` (resumeSession placeholder)

**What:** On `d9cdcee1` msg 7, the empty assistant bubble persisted in the
transcript because `persist()` ran with `finalText=""` after the error
return. The UI shows an empty bubble.

**How:**
1. Mark the placeholder as pending:
   `{"role":"assistant","text":"","pending":true}` instead of just `""`.
2. The frontend renders `pending: true` as loading dots (it already does
   this for empty text during streaming), not an empty bubble.
3. On success, `persist()` overwrites with real content and drops `pending`.
4. In `handleChat`'s final persist call (main.go:282), if `finalText == ""`
   and `len(toolCalls) == 0`, delete the placeholder row instead of
   persisting an empty bubble:
   ```go
   if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil {
       st.deleteMessage(pctx, msgID)
   } else {
       persist()
   }
   ```

### Phase C — Stop the agent re-executing on clarification (addresses #4, #5)

#### C.1 — `completeTask`: reject re-completion of a terminal session

**File:** `cmd/nomos/store.go:completeTask`

**How:**
1. Before the UPDATE, fetch the current status. If it's already `done`,
   `failed`, or `partial`, return without re-updating and surface a no-op
   message:
   ```go
   var current string
   s.pool.QueryRow(ctx, `SELECT status FROM agent_sessions WHERE id=$1`, sessionID).Scan(&current)
   if current == "done" || current == "failed" || current == "partial" {
       return nil  // already terminal — silently no-op
   }
   ```
   Or, stronger, return an error from `completeTask` and have the caller
   (tasks.go:275) translate it into a tool-result message:
   `"Session is already complete (status=done). If you want to keep working, call update_plan_step + run; do not call complete_task again."`

2. The error path is preferred — the agent sees it in the tool result and
   stops trying to re-complete.

#### C.2 — SOUL.md: handle "the UI is inconsistent" complaints without re-executing

**File:** `nomos/SOUL.md`

**How:** Add a short rule:
```
If the operator points out that the chat and the sidebar/plan panel disagree,
DO NOT re-run the work. Investigate the discrepancy by reading state:
  get_plan_steps / list current step states → reconcile with a single
  update_plan_step call. If the panel is correct and the chat is stale,
  summarize the panel in your reply. If the chat is correct and the panel
  is stale, fix the panel with update_plan_step. Never re-execute tool
  work just to fix a display mismatch.
```

### Phase D — Writeback enforcement that actually sticks (addresses #7)

The current warnings are too easy to ignore because they appear after the
agent has already moved on mentally. Make them structural.

#### D.1 — `completeTask`: refuse to mark success without writeback when state was discovered

**File:** `cmd/nomos/store.go:completeTask` + `cmd/nomos/tasks.go:254-282`

**How:** Convert the warning into a refusal when the session actually ran
discovery tools:
1. Extend `hadEntityWriteback` (store.go:624) into `hadDiscoveryAndWriteback`:
   ```sql
   -- did the session run discovery?
   SELECT EXISTS(SELECT 1 FROM audit_log
     WHERE session_id=$1 AND tool_name IN ('run','get_entity','get_relations','list_lxcs','list_entities'))
   -- AND did it write back?
   SELECT EXISTS(SELECT 1 FROM audit_log
     WHERE session_id=$1 AND tool_name IN ('update_entity_attributes','create_relationship'))
   ```
2. In `completeTask`, if `discovery=true AND writeback=false` AND `outcome`
   is `success`:
   - **Force-downgrade** the outcome to `partial`.
   - Return a hard error (not just a warning) that the agent must act on:
     `"Refused: this session ran discovery (run/get_entity/...) but did not call update_entity_attributes or create_relationship. Call those now to persist the facts you learned, then call complete_task again. Outcome downgraded to 'partial' until you do."`
3. The agent gets the error in the tool result, sees the directive, and is
   forced to call `update_entity_attributes` before it can complete.

This is the structural version of 5.4/5.5 from the prior plan — warnings
didn't work; enforcement will.

#### D.2 — `propose_plan`: auto-append a writeback step if missing

**File:** `cmd/nomos/tasks.go:206-219`

**How:** Instead of (or in addition to) the warning string, append a
synthetic writeback step when none of the proposed steps mention
`update_entity_attributes`:
```go
hasWritebackStep := false
for _, s := range steps {
    if strings.Contains(s.Title+s.Detail, "update_entity_attributes") ||
       strings.Contains(s.Title+s.Detail, "create_relationship") {
        hasWritebackStep = true
        break
    }
}
if !hasWritebackStep {
    steps = append(steps, planStepInput{
        Title:  "Write back entity attributes and relationships",
        Detail: "Call update_entity_attributes for every entity you ran run/get_entity against (versions, states, hosts, IPs), and create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities.",
    })
    // re-call proposePlan with the extended steps, or append directly to the
    // already-persisted plan via a second INSERT.
}
```
The agent then sees the explicit step in its own plan and the seq-order
enforcement (5.6) forces it to complete that step last.

### Phase E — Reduce turn-1 fan-out (addresses #6)

The `722d8878` failure session spent 41 tool calls re-discovering what was
already in the DB.

#### E.1 — SOUL.md: prefer knowledge over re-execution

**File:** `nomos/SOUL.md`

**How:** Add to the discovery section:
```
BEFORE calling `run` for fleet-wide facts (apt counts, service versions,
host states), call search_knowledge and get_knowledge_content for the
relevant entity or topic. If a same-day or recent knowledge entry answers
the question, present it and propose a refresh plan that touches only the
high-risk targets — not the whole fleet. Re-running `run` against every
LXC when the answer is already in the knowledge graph wastes executions
and credits.
```

#### E.2 — `list_lxcs`: include last-audited hint in the result

**File:** `internal/mcp/server.go:list_lxcs` handler

**How:** When returning LXCs, include for each row the most recent
`knowledge_entities.created_at` linked via `about` edges with kind
`investigation` or `document` and a tag matching `audit`/`update`. The
agent then sees "nextcloud — last audited 2026-07-14 (today)" and can skip
re-running it.

This is a smaller tweak than E.1 (which is the load-bearing fix) — the data
hint makes the SOUL.md rule easy to follow.

---

### Phase F — SOUL.md: be crisp, not repetitive (addresses the operator's "more crisp and clear with the agent" feedback)

SOUL.md grew three overlapping sections across the last 20 commits:

| Section | Added by | Says |
|---|---|---|
| `## ⚠️ MANDATORY TASK FLOW` (top) | `5caf49b` (Jul 14) | 6-step flow: set_goal → pre-plan → propose → approve → execute → writeback |
| `## Every chat is a task` (mid) | `e30813a` (Jul 11) | Same 6-step flow, longer, plus the trivial-task degenerate case |
| `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` (inside "Every chat") | `60effcb` (Jul 14) | Writeback rule, third time |

The agent has three places telling it the same thing. The MANDATORY TASK
FLOW section at the top is the right one to keep — it's the most directive
and the closest to the system-prompt boundary. The other two are
lower-fold repetition that bloats context and dilutes the directive.

#### F.1 — Consolidate SOUL.md to one task-flow section

**File:** `nomos/SOUL.md`

**How:**
1. Keep the `## ⚠️ MANDATORY TASK FLOW` section at the top verbatim — it's
   the load-bearing version.
2. Replace the `## Every chat is a task` section (lines ~85-165) with a
   three-line reference: "Every non-trivial chat follows the MANDATORY
   TASK FLOW at the top of this file. The flow scales down: a trivial
   read-only question (e.g. 'status of Y?') is a degenerate case — answer
   directly and call `complete_task` with a one-line summary, no
   propose_plan ceremony."
3. Remove the `### ⚠️ AFTER EVERY TASK: WRITE BACK OR LOSE IT` subsection
   entirely — its content is already step 6 of MANDATORY TASK FLOW and
   step 3 of "Every chat is a task." Three statements of the same rule
   don't make it more enforced; they make the file longer.
4. Result: the file is ~80 lines shorter, the agent has one place to read
   the task contract, and the directive is unmissable because it's no
   longer competing with two paraphrased copies.

This is reversible prose work, but it directly addresses the operator's
feedback that the agent isn't being "crisp and clear" with itself.

#### F.2 — Make tool-result strings directive, not advisory

**Files:** `cmd/nomos/tasks.go` (the result strings for `set_goal`,
`propose_plan`, `update_plan_step`, `complete_task`)

**How:** Audit each tool-result string for hedging language and tighten:

| Current | Tightened |
|---|---|
| `"Goal set: <goal>. Now do a PRE-PLAN: gather information with read-only tools ... Do NOT call run yet."` | `"Goal set. NEXT: pre-plan (read-only tools only). Then propose_plan. Do not call run."` |
| `"Plan set: N step(s). Now STOP and present the plan to the operator — do NOT call run yet. Wait for them to approve ..."` | `"Plan set (N steps). STOP. Wait for operator approval. Do not call run."` |
| `"Step N → status"` | `"Step N → status. (Use update_plan_step to advance; do not re-propose.)"` — only on the first call per session, otherwise unchanged. |
| `"⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch."` | (Replaced by D.1's refusal when discovery ran.) |

Short, imperative, no hedging. The agent's behavior in `d9cdcee1` shows
that long tool-result strings with "consider revising the last step" are
treated as informational; short imperatives ("STOP. Do not call run.")
are followed.

#### F.3 — State the approval vocabulary in the plan-result string

**File:** `cmd/nomos/tasks.go:206-219` (propose_plan result)

**How:** Add the approval vocabulary to the propose_plan result so the
agent recognizes "proceed", "go", "continue", "yes", "approved", "ok" as
approval and does NOT re-propose on those:
```
Plan set (N steps). STOP. Wait for operator approval.
Approval vocabulary: "approved", "yes", "go", "proceed", "continue", "ok".
On approval, advance with update_plan_step + run. Do NOT call propose_plan again.
```
This directly addresses finding #3's cause: the agent re-proposed on
"proceed with the rest" because SOUL.md only listed "approved / yes / go
ahead" as approval vocabulary. Make the list match what operators
actually type.

---

## Sequencing & priority

| # | Fix | Effort | Impact | Phase |
|---|---|---|---|---|
| A.2 | `proposePlan` refuses re-proposal when in flight | S | **Blocker** — directly removes the duplication the operator saw | A |
| B.1 | Emit `done` after `error` in `chatWith` | S | **Blocker** — breaks the three-bug chain at the amplifier | B |
| B.2 | Reconnect note carries user's last message + plan state | S | **Blocker** — fixes the divergence cause | B |
| A.1 | Set `generation` on INSERT | S | High — needed for any future explicit-revise flow | A |
| A.3 | `propose_plan` tool description states the contract | S | High — agent reads tool schema, often ignores SOUL.md | A |
| F.1 | Consolidate SOUL.md to one task-flow section | S | High — addresses "be more crisp" feedback directly | F |
| F.2 | Tighten tool-result strings to imperatives | S | Medium — observable behavior change | F |
| F.3 | Approval vocabulary in propose_plan result | S | High — fixes the "proceed" → empty-response trigger | F |
| B.3 | Escalate recovery note per resume retry | S | High — turns 3 identical empties into a real recovery | B |
| D.1 | Refuse `complete_task` without writeback | M | **Blocker** — fixes the knowledge loop | D |
| D.2 | Auto-append writeback step to plans | M | High — addresses the cause | D |
| B.4 | Surface real model error text | S | Medium — operator can diagnose | B |
| C.1 | Reject re-completion of terminal sessions | S | Medium — stops duplicate `complete_task` | C |
| C.2 | SOUL.md: don't re-execute on UI complaints | S | Medium — prevents the 6 wasted `run` calls | C |
| B.5 | Back off between resume retries | S | Low-medium | B |
| B.6 | Don't persist empty placeholder as bubble | M | Cosmetic — but visible to operators | B |
| A.4 | Verify PlanProgress grouping renders | S | Depends on A.1 | A |
| E.1 | SOUL.md: prefer knowledge over re-execution | S | Medium — saves credits on fleet audits | E |
| E.2 | `list_lxcs` last-audited hint | M | Low — nice-to-have | E |

**Suggested order:** A.2 + B.1 + B.2 (the three blockers, ship together) →
F (crispness, ships alongside) → A.1/A.3/A.4 → D → B.3/B.4/B.5/B.6 → C → E.

The three blockers form a complete fix for the operator's reported bug:
- **A.2** stops the duplication from being *possible* (refuse re-proposal).
- **B.1** stops the empty response from *triggering* a reconnect/resume
  (emit `done` after `error`).
- **B.2** makes any *genuine* reconnect carry the right context (advance
  the plan, don't re-report).
Together they close the three-bug chain end-to-end. F.3 (approval
vocabulary) closes the *trigger* of the empty response itself.

---

## Verification

After deploying each phase, replay the same operator prompt in a fresh
session and check:

- **Phase A:** Call `propose_plan` twice (manually if needed) and confirm
  the sidebar shows "Current plan" + a collapsed "Plan v1 (replaced)"
  section, not a flat 10-step list.
- **Phase B:** Force an empty response (e.g. temporarily throttle OpenRouter
  to 0 RPM, or use a stub model that returns `""`). Confirm: (a) the
  frontend shows the error inline and does NOT trigger a reconnect/resume
  (no `auto: true` message appears 15 seconds later); (b) the operator sees
  the real error text, not "empty or unusable response"; (c) if you then
  disconnect the network for real, the reconnect note says "advance the
  plan" (not "report state") and the agent calls `update_plan_step` + `run`,
  not `propose_plan`.
- **Phase C:** Start a session, let it `complete_task`, then send a follow-up
  complaint. Confirm the agent does NOT call `complete_task` again and does
  NOT re-run the original `run` calls.
- **Phase D:** Run a fleet-audit prompt. Confirm the agent cannot reach
  `complete_task` with `outcome=success` without first calling
  `update_entity_attributes` for at least the LXCs it ran `run` against.
- **Phase E:** Confirm a same-day audit prompt produces a turn-1 with ≤5
  tool calls (search_knowledge + get_knowledge_content + small
  propose_plan), not 41.
- **Phase F:** Count SOUL.md lines (target: ~80 fewer than current). Replay
  the "proceed with the rest" prompt and confirm the agent does NOT call
  `propose_plan` again (it gets a refusal error on the call, then advances
  via `update_plan_step` + `run`).
