Skip to content

feat(session): /goal — a definition of done the harness pursues until it holds - #142

Merged
filipeforattini merged 3 commits into
mainfrom
feat/goal-loop
Sep 6, 2026
Merged

feat(session): /goal — a definition of done the harness pursues until it holds#142
filipeforattini merged 3 commits into
mainfrom
feat/goal-loop

Conversation

@filipeforattini

@filipeforattini filipeforattini commented Sep 5, 2026

Copy link
Copy Markdown

/goal: state a definition of done, and the harness pursues it until an independent check says it holds. Behind REDCODE_EXPERIMENTAL_GOAL. This PR is the loop and the HTTP surface; the TUI and app commands follow in the next one.

What was studied

pi (nothing native — a before_run_end hook and a ## Goal line in its summary, the anti-pattern), oh-my-pi (a full /goal mode: state outside the transcript re-rendered per turn, goal complete behind a six-step evidence audit, token budget, pause on interrupt and on resume, the one-sentence anti-drift clause), hermes (the closest prior art: a small judge with DONE/CONTINUE/BLOCKED/WAIT, a contract of outcome/verification/constraints/boundaries/stop_when, shell gates, a 20-turn budget, fail-open, the goal migrated across compaction rather than pinned), the Claude Code /goal docs (a prompt-based Stop hook with a Haiku evaluator), the Codex write-ups ("write the verify command before the goal"), SWE-agent. The guardrails that recur everywhere are the ones here.

How it works

  • State: Session.metadata.goal — objective, contract, gates, status, turn budget, last verdict. A different table from messages, so compaction cannot touch it. SessionReminders re-renders the <goal> block into every turn from that record, with the turn count and the sentence against drift: keep the full objective intact across turns; never redefine success as a smaller, easier, or already-completed subset; running out of turns is not completion.
  • The end of a turn (prompt.ts, the slot after the todo continuation): gates run first — a failing one is more work and the judge is never asked; otherwise goal_judge, a hidden agent on the small model, reads the objective, the contract, any goal_complete claim, the last answer and the background jobs, and answers one JSON verdict. SessionGoal.decide turns that into: continue (one more synthetic user message, still inside ensureRunning, so the session stays busy and the surfaces see one turn), done, pause (BLOCKED, three unreadable verdicts, the budget), or wait (background work in flight; the loop breaks and the judge runs again when the work reports through inject).
  • Never restarts itself: Ctrl+C pauses it as "interrupted"; a goal driven by a different process is paused at the top of the next turn ("resumed in a new process"); /goal resume is the only way back. Compaction's own auto-continue is suppressed while a goal is active — the goal loop owns the idle boundary.
  • Fail open: a judge that errors, times out or answers unreadably is a CONTINUE with a warn on the guard log. Every decision is a goal row there, so redcode debug guards is the audit trail.
  • goal_complete({ evidence }): the agent's side of "done" — a claim the judge reads against the criteria, never a way to end the loop by itself.
  • HTTP: GET/POST /session/:id/goal, …/goal/pause|resume|drop|budget. POST parses the text (free text plus verify:, constraints:, boundaries:, stop when:, gate: lines) and starts the first turn with the objective as the user's message. SDK regenerated.
  • Config: experimental.goal.{max_turns, judge_timeout, gate_timeout}.

Tested

test/session/goal.test.ts (16): parse, render, continuation, every decide branch including the budget and the waiting override, verdict parsing on clean/fenced/garbage text, metadata round-trip. In the prompt suite, against the test provider with the judge's answers queued by request (7): CONTINUE then DONE inside one run; the budget pauses with the reason; BLOCKED; a failing gate continues with its output and the judge is never called; goal_complete's evidence reaches the judge and a rejected claim continues; an unreadable verdict continues; cancel pauses as interrupted; a foreign boot pauses. Prompt, orphan, tool, agent, design suites: 565 pass. Core agent test with the judge agent. Typecheck clean in redcode and core.

Next

TUI and app commands and status (/goal, /goal show|pause|resume|drop|budget), then subagents inheriting the goal with WAIT semantics, then real fan-out.

https://claude.ai/code/session_014XwJPDhq1ahcm3rd454WzQ


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

… it holds

The goal lives in the session's metadata, not the transcript, and is re-rendered into every
turn by the reminders, so compaction cannot paraphrase it away and the model cannot quietly
shrink it. At the end of each turn: gates (shell commands that must exit 0) run first; if
they pass, a small judge reads the objective, the contract, any goal_complete claim and the
last answer, and says DONE, CONTINUE, BLOCKED or WAIT. CONTINUE is one more synthetic user
message and another pass through the loop — the turn never leaves ensureRunning, so the
session stays busy and the surfaces see one turn. DONE ends it with the goal met. BLOCKED,
three unreadable verdicts, and the turn budget pause it with the reason. WAIT parks it while
background work runs. The judge fails open: no answer is a CONTINUE with a warning.

Guardrails from what hermes, oh-my-pi and Claude Code converged on: a turn budget (20), the
one sentence against drift, pause on interruption and on a new process (a loop never restarts
itself; /goal resume is the only way back), no competing auto-continue from compaction while
a goal is active, and every decision on the guard log so `redcode debug guards` is the audit
trail. The decisions are pure (`goal.ts`) and tested without a model; the loop is tested
against the test provider with the judge's answers queued by request.

Behind REDCODE_EXPERIMENTAL_GOAL. Endpoints on the session group; SDK regenerated. The TUI
and app commands follow in the next PR.

Claude-Session: https://claude.ai/code/session_014XwJPDhq1ahcm3rd454WzQ
The exerciser gate wants one scenario per route. The goal is seeded straight into session
metadata through a new sessionMetadata helper, so pause, drop and budget act on a real one
without a model; set and resume fork the first turn, so they run against the fake LLM the way
summarize does.

Claude-Session: https://claude.ai/code/session_014XwJPDhq1ahcm3rd454WzQ
…he user's reason

goalSet and goalResume forked the first turn into the request's scope, which closes with the
response and took the turn with it — the exerciser's effect mode showed the model was never
called. They fork into the server's scope now, as prompt_async does. goalPause cancelled the
run first, and cancel pauses an active goal as "interrupted", so the reason the user asked for
was overwritten; it pauses first, then cancels.

Claude-Session: https://claude.ai/code/session_013JrKXL4pdgTu2EtCjgiYyq
@filipeforattini
filipeforattini merged commit 0d80aee into main Sep 6, 2026
12 checks passed
@filipeforattini
filipeforattini deleted the feat/goal-loop branch September 6, 2026 00:35
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.

1 participant