Goal Loop
The goal-loop is the runtime behind /goal. It lives in packages/goal/ as pure logic, then gets consumed by the daemon's RPC layer and surfaced by the TUI's LiveFocalStripWithGoal overlay and the Electron app's GoCommandBar.
Pure logic. Mock executor + mock judge in tests. No I/O leaks. The daemon owns persistence, ledger, and event fanout.
Components
packages/goal/
types.ts Budget, Counters, Receipt, GoalEvent, JudgeVerdict
goal-loop.ts The orchestrator. Turn loop, budget enforcement,
judge dispatch, sub-goal injection, abort handling.
state-machine.ts pending -> running -> judging -> completed|stopped|failed
budget.ts Per-axis counters; resolveBudget, shouldStop
judge.ts Verdict validation, anti-collusion gate,
confidence floor enforcement
receipt.ts Final-result assembly: verdict + cost + evidence
executor-eight.ts EightExecutor - wraps packages/eight/agent.ts
judge-failover.ts FailoverJudge - local-first chain via
packages/providers/failover.ts
persistence.ts GoalPersistence - SQLite goal_runs + goal_events
ledger.ts Append-only HMAC-signed hash-chain JSONLState machine
pending -> running -> judging -> (running | completed | stopped | failed)
^
+-- terminal statesTransitions are explicit. IllegalTransitionError thrown on any non-allowed step. The same machine is used in unit tests against mocks and in production against real models.
Judge contract
Every turn the executor produces a turn summary. The judge receives the goal text, the turn summary, and a falsifiable success criterion (extracted at goal-start by a lightweight rewrite of the goal). The judge returns:
type JudgeVerdict = {
decision: "satisfied" | "continue" | "failed";
confidence: number; // 0..1
reason: string; // one short sentence, no AI-speak
};Non-negotiable judge rules
- Anti-collusion: the judge constructor rejects if judge model id equals executor model id. Self-grading defeats the loop.
- Fail open: any judge failure (timeout, malformed JSON, network error, unreachable provider) returns
{decision: "continue", confidence: 0, reason: "judge unavailable, deferring to budget"}. Never wedge. - Structured output only: free-text verdicts are parse-rejected.
- Verifies by execution, not self-report: the prompt instructs the judge to look at evidence, not to trust the agent's claim that it succeeded.
Budget enforcement
Five axes, all enforced before the judge is even called:
maxTurns per-run turn ceiling
maxTokens in + out across all turns
maxWallclockMs real time from goal.started to terminal state
maxFilesChanged files touched via Write/Edit/Delete tools
maxEgressBytes HTTP body bytes shipped by the agent
maxDissentStreak consecutive "continue" verdicts before forced stopDefaults are local-first: 12 turns, 100k tokens, 10 min, 50 files, 25MB egress, 8 dissent streak. Per-run overrides via daemon RPC.
Persistence
packages/db/migrations.ts v2 adds:
CREATE TABLE goal_runs (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
goal_text TEXT NOT NULL,
status TEXT NOT NULL,
budget_turns INTEGER,
budget_tokens INTEGER,
budget_wallclock_ms INTEGER,
judge_verdict TEXT,
started_at INTEGER,
ended_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE goal_events (
run_id TEXT NOT NULL,
seq INTEGER NOT NULL,
kind TEXT NOT NULL,
payload TEXT NOT NULL,
ts INTEGER NOT NULL,
PRIMARY KEY (run_id, seq)
);/goal resume hydrates a run from these tables after a daemon restart.
Append-only ledger
Every goal event ALSO writes to ~/.8gent/runs/{runId}/ledger.jsonl:
{"seq":1,"prev_hash":"00..00","hash":"<sha256>","ts":1700000000,"kind":"run.started","payload":{...},"sig":"<hmac>"}
{"seq":2,"prev_hash":"<prev>","hash":"<sha256>","ts":1700000001,"kind":"turn.completed","payload":{...},"sig":"<hmac>"}hash = sha256(prev_hash || canonical(payload))sig = hmac-sha256(canonical(payload), daemon_key)- Daemon key at
~/.8gent/keys/state-hmac.key, mode 0600, auto-generated on first use Ledger.verify()walks the chain end-to-end, returns the first failure with{atSeq, reason}
A tampered payload, a broken chain link, or a forged signature all surface. The ledger is the audit trail for autonomous runs.
Daemon RPC
Five inbound methods + three event types:
goal.start { sessionId, goal, budget?, judgeModel? } -> { runId }
goal.status { runId } -> { snapshot }
goal.subgoal { runId, text } -> { accepted }
goal.abort { runId } -> { accepted }
goal.resume { runId } -> { known }
(events streamed)
goal.turn per turn
goal.judge per verdict
goal.done terminal stateBoth the TUI's GoalClient and the Electron app's goal-client.ts speak this protocol. In-process transport (no daemon required) and WebSocket transport (daemon over localhost:18789) both implement the same GoalTransport interface.
Sub-agents
When a goal needs cross-functional work, the loop can spawn sub-agents via packages/eight/harness/spawn.ts. Each sub-agent runs in an isolated git worktree with capability narrowing, persona validation, and a structured result that flows back to the parent. The agent pool caps concurrency at 10.