P1 plan-first: run handler refuses without propose_plan (structural gate,
not SOUL.md prose). Plan window decoupled from set_goal — config_mutation
auto-run only on operator approval (assent window). Closes the approval-free
config_mutation hole confirmed in session d0d562e0.
P2 iteration: reopenSession flips terminal→executing, marks prior plan steps
replaced, clears outcome. proposePlan excludes replaced from in-flight check,
bumps generation. A follow-up on a completed session starts a new sub-task
with a fresh plan — no more errPlanInFlight dead end.
P3 reasoning: accumulate per-iteration text into the persisted row instead
of overwriting with the last text event. Reload shows intermediate thinking,
not just the final summary.
P4 read-only allowlist: add find, tree, locate, systemctl list-timers/
list-unit-files/show, timedatectl, hostnamectl, systemd-analyze, rclone
ls/lsl/md5sum/check/cryptcheck. Fixes the find misclassification from
d0d562e0.
P5 eval harness: new assertion kinds (proposes_plan, plan_before_run,
plan_generations), multi-turn followups, fetch /sessions/{id}/plan. Four
manifests under evals/.
P6 SOUL.md: strip degenerate-case carve-out, add ITERATE step, update
set_goal guidance.
VERSION 0.6.0 → 0.7.0
20 KiB
2026-07-15 — Plan-first enforcement, iteration, and audit gaps
Status: Done — 2026-07-15. P1–P6 implemented; build + tests + vet
pass. Follow-up to the session audit of
d0d562e0 ("Determine when the last backup to Proton Drive ran and when the
next one is scheduled"). The audit surfaced that the agent answered
successfully but never proposed a plan, and that a follow-up asking the
agent to act on its own findings has no working path. This plan closes both,
plus the related reliability gaps the audit turned up.
Grounded in:
cmd/nomos/tasks.go(set_goal, propose_plan, complete_task handlers)cmd/nomos/store.go(setGoal, proposePlan, completeTask, plan window)internal/mcp/server.go(run risk gate, plan/assent windows)internal/policy/command.go(ClassifyCommand read-only allowlist)nomos/SOUL.md(MANDATORY TASK FLOW + degenerate-case carve-out)cmd/nomos/eval/manifest.go+eval/main.go(assertion kinds, followup)
Why there was no plan
Two independent causes, both required for the skip to happen:
-
SOUL.md explicitly exempts read-only questions from the plan flow.
SOUL.md:51-53andSOUL.md:111-115declare a "trivial read-only task ('status of Y?')" a degenerate case: answer directly,complete_taskwith a one-line summary, "no propose_plan ceremony." The Proton Drive question looks on its face like "status of Y?", so the agent applied the carve-out. It then went on to callruntwice — so it wasn't actually degenerate, but the exemption had already been invoked. -
There is no structural gate forcing
propose_planbeforerun. The only enforcement is SOUL.md prose.internal/mcp/server.go:1312executes read-only commands immediately with no check that a plan exists for the session. The agent can honor the rule or skip it, and weaker models skip it. The D.1 writeback gate works precisely because it's structural (complete_taskrefuses withoutupdate_entity_attributes); there is no equivalent forpropose_plan.
Findings
F1 — BLOCKER. Plan window opens on set_goal, before any plan or approval.
tasks.go:183 calls openPlanWindow inside the set_goal handler →
nomos:plan:<session>="active" immediately (store.go:473). Result: any
config_mutation run auto-executes with zero operator approval. This
session proves it — no propose_plan, no update_plan_step (confirmed:
/sessions/{id}/plan → steps:null), yet the 2nd run was tagged
"config_mutation, auto via plan" (server.go:1331-1344). The inline
comment ("the goal IS the start of a plan… operator approves the plan via
propose_plan") is self-contradictory: the window is already open, so
propose_plan's "STOP, wait for approval" (tasks.go:248) is unenforceable.
This is a safety regression, not a style issue.
F2 — BLOCKER. Iterative follow-ups have no working path.
Scenario: this session completes; the operator sends a follow-up "now look
into the repos backup failure" on the same session. Traced path:
completeTask(store.go:716-718) deletesnomos:plan:<session>fromautonomy_settingsbut does not clearsession_plan_stepsrows.- Follow-up arrives →
main.go:227touchSessiononly updateslast_active_at; status staysdone. - Agent calls
set_goal→setGoalflips status toexecuting(store.go:460) and re-opens the plan window (F1 again). - Agent calls
propose_plan→proposePlan(store.go:517-527) checksbool_or(status <> 'pending'). Old steps are alldone→anyStarted=true→ returnserrPlanInFlight→ REFUSED. The refusal text says "Re-propose only if the operator explicitly asks" but there is no code path honoring that — re-callingpropose_planhits the same guard. Dead end. There is noreset_plan/close_plantool.
So the design assumed one plan per session. There is no "iteration" / "next plan" concept. The only escape is starting a brand-new session, which loses the conversational thread and the LLM's replayed context.
Note: in this audited session there were no plan steps (F1 — no plan was
ever proposed), so proposePlan would actually succeed on a follow-up here.
But in a plan-always world the first session WOULD have steps, and the
follow-up would be blocked. Fixing plan-always without fixing iteration
would create a new blocker. They must ship together.
F3 — FRICTION. Thinking replaced by summary on reload.
main.go:252-267 and continue.go:204-219 persist one placeholder
assistant row per turn and updateMessage it per tool call, storing only
finalText (the last text event) + an ever-growing toolCalls slice.
Intermediate per-turn reasoning (streamed live via text/text_delta,
agent.go:360,410) is overwritten. The DB has 2 rows total for this
session; on reload you see only the final 547-char summary + a flat list of
15 tool calls. Same defect breaks LLM replay fidelity on resume — the model
can't see its own prior reasoning.
F4 — FRICTION. Read-only command misclassified as config_mutation.
The 2nd run was pure inspection (ls|head|tail|echo|find|journalctl) but
classified config_mutation because find is absent from
readOnlyLeadPattern (command.go:69-78); allSegmentsReadOnly trips on
the find segment and escalates. Harmless here only because F1 auto-ran it
anyway — but in a properly-gated session it would force an unnecessary
approval, and it masks the real danger of F1.
F5 — COSMETIC. Contradictory set_goal instruction.
set_goal returns "Then propose_plan. Do not call run" (tasks.go:184) for
every task, yet a read-only inspection task legitimately needs run and
doesn't need a plan (under the current carve-out). The guidance is both
ignored (F1) and wrong for this task class. Resolved by F6's plan-always
model.
F6 — DESIGN. Plan-always is the desired model (operator directive).
The operator wants: the first thing the agent does is make a plan, even when
actions are read-only and need no user approval. This supersedes the SOUL.md
degenerate-case carve-out. A one-step plan ("Inspect X, report, write back")
is acceptable for trivial questions, but propose_plan is mandatory.
F7 — EVAL. Eval harness can't express iteration or plan-always.
proposes_plan_once(manifest.go:91) counts total across the whole transcript → a 2-iteration session legitimately callingpropose_plantwice would FAIL. There is no per-turn or "plan generation count" assertion.no_rerun(manifest.go:32, not yet implemented as a kind but documented) assertsrunNOT called after the followup → directly conflicts with an iterative follow-up that needs to run.- No assertion for "session reopened from
done→executing" or "a second plan generation was created." - The manifest supports only one
followupfield (manifest.go:14), so multi-turn iteration beyond 2 turns isn't expressible.
Improvement plan (prioritized)
P1 — Make plan-first structural (BLOCKER, ships with P2)
Goal: every task proposes a plan before any run, read-only or not. No
SOUL.md-only enforcement.
- Add a
session_has_plangate in therunhandler. Ininternal/mcp/server.gorun(), before the read-only fast path (server.go:1312) and the plan/assent windows, check whethersession_plan_stepshas any row for this session. IfsessionID != ""and no plan exists, refuse:"No plan for this session. Call set_goal then propose_plan before run — even read-only tasks require a one-step plan."Mirror D.1's refusal pattern (tasks.go:313). This makes plan-first a hard gate, not prose. Read-only commands still auto-execute once a plan exists (they're read-only); the gate is about ordering, not approval. - Strip the degenerate-case carve-out from SOUL.md.
- Remove
SOUL.md:51-53's "degenerate case" sentence. - Rewrite
SOUL.md:108-115("Every chat is a task") to: every task proposes a plan; a one-step plan is fine for trivial questions butpropose_planis mandatory; only a pure-DB Q&A that calls norunmay skip the plan (still callset_goal+complete_task).
- Remove
- Decouple the plan window from approval (fixes F1).
- Remove
openPlanWindowfrom theset_goalhandler (tasks.go:179-183).set_goalrecords the goal + sets status only. - Open the plan window only on approval: the chat-assent grant
(
agent.go:318) and the explicit-approve path (agent.go:331). This restores propose → approve → execute forconfig_mutationsteps. - Read-only steps need no approval and no window — they auto-run because they're read-only, not because a window is open.
- Remove
Severity: blocker. Files: tasks.go:171-184, store.go:455-481,
server.go:1312-1345, nomos/SOUL.md:6-53,108-115.
P2 — Support iterative follow-ups (BLOCKER, ships with P1)
Goal: a completed session can be reopened by a follow-up, and the agent can propose a new plan for the new sub-task. Iteration, not re-execution.
- Add
reopenSessionon first follow-up after completion. Inmain.gochat handler, whensessionID != ""and the session is already terminal (done/failed), flip status back toexecuting, clearoutcome/summary, and stamplast_active_at. Do this in the handler (not inset_goal) so the reopen happens even if the agent's first action is a tool call rather thanset_goal. Emit atask.reopenedevent for the panel. - Clear prior plan steps on reopen, bump generation.
Extend
reopenSessionto mark allsession_plan_stepsfor the session asreplaced(a status already recognized byupdatePlanStep:612) and delete thenomos:plan:<session>autonomy key. The nextpropose_planthen seesanyStarted=false(no non-pending rows) and takes the fresh path withgeneration = MAX(generation)+1. This gives the panel a clean new plan list while preserving the prior plan's history (thereplacedrows + generation counter) for audit.- Alternative considered: delete the rows outright. Rejected — the
replacedstatus + generation column already exist for exactly this and preserve the audit trail.
- Alternative considered: delete the rows outright. Rejected — the
- Fix the
errPlanInFlightrefusal text to point at the reopen path.tasks.go:240currently says "Re-propose only if the operator explicitly asks" with no way to do it. After P2.2 the operator's follow-up is the explicit ask — the reopen clears the in-flight flag. Update the text to:"A plan from a prior turn is complete. If the operator's new message is a follow-up sub-task, the session has been reopened — propose a fresh plan for it." - SOUL.md: document iteration. Add a "7. ITERATE" step to the task flow:
a completed session accepts a follow-up as a new sub-task; call
set_goal(new goal) →propose_plan(new generation) → execute. Do not re-open the old plan.
Severity: blocker. Files: main.go:217-241, store.go (new
reopenSession), tasks.go:240, nomos/SOUL.md.
P3 — Persist per-turn reasoning, not just final summary (FRICTION)
Goal: reload shows what the operator saw live; LLM replay on resume is faithful.
- Insert one assistant row per turn, not one per session.
In
main.go:252-310andcontinue.go:190-312, insert a new row when a freshtext/tool_usecycle begins rather than overwriting the same placeholder. Keep the placeholder for the current turn only. - Accumulate text deltas instead of overwriting
finalText.agent.go:360emitstext_delta; thepersistclosure should append into atextParts []stringand join ondone, not replacefinalTexton eachtextevent (agent.go:410). Intermediate reasoning between tool calls is then preserved in the row'stextfield. - Truncate per-row tool results (already done by
truncateToolResults,store.go:129) — verify the cap is sane for the multi-row case.
Severity: friction. Files: main.go:245-310, continue.go:190-312,
store.go:123-159, agent.go:360,410.
P4 — Expand the read-only allowlist (FRICTION)
- Add to
readOnlyLeadPattern(command.go:69-78):find,tree,locate,systemctl (list-units|list-unit-files|list-timers|show),rclone (ls|lsl|md5|check),timedatectl,hostnamectl,systemd-analyze. - Add unit cases to
command_test.gofor the exactfind /var/log/rclone-backup/ -name 'runs.jsonl'command from this session, plus a compoundls -lt … && tail … && find …case.
Severity: friction. Files: internal/policy/command.go:69-78,
internal/policy/command_test.go.
P5 — Extend the eval harness for plan-always + iteration (F7)
Goal: P1 and P2 can't regress silently; the harness can express the scenarios the operator cares about.
- New assertion kinds (
manifest.goscoreOne):proposes_plan—propose_plancalled >= 1 time (plan-always; replaces the carve-out-dependentno_propose_planfor the new model).plan_before_run— the firstruncall's transcript index is strictly greater than the firstpropose_planindex (ordering gate). Requirestranscriptto expose per-call message index (add a helper).plan_generations— the persisted plan has exactlyvaluedistinctgenerationvalues insession_plan_steps(1 for single-task, 2 for one iteration). Needs a new fetch infetchTranscripthitting/sessions/{id}/plan(already exists, returnssteps).reopens_session— session wentdone→executingbetween the prompt and followup turns. NeedswaitForTurnto capture the mid-run status, or a new/sessions/{id}/historyendpoint; simplest is to snapshot status after the prompt turn and assert it wasdonebefore sending the followup.no_rerunis removed (it conflicts with iteration); replace usages withplan_generations.
- Multi-turn follow-ups. Change
conversation.Followup string(manifest.go:14) toFollowups []stringand loop inmain.go:128-139, callingwaitForTurnafter each. Backward-compatible: a scalarfollowupstill parses by adding a YAML unmarshaler alias, or just migrate existing manifests (there are none in-repo —evals/is empty). - New manifest files under
evals/:plan-always-readonly.yaml— a read-only question that would have been a degenerate case under the old SOUL. Assertsproposes_plan,plan_before_run,completes,writes_back.iteration-followup.yaml— prompt completes a read-only task; followup asks the agent to fix what it found (config_mutation). Assertsplan_generations: 2,reopens_session,completes,no_duplicate_complete(per-turn — may need a per-turn variant).iteration-readonly.yaml— two read-only sub-tasks back-to-back. Assertsplan_generations: 2,proposes_plan(>=2),max_run_callsbounded.no-plan-no-run.yaml— a pure-DB Q&A ("list all LXCs"). Assertsno_run,no_propose_plan(the only remaining carve-out),completes.
Severity: friction (blocks regression detection for P1/P2).
Files: cmd/nomos/eval/manifest.go, cmd/nomos/eval/main.go, new
evals/*.yaml.
P6 — Differentiate task classes in set_goal guidance (COSMETIC, F5)
Once P1 lands, set_goal's return text (tasks.go:184) should say: "Next:
gather context with read-only tools, then propose_plan (mandatory, even
for read-only tasks — a one-step plan is fine). Do not call run before
propose_plan." Drop the "Do not call run" absolute since read-only run
is valid after a plan exists.
Severity: cosmetic. Files: tasks.go:184.
Sequencing
- Ship together: P1 (plan-first gate) + P2 (iteration). P1 without P2
makes every completed session un-reopenable; P2 without P1 leaves the
approval-free
config_mutationhole. - P5 (evals) lands with P1/P2 as the regression net.
- P3 (reasoning persistence) and P4 (read-only allowlist) are independent and can ship in the same change or after.
Verification
go test ./cmd/nomos/... ./internal/policy/...— new unit tests for the plan gate (P1.1), reopen + generation bump (P2.2), read-only allowlist (P4.2). DONE 2026-07-15: all pass.go build ./...+go vet ./...— DONE 2026-07-15: clean.go run ./cmd/nomos/eval -manifest evals/*.yamlagainst a live nomos — all four new manifests PASS. Pending: requires live fleet + credits.- Manual: replay the Proton Drive prompt, confirm a plan is proposed and
the read-only
runs execute without approval; send "now fix thereposfailure" as a follow-up, confirm a second plan generation is created and the session reopens. Pending: requires live fleet.
Implementation log — 2026-07-15
All P1–P6 implemented in one change. VERSION bumped 0.6.0 → 0.7.0 (minor: new features).
What landed
- P1 plan-first gate:
internal/mcp/server.go— newsessionHasPlanhelper + gate at the top ofclassifyAndGate(before the dedup check). Refusesrunwith a directive when no plan exists for the session. - P1 plan window decoupled:
cmd/nomos/tasks.go—openPlanWindowremoved fromset_goal.cmd/nomos/store.go—openPlanWindowfunc deleted,proposePlanno longer setsnomos:plan:<session>.server.go—planWindowActivefunc + its check block deleted. The assent window (opened only on operator approval inagent.go:317,333) is the sole gate forconfig_mutationauto-run. - P1 SOUL.md: degenerate-case carve-out stripped (§6, "Every chat is a
task"). Replaced with "propose_plan is mandatory for any task that calls
run — even read-only." Pure-DB Q&A (no
run) is the only remaining carve-out. - P2 reopenSession:
cmd/nomos/store.go— newreopenSessionflips statusdone/failed→executing, marks all plan steps asreplaced, clears outcome/summary, emitstask.reopenedevent. - P2 caller:
cmd/nomos/main.go— chat handler callsreopenSessionbeforetouchSessionon every follow-up (no-op if session is still active). - P2 proposePlan fix:
anyStartedcheck excludesreplaced; DELETE only pending steps (replaced kept for generation counter + audit). New steps start atmax(seq)(no collisions across generations). - P2 errPlanInFlight text: updated to mention the reopen path.
- P2 SOUL.md: new "7. ITERATE" step documents the follow-up flow.
- P3 reasoning persistence:
cmd/nomos/agent.go— emitstextevent for intermediate reasoning (text + tool calls in same iteration).cmd/nomos/main.go+cmd/nomos/continue.go—textParts []stringaccumulator joins with\n\ninstead of overwritingfinalText. - P4 read-only allowlist:
internal/policy/command.go— addedfind,tree,locate,systemctl list-timers/list-unit-files/show,timedatectl,hostnamectl,systemd-analyze,rclone ls/lsl/md5sum/ check/cryptcheck.command_test.go— 11 new read-only cases + the exact compound from sessiond0d562e0. - P5 eval harness:
cmd/nomos/eval/manifest.go— new assertion kinds (proposes_plan,plan_before_run,plan_generations);Followup→Followups []string(backward-compat viafollowups()method).cmd/nomos/eval/main.go— multi-turn followup loop;fetchTranscriptalso fetches/sessions/{id}/plan;distinctGenerations()helper. Four manifests underevals/:plan-always-readonly.yaml,iteration-followup.yaml,iteration-readonly.yaml,no-plan-no-run.yaml. - P6 set_goal text: updated in P1.3 to say "propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). Do not call run before propose_plan."