Skip to content

fix(goal): goal-scoped todo epochs, diff-gated steering, real completion (#318) - #319

Merged
justrach merged 3 commits into
mainfrom
fix/318-goal-epochs
Jul 30, 2026
Merged

fix(goal): goal-scoped todo epochs, diff-gated steering, real completion (#318)#319
justrach merged 3 commits into
mainfrom
fix/318-goal-epochs

Conversation

@justrach

Copy link
Copy Markdown
Owner

Fixes #318/goal reattached unscoped stale todos on every turn and stayed active after normal completion.

Informed by a comparative study of how other harnesses handle this (openai/codex source + DeepWiki, cline, opencode, gemini-cli). Every mechanism below has a named precedent.

Root causes fixed

  1. Todos had no owner. TodoItem was {content, status}; /goal <new> replaced only root.goal, so the new goal inherited every stale item and presented them as its own checklist each turn.
  2. The checklist was re-pasted into every turn as persisted user history. The model already sees the list in todo_write tool results — the re-paste was pure duplication, and compaction summarized the 40 repeated copies into "the user's goal", making stale steering unremovable by /goal clear.
  3. Normal completion never completed the goal. Only the /loop controller ever wrote .complete, so a /goal steered every future turn forever.
  4. /loop never stopped naturally: with no completion signal, continuationDecision burned all 25 iterations on one-turn prompts.
  5. Todos weren't persisted but survived in-process across /resume, leaking one conversation's checklist into another session's goal.

Changes

  • src/goal_state.zig (new, pure, unit-tested): epochs, parkSuperseded, steeringGate (diff-gating), completionDecision, supersession notes, applyGoalSteering.
  • Epochs: Goal.epoch + TodoItem.epoch; todo_write stamps items with the authoring goal (codex ThreadGoal.goal_id; opencode session-keyed TodoTable; cline task-scoped focus chain).
  • Supersession boundary: /goal <new> parks the old checklist and queues a one-shot note ported from codex's objective_updated.md: "the new objective supersedes… avoid continuing work that only served the previous objective." /goal clear closes its checklist. A fresh goal adopts an in-flight pre-goal plan.
  • Diff-gated steering: the goal note drops its embedded checklist and injects only on change / resume / post-compaction / every 8th turn (codex WorldState render_diff; cline shouldIncludeFocusChainInstructions). Compaction invalidates the fingerprint so steering is re-stated in full afterwards (codex world_state_baseline = None on history rewrite).
  • Completion is a state transition: attempt_completion sets goal.status = .complete (+ trace note), with a Cline-style double-check when current-epoch items are open — first call refused with the open list, an explicit second call closes anyway.
  • /loop natural finish: a turn with zero tool calls ends the loop (codex RegularTask semantics — attempt_completion is budget-exempt and doesn't count); the harness note now states the completion contract.
  • Persistence: session JSON gains todos (+goal.epoch); resume replaces the list wholesale and reconciles a restored-active goal with a finished checklist to .complete. Legacy sessions load unchanged (epoch defaults to 0).

392/392 tests pass; all modules stay ≤600 lines (new logic lives in goal_state.zig; renderTodos moved there from agent.zig).

Follow-ups deliberately not in this PR (from the same review)

  • Shared-worktree detection (/goal reattaches unscoped stale todos on every turn and stays active after normal completion #318 safeguard 4): pid+session heartbeat lock under .graff/; neither codex nor opencode has this — cline's checkpoint lock is the model.
  • Ephemeral steering transport: stop persisting harness notes as literal user text entirely (codex InternalModelContextFragment); diff-gating removes most of the amplification, the transport change is the endgame.
  • Subagent runaway bounds: children skip the tool-call budget and dedupe gate entirely (rejectToolCall early-out on self.sub), and Esc doesn't signal or kill background subagents.
  • Behavior-trace vocabulary: goal_set/goal_replaced/goal_status_changed/todos_replaced events (this PR adds tracer notes at goal transitions as a first step).

🤖 Generated with Claude Code

https://claude.ai/code/session_01XjuHQ7vTXWYnVAfUo5EWqH

justrach and others added 2 commits July 28, 2026 16:29
…#318)

/goal reattached every stale todo to each turn and stayed active forever
after normal completion. Now:

- Goal + TodoItem carry an epoch; todo_write stamps items with the goal
  that authored them (new src/goal_state.zig, pure + unit-tested).
- Replacing a goal parks the superseded checklist and queues a one-shot
  codex-style supersession note instead of bequeathing the items.
- /goal clear closes its checklist; /clear and /new reset steering state.
- The steering note no longer embeds the checklist (the model already
  sees it in todo_write results) and is diff-gated: injected on change,
  on resume, after compaction, or every 8th turn - not verbatim every
  turn (compaction was memorizing dead goals from the repeats).
- attempt_completion completes the goal (with a double-check when
  current-epoch items are still open), so a goal ends without needing
  /loop's controller.
- /loop: a turn that uses no tools is a natural finish - a one-turn
  prompt no longer burns all 25 continuations; the harness note now
  states the completion contract.
- Sessions persist todos with their epoch; resume replaces the list
  wholesale and reconciles a finished active goal to .complete.

Closes #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjuHQ7vTXWYnVAfUo5EWqH
A 16-agent adversarial review of the first commit confirmed six defects,
all fixed here:

- A zero-tool /loop turn was fed to continuationDecision as "checklist
  complete" and silently flipped an active goal to .complete (including
  the turn where the harness itself had just refused attempt_completion).
  The loop still stops on a natural finish, but only real work_done
  (completed set, or the checklist finished) may complete the goal.
- attempt_completion closed a checklist-less goal on the first interim
  call (guaranteed under --strict). completionGate now requires either a
  fully-completed checklist or an explicit second confirming call.
- /review turns hit the goal gate and could complete the user's standing
  goal from inside an isolated review; goalActive excludes review_mode.
- The steering fingerprint was reset on only one of the history-rewrite
  paths. It now resets at the source - compact(), emergencyTrim(),
  /rewind - covering /compact, mid-turn, overflow recovery, and resume.
- pending_goal_note and the fingerprint survived loadSession, leaking a
  stale supersession note into a resumed conversation.
- /goal clear with no goal set destroyed the model's unscoped working
  checklist and steered about a phantom goal; it is now a no-op, and the
  over-eager resume reconcile (which silently completed phase-style
  goals whose current checklist happened to be finished) is removed.

392/392 tests pass; completionGate has direct unit coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjuHQ7vTXWYnVAfUo5EWqH
@justrach

Copy link
Copy Markdown
Owner Author

Ran a 16-agent adversarial review (3 lenses × per-finding skeptic verification) over the first commit. 15 findings survived verification, collapsing into 6 real defect classes — all fixed in d6876e3:

  1. Loop natural-finish could kill the goal: tool_calls_this_turn == 0 was passed to continuationDecision as todos_all_completed, so a prose-only /loop turn (including one whose only tool call was an attempt_completion the gate had just refused — it's counter-exempt) flipped an active goal to .complete silently. Now split: a zero-tool turn stops the loop, but only work_done completes the goal.
  2. Checklist-less goals died on turn 1 (guaranteed under --strict): open == 0 accepted immediately when no checklist existed. completionGate now requires a finished checklist or an explicit second confirming call.
  3. /review could complete the user's goal from inside an isolated review — goalActive now excludes review_mode.
  4. Fingerprint reset missed 5 of 6 history-rewrite paths (/compact, mid-turn compaction, overflow recovery, /rewind, resume) — the goal note could stay suppressed for up to 8 turns after its only copy was summarized away. Reset moved to the source: compact(), emergencyTrim(), /rewind, loadSession.
  5. pending_goal_note survived loadSession, injecting a stale "goal was cleared" note into a different resumed conversation.
  6. /goal clear with no goal destroyed the model's unscoped working checklist and steered about a phantom goal; and the resume reconcile silently completed phase-style goals whose current checklist happened to be momentarily finished — both removed.

One finding was refuted (the provider-failover double-check spend — mechanics right, consequence doesn't materialize). 392/392 tests green after the round.

…tdout

Live behavioral testing (6 agents driving the built binary through real
sessions) passed 52/53 checks. The one real failure: the completion
deferral promises "open items will be parked", but force-closing the
goal left the todos in place - /goal status then read the contradictory
"Status: complete. Checklist: 1 item(s) open." Completion now closes the
goal's epoch checklist (goal_state.closeEpoch).

Also, the deferral was invisible on stdout (meta tool results are not
echoed), so the user saw the completion text twice with no explanation;
the gate now prints "⏸ completion deferred" when it fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XjuHQ7vTXWYnVAfUo5EWqH
@justrach

Copy link
Copy Markdown
Owner Author

Live behavioral test round — 6 agents drove the built binary (kimi/k3 turns, isolated workspaces) through the full goal/todo surface, asserting on transcripts + persisted session JSON: 52/53 checks passed, and the one failure is fixed in the commit above.

Scenario Result Key observations (quoted from actual runs)
goal-lifecycle 13/13 steering note byte-exact incl. completion contract; pause suppresses injection on a real turn; cleared-note injected exactly once then drains; epoch round-trips as int
steering-diffgate 5/5 exactly ONE [standing goal: per session-load; suppressed on repeat even while the checklist churned (fingerprint is on the note text — by design); re-injected once after --resume
completion-gate 5/5 "completion deferred: … no checklist yet" → second call → "🎯 standing goal complete", status:"complete", no steering afterward
supersession-parking 7/8 (superseded "…" — parked 1 unfinished checklist item(s)) printed; epoch 1→2; supersession note reaches the model in-message. FAIL (now fixed): force-close left the "parked" items live — Status: complete. Checklist: 1 item(s) open.
loop-natural-finish 7/7 one turn → ↩ /loop stopped — accepted; the zero-tool loop turn left an unrelated active goal untouched (the adversarial-review fix, confirmed live)
resume-isolation 15/15 /new inherits nothing; hand-crafted legacy session (no epoch, no todos field) loads clean; wholesale list replacement across session switches; nothing leaks via global state

Checklist so far: (the #318 amplification marker) appeared zero times across every session file and log.

Design notes surfaced by the testers, left as-is intentionally: the completion gate is a confirm-once speed bump, not a hard wall — a model can close a goal with two consecutive calls (Cline's double-check semantics; a hard wall would recreate the goal-that-never-ends). And the diff-gate fingerprints the note text, not checklist deltas — the checklist already reaches the model as todo_write results.

Post-fix re-verification: ⏸ completion deferred now prints, and force-close ends with Status: complete. Checklist: 0 item(s) open. / (no todos) / todos: [] in the session file.

@justrach
justrach merged commit 4a4ff39 into main Jul 30, 2026
6 checks passed
@justrach

Copy link
Copy Markdown
Owner Author

Shipped in v0.0.221.

This branch grew well past its original scope during review, so it landed through release/v0.0.221 -> main (4a00daa) rather than through this PR. Everything here is in that release, plus the follow-up work the review turned up.

What landed beyond the original commits:

  • --goal is a STANDING objective: the model cannot retire it, it survives a resume idempotently, and it steers every turn including --json/-p/SDK sessions.
  • nextEpoch now derives from the todo list as well as the live goal, so a new objective can no longer land on a parked epoch and inherit its checklist.
  • completion_gate_armed is reset on every session restore, and any accepted claim spends the arm (a goal that never retires must not ride one refusal forever).
  • A leftover .complete goal no longer ends the next /loop at iteration 1.
  • Compaction and emergency-trim now restate the live checklist, so the model stops rebuilding its plan from prose and dropping finished work.
  • /loop continuation prompts diff-gate the checklist copy instead of pasting it every turn.

Four adversarial verification rounds ran over this; three of them found real defects in the newest code, including two that recreated #318 through new doors. Details are in the commit bodies.

Closing in favor of the release branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/goal reattaches unscoped stale todos on every turn and stays active after normal completion

1 participant