diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0a2d963b6..89f1e70d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,7 +89,6 @@ In TUI chat mode there is no completion gate — the session stays open across t - Product non-TUI agent path that **shares** the TUI stack (session mode, ChatDirector, toolset, permission gate, MCP, plugins, hooks, run-sink) without the OpenTUI shell - Bootstrap is intentionally a **forked copy** of the TUI path (not a shared factory yet). Intentional deltas vs TUI: - No workflow controller (`isWorkflowActive` is always false) - - No goal governor / multi-turn goal loop (single primary `send`) - Non-interactive permission gate by default; optional stdin for `ask_operator` - Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"` - Streams assistant text deltas to stdout; lifecycle errors to stderr @@ -106,7 +105,7 @@ In TUI chat mode there is no completion gate — the session stays open across t Two directors, selected by role: -- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, multi-turn chat semantics, and an optional **goal governor** (session-scoped auto-continue until every acceptance criterion is done). It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. +- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode. - **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less completion with **zero tool calls in the entire run** is returned as a **never-acted** salvage report (not a successful implement); explore/read-only workers that used tools then replied with findings remain normal completes. Hard stops also fire after 2 consecutive identical tool-call fingerprints (**no-progress**), on progressive re-read pressure (**thrash** — the same path re-read past a limit amid enough tool volume, tracked by `src/subagent/thrash.ts`), or after the leaf turn budget (**turn-budget**, default 30, overridable via `task(maxTurns)`, agent profile `maxTurns`, or `settings.subagentMaxTurns`, capped at 100), each returning a structured salvage report (reason, partial findings, blockers) so a thrashing child cannot burn tokens indefinitely. A fourth hard stop, **repetition**, is detected outside the director entirely: `runSubAgent`'s stream sink watches the streamed text of the in-flight cycle for degenerate token loops (`src/subagent/repetition.ts`) — whitespace-collapsed raw text, a smallest-period KMP check over the probe tail, default window >= 16 chars repeated >= 8 times, evaluated every 256 streamed chars — and on a hit aborts the run controller mid-cycle, returning a `repetition` salvage report that leads with the looped window and warns the parent against re-dispatching the identical brief. Because directors only see completed turns, this is the only stop that can catch a loop inside a single turn that never finishes. A one-shot **report-forced** signal fires a few turns before the cap while the leaf is still tooling — it is not a stop: the director injects a wrap-up nudge and lets the leaf finish on its own, so turn-budget stays reachable for a leaf still making progress. Operator/parent cancel after any progress likewise returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. Optional `task(tier=)` (`fast` | `standard` | `clever`) overrides profile inference, profile tier, and the parent provider for that spawn only, and fails closed when the tier is unconfigured. The parent `task` tool keeps a session-scoped brief-dispatch ledger (`src/subagent/brief-dispatch.ts`): fingerprints cover prompt + agent + intent + success_criteria + do_not (not maxTurns/description/tier). After thrash / no-progress / repetition / never-acted salvage, an identical re-dispatch is hard-blocked for the rest of the parent chat; change at least one fingerprint field to force a re-run. Turn-budget salvage still invites a higher maxTurns for a few same-brief retries without a successful complete, then flips the parent hint to stop and change approach (soft — further identical dispatches are still admitted). A successful complete resets the same-brief retry budget. @@ -127,7 +126,7 @@ Defaults are permissive (12 / 20 turn-only thresholds, 5-minute stall timeout) s #### Main-session loop protection -The ChatDirector counts consecutive assistant turns that contain tool calls and no text (`toolOnlyStreak`), reset by any turn with text and by every fresh operator message. A dismissed `ask_operator` counts as a no-progress, tool-only turn — the decline path does not reset the streak. At `toolOnlyTurnNudgeAt` the director arms a one-shot ephemeral wrap-up nudge; at `toolOnlyTurnPauseAt` it stops issuing infers entirely and replies with a loud, operator-facing pause message ("Auto-paused: the model ran N steps in a row without explaining its progress. Send a message to resume", `src/agent/director.ts:424-426`), using the same `capabilities.reply()` channel the workflow-stall message already uses to reach the TUI — no new director-to-UI channel was needed. Because a turn with pending `tool_call` blocks must be followed by tool results before anything else (a bare nudge turn on top of pending tool calls is a provider-invalid conversation), both the nudge and the pause are applied by rewriting the `infer` action that follows once those pending tools have resolved — the same one-shot rewrite shape as the sub-agent report-forced wiring below. This loop-protection rewrite runs with the **highest precedence** among the terminal/continuation rewrites in `decideInner`: it is checked before the workflow-idle, open-task, and goal-governor continuation nudges, since those exist to keep a session moving — exactly the behavior the pause guards against. Resuming is just the operator sending a new message, which resets the streak and un-pauses through the same reset path as the other nudge budgets. +The ChatDirector counts consecutive assistant turns that contain tool calls and no text (`toolOnlyStreak`), reset by any turn with text and by every fresh operator message. A dismissed `ask_operator` counts as a no-progress, tool-only turn — the decline path does not reset the streak. At `toolOnlyTurnNudgeAt` the director arms a one-shot ephemeral wrap-up nudge; at `toolOnlyTurnPauseAt` it stops issuing infers entirely and replies with a loud, operator-facing pause message ("Auto-paused: the model ran N steps in a row without explaining its progress. Send a message to resume", `src/agent/director.ts:424-426`), using the same `capabilities.reply()` channel the workflow-stall message already uses to reach the TUI — no new director-to-UI channel was needed. Because a turn with pending `tool_call` blocks must be followed by tool results before anything else (a bare nudge turn on top of pending tool calls is a provider-invalid conversation), both the nudge and the pause are applied by rewriting the `infer` action that follows once those pending tools have resolved — the same one-shot rewrite shape as the sub-agent report-forced wiring below. This loop-protection rewrite runs with the **highest precedence** among the terminal/continuation rewrites in `decideInner`: it is checked before the workflow-idle and open-task continuation nudges, since those exist to keep a session moving — exactly the behavior the pause guards against. Resuming is just the operator sending a new message, which resets the streak and un-pauses through the same reset path as the other nudge budgets. #### Sub-agent stall management @@ -137,7 +136,18 @@ The ChatDirector counts consecutive assistant turns that contain tool calls and The reactor only persists a response turn to `turns.jsonl` on `inference.done`, so a cycle that is cancelled, aborted, errors, or is otherwise interrupted mid-stream would leave nothing behind. A cycle-text recorder (`src/session/stream-journal.ts`) closes that gap by buffering the in-flight cycle's streamed text in memory — no writes on the happy path — and appending one JSON record (`{reason, chars, text}`) to `partial.jsonl`, alongside `turns.jsonl` in the session context dir, on abnormal cycle end. It is wired into the sub-agent run loop, the exec runner (flushed on failed sends), and the TUI runner (flushed on interrupt and on session rotation, before the context dir is repointed). -Both adopt the shared **compaction governor** (`src/agent/compaction.ts`) described below. The ChatDirector may also attach a **goal governor** (`src/agent/goal.ts`) that rewrites clean terminal yields (`wait`/`reply`) into re-inference while a session goal is active and not every criterion is done. The operator brief is expanded into a multi-item acceptance checklist via `manage_goal`; empty criteria nudge the agent to define them before the evaluator runs. Achievement prefers checklist completion; a fail-open one-shot evaluator (`src/agent/goal-evaluator.ts`) is secondary. Goal intercept runs **after** compaction, workflow open-step, and open-task nudges so those rails keep precedence. While a goal is **active**, the TUI permission gate arms a short auto-skip timeout (`src/permission/goal-approval-timeout.ts`, ~15s) so an unattended goal cannot park overnight on an approval modal; the deny carries a message the agent can act on. Sub-agent directors do not attach a goal governor. +Both adopt the shared **compaction governor** (`src/agent/compaction.ts`) described below. + +**Removed: goal subsystem.** The ChatDirector previously attached a goal governor (`src/agent/goal.ts`, `src/agent/goal-evaluator.ts`, `src/agent/manage-goal.ts`, `src/session/goal-state.ts`, `src/permission/goal-approval-timeout.ts`) that rewrote clean terminal yields (`wait`/`reply`) into re-inference while a session-scoped `/goal` was active and not every acceptance criterion was done. It was deleted for being too complex to keep as-is; the capability it provided — deciding whether to re-enter inference after a clean yield — moves to the director, generalized beyond a single-goal acceptance checklist. The contract the replacement must satisfy: + +- **Inputs:** the base terminal actions (`ReactorAction[]`, already produced by `super.decide`), the reactor capabilities (for building an `infer` action), and context — `atWorkflowGate` (bool), `lastTurnHadContent` (bool), and evidence derived from the turn history (the deleted `evidenceFromTurns` in `goal-evaluator.ts` built a bounded excerpt of recent turns for a model-backed evaluator to judge completion against; the replacement needs its own definition of what "evidence" means for its own completion signal, since nothing that shape exists anymore). +- **Return convention:** async, returns the rewritten action array or `null`; `null` means decline (let the base terminal action stand). The call site is `const rewrite = await replacement.interceptTerminal(...); if (rewrite !== null) return rewrite;` — same shape as every other terminal rewrite in `decideInner`. +- **Precedence:** called last among terminal rewrites, after compaction, workflow open-step, and open-task nudges, so those rails keep precedence; loop protection (`applyToolOnlyLoopProtection`) outranks all of them and runs first. +- **Compaction survival (the trap to avoid):** the deleted `SummaryContext.goal` field existed specifically because a continuation mechanism whose state lives only in the turn history dies at the first compaction — the goal's brief/status/criteria summary was injected into the compaction prompt so the continue-rule survived across it. Any replacement state needs the same treatment, or long auto-continue runs will silently stop continuing the moment compaction fires. +- **Token attribution:** the deleted governor's `noteMainTokens` hook fired on every `inference.done` to feed a soft token budget. If budgeted auto-continue is in scope for the replacement, it needs an equivalent hook point; if not, that's a deliberately dropped capability, not an oversight. +- **Launch-time tool gating:** the deleted `hasGoalAtLaunch` flag let persisted continuation state (loaded before the first inference call) gate which tools were advertised in the wire prefix (see `tool-search.ts` — the tools array is a provider cache prefix, so gating facts must be fixed for the session's life). A replacement that persists its own state across sessions will face the same launch-ordering constraint: state must load before the tool list is built. + +The auto-deny approval timeout the goal governor armed (`timeoutMs`/`timeoutMessage` on the TUI permission gate) is generic plumbing that survives in `src/tui/gate-events.ts` / `src/tui/request-approval.ts` with no current caller — a future generalized auto-continue mechanism owns re-arming it. The agent maintains an optional **`manage_tasks`** list (create/update via the homonymous tool). The TUI task panel reflects director task state; `manage_tasks` tool calls are collapsed into a dedicated content block in the event stream. @@ -152,7 +162,7 @@ When a cycle's input tokens cross a threshold, the director compacts the inferen The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor, after emitting `compact`, self-delivers a content-less inbound message (a host-supplied `requestContinuation` callback). That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. -Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. When a session goal is active, its brief, status, and acceptance-criteria summary are injected into the summary context so the continue-rule survives compaction. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session. +Compaction replaces older turns with a structured, workflow-aware summary rather than a stats blob: sections for **What Happened / What We're Doing / Relevant Links / Action Items / Next Steps**, with the active workflow and step woven in so compacting mid-`/build` or mid-`/plan` preserves the contract. The summary is produced by a one-shot model call; on any failure it falls back to a deterministic summary so a compaction cycle never breaks the session. ### Web Tools and Providers (`src/web/`) @@ -286,7 +296,7 @@ tool call Approval scopes offered: Allow Once (persist nothing), Allow Always for a file or its directory (file tools), or a command shape (shell). There is intentionally no "all files" rung. -**Known live gap.** A queued gate's display-dependent timers (goal-mode auto-skip, tool-budget pause ceiling) currently arm when the request is *received*, not when it is actually shown to the operator — a request sitting behind others in the queue can burn its whole timeout invisibly. `ask_operator` also has no timeout/abort safety net at all, unlike the permission gate, so a queued operator question behind a stuck overlay can hang a run. Both live in `src/tui-opentui/gate-wire.ts`'s `onPermission`/`onOperator`. This callout should be removed once that fix ships — do not let it become permanent known-issue debt. +A queued gate's display-dependent timers (auto-deny timeout, tool-budget pause ceiling) arm when the request is actually shown to the operator, not when it is received — a request sitting behind others in the queue does not burn its timeout invisibly. `ask_operator` has the same abort/timeout safety net as the permission gate, so a queued operator question behind a stuck overlay cannot hang a run. Both live in `src/tui-opentui/gate-wire.ts`'s `onPermission`/`onOperator`. ### TUI (`src/tui-opentui/`) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index cf7906754..c2f52ee3d 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -48,7 +48,7 @@ $ corbits exec "Add JWT auth to the API" $ corbits run "Add JWT auth to the API" ``` -Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller or goal governor; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set (or auto mode covers them). `ask_operator` reads a single line from stdin when available. +Same directors, tools, permissions, MCP, plugins, and hooks as the TUI — without the OpenTUI shell. The exec bootstrap is a deliberate fork of the TUI path (not a shared factory yet); see `docs/ARCHITECTURE.md` “Exec Runner” for intentional deltas (no workflow controller; single primary send; non-interactive permission gate). Compaction continuation matches TUI so long runs do not stall after compact. Streams assistant text to stdout for scripts and CI. Non-interactive by default: actions that need operator approval are denied unless `--dangerously-skip-permissions` is set (or auto mode covers them). `ask_operator` reads a single line from stdin when available. Local multi-model capability checks use this path (`bun run eval:capability`); see `evals/capability/README.md`. @@ -76,14 +76,12 @@ Continues from the last saved state in the working directory. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (open the agent configuration surface — connect providers with **c** / **Ctrl+A**, pick models, tiers, and profiles), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/goal` (session goal: expand a brief into an acceptance checklist and auto-continue until every criterion is done — see `/goal [turns] `, `/goal pause|resume|clear|status`, optional `--tokens N` / `--replace`), plus a `/` command per available workflow. Plugins can register additional commands. +The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (open the agent configuration surface — connect providers with **c** / **Ctrl+A**, pick models, tiers, and profiles), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, and `/mcp`, plus a `/` command per available workflow. Plugins can register additional commands. Providers are **models-first**: there is no standalone `/login` command. `/model` opens on a **model list** (Recent, Favorites, then providers) so you pick a model without drilling provider first. **Alt+A** (or **c**) opens Connect; **Alt+F** toggles favorite on the highlighted model; **a** opens the advanced provider drill-down (edit/delete/tiers). Connect lists first-class providers (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom). OAuth providers open their existing browser login; API-key providers show an **auth-only** form (key + fixed catalog base URL), validate, and persist pre-seeded models for immediate selection. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. `/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-first (Recent / Favorites / Providers); connect, tiers, and profiles remain reachable from the same surface. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials). -`/goal ` arms a session-scoped goal governor. The operator brief is **not** the completion condition: the agent must clarify success (via `ask_operator` when vague) and expand it into a multi-item **acceptance** checklist with `manage_goal` *before* substantial work. Work steps go in `manage_tasks` (shown as **Work** while a goal is active) — separate from acceptance. Lifecycle phases surface in the UI: **planning** (define Acceptance) → **implementing** (Work primary; Acceptance compact; `doing` on a criterion stays here) → **reviewing** (starts when any criterion is `done` or `blocked`) → **completed** (all non-cancelled criteria done; auto-achieves). After each clean yield the agent is re-inferred until every acceptance criterion is done, a finite turn/token budget soft-stops, or the operator pauses/clears. **Default turn budget is unlimited** (`0`); an optional leading integer caps continues (`/goal 40 ship the feature`). Resume restores a prior goal as **paused** (never silently re-armed); unlimited goals stay unlimited on resume, finite ones get headroom. While a goal is **active**, permission prompts that still need a human answer auto-skip after ~15s with a note back to the agent (human may be away — continue another way); the operator can still approve/deny earlier. Pair with auto mode and/or `--dangerously-skip-permissions` for longer unattended runs. Goal mode does not shrink tools, skills, slash commands, sub-agents, or MCP. - ## Lifecycle Hooks Config-driven `postTurn` and `postRun` hooks (TypeScript or shell) run automatically, discovered from `.corbits/hooks` (per-repo) and `~/.corbits/hooks` (global). `postTurn` receives aggregated turn context (tool calls, results, token usage, duration); `postRun` receives a run summary. The TUI hook panel lists discovered hooks and lets the user enable/disable them. See `docs/HOOKS.md`. diff --git a/docs/TUI.md b/docs/TUI.md index bf88d14e8..0a8ecb48c 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -17,7 +17,7 @@ palette," a plain question — never the word "overlay." There is no titlebar, no status strip, and no key-hint row as permanent chrome. The prompt box is the only permanent chrome in the shell: it is -anchored at the bottom in every state, and everything else — goal/task/agents +anchored at the bottom in every state, and everything else — task/agents strips, notices, banners, the overlay host — is optional and collapses to zero rows when it has nothing to say (`src/tui-opentui/geometry/zones.ts`). The transcript is residual: whatever rows remain after chrome and any open @@ -32,7 +32,7 @@ terminal with nothing optional showing, the transcript floor is 12 rows proposed 8 rows (`OVERLAY_TRANSCRIPT_FLOOR`) so the log stays glanceable underneath a permission prompt. When space is scarce, collapse follows a fixed order — transient banners first, then settings/plugin notices, then -goal/task/agents strips, then progress, then the prompt itself shrinks one +task/agents strips, then progress, then the prompt itself shrinks one row at a time down to its 3-row base — never the transcript (`COLLAPSE_ORDER` in `zones.ts`). @@ -134,7 +134,7 @@ agent, with `agentId` as a tiebreak for a simultaneous fan-out. Under space pressure, the zone shrinks one row at a time toward 1 rather than collapsing straight to 0 (`COLLAPSE_ORDER` treats it like `progress`, -not like the single-row `goal`/`task` strips) — a 1-row panel still carries +not like the single-row `task` strip) — a 1-row panel still carries the stalest agent plus its `+N more` trailer, so it stays meaningful all the way down. Only once every other collapsible zone ahead of it in `COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the diff --git a/src/agent/director.ts b/src/agent/director.ts index c7d071a20..1055e34fa 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -20,8 +20,6 @@ import { type } from "arktype"; import { applyManageTasks, hasActiveTasks, parseManageTasksArgs, type Task } from "./tasks.js"; import { createCorbitsRetryPolicy } from "./retry-policy.js"; import { isInternalRecoveryAbortRaw } from "../inference-abort.js"; -import type { GoalGovernor } from "./goal.js"; -import { evidenceFromTurns } from "./goal-evaluator.js"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; import { resolveModelFamilyPolicy, type ModelFamilyPolicy } from "./model-family-policy.js"; import { PRESENT_VIEW_PRIMITIVES_GUIDANCE } from "./tool-schema-normalize.js"; @@ -67,8 +65,8 @@ function inferWithNudge( // Assumes a bare wait always means the turn is over. That holds for every // current wait path: DefaultDirector in conversational mode (the only mode // ChatDirector uses) yields a bare wait only on an empty model turn, and its -// halt path already carries a reply; the compaction, workflow, open-task, and -// goal rewrites either keep those terminals or replace them with an infer. +// halt path already carries a reply; the compaction, workflow, and open-task +// rewrites either keep those terminals or replace them with an infer. // A future wait that pauses mid-turn while expecting more work must not be // settled here. function ensureCycleSettlesWithReply( @@ -363,7 +361,6 @@ class ChatDirectorImpl extends DefaultDirector { private lastTaskSummary: string | undefined; private startedAt = Date.now(); private readonly compaction: CompactionGovernor; - private goal: GoalGovernor | undefined; private readonly modelFamilyPolicy: ModelFamilyPolicy; // Consecutive assistant turns that contain tool calls and no text. Reset on // any turn with text and on every fresh user message — a weak model that @@ -393,14 +390,6 @@ class ChatDirectorImpl extends DefaultDirector { this.workflowCoordinator = coordinator; } - setGoalGovernor(goal: GoalGovernor | undefined): void { - this.goal = goal; - } - - getGoalGovernor(): GoalGovernor | undefined { - return this.goal; - } - updateToolDefinitions(toolDefinitions: ToolDefinition[]): void { this._toolDefinitions = toolDefinitions; } @@ -629,16 +618,6 @@ class ChatDirectorImpl extends DefaultDirector { this.pendingToolOnlyNudge = true; } - // Attribute main-loop tokens to an active goal for soft token budgets. - if (this.goal !== undefined) { - const u = event.usage; - if (u !== undefined) { - const n = - (typeof u.input === "number" ? u.input : 0) + - (typeof u.output === "number" ? u.output : 0); - if (n > 0) this.goal.noteMainTokens(n); - } - } if (this.workflowCoordinator?.isActive()) { if (hasToolCalls) { this.workflowIdleTurns = 0; @@ -723,7 +702,7 @@ class ChatDirectorImpl extends DefaultDirector { const compacted = this.compaction.interceptActions(event, baseActions, capabilities); if (compacted !== null) return compacted; - // Loop protection takes precedence over workflow/open-task/goal + // Loop protection takes precedence over workflow/open-task // continuation nudges below: those exist to keep a session moving, // which is exactly the behavior the pause is guarding against. A tool // call turn (like the one that triggered this) must still execute @@ -789,17 +768,6 @@ class ChatDirectorImpl extends DefaultDirector { } } - // Goal continue-rule runs last among terminal rewrites so open-task and - // workflow nudges keep precedence. Only fires when we would otherwise yield. - if (this.goal !== undefined) { - const goalRewrite = await this.goal.interceptTerminal(baseActions, capabilities, { - atWorkflowGate, - lastTurnHadContent: this.lastInferenceTurnHadContent, - evidence: evidenceFromTurns(state.turns ?? []), - }); - if (goalRewrite !== null) return goalRewrite; - } - return base; } } @@ -839,8 +807,6 @@ export function hydrateTasksFromTurns(turns: ConversationTurn[]): Task[] { export interface ChatDirector extends ReactorDirector { updateToolDefinitions(toolDefinitions: ToolDefinition[]): void; setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void; - setGoalGovernor(goal: GoalGovernor | undefined): void; - getGoalGovernor(): GoalGovernor | undefined; getTasks(): Task[]; restoreTasks(tasks: Task[]): void; getContextEstimate(): { tokens: number; isEstimate: boolean }; diff --git a/src/agent/goal-evaluator.test.ts b/src/agent/goal-evaluator.test.ts deleted file mode 100644 index 3ee51c5dd..000000000 --- a/src/agent/goal-evaluator.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; -import { createGoalEvaluator, evidenceFromTurns } from "./goal-evaluator.js"; - -const source: InferenceSource = { - provider: "test", - model: "test-model", -} as InferenceSource; - -describe("createGoalEvaluator", () => { - test("returns error verdict when no source is configured", async () => { - const evaluate = createGoalEvaluator({ getSource: () => undefined }); - const v = await evaluate({ condition: "tests pass", evidence: "ok" }); - expect(v.met).toBe(false); - expect(v.error).toBe(true); - expect(v.reason).toContain("No evaluator model"); - }); - - test("returns not-met when evidence is empty", async () => { - const evaluate = createGoalEvaluator({ - getSource: () => source, - complete: async () => { - throw new Error("should not be called"); - }, - }); - const v = await evaluate({ condition: "x", evidence: " " }); - expect(v.met).toBe(false); - expect(v.error).toBeUndefined(); - expect(v.reason).toContain("No evidence"); - }); - - test("parses a clean JSON verdict", async () => { - const evaluate = createGoalEvaluator({ - getSource: () => source, - complete: async () => ({ - text: '{"met": true, "reason": "all unit tests green"}', - evalTokens: 42, - }), - }); - const v = await evaluate({ condition: "tests green", evidence: "bun test: 10 pass" }); - expect(v).toEqual({ met: true, reason: "all unit tests green", evalTokens: 42 }); - }); - - test("parses JSON embedded in prose", async () => { - const evaluate = createGoalEvaluator({ - getSource: () => source, - complete: async () => ({ - text: 'Here is my decision:\n{"met": false, "reason": "build still failing"}\nThanks.', - evalTokens: 10, - }), - }); - const v = await evaluate({ condition: "build passes", evidence: "error TS" }); - expect(v.met).toBe(false); - expect(v.reason).toBe("build still failing"); - }); - - test("fail-open on model call failure", async () => { - const evaluate = createGoalEvaluator({ - getSource: () => source, - complete: async () => { - throw new Error("network down"); - }, - }); - const v = await evaluate({ condition: "x", evidence: "y" }); - expect(v.met).toBe(false); - expect(v.error).toBe(true); - expect(v.reason).toContain("network down"); - }); - - test("fail-open on invalid schema", async () => { - const evaluate = createGoalEvaluator({ - getSource: () => source, - complete: async () => ({ text: '{"ok": true}', evalTokens: 1 }), - }); - const v = await evaluate({ condition: "x", evidence: "y" }); - expect(v.met).toBe(false); - expect(v.error).toBe(true); - expect(v.reason).toContain("schema"); - }); -}); - -describe("evidenceFromTurns", () => { - test("summarizes recent user and assistant text and tool calls", () => { - const turns: ConversationTurn[] = [ - { - role: "user", - content: [{ type: "text", text: "fix the bug" }], - timestamp: 1, - }, - { - role: "assistant", - content: [ - { type: "text", text: "I'll edit the file" }, - { type: "tool_call", id: "1", name: "edit_file", arguments: { path: "a.ts" } }, - ], - timestamp: 2, - }, - ]; - const evidence = evidenceFromTurns(turns); - expect(evidence).toContain("fix the bug"); - expect(evidence).toContain("edit_file"); - expect(evidence).toContain("path=a.ts"); - }); - - test("includes shell command in tool-call evidence", () => { - const turns: ConversationTurn[] = [ - { - role: "assistant", - content: [ - { - type: "tool_call", - id: "1", - name: "run_shell", - arguments: { command: "bun test src/agent/goal.test.ts" }, - }, - ], - timestamp: 1, - }, - ]; - const evidence = evidenceFromTurns(turns); - expect(evidence).toContain("run_shell"); - expect(evidence).toContain("command=bun test src/agent/goal.test.ts"); - }); -}); diff --git a/src/agent/goal-evaluator.ts b/src/agent/goal-evaluator.ts deleted file mode 100644 index b32248793..000000000 --- a/src/agent/goal-evaluator.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { type } from "arktype"; -import { runInference, type Dependencies } from "@intx/inference"; -import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; -import type { GoalEvaluateArgs, GoalEvaluateFn, GoalEvaluateVerdict } from "./goal.js"; - -const VerdictSchema = type({ - met: "boolean", - reason: "string", -}); - -const SYSTEM_INSTRUCTION = [ - "You are a strict goal-completion evaluator for a coding agent session.", - "Decide whether the stated goal condition is verifiably met based ONLY on the", - "evidence provided. Do not invent work that is not evidenced.", - "", - "Rules:", - "- met=true only when the evidence clearly shows the condition is satisfied.", - "- If evidence is missing, incomplete, or only claims progress without proof,", - " met=false.", - "- Prefer false negatives over false positives.", - "- reason must be one short sentence a human can act on.", - "", - "Respond with a single JSON object and nothing else:", - '{ "met": true | false, "reason": "" }', -].join("\n"); - -export type GoalEvaluatorCompleteFn = ( - turns: ConversationTurn[], - source: InferenceSource, - signal: AbortSignal, -) => Promise<{ text: string; evalTokens: number }>; - -export type CreateGoalEvaluatorOpts = { - /** Resolve the inference source for evaluation (prefer fast tier). */ - getSource: () => InferenceSource | undefined; - deps?: Dependencies; - complete?: GoalEvaluatorCompleteFn; - signal?: () => AbortSignal; -}; - -function extractJSONObject(text: string): unknown { - const trimmed = text.trim(); - try { - return JSON.parse(trimmed); - } catch { - // Models sometimes wrap JSON in prose or fences; pull the first object. - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start >= 0 && end > start) { - return JSON.parse(trimmed.slice(start, end + 1)); - } - throw new Error("evaluator response was not valid JSON"); - } -} - -function defaultComplete(deps: Dependencies): GoalEvaluatorCompleteFn { - return async (turns, source, signal) => { - let seq = 0; - let out = ""; - let evalTokens = 0; - for await (const event of runInference({ - turns, - source, - signal, - nextSeq: () => seq++, - deps, - })) { - if (event.type === "inference.done") { - for (const block of event.data.turn.content) { - if (block.type === "text") out += block.text; - } - const usage = event.data.usage; - if (usage !== undefined) { - evalTokens = (usage.input ?? 0) + (usage.output ?? 0); - } - } else if (event.type === "inference.error") { - throw new Error(event.data.error.message); - } - } - return { text: out.trim(), evalTokens }; - }; -} - -function buildEvidencePrompt(condition: string, evidence: string): string { - const body = - evidence.trim().length > 0 - ? evidence.trim() - : "(no evidence provided — treat as not met)"; - return [ - `Goal condition:\n${condition.trim()}`, - "", - "Evidence from the session:", - body, - ].join("\n"); -} - -/** - * One-shot, no-tools evaluator. Fail-open callers treat thrown errors and - * `{ error: true }` as not-met; this helper returns structured verdicts and - * only throws when the model call itself fails (caller may catch). - */ -export function createGoalEvaluator(opts: CreateGoalEvaluatorOpts): GoalEvaluateFn { - const complete = opts.complete ?? defaultComplete(opts.deps ?? ({} as Dependencies)); - - return async (args: GoalEvaluateArgs): Promise => { - const source = opts.getSource(); - if (source === undefined) { - return { - met: false, - reason: "No evaluator model configured (set a fast tier or session model).", - error: true, - }; - } - - if (args.evidence.trim().length === 0) { - return { - met: false, - reason: "No evidence available to verify the goal.", - }; - } - - const turns: ConversationTurn[] = [ - { - role: "system", - content: [{ type: "text", text: SYSTEM_INSTRUCTION }], - timestamp: Date.now(), - }, - { - role: "user", - content: [{ type: "text", text: buildEvidencePrompt(args.condition, args.evidence) }], - timestamp: Date.now(), - }, - ]; - - const signal = opts.signal?.() ?? new AbortController().signal; - let text: string; - let evalTokens = 0; - try { - const result = await complete(turns, source, signal); - text = result.text; - evalTokens = result.evalTokens; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - met: false, - reason: `Evaluator call failed: ${message}`, - error: true, - evalTokens, - }; - } - - if (text.length === 0) { - return { - met: false, - reason: "Evaluator returned empty response.", - error: true, - evalTokens, - }; - } - - let raw: unknown; - try { - raw = extractJSONObject(text); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - met: false, - reason: `Evaluator parse failed: ${message}`, - error: true, - evalTokens, - }; - } - - const parsed = VerdictSchema(raw); - if (parsed instanceof type.errors) { - return { - met: false, - reason: `Evaluator schema invalid: ${parsed.summary}`, - error: true, - evalTokens, - }; - } - - return { - met: parsed.met, - reason: parsed.reason.trim().length > 0 ? parsed.reason.trim() : parsed.met ? "met" : "not met", - evalTokens, - }; - }; -} - -/** Build a bounded evidence string from recent conversation turns. */ -export function evidenceFromTurns(turns: ConversationTurn[], maxChars = 12_000): string { - const chunks: string[] = []; - let used = 0; - // Prefer recent turns: walk from the end and reverse for chronological order. - for (let i = turns.length - 1; i >= 0; i--) { - const turn = turns[i]; - if (turn === undefined) continue; - const parts: string[] = []; - for (const block of turn.content) { - if (block.type === "text" && block.text.trim().length > 0) { - parts.push(block.text.trim().slice(0, 800)); - } else if (block.type === "tool_call") { - const argSummary = summarizeToolCallArgs(block.arguments); - parts.push(`tool ${block.name}${argSummary.length > 0 ? ` ${argSummary}` : ""}`); - } else if (block.type === "tool_result") { - const text = - typeof block.content === "string" - ? block.content - : Array.isArray(block.content) - ? block.content - .map((c) => (c && typeof c === "object" && "text" in c ? String((c as { text: unknown }).text) : "")) - .join("") - : ""; - if (text.trim().length > 0) { - parts.push(`result: ${text.trim().slice(0, 400)}`); - } - } - } - if (parts.length === 0) continue; - const line = `${turn.role}: ${parts.join(" | ")}`; - if (used + line.length > maxChars) break; - chunks.push(line); - used += line.length; - } - return chunks.reverse().join("\n"); -} - -const EVIDENCE_ARG_KEYS = ["command", "path", "description", "query", "pattern", "url"] as const; - -/** Compact tool-call arg summary for the evaluator (not a full dump). */ -export function summarizeToolCallArgs(args: unknown): string { - if (args === null || typeof args !== "object") return ""; - const record = args as Record; - const bits: string[] = []; - for (const key of EVIDENCE_ARG_KEYS) { - const value = record[key]; - if (typeof value === "string" && value.trim().length > 0) { - bits.push(`${key}=${value.trim().slice(0, 120)}`); - } - } - return bits.join(" "); -} diff --git a/src/agent/goal.test.ts b/src/agent/goal.test.ts deleted file mode 100644 index d9f3caf5f..000000000 --- a/src/agent/goal.test.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ReactorAction, ReactorCapabilities } from "@intx/types/runtime"; -import { - createGoalGovernor, - deriveGoalPhase, - formatGoalCompleted, - formatGoalDuration, - formatGoalStatus, - formatGoalTurns, - goalKickoffUserMessage, - goalShowsAcceptancePanel, - goalShowsWorkPrimary, - isUnlimitedTurnBudget, - DEFAULT_GOAL_TURN_BUDGET, - type GoalCriterion, - type GoalEvaluateFn, - type GoalInterceptContext, -} from "./goal.js"; - -const capabilities = { - infer: (options?: unknown) => - ({ type: "infer", ...(options !== undefined ? { options } : {}) }) as ReactorAction, - reply: (content: string) => ({ type: "reply", content }) as ReactorAction, - wait: () => ({ type: "wait" }) as ReactorAction, -} as unknown as ReactorCapabilities; - -const waitTerminal: ReactorAction[] = [{ type: "wait" }]; -const replyTerminal: ReactorAction[] = [{ type: "reply", content: "done for now" }]; -const inferAction: ReactorAction[] = [{ type: "infer" }]; - -function ctx(partial?: Partial): GoalInterceptContext { - return { - atWorkflowGate: false, - lastTurnHadContent: true, - evidence: "tests pass; files updated", - ...partial, - }; -} - -function alwaysNotMet(reason = "not yet"): GoalEvaluateFn { - return async () => ({ met: false, reason }); -} - -function alwaysMet(reason = "condition satisfied"): GoalEvaluateFn { - return async () => ({ met: true, reason }); -} - -function failingEval(message = "network down"): GoalEvaluateFn { - return async () => ({ met: false, reason: message, error: true }); -} - -/** Minimal two-item checklist so intercept uses the criteria path. */ -function seedCriteria( - g: ReturnType, - statuses: Array = ["todo", "todo"], -): void { - g.setCriteria( - statuses.map((status, i) => ({ - id: `c${i + 1}`, - title: `criterion ${i + 1}`, - status, - })), - ); -} - -describe("goal governor state machine", () => { - test("default turn budget is unlimited (0)", () => { - expect(DEFAULT_GOAL_TURN_BUDGET).toBe(0); - expect(isUnlimitedTurnBudget(0)).toBe(true); - expect(isUnlimitedTurnBudget(25)).toBe(false); - expect(formatGoalTurns(3, 0)).toBe("3/∞"); - expect(formatGoalTurns(3, 25)).toBe("3/25"); - - const g = createGoalGovernor({ evaluate: alwaysNotMet(), now: () => 1000 }); - const first = g.set("all tests green"); - expect(first.turnBudget).toBe(0); - expect(first.status).toBe("active"); - expect(first.brief).toBe("all tests green"); - expect(first.criteria).toEqual([]); - }); - - test("set activates and replace resets counters", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet(), now: () => 1000 }); - expect(g.get()).toBeNull(); - - const first = g.set("all tests green", { turnBudget: 10 }); - expect(first.status).toBe("active"); - expect(first.brief).toBe("all tests green"); - expect(first.condition).toBe("all tests green"); - expect(first.turnBudget).toBe(10); - expect(first.startedAt).toBe(1000); - - const second = g.set("ship the feature"); - expect(second.brief).toBe("ship the feature"); - expect(second.turnsUsed).toBe(0); - expect(second.status).toBe("active"); - expect(second.turnBudget).toBe(0); - expect(second.criteria).toEqual([]); - }); - - test("setCriteria expands the real goal and synthesizes condition", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("ship the feature"); - expect(g.get()?.phase).toBe("planning"); - const snap = g.setCriteria([ - { id: "c1", title: "typecheck clean", status: "todo" }, - { id: "c2", title: "tests green", status: "todo" }, - ]); - expect(snap?.criteria).toHaveLength(2); - expect(snap?.condition).toContain("typecheck clean"); - expect(snap?.condition).toContain("tests green"); - expect(snap?.brief).toBe("ship the feature"); - expect(snap?.phase).toBe("implementing"); - }); - - test("lifecycle phase advances planning → implementing → reviewing → completed", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("ship"); - expect(g.get()?.phase).toBe("planning"); - expect(goalShowsAcceptancePanel("planning")).toBe(true); - expect(goalShowsWorkPrimary("planning")).toBe(false); - - g.setCriteria([ - { id: "c1", title: "a", status: "todo" }, - { id: "c2", title: "b", status: "todo" }, - ]); - expect(g.get()?.phase).toBe("implementing"); - expect(goalShowsWorkPrimary("implementing")).toBe(true); - expect(goalShowsAcceptancePanel("implementing")).toBe(false); - - // `doing` alone stays implementing — review starts on done/blocked. - g.updateCriteria([{ id: "c1", status: "doing" }]); - expect(g.get()?.phase).toBe("implementing"); - - g.updateCriteria([{ id: "c1", status: "done", note: "ok" }]); - expect(g.get()?.phase).toBe("reviewing"); - expect(goalShowsAcceptancePanel("reviewing")).toBe(true); - - g.updateCriteria([{ id: "c2", status: "done", note: "ok" }]); - expect(g.get()?.status).toBe("achieved"); - expect(g.get()?.phase).toBe("completed"); - }); - - test("deriveGoalPhase treats blocked as review start, not mere doing", () => { - expect( - deriveGoalPhase( - [ - { id: "c1", title: "a", status: "doing" }, - { id: "c2", title: "b", status: "todo" }, - ], - "active", - ), - ).toBe("implementing"); - expect( - deriveGoalPhase( - [ - { id: "c1", title: "a", status: "blocked" }, - { id: "c2", title: "b", status: "todo" }, - ], - "active", - ), - ).toBe("reviewing"); - }); - - test("updateCriteria patches status and notes", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("ship"); - g.setCriteria([ - { id: "c1", title: "a", status: "todo" }, - { id: "c2", title: "b", status: "todo" }, - ]); - const snap = g.updateCriteria([{ id: "c1", status: "done", note: "verified" }]); - expect(snap?.criteria.find((c) => c.id === "c1")?.status).toBe("done"); - expect(snap?.criteria.find((c) => c.id === "c1")?.note).toBe("verified"); - expect(snap?.criteria.find((c) => c.id === "c2")?.status).toBe("todo"); - expect(snap?.status).toBe("active"); - }); - - test("updateCriteria marks achieved when last open criterion is done", () => { - let clock = 1_000_000; - const g = createGoalGovernor({ - evaluate: alwaysNotMet(), - now: () => clock, - }); - g.set("ship"); - g.setCriteria([ - { id: "c1", title: "a", status: "done" }, - { id: "c2", title: "b", status: "todo" }, - ]); - expect(g.get()?.status).toBe("active"); - clock = 1_000_000 + 125_000; // 2m 5s later - const snap = g.updateCriteria([{ id: "c2", status: "done", note: "green" }]); - expect(snap?.status).toBe("achieved"); - expect(snap?.lastReason).toBe("All acceptance criteria marked done."); - expect(snap?.completedAt).toBe(1_125_000); - expect(formatGoalCompleted(snap!)).toBe("Goal completed in 2m 5s"); - // Duration freezes — later wall clock must not change the line. - clock = 9_999_999; - expect(formatGoalCompleted(g.get()!)).toBe("Goal completed in 2m 5s"); - }); - - test("setCriteria marks achieved when create lands fully done", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("already done"); - const snap = g.setCriteria([ - { id: "c1", title: "a", status: "done" }, - { id: "c2", title: "b", status: "done" }, - ]); - expect(snap?.status).toBe("achieved"); - }); - - test("updateCriteria ignores cancelled when deciding achieved", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("ship"); - g.setCriteria([ - { id: "c1", title: "a", status: "todo" }, - { id: "c2", title: "skip", status: "cancelled" }, - ]); - const snap = g.updateCriteria([{ id: "c1", status: "done" }]); - expect(snap?.status).toBe("achieved"); - }); - - test("pause and resume extend a finite turn budget", () => { - const g = createGoalGovernor({ - evaluate: alwaysNotMet(), - defaultTurnBudget: 5, - defaultResumeExtend: 10, - }); - g.set("x"); - expect(g.pause()?.status).toBe("paused"); - expect(g.resume()?.status).toBe("active"); - expect(g.get()?.turnBudget).toBe(10); - }); - - test("resume keeps unlimited turn budget unlimited", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("ship it"); - expect(g.get()?.turnBudget).toBe(0); - g.pause(); - const resumed = g.resume(); - expect(resumed?.turnBudget).toBe(0); - expect(resumed?.status).toBe("active"); - }); - - test("clear returns to inactive", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("x"); - g.clear(); - expect(g.get()).toBeNull(); - }); - - test("restore turns active into paused and preserves criteria", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - const paused = g.restore({ - status: "active", - condition: "migrate auth", - brief: "migrate auth", - criteria: [{ id: "c1", title: "users can log in", status: "doing" }], - startedAt: 50, - turnBudget: 25, - turnsUsed: 3, - }); - expect(paused?.status).toBe("paused"); - expect(paused?.brief).toBe("migrate auth"); - expect(paused?.criteria).toHaveLength(1); - expect(paused?.criteria[0]?.title).toBe("users can log in"); - expect(paused?.turnsUsed).toBe(3); - - g.restore({ status: "cleared", condition: "old", startedAt: 1, turnBudget: 5 }); - expect(g.get()).toBeNull(); - }); - - test("restore falls back brief from condition for older goal.json", () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - const paused = g.restore({ - status: "active", - condition: "legacy condition only", - startedAt: 1, - turnBudget: 0, - }); - expect(paused?.brief).toBe("legacy condition only"); - expect(paused?.criteria).toEqual([]); - }); -}); - -describe("goalKickoffUserMessage", () => { - test("set path requires clarify-before-work and manage_goal expansion", () => { - const text = goalKickoffUserMessage("test goal", "set"); - expect(text).toContain("test goal"); - expect(text.toLowerCase()).toContain("clarif"); - expect(text).toMatch(/Do not run tests/i); - expect(text).toMatch(/until success is defined/i); - expect(text).toMatch(/do not invert/i); - expect(text).toContain("manage_goal"); - expect(text).toContain("manage_tasks"); - expect(text).toMatch(/Acceptance/i); - expect(text).toMatch(/3–12|multi-item/i); - }); - - test("resume path continues without re-forcing full setup ritual", () => { - const text = goalKickoffUserMessage("all tests pass", "resume"); - expect(text).toContain("resumed"); - expect(text).toContain("all tests pass"); - expect(text).not.toMatch(/Order of operations/i); - expect(text).toContain("manage_goal"); - expect(text).toContain("manage_tasks"); - }); -}); - -describe("goal interceptTerminal", () => { - test("inactive is a no-op", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - expect(await g.interceptTerminal(waitTerminal, capabilities, ctx())).toBeNull(); - }); - - test("paused is a no-op", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("x"); - g.pause(); - expect(await g.interceptTerminal(waitTerminal, capabilities, ctx())).toBeNull(); - }); - - test("non-terminal actions are a no-op", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("x"); - expect(await g.interceptTerminal(inferAction, capabilities, ctx())).toBeNull(); - }); - - test("workflow gate is a no-op", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("x"); - expect( - await g.interceptTerminal(waitTerminal, capabilities, ctx({ atWorkflowGate: true })), - ).toBeNull(); - }); - - test("empty criteria nudges to manage_goal without calling evaluator", async () => { - let evals = 0; - const g = createGoalGovernor({ - evaluate: async () => { - evals++; - return { met: true, reason: "should not run" }; - }, - }); - g.set("vague goal"); - const actions = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(actions?.some((a) => a.type === "infer")).toBe(true); - expect(evals).toBe(0); - const infer = actions?.find((a) => a.type === "infer") as { - type: "infer"; - options?: { ephemeralTurns?: Array<{ content: Array<{ type: string; text?: string }> }> }; - }; - const nudgeText = infer.options?.ephemeralTurns?.[0]?.content?.[0]?.text ?? ""; - expect(nudgeText).toContain("manage_goal"); - expect(nudgeText).toContain("Acceptance criteria not defined"); - }); - - test("not-met rewrites wait into re-infer with checklist nudge", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet("still failing") }); - g.set("tests green"); - seedCriteria(g); - const actions = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(actions).not.toBeNull(); - expect(actions?.some((a) => a.type === "wait")).toBe(false); - expect(actions?.some((a) => a.type === "infer")).toBe(true); - const infer = actions?.find((a) => a.type === "infer") as { - type: "infer"; - options?: { ephemeralTurns?: Array<{ content: Array<{ type: string; text?: string }> }> }; - }; - const nudgeText = infer.options?.ephemeralTurns?.[0]?.content?.[0]?.text ?? ""; - expect(nudgeText).toContain("tests green"); - expect(nudgeText).toContain("criterion 1"); - expect(g.get()?.status).toBe("active"); - expect(g.get()?.turnsUsed).toBe(1); - }); - - test("all criteria done yields terminal and marks achieved", async () => { - const g = createGoalGovernor({ evaluate: alwaysMet("all green") }); - g.set("tests green"); - seedCriteria(g, ["done", "done"]); - const actions = await g.interceptTerminal(replyTerminal, capabilities, ctx()); - expect(actions).toBeNull(); - expect(g.get()?.status).toBe("achieved"); - expect(g.get()?.lastReason).toContain("All acceptance criteria"); - }); - - test("evaluator met is ignored while criteria remain open", async () => { - const g = createGoalGovernor({ evaluate: alwaysMet("llm says yes") }); - g.set("tests green"); - seedCriteria(g, ["todo", "done"]); - const actions = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(actions?.some((a) => a.type === "infer")).toBe(true); - expect(g.get()?.status).toBe("active"); - }); - - test("empty evidence skips evaluator while criteria remain open", async () => { - let evals = 0; - const g = createGoalGovernor({ - evaluate: async () => { - evals++; - return { met: true, reason: "should not run" }; - }, - }); - g.set("tests green"); - seedCriteria(g); - const actions = await g.interceptTerminal( - waitTerminal, - capabilities, - ctx({ evidence: " " }), - ); - expect(evals).toBe(0); - expect(actions?.some((a) => a.type === "infer")).toBe(true); - expect(g.get()?.status).toBe("active"); - expect(g.get()?.consecutiveEvalFailures).toBe(0); - }); - - test("fail-open treats evaluator errors as not-met", async () => { - const g = createGoalGovernor({ - evaluate: failingEval("boom"), - maxConsecutiveEvalFailures: 3, - }); - g.set("x"); - seedCriteria(g); - const actions = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(actions?.some((a) => a.type === "infer")).toBe(true); - expect(g.get()?.status).toBe("active"); - expect(g.get()?.consecutiveEvalFailures).toBe(1); - }); - - test("consecutive evaluator failures pause the goal", async () => { - const g = createGoalGovernor({ - evaluate: failingEval("boom"), - maxConsecutiveEvalFailures: 2, - }); - g.set("x"); - seedCriteria(g); - expect( - (await g.interceptTerminal(waitTerminal, capabilities, ctx()))?.some((a) => a.type === "infer"), - ).toBe(true); - const second = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(second).toBeNull(); - expect(g.get()?.status).toBe("paused"); - expect(g.get()?.lastReason).toContain("evaluator failures"); - }); - - test("empty yields pause after the configured streak", async () => { - let evals = 0; - const g = createGoalGovernor({ - evaluate: async () => { - evals++; - return { met: false, reason: "should not run" }; - }, - maxConsecutiveEmptyYields: 2, - }); - g.set("x"); - seedCriteria(g); - const first = await g.interceptTerminal( - waitTerminal, - capabilities, - ctx({ lastTurnHadContent: false }), - ); - expect(first?.some((a) => a.type === "infer")).toBe(true); - expect(evals).toBe(0); - - const second = await g.interceptTerminal( - waitTerminal, - capabilities, - ctx({ lastTurnHadContent: false }), - ); - expect(second).toBeNull(); - expect(g.get()?.status).toBe("paused"); - expect(evals).toBe(0); - }); - - test("turn budget allows one continue then soft-stops on the next not-met", async () => { - const g = createGoalGovernor({ - evaluate: alwaysNotMet(), - defaultTurnBudget: 1, - }); - g.set("x", { turnBudget: 1 }); - seedCriteria(g); - const first = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(first?.some((a) => a.type === "infer")).toBe(true); - expect(g.get()?.turnsUsed).toBe(1); - expect(g.get()?.status).toBe("active"); - - const second = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(second).toBeNull(); - expect(g.get()?.status).toBe("budget_limited"); - expect(g.get()?.lastReason).toContain("Turn budget"); - }); - - test("unlimited turn budget does not soft-stop on turns alone", async () => { - const g = createGoalGovernor({ evaluate: alwaysNotMet() }); - g.set("keep going"); - seedCriteria(g); - for (let i = 0; i < 30; i++) { - const next = await g.interceptTerminal(waitTerminal, capabilities, ctx()); - expect(next?.some((a) => a.type === "infer")).toBe(true); - } - expect(g.get()?.status).toBe("active"); - expect(g.get()?.turnsUsed).toBe(30); - expect(g.get()?.turnBudget).toBe(0); - }); - - test("token budget soft-stops when main+eval exceed the cap", async () => { - const g = createGoalGovernor({ - evaluate: async () => ({ met: false, reason: "no", evalTokens: 50 }), - }); - g.set("x", { tokenBudget: 100 }); - seedCriteria(g); - const actions = await g.interceptTerminal( - waitTerminal, - capabilities, - ctx({ mainTurnTokens: 60 }), - ); - expect(actions).toBeNull(); - expect(g.get()?.status).toBe("budget_limited"); - }); - - test("formatGoalStatus covers inactive, planning, and checklist progress", () => { - expect(formatGoalStatus(null)).toContain("No goal is set"); - const g = createGoalGovernor({ evaluate: alwaysNotMet(), now: () => Date.now() - 5000 }); - g.set("ship it", { turnBudget: 12 }); - const planning = formatGoalStatus(g.get()); - expect(planning).toContain("ship it"); - expect(planning).toContain("Phase: planning"); - expect(planning).toMatch(/planning|not planned|criteria/i); - - g.setCriteria([ - { id: "c1", title: "typecheck", status: "done" }, - { id: "c2", title: "tests", status: "todo" }, - ]); - const text = formatGoalStatus(g.get()); - expect(text).toContain("typecheck"); - expect(text).toContain("tests"); - expect(text).toContain("Phase: reviewing"); - expect(text).toMatch(/1\/2|Progress/i); - }); - - test("formatGoalStatus freezes completed duration and drops turn counting", () => { - let clock = 5_000_000; - const g = createGoalGovernor({ - evaluate: alwaysNotMet(), - now: () => clock, - }); - g.set("done ship", { turnBudget: 10 }); - g.setCriteria([ - { id: "c1", title: "a", status: "todo" }, - ]); - clock = 5_000_000 + 90_000; - g.updateCriteria([{ id: "c1", status: "done" }]); - const text = formatGoalStatus(g.get()); - expect(text).toContain("Goal completed in 1m 30s"); - expect(text).not.toMatch(/Turns:/); - expect(text).not.toContain("State: achieved"); - }); - - test("formatGoalDuration covers seconds minutes hours", () => { - expect(formatGoalDuration(0)).toBe("0s"); - expect(formatGoalDuration(45_000)).toBe("45s"); - expect(formatGoalDuration(125_000)).toBe("2m 5s"); - expect(formatGoalDuration(3_661_000)).toBe("1h 1m"); - }); - - test("kickoff message names lifecycle phases", () => { - const msg = goalKickoffUserMessage("typecheck clean"); - expect(msg).toContain("planning"); - expect(msg).toContain("implementing"); - expect(msg).toContain("reviewing"); - expect(msg).toContain("completed"); - expect(msg).toContain("manage_goal"); - expect(msg).toContain("manage_tasks"); - }); -}); diff --git a/src/agent/goal.ts b/src/agent/goal.ts deleted file mode 100644 index 7a0be5f00..000000000 --- a/src/agent/goal.ts +++ /dev/null @@ -1,850 +0,0 @@ -import { type } from "arktype"; -import type { ExtendedInferenceOptions } from "@intx/inference"; -import type { - ConversationTurn, - ReactorAction, - ReactorCapabilities, -} from "@intx/types/runtime"; - -// Canonical goal-status and criterion-status definitions. Every other module -// that needs these literals (persistence validation, the manage_goal tool -// schema) imports the schema or type from here rather than redeclaring it. -export const GoalStatusSchema = type( - "'inactive' | 'active' | 'paused' | 'achieved' | 'cleared' | 'budget_limited' | 'blocked'", -); -export type GoalStatus = typeof GoalStatusSchema.infer; - -/** - * Lifecycle phase for a live goal — orthogonal to autonomy status (paused / - * budget_limited / blocked). Derived from acceptance checklist progress so the - * UI can show Work during implement and Acceptance during review. - * - * planning → brief set, acceptance checklist not yet defined - * implementing → acceptance defined; no done/blocked items yet (Work primary) - * reviewing → at least one criterion is done or blocked (verifying Acceptance) - * completed → all non-cancelled criteria done (status usually achieved) - */ -export type GoalPhase = "planning" | "implementing" | "reviewing" | "completed"; - -export const GOAL_PHASES: readonly GoalPhase[] = [ - "planning", - "implementing", - "reviewing", - "completed", -] as const; - -export const GoalCriterionStatusSchema = type( - "'todo' | 'doing' | 'done' | 'blocked' | 'cancelled'", -); -/** One acceptance criterion in the expanded goal checklist. */ -export type GoalCriterionStatus = typeof GoalCriterionStatusSchema.infer; - -export const GoalCriterionSchema = type({ - id: "string>0", - /** Concrete, checkable success item — not a work step title. */ - title: "string>0", - status: GoalCriterionStatusSchema, - /** Optional evidence note when done/blocked. */ - "note?": "string", -}); -export type GoalCriterion = typeof GoalCriterionSchema.infer; - -export type GoalSnapshot = { - status: GoalStatus; - /** - * Lifecycle phase derived from acceptance progress. Always present on values - * returned from get()/emit() (recomputed via attachPhase). - */ - phase: GoalPhase; - /** Original operator brief from `/goal …` (not the expanded checklist). */ - brief: string; - /** Expanded acceptance criteria — the real goal definition once planned. */ - criteria: GoalCriterion[]; - /** - * Synthesized acceptance text for the evaluator / legacy paths. - * Prefer `criteria` for UX and met checks when non-empty. - */ - condition: string; - startedAt: number; - /** - * Wall-clock when status flipped to achieved. Freezes the "Goal completed in …" - * duration so the UI stops counting after success. - */ - completedAt?: number; - turnBudget: number; - turnsUsed: number; - tokenBudget?: number; - mainTokens: number; - evalTokens: number; - lastReason?: string; - consecutiveEvalFailures: number; - consecutiveEmptyYields: number; -}; - -export type GoalEvaluateVerdict = { - met: boolean; - reason: string; - /** Tokens spent on this evaluation call (input + output). */ - evalTokens?: number; - /** True when the evaluator itself failed (network, parse, etc.). */ - error?: boolean; -}; - -export type GoalEvaluateArgs = { - condition: string; - evidence: string; -}; - -export type GoalEvaluateFn = (args: GoalEvaluateArgs) => Promise; - -export type GoalSetOpts = { - turnBudget?: number; - tokenBudget?: number; -}; - -export type GoalResumeOpts = { - extendTurnBudget?: number; -}; - -export type GoalInterceptContext = { - /** Workflow gate steps are legitimate operator pauses — do not auto-continue. */ - atWorkflowGate: boolean; - /** Last inference turn produced text or tool calls (false = empty yield). */ - lastTurnHadContent: boolean; - /** Conversation excerpt for the evaluator. */ - evidence: string; - /** Main-loop tokens from the just-finished inference turn, if known. */ - mainTurnTokens?: number; -}; - -export type CreateGoalGovernorOpts = { - evaluate: GoalEvaluateFn; - defaultTurnBudget?: number; - maxConsecutiveEvalFailures?: number; - maxConsecutiveEmptyYields?: number; - defaultResumeExtend?: number; - onChange?: (snapshot: GoalSnapshot) => void; - now?: () => number; -}; - -export type GoalGovernor = ReturnType; - -export const DEFAULT_GOAL_TURN_BUDGET = 0; -export const DEFAULT_GOAL_RESUME_EXTEND = 25; -export const DEFAULT_MAX_EVAL_FAILURES = 3; -export const DEFAULT_MAX_EMPTY_YIELDS = 2; - -/** `0` (and negative) means no turn soft-stop — goal runs until met, paused, or cleared. */ -export function isUnlimitedTurnBudget(turnBudget: number): boolean { - return turnBudget <= 0; -} - -/** Display helper for status lines and the TUI header. */ -export function formatGoalTurns(turnsUsed: number, turnBudget: number): string { - if (isUnlimitedTurnBudget(turnBudget)) return `${turnsUsed}/∞`; - return `${turnsUsed}/${turnBudget}`; -} - -/** Compact wall-clock duration for "Goal completed in …". */ -export function formatGoalDuration(ms: number): string { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - if (totalSeconds < 60) return `${totalSeconds}s`; - const seconds = totalSeconds % 60; - const totalMinutes = Math.floor(totalSeconds / 60); - if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`; - const minutes = totalMinutes % 60; - const hours = Math.floor(totalMinutes / 60); - return `${hours}h ${minutes}m`; -} - -/** - * Frozen completion line once achieved. Uses completedAt when present so the - * duration does not keep ticking after success. - */ -export function formatGoalCompleted(snap: GoalSnapshot, nowMs?: number): string | null { - if (snap.status !== "achieved" && snap.phase !== "completed") return null; - if (snap.startedAt <= 0 || snap.completedAt === undefined) return "Goal completed"; - const end = snap.completedAt; - void nowMs; // reserved for tests / future live-preview before freeze - return `Goal completed in ${formatGoalDuration(end - snap.startedAt)}`; -} - -/** Progress over non-cancelled criteria. */ -export function goalCriteriaProgress(criteria: GoalCriterion[]): { done: number; total: number } { - const counted = criteria.filter((c) => c.status !== "cancelled"); - return { - done: counted.filter((c) => c.status === "done").length, - total: counted.length, - }; -} - -export function criteriaAllDone(criteria: GoalCriterion[]): boolean { - const counted = criteria.filter((c) => c.status !== "cancelled"); - return counted.length > 0 && counted.every((c) => c.status === "done"); -} - -/** - * Derive lifecycle phase from checklist progress. - * Autonomy status (paused / blocked / budget_limited) does not change phase — - * only acceptance progress and achieved do. - * - * `doing` alone does **not** enter reviewing — agents often mark a criterion - * "doing" while still implementing. Review starts on the first verified - * (`done`) or stuck (`blocked`) acceptance item. - */ -export function deriveGoalPhase( - criteria: GoalCriterion[], - status: GoalStatus, -): GoalPhase { - if (status === "achieved" || criteriaAllDone(criteria)) return "completed"; - if (criteria.length === 0) return "planning"; - const reviewStarted = criteria.some( - (c) => c.status === "done" || c.status === "blocked", - ); - if (reviewStarted) return "reviewing"; - return "implementing"; -} - -/** Full Acceptance panel: planning (define it), reviewing, completed. */ -export function goalShowsAcceptancePanel(phase: GoalPhase): boolean { - return phase === "planning" || phase === "reviewing" || phase === "completed"; -} - -/** Work is the primary chrome surface while implementing. */ -export function goalShowsWorkPrimary(phase: GoalPhase): boolean { - return phase === "implementing"; -} - -/** Build evaluator/legacy condition text from brief + criteria. */ -export function synthesizeGoalCondition(brief: string, criteria: GoalCriterion[]): string { - if (criteria.length === 0) return brief; - const lines = criteria - .filter((c) => c.status !== "cancelled") - .map((c) => `- [${c.status}] ${c.title}`); - return `Brief: ${brief}\nAcceptance criteria:\n${lines.join("\n")}`; -} - -function cloneCriteria(criteria: GoalCriterion[]): GoalCriterion[] { - return criteria.map((c) => ({ ...c })); -} - -function emptySnapshot(defaultTurnBudget: number): GoalSnapshot { - return { - status: "inactive", - brief: "", - criteria: [], - condition: "", - phase: "planning", - startedAt: 0, - turnBudget: defaultTurnBudget, - turnsUsed: 0, - mainTokens: 0, - evalTokens: 0, - consecutiveEvalFailures: 0, - consecutiveEmptyYields: 0, - }; -} - -function attachPhase(snap: GoalSnapshot): GoalSnapshot { - return { - ...snap, - phase: deriveGoalPhase(snap.criteria, snap.status), - }; -} - -function goalNudgeTurn(text: string): ConversationTurn { - return { - role: "user", - content: [{ type: "text", text: text.trim() }], - timestamp: Date.now(), - }; -} - -function withEphemeralNudge(options: ExtendedInferenceOptions, nudge: string): ExtendedInferenceOptions { - const turn = goalNudgeTurn(nudge); - const existing = options.ephemeralTurns; - if (existing === undefined || existing.length === 0) { - return { ...options, ephemeralTurns: [turn] }; - } - return { ...options, ephemeralTurns: [...existing, turn] }; -} - -function inferWithNudge( - capabilities: ReactorCapabilities, - nudge: string, - options?: ExtendedInferenceOptions, -): ReactorAction { - return capabilities.infer(withEphemeralNudge(options ?? {}, nudge)); -} - -function isCleanTerminal(actions: ReactorAction[]): boolean { - const hasTerminal = actions.some((a) => a.type === "wait" || a.type === "reply"); - if (!hasTerminal) return false; - return !actions.some((a) => a.type === "infer" || a.type === "execute_tools"); -} - -function stripTerminal(actions: ReactorAction[]): ReactorAction[] { - return actions.filter( - (a): a is Exclude => - a.type !== "wait" && a.type !== "reply", - ); -} - -function tokensTotal(snap: GoalSnapshot): number { - return snap.mainTokens + snap.evalTokens; -} - -function notMetNudge(brief: string, reason: string, criteria: GoalCriterion[]): string { - const progress = goalCriteriaProgress(criteria); - const open = criteria.filter( - (c) => c.status === "todo" || c.status === "doing" || c.status === "blocked", - ); - const openLines = - open.length > 0 - ? open.map((c) => ` - [${c.status}] ${c.title}`).join("\n") - : " (none listed — define criteria with manage_goal if still empty)"; - return ( - "\n\nGoal still active — acceptance criteria not all done.\n" + - `Brief: ${brief}\n` + - `Progress: ${progress.done}/${progress.total}\n` + - `Open:\n${openLines}\n` + - `Note: ${reason}\n` + - "Mark each acceptance criterion done via manage_goal only when verifiably complete. " + - "Keep manage_tasks (Work) live — add, cancel, re-title, and status-update steps as the plan changes. " + - "Do not stop until every criterion is done, or the operator pauses/clears the goal." - ); -} - -/** - * User message injected when `/goal` sets or resumes. - * The expanded checklist (via manage_goal) is the real goal — not the raw brief. - */ -export function goalKickoffUserMessage( - brief: string, - phase: "set" | "resume" = "set", -): string { - if (phase === "resume") { - return ( - `Goal resumed.\nBrief: ${brief}\n` + - "Continue the lifecycle: planning → implementing → reviewing → completed. " + - "Update manage_tasks (Work) during implementing; mark manage_goal (Acceptance) as you verify in review. " + - "If criteria are still empty or vague, define or clarify them before more work." - ); - } - return ( - `Session goal brief:\n${brief}\n\n` + - "Two lists (do not conflate them):\n" + - "- manage_goal = Acceptance — what \"done\" means (checkable success criteria).\n" + - "- manage_tasks = Work — the steps you take to get there.\n\n" + - "Lifecycle phases (the UI follows these):\n" + - "1. planning — define Acceptance via manage_goal create (before heavy work).\n" + - "2. implementing — execute Work via manage_tasks; leave Acceptance items todo until you verify.\n" + - "3. reviewing — mark Acceptance done (with evidence) or blocked as you check each criterion.\n" + - "4. completed — every non-cancelled Acceptance item is done (auto-achieves the goal).\n\n" + - "Order of operations (do not invert):\n" + - "1. If the brief is vague or multi-interpretable, ask_operator ONE short clarifying question. " + - "Do not run tests, make edits, install deps, or explore the repo until success is defined.\n" + - "2. Call manage_goal with action=\"create\" and a detailed multi-item acceptance checklist " + - "(typically 3–12 concrete, checkable conditions). Expand the brief — do not restate it as a single item. " + - "Each item must be independently verifiable (e.g. \"bun test exits 0\", \"typecheck clean\", " + - "\"PR description documents migration steps\").\n" + - "3. Call manage_tasks with action=\"create\" for the work plan to satisfy those criteria " + - "(implementation steps, not acceptance restatements).\n" + - "4. Implement with Work live: update/add/cancel manage_tasks as the plan evolves. " + - "Mark Acceptance done (with evidence) or blocked only when verifying — that enters reviewing. " + - "The goal is achieved only when every acceptance criterion is done." - ); -} - -/** - * Session-scoped goal continue-rule. Same family as CompactionGovernor: a - * factory that owns state and rewrites terminal actions when the goal is active - * and not yet met. Does not shrink the tool surface — only decides whether to - * re-enter inference after a clean yield. - */ -export function createGoalGovernor(opts: CreateGoalGovernorOpts) { - const defaultTurnBudget = opts.defaultTurnBudget ?? DEFAULT_GOAL_TURN_BUDGET; - const maxEvalFailures = opts.maxConsecutiveEvalFailures ?? DEFAULT_MAX_EVAL_FAILURES; - const maxEmptyYields = opts.maxConsecutiveEmptyYields ?? DEFAULT_MAX_EMPTY_YIELDS; - const defaultResumeExtend = opts.defaultResumeExtend ?? DEFAULT_GOAL_RESUME_EXTEND; - const now = opts.now ?? (() => Date.now()); - - let snapshot: GoalSnapshot = emptySnapshot(defaultTurnBudget); - - function emit(): GoalSnapshot { - snapshot = attachPhase(snapshot); - const copy: GoalSnapshot = { - ...snapshot, - criteria: cloneCriteria(snapshot.criteria), - }; - opts.onChange?.(copy); - return copy; - } - - function get(): GoalSnapshot | null { - if (snapshot.status === "inactive") return null; - const phased = attachPhase(snapshot); - return { - ...phased, - criteria: cloneCriteria(phased.criteria), - }; - } - - function set(brief: string, setOpts?: GoalSetOpts): GoalSnapshot { - const trimmed = brief.trim(); - snapshot = { - status: "active", - phase: "planning", - brief: trimmed, - criteria: [], - condition: trimmed, - startedAt: now(), - turnBudget: setOpts?.turnBudget ?? defaultTurnBudget, - turnsUsed: 0, - ...(setOpts?.tokenBudget !== undefined ? { tokenBudget: setOpts.tokenBudget } : {}), - mainTokens: 0, - evalTokens: 0, - consecutiveEvalFailures: 0, - consecutiveEmptyYields: 0, - }; - return emit(); - } - - /** Replace the full acceptance checklist (manage_goal create). */ - function setCriteria(criteria: GoalCriterion[]): GoalSnapshot | null { - if (snapshot.status === "inactive" || snapshot.status === "cleared") return null; - const next = criteria.map((c) => ({ - id: c.id, - title: c.title.trim(), - status: c.status, - ...(c.note !== undefined && c.note.length > 0 ? { note: c.note } : {}), - })); - snapshot = { - ...snapshot, - criteria: next, - condition: synthesizeGoalCondition(snapshot.brief, next), - }; - maybeAchieveFromCriteria(); - return emit(); - } - - /** Patch criteria by id (manage_goal update). */ - function updateCriteria( - updates: Array<{ id: string; title?: string; status?: GoalCriterionStatus; note?: string }>, - ): GoalSnapshot | null { - if (snapshot.status === "inactive" || snapshot.status === "cleared") return null; - if (updates.length === 0) return get(); - const byId = new Map(updates.map((u) => [u.id, u])); - const next = snapshot.criteria.map((c) => { - const patch = byId.get(c.id); - if (patch === undefined) return c; - return { - id: c.id, - title: patch.title !== undefined ? patch.title.trim() : c.title, - status: patch.status ?? c.status, - ...(patch.note !== undefined - ? patch.note.length > 0 - ? { note: patch.note } - : {} - : c.note !== undefined - ? { note: c.note } - : {}), - }; - }); - snapshot = { - ...snapshot, - criteria: next, - condition: synthesizeGoalCondition(snapshot.brief, next), - }; - maybeAchieveFromCriteria(); - return emit(); - } - - /** - * Checklist is the source of truth: once every non-cancelled criterion is done, - * flip to achieved immediately (do not wait for the next clean yield). - */ - function maybeAchieveFromCriteria(): void { - if (snapshot.status === "achieved" || snapshot.status === "cleared" || snapshot.status === "inactive") { - return; - } - if (!criteriaAllDone(snapshot.criteria)) return; - markAchieved("All acceptance criteria marked done."); - } - - function markAchieved(reason: string): void { - if (snapshot.status === "achieved") return; - snapshot = { - ...snapshot, - status: "achieved", - completedAt: snapshot.completedAt ?? now(), - lastReason: reason, - }; - } - - function pause(): GoalSnapshot | null { - if (snapshot.status !== "active") return get(); - snapshot = { ...snapshot, status: "paused" }; - return emit(); - } - - function resume(resumeOpts?: GoalResumeOpts): GoalSnapshot | null { - if ( - snapshot.status !== "paused" && - snapshot.status !== "budget_limited" && - snapshot.status !== "blocked" - ) { - return get(); - } - const extend = resumeOpts?.extendTurnBudget ?? defaultResumeExtend; - const { lastReason: _cleared, ...rest } = snapshot; - // Unlimited goals stay unlimited on resume. Finite budgets get headroom so - // /goal resume after budget_limited (or pause) can keep going. - const nextTurnBudget = isUnlimitedTurnBudget(snapshot.turnBudget) - ? 0 - : snapshot.turnsUsed + extend; - snapshot = { - ...rest, - status: "active", - turnBudget: nextTurnBudget, - consecutiveEvalFailures: 0, - consecutiveEmptyYields: 0, - }; - return emit(); - } - - function clear(): void { - if (snapshot.status === "inactive") return; - const { lastReason: _cleared, ...rest } = snapshot; - snapshot = { - ...rest, - status: "cleared", - }; - emit(); - snapshot = emptySnapshot(defaultTurnBudget); - } - - /** - * Restore from session disk. Active goals become paused so autonomy is never - * silently re-armed; achieved/cleared stay terminal; inactive is a no-op. - */ - function restore(saved: { - status: GoalStatus; - condition: string; - brief?: string; - criteria?: GoalCriterion[]; - startedAt: number; - completedAt?: number; - turnBudget: number; - turnsUsed?: number; - tokenBudget?: number; - mainTokens?: number; - evalTokens?: number; - lastReason?: string; - }): GoalSnapshot | null { - if (saved.status === "inactive" || saved.status === "cleared") { - snapshot = emptySnapshot(defaultTurnBudget); - return null; - } - const restoredStatus: GoalStatus = - saved.status === "active" ? "paused" : saved.status === "achieved" ? "achieved" : "paused"; - const brief = (saved.brief ?? saved.condition).trim(); - const criteria = cloneCriteria(saved.criteria ?? []); - snapshot = { - status: restoredStatus, - phase: deriveGoalPhase(criteria, restoredStatus), - brief, - criteria, - condition: synthesizeGoalCondition(brief, criteria) || saved.condition, - startedAt: saved.startedAt, - ...(saved.completedAt !== undefined ? { completedAt: saved.completedAt } : {}), - turnBudget: saved.turnBudget, - turnsUsed: saved.turnsUsed ?? 0, - ...(saved.tokenBudget !== undefined ? { tokenBudget: saved.tokenBudget } : {}), - mainTokens: saved.mainTokens ?? 0, - evalTokens: saved.evalTokens ?? 0, - ...(saved.lastReason !== undefined ? { lastReason: saved.lastReason } : {}), - consecutiveEvalFailures: 0, - consecutiveEmptyYields: 0, - }; - return emit(); - } - - function noteMainTokens(n: number): void { - if (snapshot.status !== "active" && snapshot.status !== "paused") return; - if (n <= 0) return; - snapshot = { ...snapshot, mainTokens: snapshot.mainTokens + n }; - // Keep phase attached for UI listeners (same contract as emit/get). - opts.onChange?.(attachPhase({ ...snapshot, criteria: cloneCriteria(snapshot.criteria) })); - } - - function turnBudgetExhausted(): boolean { - // 0 = unlimited: never soft-stop on turns alone. - if (isUnlimitedTurnBudget(snapshot.turnBudget)) return false; - // turnsUsed is the number of continue attempts already spent. Soft-stop when - // the next continue would exceed the budget (turnsUsed >= turnBudget means - // no more continues remain). - return snapshot.turnsUsed >= snapshot.turnBudget; - } - - function tokenBudgetExceeded(): boolean { - return ( - snapshot.tokenBudget !== undefined && tokensTotal(snapshot) >= snapshot.tokenBudget - ); - } - - function markBudgetLimited(kind: "turns" | "tokens"): null { - const reason = - kind === "turns" - ? `Turn budget reached (${snapshot.turnsUsed}/${snapshot.turnBudget}). Use /goal resume to continue.` - : `Token budget reached (${tokensTotal(snapshot)}/${snapshot.tokenBudget}). Use /goal resume to continue.`; - snapshot = { - ...snapshot, - status: "budget_limited", - lastReason: reason, - }; - emit(); - // Keep the base terminal yield; status is visible via get()/onChange. - return null; - } - - /** - * Soft-stop after a not-met decision: count the continue attempt, then either - * re-infer or enter budget_limited. Budget N means N re-infers are allowed. - */ - function continueOrBudget( - actions: ReactorAction[], - capabilities: ReactorCapabilities, - reason: string, - ): ReactorAction[] | null { - if (turnBudgetExhausted()) { - snapshot = { ...snapshot, lastReason: reason }; - return markBudgetLimited("turns"); - } - if (tokenBudgetExceeded()) { - snapshot = { ...snapshot, lastReason: reason }; - return markBudgetLimited("tokens"); - } - snapshot = { - ...snapshot, - turnsUsed: snapshot.turnsUsed + 1, - lastReason: reason, - }; - if (tokenBudgetExceeded()) { - return markBudgetLimited("tokens"); - } - emit(); - return [ - ...stripTerminal(actions), - inferWithNudge( - capabilities, - notMetNudge(snapshot.brief || snapshot.condition, reason, snapshot.criteria), - ), - ]; - } - - /** - * After base decide (and compaction / workflow / open-task rules), rewrite a - * clean terminal yield into a re-infer when the goal is active and not met. - * Returns null when the base actions should stand unchanged (including soft-stop). - */ - async function interceptTerminal( - actions: ReactorAction[], - capabilities: ReactorCapabilities, - ctx: GoalInterceptContext, - ): Promise { - if (snapshot.status !== "active") return null; - if (ctx.atWorkflowGate) return null; - if (!isCleanTerminal(actions)) return null; - - if (ctx.mainTurnTokens !== undefined && ctx.mainTurnTokens > 0) { - snapshot = { ...snapshot, mainTokens: snapshot.mainTokens + ctx.mainTurnTokens }; - } - - if (tokenBudgetExceeded()) { - return markBudgetLimited("tokens"); - } - if (turnBudgetExhausted()) { - return markBudgetLimited("turns"); - } - - // Empty / contentless yield: progress guard before spending an eval call. - if (!ctx.lastTurnHadContent) { - const empty = snapshot.consecutiveEmptyYields + 1; - snapshot = { ...snapshot, consecutiveEmptyYields: empty }; - if (empty >= maxEmptyYields) { - snapshot = { - ...snapshot, - status: "paused", - lastReason: `Paused after ${empty} consecutive empty yields. Use /goal resume to continue.`, - }; - emit(); - return null; - } - return continueOrBudget( - actions, - capabilities, - "Empty yield — no text or tool calls on the last turn.", - ); - } - snapshot = { ...snapshot, consecutiveEmptyYields: 0 }; - - // Checklist is the source of truth once planned. - if (snapshot.criteria.length === 0) { - return continueOrBudget( - actions, - capabilities, - "Acceptance criteria not defined yet. Call manage_goal create with a detailed multi-item checklist " + - "(or ask_operator if the brief is still vague).", - ); - } - - if (criteriaAllDone(snapshot.criteria)) { - markAchieved("All acceptance criteria marked done."); - emit(); - return null; - } - - // Open criteria: checklist is the source of truth. Skip the LLM evaluator - // when there is no evidence — it cannot mark met while items remain open and - // would only burn tokens for a soft reason string. - const open = snapshot.criteria.filter( - (c) => c.status === "todo" || c.status === "doing" || c.status === "blocked", - ); - const progress = goalCriteriaProgress(snapshot.criteria); - const openSummary = open.map((c) => `[${c.status}] ${c.title}`).join("; "); - const evidence = ctx.evidence.trim(); - - if (evidence.length === 0) { - return continueOrBudget( - actions, - capabilities, - `${progress.done}/${progress.total} criteria done. Open: ${openSummary}. ` + - "Continue work and mark Acceptance done via manage_goal when verified.", - ); - } - - // Fail-open evaluator: soft second opinion for the nudge reason only — - // met is never true while criteria remain open (checklist wins). - let verdict: GoalEvaluateVerdict; - try { - verdict = await opts.evaluate({ - condition: snapshot.condition, - evidence, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - verdict = { met: false, reason: `Evaluator error: ${message}`, error: true }; - } - - if (verdict.evalTokens !== undefined && verdict.evalTokens > 0) { - snapshot = { ...snapshot, evalTokens: snapshot.evalTokens + verdict.evalTokens }; - } - - if (verdict.error === true) { - const failures = snapshot.consecutiveEvalFailures + 1; - snapshot = { - ...snapshot, - consecutiveEvalFailures: failures, - lastReason: verdict.reason, - }; - if (failures >= maxEvalFailures) { - snapshot = { - ...snapshot, - status: "paused", - lastReason: `Paused after ${failures} consecutive evaluator failures. Last: ${verdict.reason}`, - }; - emit(); - return null; - } - return continueOrBudget( - actions, - capabilities, - `${progress.done}/${progress.total} criteria done. Open: ${openSummary}. ${verdict.reason}`, - ); - } - - snapshot = { - ...snapshot, - consecutiveEvalFailures: 0, - lastReason: verdict.reason, - }; - - // Checklist still open — never auto-achieve from evaluator alone. - return continueOrBudget( - actions, - capabilities, - `${progress.done}/${progress.total} criteria done. Open: ${openSummary}. ${verdict.reason}`, - ); - } - - return { - get, - set, - setCriteria, - updateCriteria, - pause, - resume, - clear, - restore, - noteMainTokens, - interceptTerminal, - }; -} - -const CRITERION_GLYPH: Record = { - todo: "○", - doing: "●", - done: "✓", - blocked: "!", - cancelled: "✗", -}; - -export function formatGoalStatus(snap: GoalSnapshot | null): string { - if (snap === null || snap.status === "inactive") { - return "No goal is set. Use /goal to start one — the agent expands it into a checklist."; - } - - const progress = goalCriteriaProgress(snap.criteria); - const phase = snap.phase; - const completed = formatGoalCompleted(snap); - const lines: string[] = [ - `Brief: ${snap.brief || snap.condition}`, - `Phase: ${phase}`, - ]; - - if (completed !== null) { - lines.push(completed); - } - - if (snap.criteria.length === 0) { - lines.push("Criteria: (not planned yet — waiting for manage_goal create)"); - } else { - lines.push(`Progress: ${progress.done}/${progress.total}`); - for (const c of snap.criteria) { - const glyph = CRITERION_GLYPH[c.status]; - const note = c.note !== undefined && c.note.length > 0 ? ` — ${c.note}` : ""; - lines.push(` ${glyph} ${c.title}${note}`); - } - } - - if (snap.status !== "active" && snap.status !== "achieved") { - lines.push(`State: ${snap.status}`); - } - if (snap.lastReason !== undefined && snap.lastReason.length > 0) { - lines.push(`Note: ${snap.lastReason}`); - } - // Stop counting turns after success; only surface budgets while still running - // or when soft-stopped on budget. - if ( - snap.status !== "achieved" && - (!isUnlimitedTurnBudget(snap.turnBudget) || snap.status === "budget_limited") - ) { - lines.push(`Turns: ${formatGoalTurns(snap.turnsUsed, snap.turnBudget)}`); - } - if (snap.status !== "achieved" && snap.tokenBudget !== undefined) { - lines.push(`Tokens: ${tokensTotal(snap)}/${snap.tokenBudget}`); - } - - return lines.join("\n"); -} diff --git a/src/agent/manage-goal.ts b/src/agent/manage-goal.ts deleted file mode 100644 index f89e1b753..000000000 --- a/src/agent/manage-goal.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * manage_goal — agent tool for the goal acceptance checklist. - * - * Parallel to manage_tasks, but the list is the *goal definition* (what - * "done" means), not a work plan. Only useful while a session goal is set. - */ - -import { stringTool } from "@intx/agent"; -import type { AgentTool } from "@intx/agent"; -import type { ToolDefinition } from "@intx/types/runtime"; -import { type } from "arktype"; - -import type { GoalCriterion, GoalCriterionStatus, GoalGovernor } from "./goal.js"; -import { formatGoalStatus, goalCriteriaProgress, GoalCriterionStatusSchema } from "./goal.js"; - -const ManageGoalArgsSchema = type({ - action: "'create' | 'update'", - "tasks?": type({ - id: "string>0", - title: "string>0", - "status?": GoalCriterionStatusSchema, - "note?": "string", - }).array(), - "updates?": type({ - id: "string>0", - "title?": "string>0", - "status?": GoalCriterionStatusSchema, - "note?": "string", - }).array(), -}); - -export type ManageGoalArgs = typeof ManageGoalArgsSchema.infer; - -export const manageGoalDefinition: ToolDefinition = { - name: "manage_goal", - description: - "Define or update the session goal's acceptance checklist (what \"done\" means). " + - "This is not a work plan — use manage_tasks for implementation steps. " + - "action=\"create\" replaces the full list of concrete, checkable success criteria " + - "(expand the operator brief into typically 3–12 items — do not restate it as one item). " + - "action=\"update\" patches items by id (status/title/note). " + - "The goal is met only when every non-cancelled criterion is done. " + - "Requires an active /goal.", - inputSchema: { - type: "object", - properties: { - action: { - type: "string", - enum: ["create", "update"], - description: "\"create\" replaces the checklist; \"update\" patches by id.", - }, - tasks: { - type: "array", - description: "For action=\"create\": the full acceptance checklist.", - items: { - type: "object", - properties: { - id: { type: "string", description: "Stable id (e.g. c1, c2)." }, - title: { - type: "string", - description: "Concrete, independently checkable acceptance criterion.", - }, - status: { - type: "string", - enum: ["todo", "doing", "done", "blocked", "cancelled"], - description: "Defaults to \"todo\".", - }, - note: { type: "string", description: "Optional evidence when done/blocked." }, - }, - required: ["id", "title"], - }, - }, - updates: { - type: "array", - description: "For action=\"update\": per-criterion patches.", - items: { - type: "object", - properties: { - id: { type: "string" }, - title: { type: "string" }, - status: { - type: "string", - enum: ["todo", "doing", "done", "blocked", "cancelled"], - }, - note: { type: "string" }, - }, - required: ["id"], - }, - }, - }, - required: ["action"], - }, -}; - -export function parseManageGoalArgs(rawArgs: unknown): ManageGoalArgs | null { - const result = ManageGoalArgsSchema(rawArgs); - return result instanceof type.errors ? null : result; -} - -function summarize(snap: NonNullable>): string { - const progress = goalCriteriaProgress(snap.criteria); - const phase = snap.phase; - const head = - snap.status === "achieved" - ? `Goal achieved — all ${progress.total} acceptance criteria done.` - : `Goal checklist updated (${progress.done}/${progress.total} done, phase=${phase}).`; - return `${head}\n${formatGoalStatus(snap)}`; -} - -/** - * Build the manage_goal AgentTool. `getGovernor` is read on each call so the - * tool can be registered before the governor exists and still work once wired. - */ -export function createManageGoalTool(getGovernor: () => GoalGovernor | null): AgentTool { - return stringTool({ - definition: manageGoalDefinition, - handler: async (rawArgs: Record): Promise => { - const parsed = parseManageGoalArgs(rawArgs); - if (parsed === null) { - return "Error: manage_goal requires action ('create' or 'update')."; - } - const gov = getGovernor(); - if (gov === null) { - return "Error: no goal is set. Use /goal first."; - } - const current = gov.get(); - if (current === null) { - return "Error: no goal is set. Use /goal first."; - } - - if (parsed.action === "create") { - const tasks = parsed.tasks ?? []; - if (tasks.length === 0) { - return 'Error: action="create" requires a non-empty tasks array of acceptance criteria.'; - } - const ids = new Set(); - for (const t of tasks) { - if (ids.has(t.id)) return `Error: duplicate criterion id: ${t.id}`; - ids.add(t.id); - } - const criteria: GoalCriterion[] = tasks.map((t) => ({ - id: t.id, - title: t.title.trim(), - status: (t.status ?? "todo") as GoalCriterionStatus, - ...(t.note !== undefined && t.note.length > 0 ? { note: t.note } : {}), - })); - const snap = gov.setCriteria(criteria); - if (snap === null) return "Error: no goal is set."; - return summarize(snap); - } - - const updates = parsed.updates ?? []; - if (updates.length === 0) { - return 'Error: action="update" requires a non-empty updates array.'; - } - const known = new Set(current.criteria.map((c) => c.id)); - for (const u of updates) { - if (!known.has(u.id)) { - return `Error: unknown criterion id: ${u.id}. Known: ${[...known].join(", ") || "(none)"}`; - } - } - const snap = gov.updateCriteria( - updates.map((u) => ({ - id: u.id, - ...(u.title !== undefined ? { title: u.title } : {}), - ...(u.status !== undefined ? { status: u.status as GoalCriterionStatus } : {}), - ...(u.note !== undefined ? { note: u.note } : {}), - })), - ); - if (snap === null) return "Error: no goal is set."; - return summarize(snap); - }, - }); -} diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 15426c982..e3f1b2f26 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -11,7 +11,6 @@ import { // (tests, ad-hoc prompt previews). Real sessions always pass their detected // availability — see tui/runner.ts and exec/runner.ts. const DEFAULT_TOOL_AVAILABILITY: ToolAvailability = { - hasGoalAtLaunch: true, languageServerAvailable: true, }; import { PRODUCT_NAME, SETTINGS_DIR_NAME } from "../branding.js"; @@ -128,7 +127,7 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- Preserve unrelated user edits; never revert changes you did not make unless the brief requires it.", ] : [ - "- Clear, bounded coding requests: proceed autonomously; use ask_operator only when permission blocks you or the goal is genuinely ambiguous (missing repro, conflicting instructions, destructive choice).", + "- Clear, bounded coding requests: proceed autonomously; use ask_operator only when permission blocks you or the request is genuinely ambiguous (missing repro, conflicting instructions, destructive choice).", "- Questions, reviews, and product/visual feedback: answer or diagnose first; do not edit until the user wants a change.", "- Preserve unrelated user edits; never revert changes you did not make unless asked.", "- Unexpected changes in files you did not touch: stop and ask_operator.", @@ -207,10 +206,7 @@ const TOOL_SUMMARIES: Record = { "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; optional maxTurns sets the worker inference budget; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", search_agents: "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", - manage_tasks: - "maintain Work checklist — create/replace, update status, append, cancel; under /goal this is Work (steps), not Acceptance; primary surface during implementing", - manage_goal: - "define/update Acceptance checklist (what done means) while /goal is set — planning first; mark done/blocked only when verified (that enters reviewing); not a work plan (use manage_tasks)", + manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", submit_output: "signal the task is complete — the only way to finish", ask_operator: "pause and ask the user when blocked or genuinely ambiguous", present: "dynamically render aligned/structured output using the layout primitives (stack/row/grid/text etc)", diff --git a/src/agent/tasks.ts b/src/agent/tasks.ts index 84a79e004..24d2aeb02 100644 --- a/src/agent/tasks.ts +++ b/src/agent/tasks.ts @@ -3,7 +3,7 @@ import type { ToolDefinition } from "@intx/types/runtime"; // A task is a unit of work the agent registered for itself. The agent owns the // list — it adds, renames, cancels, and status-updates items as the plan -// evolves mid-run (especially under /goal Work vs Acceptance). +// evolves mid-run. export const TaskStatusSchema = type("'todo' | 'doing' | 'done' | 'cancelled'"); export type TaskStatus = typeof TaskStatusSchema.infer; @@ -46,7 +46,6 @@ export const manageTasksDefinition: ToolDefinition = { "action=\"update\" patches by id: status (todo→doing→done/cancelled), title edits, " + "and appends when the id is new and title is set. " + "Keep this list live — add, cancel, and re-title steps as you learn more. " + - "Under /goal this is Work (how you get there); manage_goal is Acceptance (what done means). " + "Skip for trivial single-step changes.", inputSchema: { type: "object", diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index c3e98905b..f41d3d1ec 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -13,11 +13,9 @@ import { } from "./tool-search.js"; const FULL_AVAILABILITY: ToolAvailability = { - hasGoalAtLaunch: true, languageServerAvailable: true, }; const NO_AVAILABILITY: ToolAvailability = { - hasGoalAtLaunch: false, languageServerAvailable: false, }; @@ -82,21 +80,12 @@ describe("createToolIndex", () => { expect(coreToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).not.toContain("present"); }); - test("manage_goal is advertised only when the session starts with a goal", () => { - expect( - coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: true, languageServerAvailable: true }), - ).toContain("manage_goal"); - expect( - coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }), - ).not.toContain("manage_goal"); - }); - test("lsp is advertised only when a language server was detected at startup", () => { expect( - coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }), + coreToolNamesForSessionMode("orchestrator", { languageServerAvailable: true }), ).toContain("lsp"); expect( - coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: false }), + coreToolNamesForSessionMode("orchestrator", { languageServerAvailable: false }), ).not.toContain("lsp"); }); @@ -243,7 +232,6 @@ describe("advertisedTools", () => { // re-evaluated per turn — simulate several turns by calling with the same // captured prefix and confirm the wire array never drifts. const prefix = advertisedToolNamesForSessionMode("orchestrator", { - hasGoalAtLaunch: false, languageServerAvailable: true, }); const turn1 = JSON.stringify(advertisedTools(registry, [], prefix)); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 5326949c9..89608ac4a 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -22,7 +22,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [ "run_shell", "ask_operator", "manage_tasks", - "manage_goal", "tool_search", "use_skill", "search_agents", @@ -38,16 +37,9 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task" // Session-start facts that gate a core tool's advertisement. Each must be // knowable once, before the first inference call, and must never change for // the life of the session — the tools array is a provider cache prefix (see -// ADVERTISED_TOOL_NAMES below), so a value that could flip mid-session (e.g. -// "is a goal active right now") would force a re-prefill worse than the -// schema bytes it saves. `manage_tasks` is intentionally NOT gated here: the -// goal-kickoff sequence (see goalKickoffUserMessage in ./goal.ts) instructs -// the model to call manage_goal then manage_tasks back to back, so hiding -// manage_tasks would trade one tool_search round trip for two. +// ADVERTISED_TOOL_NAMES below), so a value that could flip mid-session would +// force a re-prefill worse than the schema bytes it saves. export type ToolAvailability = { - // Whether the session was resumed with an active/paused/budget-limited goal - // already persisted — not whether one exists at the current instant. - hasGoalAtLaunch: boolean; // Whether a language server was resolvable for this project at startup — // not whether one currently responds. languageServerAvailable: boolean; @@ -60,7 +52,6 @@ export function coreToolNamesForSessionMode( const orchestratorEnabled = sessionModeEnablesSubAgents(mode); return CORE_TOOL_NAMES.filter((name) => { if (!orchestratorEnabled && ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name)) return false; - if (name === "manage_goal") return availability.hasGoalAtLaunch; if (name === "lsp") return availability.languageServerAvailable; return true; }); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 31462c5aa..e41377df4 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -9,8 +9,6 @@ import { presentDefinition, } from "../agent/director.js"; import { manageTasksDefinition } from "./tasks.js"; -import { createManageGoalTool } from "./manage-goal.js"; -import type { GoalGovernor } from "./goal.js"; import { validateView } from "../tui/view/index.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { @@ -109,13 +107,11 @@ export type AgentToolsetArgs = { isWorkflowActive?: () => boolean; // Primary session mode: single-agent sessions omit sub-agent tooling. sessionMode?: SessionMode; - // Session-start facts gating manage_goal/lsp advertisement. Omitted callers - // (tests, ad-hoc toolset construction) get both advertised, matching prior - // behavior. Real sessions always pass their detected values — see - // tool-search.ts for why these must be fixed for the session's life. + // Session-start facts gating lsp advertisement. Omitted callers (tests, + // ad-hoc toolset construction) get it advertised, matching prior behavior. + // Real sessions always pass their detected values — see tool-search.ts for + // why these must be fixed for the session's life. toolAvailability?: ToolAvailability; - // When a goal governor is live, manage_goal mutates its acceptance checklist. - getGoalGovernor?: () => GoalGovernor | null; // When provided, the agent gets a `task` tool that delegates to autonomous // sub-agents. Omitted in contexts that cannot spawn sub-agents (e.g. tests). subAgent?: { @@ -181,7 +177,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise, _signal: AbortSignal): Promise => { diff --git a/src/director.test.ts b/src/director.test.ts index 9cdfe3534..ad009043e 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -674,151 +674,3 @@ describe("transient nudges", () => { expect(options?.systemPrompt).toBeUndefined(); }); }); - -describe("goal continue-rule", () => { - const textTurn = (): ReactorInboundEvent => - ({ - type: "inference.done", - turn: { - role: "assistant", - model: "test", - timestamp: 0, - content: [{ type: "text", text: "done for now" }], - }, - usage: { input: 10, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 }, - source: { model: "test-model" }, - }) as unknown as ReactorInboundEvent; - - const stateWithTurns: ReactorState = { - turns: [ - { - role: "user", - content: [{ type: "text", text: "make tests green" }], - timestamp: 0, - }, - { - role: "assistant", - content: [{ type: "text", text: "still failing" }], - timestamp: 1, - }, - ], - } as unknown as ReactorState; - - test("active not-met goal rewrites a clean yield into re-infer", async () => { - const { createGoalGovernor } = await import("./agent/goal.js"); - const director = createChatDirector("base", [], { onTasksChange: () => {} }); - const g = createGoalGovernor({ - evaluate: async () => ({ met: false, reason: "tests still red" }), - }); - g.set("all tests pass"); - g.setCriteria([ - { id: "c1", title: "unit tests green", status: "todo" }, - { id: "c2", title: "typecheck clean", status: "todo" }, - ]); - director.setGoalGovernor(g); - - const actions = actionsArray(await director.decide(textTurn(), stateWithTurns, mockCapabilities)); - expect(actions.some((a) => a.type === "infer")).toBe(true); - expect(actions.some((a) => a.type === "reply")).toBe(false); - expect(g.get()?.turnsUsed).toBe(1); - expect(g.get()?.lastReason).toContain("tests still red"); - }); - - test("met goal leaves terminal reply and marks achieved", async () => { - const { createGoalGovernor } = await import("./agent/goal.js"); - const director = createChatDirector("base", [], { onTasksChange: () => {} }); - const g = createGoalGovernor({ - evaluate: async () => ({ met: true, reason: "green" }), - }); - g.set("all tests pass"); - g.setCriteria([ - { id: "c1", title: "unit tests green", status: "done" }, - { id: "c2", title: "typecheck clean", status: "done" }, - ]); - director.setGoalGovernor(g); - - const actions = actionsArray(await director.decide(textTurn(), stateWithTurns, mockCapabilities)); - expect(actions.some((a) => a.type === "reply")).toBe(true); - expect(actions.some((a) => a.type === "infer")).toBe(false); - expect(g.get()?.status).toBe("achieved"); - }); - - test("open-task nudge still wins over goal when tasks are open", async () => { - const { createGoalGovernor } = await import("./agent/goal.js"); - let evals = 0; - const director = createChatDirector("base", [], { onTasksChange: () => {} }); - const g = createGoalGovernor({ - evaluate: async () => { - evals++; - return { met: false, reason: "should not run yet" }; - }, - }); - g.set("x"); - director.setGoalGovernor(g); - - await director.decide( - makeInferenceDoneEvent([ - { - id: "m", - name: "manage_tasks", - args: { action: "create", tasks: [{ id: "t1", title: "work", status: "doing" }] }, - }, - ]), - stateWithTurns, - mockCapabilities, - ); - - const actions = actionsArray(await director.decide(textTurn(), stateWithTurns, mockCapabilities)); - expect(actions.some((a) => a.type === "infer")).toBe(true); - expect(evals).toBe(0); - }); -}); - -describe("onTasksChange live wiring", () => { - test("a manage_tasks tool call invokes the wired onTasksChange with the updated task list", async () => { - const updates: Array> = []; - const director = createChatDirector("base", [], { - onTasksChange: (tasks) => updates.push(tasks), - }); - - await director.decide( - makeInferenceDoneEvent([ - { id: "m", name: "manage_tasks", args: { action: "create", tasks: [{ id: "t1", title: "work", status: "doing" }] } }, - ]), - mockState, - mockCapabilities, - ); - - expect(updates).toHaveLength(1); - expect(updates[0]).toEqual([{ id: "t1", title: "work", status: "doing" }]); - }); - - test("restoreTasks seeds a resumed session's task list and notifies the consumer", () => { - const updates: Array> = []; - const director = createChatDirector("base", [], { - onTasksChange: (tasks) => updates.push(tasks), - }); - - const restored = [{ id: "t1", title: "from transcript", status: "doing" as const }]; - director.restoreTasks(restored); - - expect(director.getTasks()).toEqual(restored); - expect(updates).toEqual([restored]); - }); - - test("onTasksChange is not invoked for tool calls that are not manage_tasks", async () => { - const updates: unknown[] = []; - const director = createChatDirector("base", [], { - onTasksChange: (tasks) => updates.push(tasks), - }); - - await director.decide( - makeInferenceDoneEvent([{ id: "c", name: "read_file", args: { path: "src/foo.ts" } }]), - mockState, - mockCapabilities, - ); - - expect(updates).toHaveLength(0); - }); -}); - diff --git a/src/exec/runner.ts b/src/exec/runner.ts index f47864885..e8db9e3a9 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -316,11 +316,7 @@ export async function runExec(config: Config): Promise { const subAgentSessions = createSubAgentSessionStore(); const shellTimeout = shellTimeoutFromSettings(config.settings); const toolWatchdog = toolWatchdogFromSettings(config.settings); - // Exec has no goal governor (headless — no /goal), so a goal never starts - // at launch here. lsp is still worth detecting: exec sessions read/edit - // TypeScript projects same as the TUI. const toolAvailability: ToolAvailability = { - hasGoalAtLaunch: false, languageServerAvailable: detectLanguageServerAvailable(config.cwd), }; diff --git a/src/permission/gate.ts b/src/permission/gate.ts index 0341253f0..33d6e26a9 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -154,7 +154,7 @@ const AUTO_ALLOWED_TOOLS = new Set([ "write_file", "edit_file", "delete_file", - "manage_goal", + "manage_tasks", "present", "tool_search", "use_skill", diff --git a/src/permission/goal-approval-timeout.test.ts b/src/permission/goal-approval-timeout.test.ts deleted file mode 100644 index 9f1432463..000000000 --- a/src/permission/goal-approval-timeout.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - DEFAULT_GOAL_APPROVAL_TIMEOUT_MS, - goalApprovalTimeoutMessage, - isGoalApprovalTimeoutActive, -} from "./goal-approval-timeout.js"; - -describe("goal approval timeout helpers", () => { - test("only active goals arm the timeout", () => { - expect(isGoalApprovalTimeoutActive("active")).toBe(true); - expect(isGoalApprovalTimeoutActive("paused")).toBe(false); - expect(isGoalApprovalTimeoutActive("budget_limited")).toBe(false); - expect(isGoalApprovalTimeoutActive("inactive")).toBe(false); - expect(isGoalApprovalTimeoutActive("cleared")).toBe(false); - expect(isGoalApprovalTimeoutActive(null)).toBe(false); - expect(isGoalApprovalTimeoutActive(undefined)).toBe(false); - }); - - test("default timeout is 15s", () => { - expect(DEFAULT_GOAL_APPROVAL_TIMEOUT_MS).toBe(15_000); - }); - - test("timeout message tells the agent to skip and continue", () => { - const msg = goalApprovalTimeoutMessage(15_000); - expect(msg).toContain("15s"); - expect(msg.toLowerCase()).toContain("human may be away"); - expect(msg.toLowerCase()).toContain("skipped"); - expect(msg.toLowerCase()).toContain("do not retry"); - }); -}); diff --git a/src/permission/goal-approval-timeout.ts b/src/permission/goal-approval-timeout.ts deleted file mode 100644 index 4c7993d89..000000000 --- a/src/permission/goal-approval-timeout.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { GoalStatus } from "../agent/goal.js"; - -// While a goal is actively running, pending operator approvals must not park the -// session overnight. After this timeout the request is denied with a note the -// agent can act on, and the goal continue-rule can keep going. -export const DEFAULT_GOAL_APPROVAL_TIMEOUT_MS = 15_000; - -/** Only an actively running goal arms the approval timeout (not paused/budget-stopped). */ -export function isGoalApprovalTimeoutActive(status: GoalStatus | null | undefined): boolean { - return status === "active"; -} - -export function goalApprovalTimeoutMessage(timeoutMs: number = DEFAULT_GOAL_APPROVAL_TIMEOUT_MS): string { - const secs = Math.max(1, Math.round(timeoutMs / 1000)); - return ( - `Goal mode: operator did not respond within ${secs}s (human may be away). ` + - `Request skipped — do not retry the same gated action; continue the goal another way or finish without it.` - ); -} diff --git a/src/session/goal-state.test.ts b/src/session/goal-state.test.ts deleted file mode 100644 index ae6c1ece9..000000000 --- a/src/session/goal-state.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { - loadGoalState, - saveGoalState, - writeGoalStateRaw, -} from "./goal-state.js"; - -// Session state lands under /.corbits/projects, not under cwd, so every -// case sandboxes the home as well or the run writes into the developer's home. -describe("goal state persistence", () => { - test("round-trips an active goal and deletes on clear", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await saveGoalState( - cwd, - "s1", - { - status: "active", - condition: "tests green", - startedAt: 100, - turnBudget: 25, - turnsUsed: 3, - mainTokens: 10, - evalTokens: 2, - lastReason: "still failing", - }, - home, - ); - const loaded = await loadGoalState(cwd, "s1", home); - expect(loaded?.condition).toBe("tests green"); - expect(loaded?.turnsUsed).toBe(3); - - await saveGoalState(cwd, "s1", null, home); - expect(await loadGoalState(cwd, "s1", home)).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); - - test("round-trips brief and criteria", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await saveGoalState( - cwd, - "s1", - { - status: "active", - condition: "typecheck; tests", - brief: "ship the feature", - criteria: [ - { id: "c1", title: "typecheck clean", status: "done" }, - { - id: "c2", - title: "tests green", - status: "todo", - note: "2 failing", - }, - ], - startedAt: 100, - turnBudget: 0, - turnsUsed: 3, - mainTokens: 10, - evalTokens: 2, - }, - home, - ); - const loaded = await loadGoalState(cwd, "s1", home); - expect(loaded?.brief).toBe("ship the feature"); - expect(loaded?.criteria).toHaveLength(2); - expect(loaded?.criteria?.[0]?.status).toBe("done"); - expect(loaded?.criteria?.[1]?.note).toBe("2 failing"); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); - - test("ignores corrupt JSON", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await writeGoalStateRaw(cwd, "s1", "{not json", home); - expect(await loadGoalState(cwd, "s1", home)).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); - - test("rejects an unknown goal status", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await writeGoalStateRaw( - cwd, - "s1", - JSON.stringify({ - status: "in_progress", - condition: "tests green", - startedAt: 100, - turnBudget: 25, - turnsUsed: 3, - mainTokens: 10, - evalTokens: 2, - }), - home, - ); - expect(await loadGoalState(cwd, "s1", home)).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); - - test("rejects a criterion with an unknown status", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await writeGoalStateRaw( - cwd, - "s1", - JSON.stringify({ - status: "active", - condition: "tests green", - criteria: [ - { id: "c1", title: "typecheck clean", status: "in_review" }, - ], - startedAt: 100, - turnBudget: 25, - turnsUsed: 3, - mainTokens: 10, - evalTokens: 2, - }), - home, - ); - expect(await loadGoalState(cwd, "s1", home)).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); - - test("rejects a criterion with an empty title", async () => { - const cwd = await mkdtemp(join(tmpdir(), "goal-state-")); - const home = await mkdtemp(join(tmpdir(), "goal-state-home-")); - try { - await writeGoalStateRaw( - cwd, - "s1", - JSON.stringify({ - status: "active", - condition: "tests green", - criteria: [{ id: "c1", title: "", status: "todo" }], - startedAt: 100, - turnBudget: 25, - turnsUsed: 3, - mainTokens: 10, - evalTokens: 2, - }), - home, - ); - expect(await loadGoalState(cwd, "s1", home)).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - await rm(home, { recursive: true, force: true }); - } - }); -}); diff --git a/src/session/goal-state.ts b/src/session/goal-state.ts deleted file mode 100644 index 70d3c3ff2..000000000 --- a/src/session/goal-state.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { mkdir, writeFile, readFile, rename, unlink } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; - -import { type } from "arktype"; - -import { sessionDir } from "./index.js"; -import { GoalCriterionSchema, GoalStatusSchema } from "../agent/goal.js"; -import { atomicWrite, warnUnreadableState } from "./state.js"; - -const PersistedGoalStateSchema = type({ - status: GoalStatusSchema, - condition: "string", - /** Operator brief; falls back to condition for older goal.json files. */ - "brief?": "string", - /** Expanded acceptance checklist. */ - "criteria?": GoalCriterionSchema.array(), - startedAt: "number", - /** Wall-clock when status flipped to achieved (freezes completion duration). */ - "completedAt?": "number", - turnBudget: "number", - turnsUsed: "number", - "tokenBudget?": "number", - mainTokens: "number", - evalTokens: "number", - "lastReason?": "string", -}); - -export type PersistedGoalState = typeof PersistedGoalStateSchema.infer; - -function goalPath(cwd: string, sessionId: string, home: string): string { - return join(sessionDir(cwd, sessionId, home), "goal.json"); -} - -// Returns the parsed state, or the arktype error summary when the shape is -// invalid, so callers can surface a specific reason rather than "invalid shape". -function parsePersistedGoal(data: unknown): PersistedGoalState | { error: string } { - const result = PersistedGoalStateSchema(data); - return result instanceof type.errors ? { error: result.summary } : result; -} - -export async function saveGoalState( - cwd: string, - sessionId: string, - state: PersistedGoalState | null, - home: string = homedir(), -): Promise { - const path = goalPath(cwd, sessionId, home); - if (state === null || state.status === "inactive" || state.status === "cleared") { - try { - await unlink(path); - } catch (err) { - if ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ) { - return; - } - throw err; - } - return; - } - await atomicWrite(path, JSON.stringify(state, null, 2)); -} - -export async function loadGoalState( - cwd: string, - sessionId: string, - home: string = homedir(), -): Promise { - const path = goalPath(cwd, sessionId, home); - try { - const raw = await readFile(path, "utf8"); - const parsed = parsePersistedGoal(JSON.parse(raw)); - if ("error" in parsed) { - warnUnreadableState(path, `invalid shape: ${parsed.error}`); - return null; - } - return parsed; - } catch (err) { - if (err instanceof SyntaxError) { - warnUnreadableState(path, "corrupt JSON"); - return null; - } - if ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ) { - return null; - } - throw err; - } -} - -// Re-export mkdir helpers for tests that want to seed a goal file without going -// through saveGoalState (e.g. corrupt-file cases). Not used in production. -export async function writeGoalStateRaw( - cwd: string, - sessionId: string, - content: string, - home: string = homedir(), -): Promise { - const path = goalPath(cwd, sessionId, home); - await mkdir(dirname(path), { recursive: true }); - const tmp = `${path}.${process.pid}.tmp`; - await writeFile(tmp, content); - await rename(tmp, path); -} diff --git a/src/session/list-sessions.test.ts b/src/session/list-sessions.test.ts index ac869334f..0aaa45f39 100644 --- a/src/session/list-sessions.test.ts +++ b/src/session/list-sessions.test.ts @@ -55,3 +55,37 @@ test("listSessions prefers run.json task title when present", async () => { const row = listed.find((s) => s.sessionId === sessionId); expect(row?.task).toBe("fix resume"); }); + +// The goal subsystem persisted its own goal.json inside the session dir +// (src/session/goal-state.ts, removed). Nothing reads that file anymore, so +// a session dir left over from before the removal must still list cleanly — +// dropped on read, never fatal. +test("listSessions ignores a leftover goal.json from a pre-removal session", async () => { + const sessionId = generateSessionId(); + await initSessionDir(cwd, sessionId, home); + await writeFile( + join(sessionDir(cwd, sessionId, home), "run.json"), + JSON.stringify({ + status: "running", + turnsUsed: 1, + task: "pre-removal session", + startedAt: 1_700_000_000_000, + }), + ); + await writeFile( + join(sessionDir(cwd, sessionId, home), "goal.json"), + JSON.stringify({ + status: "active", + condition: "all tests pass", + startedAt: 1_700_000_000_000, + turnBudget: 0, + turnsUsed: 3, + mainTokens: 100, + evalTokens: 20, + }), + ); + const listed = await listSessions(cwd, home); + const row = listed.find((s) => s.sessionId === sessionId); + expect(row).toBeDefined(); + expect(row?.task).toBe("pre-removal session"); +}); diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 3e3dfdfd6..3d1f1cd54 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -22,13 +22,6 @@ export type SummaryContext = { stepIndex?: number; total?: number; }; - /** Active session goal — preserved across compaction so the continue-rule survives. */ - goal?: { - condition: string; - status: string; - brief?: string; - criteriaSummary?: string; - }; }; const SYSTEM_INSTRUCTION = [ @@ -121,18 +114,6 @@ function workflowPreamble(ctx: SummaryContext | undefined): string { `Active workflow: /${wf.name}${step}\nThis session is mid-workflow — preserve everything needed to resume it.`, ); } - const goal = ctx?.goal; - if (goal !== undefined && goal.condition.length > 0) { - const brief = goal.brief !== undefined && goal.brief.length > 0 ? goal.brief : goal.condition; - const criteria = - goal.criteriaSummary !== undefined && goal.criteriaSummary.length > 0 - ? `\nAcceptance criteria: ${goal.criteriaSummary}` - : ""; - parts.push( - `Active goal (${goal.status}): ${brief}${criteria}\n` + - "The agent must keep working until every acceptance criterion is done.", - ); - } if (parts.length === 0) return ""; return `${parts.join("\n\n")}\n\n`; } diff --git a/src/tui-opentui/README.md b/src/tui-opentui/README.md index db24a23a5..c1e23c929 100644 --- a/src/tui-opentui/README.md +++ b/src/tui-opentui/README.md @@ -9,19 +9,18 @@ Platform kit for the OpenTUI shell on the `migration/opentui-tui` branch. Pure T | `geometry/` | Pure zone registry + `resolveGeometry` | | `focus/` | Focus tree + scroll lease state machine | | `list-viewport.ts` | Pure list windowing kit | -| `chrome-state.ts` | Live goal/task/agents → `setChromeZones` lines | +| `chrome-state.ts` | Live task/agents → `setChromeZones` lines | | `shell.ts` | App shell frame (`createAppShell`) — OpenTUI **core class** API | ## Live chrome zones -Product host owns goal / work / subagent state and pushes snapshots (event or poll): +Product host owns task / subagent state and pushes snapshots (event or poll): ```ts import { formatChromeZones, setChromeZones } from "./index" -// On goal/task/subagent change: +// On task/subagent change: setChromeZones(shell, formatChromeZones({ - goal: { title: "ship cutover", phase: "implementing", status: "active" }, task: { title: "wire host", status: "doing", remaining: 1 }, agents: [{ agentId: "explore", description: "map callers", status: "running" }], })) diff --git a/src/tui-opentui/chrome-state.test.ts b/src/tui-opentui/chrome-state.test.ts index 1345f1e59..9bb230372 100644 --- a/src/tui-opentui/chrome-state.test.ts +++ b/src/tui-opentui/chrome-state.test.ts @@ -4,7 +4,6 @@ import { chromeFromSession, formatAgentsPanel, formatChromeZones, - formatGoalLine, formatTaskLine, type ChromeLiveState, } from "./chrome-state" @@ -14,53 +13,29 @@ const NOW = 1_000_000 describe("formatChromeZones", () => { test("empty state hides all zones", () => { expect(formatChromeZones({})).toEqual({ - goal: null, task: null, agents: null, }) expect( formatChromeZones({ - goal: null, task: null, agents: null, observe: null, }), ).toEqual({ - goal: null, task: null, agents: null, }) }) - test("partial: goal only", () => { - const out = formatChromeZones({ - goal: { - title: "Wave 7 residual surfaces", - phase: "implementing", - status: "active", - progress: { done: 1, total: 3 }, - }, - }) - expect(out.goal).toBe("goal: impl · 1/3 · Wave 7 residual surfaces") - expect(out.task).toBeNull() - expect(out.agents).toBeNull() - }) - test("partial: task string only", () => { const out = formatChromeZones({ task: "cutover readiness" }) - expect(out.goal).toBeNull() expect(out.task).toBe("task: cutover readiness") expect(out.agents).toBeNull() }) - test("full state formats all three zones", () => { + test("full state formats both zones", () => { const state: ChromeLiveState = { - goal: { - title: "1:1 OpenTUI cutover", - phase: "reviewing", - status: "active", - progress: { done: 2, total: 4 }, - }, task: { title: "chrome live helper", status: "doing", @@ -83,7 +58,6 @@ describe("formatChromeZones", () => { ], } const out = formatChromeZones(state, NOW) - expect(out.goal).toBe("goal: review · 2/4 · 1:1 OpenTUI cutover") expect(out.task).toBe("task: chrome live helper (+2)") expect(out.agents).toEqual([ { @@ -121,44 +95,6 @@ describe("formatChromeZones", () => { }) }) -describe("formatGoalLine", () => { - test("null / empty / inactive hide", () => { - expect(formatGoalLine(null)).toBeNull() - expect(formatGoalLine(undefined)).toBeNull() - expect(formatGoalLine({ title: " " })).toBeNull() - expect( - formatGoalLine({ title: "x", status: "inactive" }), - ).toBeNull() - expect(formatGoalLine({ title: "x", status: "cleared" })).toBeNull() - }) - - test("achieved freezes completed label", () => { - expect( - formatGoalLine({ - title: "ship cutover", - status: "achieved", - phase: "completed", - }), - ).toBe("goal: completed · ship cutover") - }) - - test("paused surfaces status", () => { - expect( - formatGoalLine({ - title: "ship cutover", - phase: "implementing", - status: "paused", - }), - ).toBe("goal: impl · paused · ship cutover") - }) - - test("title only", () => { - expect(formatGoalLine({ title: "solo brief" })).toBe( - "goal: solo brief", - ) - }) -}) - describe("formatTaskLine", () => { test("string / empty", () => { expect(formatTaskLine(null)).toBeNull() @@ -334,18 +270,8 @@ describe("formatAgentsPanel", () => { }) describe("chromeFromSession", () => { - test("maps goal governor / tasks / agents loosely", () => { + test("maps tasks / agents loosely", () => { const state = chromeFromSession({ - goal: { - brief: "ship cutover", - status: "active", - phase: "implementing", - criteria: [ - { status: "done" }, - { status: "todo" }, - { status: "cancelled" }, - ], - }, tasks: [ { title: "wire catalogs", status: "doing" }, { title: "export index", status: "todo" }, @@ -360,12 +286,6 @@ describe("chromeFromSession", () => { ], }) - expect(state.goal).toEqual({ - title: "ship cutover", - status: "active", - phase: "implementing", - progress: { done: 1, total: 2 }, - }) expect(state.task).toEqual([ { title: "wire catalogs", status: "doing" }, { title: "export index", status: "todo" }, @@ -380,16 +300,14 @@ describe("chromeFromSession", () => { ]) const zones = formatChromeZones(state, NOW) - expect(zones.goal).toBe("goal: impl · 1/2 · ship cutover") expect(zones.task).toBe("task: wire catalogs (+1)") expect(zones.agents).toEqual([ { label: "explore: map callers", tail: " · grep", stalled: false }, ]) }) - test("falls back agent id and goal condition; empty bags hide", () => { + test("falls back agent id; empty bags hide", () => { const state = chromeFromSession({ - goal: { condition: "all tests green", status: "active" }, tasks: [], agents: [ { @@ -399,17 +317,14 @@ describe("chromeFromSession", () => { }, ], }) - expect(state.goal?.title).toBe("all tests green") expect(state.task).toBeNull() expect(state.agents?.[0]?.agentId).toBe("sess-1") }) - test("null goal clears; observe passes through", () => { + test("observe passes through", () => { const state = chromeFromSession({ - goal: null, observe: { agentId: "explore", description: "watch" }, }) - expect(state.goal).toBeNull() expect(state.observe).toEqual({ agentId: "explore", description: "watch", diff --git a/src/tui-opentui/chrome-state.ts b/src/tui-opentui/chrome-state.ts index d2b2c0d8b..3f598fef0 100644 --- a/src/tui-opentui/chrome-state.ts +++ b/src/tui-opentui/chrome-state.ts @@ -1,14 +1,14 @@ /** * Live chrome zone formatter for setChromeZones. * - * Pure: structured session state → one-line goal / task / agents strings. + * Pure: structured session state → one-line task / agents strings. * Heights stay with geometry (zones max 1 row each); this module never * invents row budgets. * * ## Product host push contract * - * The shell does not poll. The product host owns live state (goal governor, - * task list, subagent store) and pushes a full snapshot whenever any of those + * The shell does not poll. The product host owns live state (task list, + * subagent store) and pushes a full snapshot whenever any of those * change: * * setChromeZones(shell, formatChromeZones(snapshot)) @@ -39,21 +39,6 @@ export type ChromeAgentSession = { readonly lastActivityAt?: number } -/** - * Goal chrome input — title + optional lifecycle fields. - * Inactive / cleared / empty title → zone hidden. - */ -export type ChromeGoalState = { - /** Brief or condition text shown after the phase/status prefix. */ - readonly title: string - /** Autonomy status: active, paused, achieved, blocked, … */ - readonly status?: string - /** Lifecycle phase: planning | implementing | reviewing | completed */ - readonly phase?: string - /** Acceptance progress when criteria exist. */ - readonly progress?: { readonly done: number; readonly total: number } | null -} - /** * Task / Work chrome input. * Empty title or all-terminal lists → zone hidden when formatting from tasks[]. @@ -77,7 +62,6 @@ export type ChromeTaskRow = { * Prefer pushing a complete snapshot on every update. */ export type ChromeLiveState = { - readonly goal?: ChromeGoalState | null /** * Compact task line: string shorthand, structured current task, or a list * of work rows (formatter picks the active item like Ink TaskView compact). @@ -110,19 +94,11 @@ export type AgentPanelRow = { /** Always-populated result for setChromeZones (null = hide zone). */ export type FormattedChromeZones = { - readonly goal: string | null readonly task: string | null /** One row per rendered agents-panel line (null = hide zone, zero rows). */ readonly agents: readonly AgentPanelRow[] | null } -const PHASE_SHORT: Record = { - planning: "plan", - implementing: "impl", - reviewing: "review", - completed: "done", -} - /** * Format structured live state into chrome zone lines for setChromeZones. * @@ -134,7 +110,6 @@ export function formatChromeZones( nowMs: number = Date.now(), ): FormattedChromeZones { return { - goal: formatGoalLine(state.goal), task: formatTaskLine(state.task), agents: formatAgentsPanel(state.agents, state.observe, nowMs), } @@ -149,43 +124,6 @@ export function chromeZonesContent(state: ChromeLiveState): ChromeZoneContent { return formatChromeZones(state) } -export function formatGoalLine( - goal: ChromeGoalState | null | undefined, -): string | null { - if (goal === null || goal === undefined) return null - const title = goal.title.trim() - if (title.length === 0) return null - - const status = goal.status?.trim().toLowerCase() - if (status === "inactive" || status === "cleared") return null - - if (status === "achieved" || goal.phase === "completed") { - return compactLine("goal", `completed · ${title}`) - } - - const parts: string[] = [] - if (goal.phase !== undefined && goal.phase.length > 0) { - parts.push(PHASE_SHORT[goal.phase] ?? goal.phase) - } - const progress = goal.progress - if ( - progress !== undefined && - progress !== null && - progress.total > 0 - ) { - parts.push(`${progress.done}/${progress.total}`) - } - if ( - status !== undefined && - status.length > 0 && - status !== "active" - ) { - parts.push(status) - } - parts.push(title) - return compactLine("goal", parts.join(" · ")) -} - export function formatTaskLine( task: ChromeTaskState | ChromeTaskRow[] | string | null | undefined, ): string | null { @@ -348,21 +286,6 @@ function compactLine(prefix: string, body: string): string { // Session-shaped → ChromeLiveState (loose mapping for product host push) // --------------------------------------------------------------------------- -/** - * Loose goal governor snapshot fields. Accepts GoalSnapshot-like objects - * without importing agent/goal (brief/condition/criteria/status/phase). - */ -export type ChromeSessionGoal = { - readonly brief?: string - readonly condition?: string - readonly title?: string - readonly status?: string - readonly phase?: string - readonly criteria?: readonly { - readonly status: string - }[] -} - /** manage_tasks / Task-shaped row (title + status). */ export type ChromeSessionTask = { readonly title: string @@ -387,66 +310,30 @@ export type ChromeSessionAgent = { * Live session bags the product host already holds. Missing fields omit zones. */ export type ChromeSessionInput = { - readonly goal?: ChromeSessionGoal | null readonly tasks?: readonly ChromeSessionTask[] | null readonly agents?: readonly ChromeSessionAgent[] | null readonly observe?: ChromeLiveState["observe"] } /** - * Map real session shapes (goal governor / tasks / subagent store) into - * ChromeLiveState for `formatChromeZones` / `setChrome`. + * Map real session shapes (tasks / subagent store) into ChromeLiveState for + * `formatChromeZones` / `setChrome`. * * Pure and store-agnostic — pass whatever the host already has; loose fields * are ignored when absent. */ export function chromeFromSession(input: ChromeSessionInput): ChromeLiveState { - const goal = mapSessionGoal(input.goal) const task = mapSessionTasks(input.tasks) const agents = mapSessionAgents(input.agents) const observe = input.observe ?? null return { - ...(goal !== undefined ? { goal } : {}), ...(task !== undefined ? { task } : {}), ...(agents !== undefined ? { agents } : {}), ...(observe !== null && observe !== undefined ? { observe } : {}), } } -function mapSessionGoal( - goal: ChromeSessionGoal | null | undefined, -): ChromeGoalState | null | undefined { - if (goal === undefined) return undefined - if (goal === null) return null - - const title = ( - goal.title ?? - goal.brief ?? - goal.condition ?? - "" - ).trim() - if (title.length === 0) return null - - const progress = progressFromCriteria(goal.criteria) - return { - title, - ...(goal.status !== undefined ? { status: goal.status } : {}), - ...(goal.phase !== undefined ? { phase: goal.phase } : {}), - ...(progress !== undefined ? { progress } : {}), - } -} - -function progressFromCriteria( - criteria: ChromeSessionGoal["criteria"], -): { done: number; total: number } | undefined { - if (criteria === undefined || criteria.length === 0) return undefined - const countable = criteria.filter((c) => c.status !== "cancelled") - if (countable.length === 0) return undefined - const done = countable.filter((c) => c.status === "done").length - return { done, total: countable.length } -} - function mapSessionTasks( tasks: readonly ChromeSessionTask[] | null | undefined, ): ChromeTaskRow[] | null | undefined { diff --git a/src/tui-opentui/demo.ts b/src/tui-opentui/demo.ts index a06253415..9eb88aa8f 100644 --- a/src/tui-opentui/demo.ts +++ b/src/tui-opentui/demo.ts @@ -10,7 +10,7 @@ * Ctrl+O=palette · Alt+C=copy * p=permissions · o=operator · m=model * s=settings · h=help · l=plugins · e=resume · n=mentions · v=observe - * g/t/a=toggle goal/task/agents chrome + * t/a=toggle task/agents chrome * f=replay fixture · r=busy · q=quit when idle */ import { createCliRenderer, type KeyEvent } from "@opentui/core" @@ -267,27 +267,6 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { return } - if ( - key.name === "g" && - !key.ctrl && - !key.meta && - shell.prompt.value.length === 0 - ) { - const on = shell.layout.heights.goal > 0 - setChromeZones(shell, { - goal: on - ? null - : formatChromeZones({ - goal: { - title: "Wave 7 residual surfaces + observe", - phase: "implementing", - status: "active", - }, - }).goal, - }) - return - } - if ( key.name === "t" && !key.ctrl && diff --git a/src/tui-opentui/gate-wire.test.ts b/src/tui-opentui/gate-wire.test.ts index 266e1e623..7ce387d5d 100644 --- a/src/tui-opentui/gate-wire.test.ts +++ b/src/tui-opentui/gate-wire.test.ts @@ -831,7 +831,7 @@ describe("permission.gate auto-deny", () => { resolved = outcome }, timeoutMs: 5, - timeoutMessage: "goal mode: no answer in time", + timeoutMessage: "auto-deny: no answer in time", }) expect(shell.overlayKind).toBe("permissions") @@ -839,7 +839,7 @@ describe("permission.gate auto-deny", () => { expect(resolved).toEqual({ allow: false, - message: "goal mode: no answer in time", + message: "auto-deny: no answer in time", }) expect(shell.overlayList).toBeNull() } finally { @@ -985,7 +985,7 @@ describe("operator.gate auto-cancel", () => { resolved = result }, timeoutMs: 5, - timeoutMessage: "goal mode: no answer in time", + timeoutMessage: "auto-cancel: no answer in time", }) expect(shell.overlayKind).toBe("operator") diff --git a/src/tui-opentui/gate-wire.ts b/src/tui-opentui/gate-wire.ts index 07ebc2386..ee409cdfd 100644 --- a/src/tui-opentui/gate-wire.ts +++ b/src/tui-opentui/gate-wire.ts @@ -487,13 +487,13 @@ export function wireGates( } // Watchdog abort (tool budget expired / parent run cancelled) and the - // goal-mode timeout both race an operator who may never answer — each + // auto-deny timeout both race an operator who may never answer — each // must resolve the gate itself rather than leave the overlay (or the // queued open) parked forever. Whichever fires first settles the queue // entry, which is itself the single-resolve guard, so the other side is // simply a no-op once it runs. // - // The goal-mode timeout is display-dependent and arms inside `open` + // The auto-deny timeout is display-dependent and arms inside `open` // (below), not here: a request sitting behind others in `pending` must // not burn its timeout while the operator has never seen it. Abort is not // display-dependent — it reflects the tool having already finished or @@ -540,7 +540,7 @@ export function wireGates( // whether the host has since moved on to a newer one. let openedGeneration: number | undefined - // Mirrors the permission gate: watchdog abort and the goal-mode timeout + // Mirrors the permission gate: watchdog abort and the auto-deny timeout // both race an operator who may never answer, and unlike the permission // path this gate previously had no safety net at all — a queued question // behind a stuck overlay hung the run forever. The timeout is diff --git a/src/tui-opentui/geometry.test.ts b/src/tui-opentui/geometry.test.ts index 0c4275b29..66f52b485 100644 --- a/src/tui-opentui/geometry.test.ts +++ b/src/tui-opentui/geometry.test.ts @@ -28,7 +28,6 @@ describe("zone registry", () => { "progress_divider", "notice", "prompt", - "goal", "task", "agents", "plugin_banner", @@ -49,7 +48,6 @@ describe("zone registry", () => { expect(ZONE_REGISTRY.prompt.min).toBe(PROMPT_BASE_ROWS); expect(ZONE_REGISTRY.notice.alwaysOn).toBe(false); expect(ZONE_REGISTRY.progress.idleDefault).toBe(0); - expect(ZONE_REGISTRY.goal.idleDefault).toBe(0); }); test("collapse order cuts temporary banners first and never cuts the prompt below base", () => { @@ -93,7 +91,7 @@ describe("resolveGeometry — 80×24 idle floor", () => { test("transcriptHeight matches regions.transcript.height", () => { const layout = idle80x24({ - visibility: { progress: true, goal: true }, + visibility: { progress: true }, }); expect(layout.regions.transcript?.height).toBe(layout.transcriptHeight); }); @@ -152,7 +150,6 @@ describe("resolveGeometry — collapse rules", () => { visibility: { progress: 2, progressDivider: true, - goal: true, task: true, agents: true, pluginBanner: true, @@ -175,7 +172,6 @@ describe("resolveGeometry — collapse rules", () => { visibility: { progress: 2, progressDivider: true, - goal: true, task: true, agents: true, pluginBanner: true, @@ -196,7 +192,6 @@ describe("resolveGeometry — collapse rules", () => { visibility: { progress: 2, progressDivider: true, - goal: true, task: true, agents: true, commandBanner: 2, @@ -213,7 +208,6 @@ describe("resolveGeometry — collapse rules", () => { visibility: { progress: 2, progressDivider: true, - goal: true, task: true, agents: true, pluginBanner: true, @@ -223,11 +217,9 @@ describe("resolveGeometry — collapse rules", () => { }); const noticeIdx = layout.collapsed.indexOf("notice"); if (noticeIdx >= 0) { - const goalIdx = layout.collapsed.indexOf("goal"); const cmdIdx = layout.collapsed.indexOf("command_banner"); expect(cmdIdx).toBeGreaterThanOrEqual(0); expect(cmdIdx).toBeLessThan(noticeIdx); - if (goalIdx >= 0) expect(goalIdx).toBeLessThan(noticeIdx); } }); }); @@ -249,7 +241,6 @@ describe("resolveGeometry — prompt growth", () => { visibility: { progress: 2, progressDivider: true, - goal: true, task: true, agents: true, commandBanner: 2, diff --git a/src/tui-opentui/geometry/resolve.ts b/src/tui-opentui/geometry/resolve.ts index ed7539dfe..b04c6dcd0 100644 --- a/src/tui-opentui/geometry/resolve.ts +++ b/src/tui-opentui/geometry/resolve.ts @@ -46,7 +46,6 @@ export type ZoneVisibility = { readonly progress?: boolean | 1 | 2; /** Progress divider (0–1). Default on when progress is shown. */ readonly progressDivider?: boolean; - readonly goal?: boolean; readonly task?: boolean; /** Agents panel: false/omit = 0 rows; true = 1 row; or an exact row count (bounded by the zone max). */ readonly agents?: boolean | number; @@ -140,7 +139,6 @@ export function desiredHeights(input: GeometryInput): MutableHeights { progress_divider: progressDivider, notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault, prompt: promptRows, - goal: vis.goal ? 1 : 0, task: vis.task ? 1 : 0, agents: clamp(boolOrRows(vis.agents, 1), 0, ZONE_REGISTRY.agents.max), plugin_banner: vis.pluginBanner ? 1 : 0, @@ -304,7 +302,6 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { // Full-shell modal: hide transcript and bottom chrome; overlay owns residual. if (mode === "full_shell") { heights.transcript = 0; - heights.goal = 0; heights.task = 0; heights.agents = 0; heights.plugin_banner = 0; diff --git a/src/tui-opentui/geometry/zones.ts b/src/tui-opentui/geometry/zones.ts index 675cf8bb7..9b7721968 100644 --- a/src/tui-opentui/geometry/zones.ts +++ b/src/tui-opentui/geometry/zones.ts @@ -8,7 +8,6 @@ export const ZONE_IDS = [ "progress_divider", "notice", "prompt", - "goal", "task", "agents", "plugin_banner", @@ -68,7 +67,6 @@ export const ZONE_REGISTRY: { readonly [K in ZoneId]: ZoneDeclaration } = { idleDefault: 5, alwaysOn: true, }, - goal: { id: "goal", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, task: { id: "task", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, // One row per running agent (bounded by AGENTS_PANEL_MAX_VISIBLE) plus an // optional trailing "+N more" row. @@ -160,7 +158,6 @@ export const COLLAPSE_ORDER = [ "command_banner", "settings_notice", "plugin_banner", - "goal", "task", "agents", "progress", @@ -175,7 +172,6 @@ export const COLLAPSE_ORDER = [ * Transcript is residual in the middle; the prompt box is the last thing painted. */ export const PAINT_ORDER = [ - "goal", "task", "agents", "transcript", diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index 75c8d536d..c44967776 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -655,7 +655,7 @@ describe("the runner host does not shadow the prompt bindings the catalog claims onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) diff --git a/src/tui-opentui/palette.test.ts b/src/tui-opentui/palette.test.ts index 75f663d46..922d59dcb 100644 --- a/src/tui-opentui/palette.test.ts +++ b/src/tui-opentui/palette.test.ts @@ -87,7 +87,7 @@ describe("palette row columns", () => { test("shortcuts come from the shell keybinding table", () => { const help = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "help") expect(paletteRowColumns(help!, shortcutForPaletteId).shortcut).toBe("?") - const toggle = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "toggle_goal") + const toggle = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "toggle_task") expect(paletteRowColumns(toggle!, shortcutForPaletteId).shortcut).toBe("") }) }) diff --git a/src/tui-opentui/palette.ts b/src/tui-opentui/palette.ts index 5ffef111a..b82db4bd7 100644 --- a/src/tui-opentui/palette.ts +++ b/src/tui-opentui/palette.ts @@ -15,7 +15,6 @@ export type PaletteActionId = | "permissions" | "operator" | "model_picker" - | "toggle_goal" | "toggle_task" | "toggle_agents" | "copy_active" @@ -28,7 +27,6 @@ const RESIDUAL_ACTION_IDS = new Set([ "permissions", "operator", "model_picker", - "toggle_goal", "toggle_task", "toggle_agents", "copy_active", @@ -88,12 +86,6 @@ export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [ keywords: ["model", "provider", "anthropic", "openai"], dispatch: "residual", }, - { - id: "toggle_goal", - label: "Toggle goal chrome", - keywords: ["goal", "chrome", "zone"], - dispatch: "residual", - }, { id: "toggle_task", label: "Toggle task chrome", diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index fb07d70aa..8e11181f1 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -373,7 +373,7 @@ export async function mountProductHost( const subAgentTools = new Map() const paintChromeZones = (): void => { if (chromeState === null) { - setChromeZones(shell, { goal: null, task: null, agents: null }) + setChromeZones(shell, { task: null, agents: null }) return } setChromeZones( @@ -406,8 +406,8 @@ export async function mountProductHost( // running agent existing: paintChromeZones() re-enters setChromeZones, // which already calls paintChrome(shell) on its own unchanged-zone // path, so calling it unconditionally would repaint chrome twice a - // tick for the common case (goal/task only, no agents) that has - // nothing time-based to refresh. + // tick for the common case (task only, no agents) that has nothing + // time-based to refresh. if (chromeState !== null && (chromeState.agents ?? []).some((a) => a.status === "running")) { paintChromeZones() } diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index 741d6a953..fc056bc15 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -134,7 +134,7 @@ describe("mountRunnerHost command surfaces", () => { onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, surfaces: { @@ -184,7 +184,7 @@ describe("mountRunnerHost model picker", () => { ], commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -212,7 +212,7 @@ describe("mountRunnerHost model picker", () => { onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -244,7 +244,7 @@ describe("mountRunnerHost model picker", () => { ], commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -279,7 +279,7 @@ describe("mountRunnerHost model picker", () => { onFavoriteToggle: (id) => toggled.push(id), commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) @@ -310,7 +310,7 @@ describe("bottom border cost run", () => { onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, readCostSummary: () => fakeCostSummary(), @@ -337,7 +337,7 @@ describe("bottom border cost run", () => { onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, readCostSummary: () => fakeCostSummary(), @@ -375,7 +375,7 @@ describe("mountRunnerHost quit key", () => { onModelSelect: () => {}, commands: [], onCommand: () => {}, - chrome: () => ({ goal: null, agents: [] }), + chrome: () => ({ agents: [] }), subAgentSessions: () => [], createRenderer: async () => harness.renderer, }) diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index d4f12b42e..e50fc7e5f 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -257,7 +257,7 @@ describe("attachSessionBridge", () => { }) test("run and the phase ramp both return to idle after a tool-less inference.done, with no connector.reply", async () => { - // Regression: a goal-governor / workflow cycle that keeps self-continuing + // Regression: a self-continuing workflow cycle // may never emit connector.reply, the only other event that clears // `run` and the turn's `isProcessing`. Without this, every future Enter // resolves to "queue" (busy is sticky) and, once the workflow stops diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index d133b9905..4376a116c 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -550,9 +550,7 @@ export type AppShell = { readonly topPad: BoxRenderable /** Blank row below the prompt box (0 on short terminals). */ readonly bottomPad: BoxRenderable - /** Optional chrome zones (constitution goal/task/agents). */ - readonly goalBox: BoxRenderable - readonly goalText: TextRenderable + /** Optional chrome zones (constitution task/agents). */ readonly taskBox: BoxRenderable readonly taskText: TextRenderable /** One row per rendered agents-panel line; rebuilt whenever the line count changes. */ @@ -1612,10 +1610,6 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.root.paddingLeft = layout.sideMargin shell.root.paddingRight = layout.sideMargin - const goalH = Math.max(0, h.goal) - shell.goalBox.height = goalH > 0 ? goalH : 1 - shell.goalBox.visible = goalH > 0 - const taskH = Math.max(0, h.task) shell.taskBox.height = taskH > 0 ? taskH : 1 shell.taskBox.visible = taskH > 0 @@ -1691,7 +1685,7 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // Rows the flow spends before the prompt box — where a floated host's bottom // edge has to land, since the landing's box sits mid-screen rather than at // the foot and covering it would hide the thing the operator types into. - const promptTop = padH + goalH + taskH + agentsH + transcriptBody + const promptTop = padH + taskH + agentsH + transcriptBody const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) : overlayH floatOverlayHost(shell, floating, Math.max(0, promptTop - hostH)) shell.overlayHost.height = hostH > 0 ? hostH : 1 @@ -1885,7 +1879,6 @@ type ShellInternals = { landingNowMs: number /** Chrome text content (empty = zone off). */ chrome: { - goal: string task: string /** Agents panel rows (empty array = zone off), one row per rendered line. */ agents: readonly AgentPanelRow[] @@ -4089,19 +4082,6 @@ export function runPaletteAction( }) return } - case "toggle_goal": { - const bag = internals.get(shell) - const on = (bag?.chrome.goal.length ?? 0) > 0 - setChromeZones(shell, { - goal: on ? null : "goal: Wave 6 palette + long-log + chrome", - }) - appendStreamRow(shell, { - role: "system", - text: on ? "goal banner off" : "goal banner on", - meta: "goal", - }) - return - } case "toggle_task": { const bag = internals.get(shell) const on = (bag?.chrome.task.length ?? 0) > 0 @@ -4163,7 +4143,6 @@ export function runPaletteAction( } export type ChromeZoneContent = { - readonly goal?: string | null readonly task?: string | null /** One row per agents-panel line. Null/empty = hide the zone. */ readonly agents?: readonly AgentPanelRow[] | null @@ -4218,7 +4197,7 @@ function renderAgentsRows( } /** - * Set agents/goal/task chrome zone content (null/empty = hide zone). + * Set agents/task chrome zone content (null/empty = hide zone). * Heights come from geometry resolve — never guessed. */ export function setChromeZones( @@ -4228,9 +4207,6 @@ export function setChromeZones( const bag = internals.get(shell) if (!bag) return - if (content.goal !== undefined) { - bag.chrome.goal = content.goal ?? "" - } if (content.task !== undefined) { bag.chrome.task = content.task ?? "" } @@ -4251,14 +4227,12 @@ export function setChromeZones( bag.chrome.agents = next } - const goalOn = bag.chrome.goal.length > 0 const taskOn = bag.chrome.task.length > 0 const agentsRowCount = bag.chrome.agents.length - shell.goalText.content = goalOn ? ` ${bag.chrome.goal}` : "" shell.taskText.content = taskOn ? ` ${bag.chrome.task}` : "" // Rebuilding N TextRenderable children is real node churn; skip it unless - // the panel's actual lines changed (not every goal/task/agents push carries + // the panel's actual lines changed (not every task/agents push carries // new agent data). if (agentsChanged) { renderAgentsRows(shell, bag.chrome.agents, shell.layout.contentWidth) @@ -4268,7 +4242,6 @@ export function setChromeZones( // row budget; retitling a zone whose row count is unchanged must not // re-resolve and re-apply the whole layout. if ( - goalOn === bag.visibility.goal && taskOn === bag.visibility.task && agentsRowCount === bag.visibility.agents ) { @@ -4279,7 +4252,6 @@ export function setChromeZones( relayout(shell, { visibility: { ...bag.visibility, - goal: goalOn, task: taskOn, agents: agentsRowCount, }, @@ -4941,21 +4913,6 @@ export function createAppShell( }) // Optional chrome zones (off by default; setChromeZones turns them on). - const goalBox = new BoxRenderable(ctx, { - id: "shell-goal", - width: "100%", - height: 1, - flexShrink: 0, - backgroundColor: UI.ground, - visible: false, - }) - const goalText = new TextRenderable(ctx, { - id: "shell-goal-text", - content: "", - fg: UI.text, - }) - goalBox.add(goalText) - const taskBox = new BoxRenderable(ctx, { id: "shell-task", width: "100%", @@ -5112,7 +5069,6 @@ export function createAppShell( promptBox.add(promptBottomRule) root.add(topPad) - root.add(goalBox) root.add(taskBox) root.add(agentsBox) root.add(transcript) @@ -5618,8 +5574,6 @@ export function createAppShell( root, topPad, bottomPad, - goalBox, - goalText, taskBox, taskText, agentsBox, @@ -5719,7 +5673,7 @@ export function createAppShell( landingSuggestionsVisible: true, landingAnimating: false, landingNowMs: 0, - chrome: { goal: "", task: "", agents: [] }, + chrome: { task: "", agents: [] }, }) transcriptSpacers.set(shell, transcriptSpacer) if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt) diff --git a/src/tui-opentui/turn-state.test.ts b/src/tui-opentui/turn-state.test.ts index bbc93cfca..41c26033d 100644 --- a/src/tui-opentui/turn-state.test.ts +++ b/src/tui-opentui/turn-state.test.ts @@ -173,8 +173,8 @@ describe("turnStateFromEvent", () => { }) test("inference.done with no active tool calls settles the turn", () => { - // Regression for CL-5563/CL-5570: a workflow/goal-governor cycle that - // keeps self-continuing may never emit connector.reply, the usual + // Regression for CL-5563/CL-5570: a self-continuing workflow cycle + // may never emit connector.reply, the usual // terminator. Without settling here too, isProcessing (and the "working" // ramp it drives) stays true forever once nothing else arrives. const s = fold([ diff --git a/src/tui-opentui/turn-state.ts b/src/tui-opentui/turn-state.ts index bcca3f0dd..c08193ebc 100644 --- a/src/tui-opentui/turn-state.ts +++ b/src/tui-opentui/turn-state.ts @@ -643,8 +643,8 @@ export function turnStateFromEvent( /** * A cycle with no active tool calls left is also a turn's real * terminator: `connector.reply` (below) is the usual signal, but a - * workflow/goal-governor cycle that keeps self-continuing may never - * emit one, and `reactor.done` fires once at shutdown, never between + * self-continuing workflow cycle may never emit one, and `reactor.done` + * fires once at shutdown, never between * turns. Without settling here, the phase line stays hot ("working") * forever once nothing more arrives. A cycle that just requested tools * only ends here, not the turn — those calls are already reflected in diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index ddf9fcf8e..379d8558d 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -275,7 +275,7 @@ describe("Wave 6: long-log windowing", () => { }) describe("Wave 6: chrome zones", () => { - test("goal / task / agents measured via geometry (not guessed)", async () => { + test("task / agents measured via geometry (not guessed)", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -283,21 +283,17 @@ describe("Wave 6: chrome zones", () => { wireKeys: false, }) try { - expect(shell.layout.heights.goal).toBe(0) expect(shell.layout.heights.task).toBe(0) expect(shell.layout.heights.agents).toBe(0) - expect(shell.goalBox.visible).toBe(false) + expect(shell.taskBox.visible).toBe(false) setChromeZones(shell, { - goal: "goal: Wave 6", task: "task: chrome zones", agents: [{ label: "explore: map callers", tail: "", stalled: false }], }) - expect(shell.layout.heights.goal).toBe(1) expect(shell.layout.heights.task).toBe(1) expect(shell.layout.heights.agents).toBe(1) - expect(shell.goalBox.visible).toBe(true) expect(shell.taskBox.visible).toBe(true) expect(shell.agentsBox.visible).toBe(true) // Transcript still holds constitution floor when possible @@ -305,13 +301,12 @@ describe("Wave 6: chrome zones", () => { await h.renderOnce() const frame = h.captureCharFrame() - expect(frame).toContain("goal: Wave 6") expect(frame).toContain("task: chrome zones") expect(frame).toContain("explore: map callers") - setChromeZones(shell, { goal: null, task: null, agents: null }) - expect(shell.layout.heights.goal).toBe(0) - expect(shell.goalBox.visible).toBe(false) + setChromeZones(shell, { task: null, agents: null }) + expect(shell.layout.heights.task).toBe(0) + expect(shell.taskBox.visible).toBe(false) expect(shell.layout.transcriptHeight).toBeGreaterThanOrEqual( IDLE_TRANSCRIPT_FLOOR, ) @@ -337,8 +332,8 @@ describe("Wave 6: chrome zones", () => { const rowsBefore = [...shell.agentsBox.getChildren()] expect(rowsBefore).toHaveLength(1) - // An unrelated goal push must not touch the agents rows. - setChromeZones(shell, { goal: "goal: unrelated" }) + // An unrelated task push must not touch the agents rows. + setChromeZones(shell, { task: "task: unrelated" }) expect([...shell.agentsBox.getChildren()]).toEqual(rowsBefore) // Pushing the exact same agent lines again must not rebuild either. diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index 76138cb09..5607718bf 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -13,7 +13,7 @@ describe("session command registry setup", () => { expect(names).toContain("model"); expect(names).toContain("settings"); expect(names).toContain("clear"); - expect(getCommand("goal")).toBeDefined(); + expect(getCommand("cost")).toBeDefined(); }); test("applies hidden commands from settings", () => { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 4bad7a493..723f61933 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -1,5 +1,4 @@ import { registerCommand } from "./registry.js"; -import { formatGoalStatus, type GoalSetOpts } from "../../agent/goal.js"; import { formatCostCommandOutput } from "../../cost/cost-summary.js"; import { formatStartupChangelog, @@ -7,74 +6,6 @@ import { resolveChangelogPath, } from "../../changelog/index.js"; -const GOAL_CLEAR_ALIASES = new Set(["clear", "stop", "off", "reset", "none", "cancel"]); - -/** - * Parse /goal args. - * - bare → status - * - pause | resume | clear(+aliases) - * - optional leading turn budget as a bare integer: `/goal 25 all tests pass` - * - optional `--tokens N` / `--replace` flags (anywhere before the condition) - * - remaining text is the condition - * - * `--turns N` is still accepted as a quiet alias for the leading integer form. - */ -export function parseGoalArgs(raw: string): { - sub?: "pause" | "resume" | "clear" | "status"; - condition?: string; - opts?: GoalSetOpts; - replace?: boolean; -} { - const trimmed = raw.trim(); - if (trimmed.length === 0) return { sub: "status" }; - - const first = trimmed.split(/\s+/, 1)[0]?.toLowerCase() ?? ""; - if (first === "pause") return { sub: "pause" }; - if (first === "resume") return { sub: "resume" }; - if (GOAL_CLEAR_ALIASES.has(first)) return { sub: "clear" }; - - let rest = trimmed; - const opts: GoalSetOpts = {}; - let replace = false; - - // Leading bare integer is the optional turn budget: /goal 25 - const leadingTurns = rest.match(/^(\d+)\s+/); - if (leadingTurns !== null) { - opts.turnBudget = Number(leadingTurns[1]); - rest = rest.slice(leadingTurns[0].length); - } - - // Optional flags: --tokens N, --replace, and legacy --turns N — any order. - for (;;) { - const turnsOrTokens = rest.match(/^--(turns|tokens)\s+(\d+)\s*/i); - if (turnsOrTokens !== null) { - const value = Number(turnsOrTokens[2]); - if (turnsOrTokens[1]!.toLowerCase() === "turns") opts.turnBudget = value; - else opts.tokenBudget = value; - rest = rest.slice(turnsOrTokens[0].length); - continue; - } - const replaceFlag = rest.match(/^--replace\s*/i); - if (replaceFlag !== null) { - replace = true; - rest = rest.slice(replaceFlag[0].length); - continue; - } - break; - } - const condition = rest.trim(); - if (condition.length === 0) return { sub: "status" }; - const result: { - sub?: "pause" | "resume" | "clear" | "status"; - condition?: string; - opts?: GoalSetOpts; - replace?: boolean; - } = { condition }; - if (Object.keys(opts).length > 0) result.opts = opts; - if (replace) result.replace = true; - return result; -} - /** * Register every built-in slash command. * @@ -220,74 +151,4 @@ export function registerBuiltInCommands(): void { }, }); - // Session-scoped goal: keep working until a verifiable condition is met. - // See docs/plans/v0.3-goal-mode.md. - registerCommand({ - name: "goal", - description: "Set a session goal brief; agent expands into an acceptance checklist", - // Claude-style free-form arg guidance. Leading turns are optional positional - // (`/goal 25 ship the feature`); omit for unlimited turns (default). - argumentHint: "[turns] ", - subcommands: [ - { name: "pause", description: "Stop auto-continue; keep the goal" }, - { name: "resume", description: "Re-arm auto-continue (extends finite turn budget if limited)" }, - { name: "clear", description: "Drop the goal" }, - { name: "status", description: "Show acceptance checklist and progress" }, - ], - - handler: (args, ctx) => { - const api = ctx.goal; - if (api === undefined) { - return { type: "message", text: "Goal mode is not available in this session." }; - } - const parsed = parseGoalArgs(args); - if (parsed.sub === "status") { - return { type: "message", text: formatGoalStatus(api.get()) }; - } - if (parsed.sub === "pause") { - const snap = api.pause(); - if (snap === null) return { type: "message", text: "No goal is set." }; - return { type: "message", text: `Goal paused.\n${formatGoalStatus(snap)}` }; - } - if (parsed.sub === "resume") { - const snap = api.resume(); - if (snap === null) { - return { type: "message", text: "No paused or budget-limited goal to resume." }; - } - api.kickoff?.(snap.brief || snap.condition, "resume"); - return { type: "message", text: `Goal resumed.\n${formatGoalStatus(snap)}` }; - } - if (parsed.sub === "clear") { - if (api.get() === null) return { type: "message", text: "No goal is set." }; - api.clear(); - return { type: "message", text: "Goal cleared." }; - } - const condition = parsed.condition ?? ""; - if (condition.length === 0) { - return { - type: "message", - text: "Usage: /goal [turns] | /goal pause | /goal resume | /goal clear | /goal status", - }; - } - const existing = api.get(); - if ( - existing !== null && - (existing.status === "active" || existing.status === "paused" || existing.status === "budget_limited") && - parsed.replace !== true - ) { - return { - type: "message", - text: - `A goal is already ${existing.status}:\n${formatGoalStatus(existing)}\n\n` + - `Clear it first (/goal clear) or replace with /goal --replace .`, - }; - } - api.set(condition, parsed.opts); - api.kickoff?.(condition, "set"); - // One-shot banner only — brief lives in GoalView chrome (multi-line here - // used to overflow chrome row accounting and collide with Work). - return { type: "message", text: "Goal set." }; - }, - }); - } diff --git a/src/tui/commands/goal.test.ts b/src/tui/commands/goal.test.ts deleted file mode 100644 index 1c2ee865d..000000000 --- a/src/tui/commands/goal.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { parseGoalArgs, registerBuiltInCommands } from "./built-in.js"; -import { getCommand } from "./registry.js"; -import type { CommandContext } from "./registry.js"; -import type { GoalSnapshot } from "../../agent/goal.js"; - -registerBuiltInCommands(); - -function snap(partial: Partial = {}): GoalSnapshot { - const condition = partial.condition ?? "tests green"; - const criteria = partial.criteria ?? []; - const status = partial.status ?? "active"; - return { - status, - phase: partial.phase ?? (criteria.length === 0 ? "planning" : "implementing"), - condition, - brief: partial.brief ?? condition, - criteria, - startedAt: partial.startedAt ?? Date.now() - 60_000, - turnBudget: partial.turnBudget ?? 0, - turnsUsed: partial.turnsUsed ?? 0, - mainTokens: partial.mainTokens ?? 0, - evalTokens: partial.evalTokens ?? 0, - consecutiveEvalFailures: partial.consecutiveEvalFailures ?? 0, - consecutiveEmptyYields: partial.consecutiveEmptyYields ?? 0, - ...(partial.tokenBudget !== undefined ? { tokenBudget: partial.tokenBudget } : {}), - ...(partial.lastReason !== undefined ? { lastReason: partial.lastReason } : {}), - }; -} - -describe("parseGoalArgs", () => { - test("empty is status", () => { - expect(parseGoalArgs("")).toEqual({ sub: "status" }); - }); - - test("pause resume clear aliases", () => { - expect(parseGoalArgs("pause").sub).toBe("pause"); - expect(parseGoalArgs("resume").sub).toBe("resume"); - expect(parseGoalArgs("clear").sub).toBe("clear"); - expect(parseGoalArgs("stop").sub).toBe("clear"); - expect(parseGoalArgs("off").sub).toBe("clear"); - }); - - test("condition alone uses default budget (turns fully optional)", () => { - const p = parseGoalArgs("all tests pass"); - expect(p.condition).toBe("all tests pass"); - expect(p.opts).toBeUndefined(); - }); - - test("leading integer is optional turn budget", () => { - const p = parseGoalArgs("10 all tests pass"); - expect(p.condition).toBe("all tests pass"); - expect(p.opts).toEqual({ turnBudget: 10 }); - }); - - test("leading turns with --tokens", () => { - const p = parseGoalArgs("10 --tokens 5000 all tests pass"); - expect(p.condition).toBe("all tests pass"); - expect(p.opts).toEqual({ turnBudget: 10, tokenBudget: 5000 }); - }); - - test("legacy --turns still accepted", () => { - const p = parseGoalArgs("--turns 10 --tokens 5000 all tests pass"); - expect(p.condition).toBe("all tests pass"); - expect(p.opts).toEqual({ turnBudget: 10, tokenBudget: 5000 }); - }); - - test("replace flag", () => { - const p = parseGoalArgs("--replace new condition"); - expect(p.condition).toBe("new condition"); - expect(p.replace).toBe(true); - expect(p.opts).toBeUndefined(); - }); - - test("replace with leading turns", () => { - const p = parseGoalArgs("5 --replace new condition"); - expect(p.condition).toBe("new condition"); - expect(p.replace).toBe(true); - expect(p.opts).toEqual({ turnBudget: 5 }); - }); -}); - -describe("/goal command", () => { - test("set kicks off and reports brief", () => { - let setCond = ""; - let kicked = ""; - let setOpts: unknown; - const ctx: CommandContext = { - signalClear: () => {}, - goal: { - get: () => null, - set: (c, opts) => { - setCond = c; - setOpts = opts; - return snap({ condition: c, brief: c, turnBudget: opts?.turnBudget ?? 0 }); - }, - pause: () => null, - resume: () => null, - clear: () => {}, - kickoff: (c) => { - kicked = c; - }, - }, - }; - const cmd = getCommand("goal"); - expect(cmd).toBeDefined(); - expect(cmd!.argumentHint).toBe("[turns] "); - - const result = cmd!.handler("ship the feature", ctx); - expect(result.type).toBe("message"); - if (result.type === "message") { - expect(result.text).toBe("Goal set."); - // Brief is shown in GoalView chrome, not the one-shot banner. - expect(result.text).not.toContain("ship the feature"); - expect(result.text).not.toContain("The agent will expand"); - expect(result.text).not.toContain("manage_goal"); - } - expect(setCond).toBe("ship the feature"); - expect(setOpts).toBeUndefined(); - expect(kicked).toBe("ship the feature"); - }); - - test("set with leading turn budget", () => { - let setOpts: { turnBudget?: number } | undefined; - const ctx: CommandContext = { - signalClear: () => {}, - goal: { - get: () => null, - set: (c, opts) => { - setOpts = opts; - return snap({ condition: c, brief: c, turnBudget: opts?.turnBudget ?? 0 }); - }, - pause: () => null, - resume: () => null, - clear: () => {}, - kickoff: () => {}, - }, - }; - const result = getCommand("goal")!.handler("12 ship the feature", ctx); - expect(result.type).toBe("message"); - expect(setOpts).toEqual({ turnBudget: 12 }); - }); - - test("status uses formatGoalStatus", () => { - const ctx: CommandContext = { - signalClear: () => {}, - goal: { - get: () => - snap({ - lastReason: "still red", - criteria: [ - { id: "c1", title: "typecheck clean", status: "done" }, - { id: "c2", title: "tests green", status: "todo" }, - ], - }), - set: () => snap(), - pause: () => null, - resume: () => null, - clear: () => {}, - }, - }; - const result = getCommand("goal")!.handler("", ctx); - expect(result.type).toBe("message"); - if (result.type === "message") { - expect(result.text).toContain("tests green"); - expect(result.text).toContain("still red"); - expect(result.text).toContain("typecheck clean"); - } - }); - - test("replace requires --replace when a goal is active", () => { - const ctx: CommandContext = { - signalClear: () => {}, - goal: { - get: () => snap({ condition: "old", brief: "old" }), - set: (c) => snap({ condition: c, brief: c }), - pause: () => null, - resume: () => null, - clear: () => {}, - kickoff: () => {}, - }, - }; - const blocked = getCommand("goal")!.handler("new goal", ctx); - expect(blocked.type).toBe("message"); - if (blocked.type === "message") { - expect(blocked.text).toContain("--replace"); - } - const ok = getCommand("goal")!.handler("--replace new goal", ctx); - expect(ok.type).toBe("message"); - if (ok.type === "message") { - expect(ok.text).toBe("Goal set."); - expect(ok.text).not.toContain("new goal"); - } - }); -}); diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index 451cdd57c..bf44e9c34 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -1,4 +1,3 @@ -import type { GoalSnapshot, GoalSetOpts, GoalResumeOpts } from "../../agent/goal.js"; import type { CostSummary } from "../../cost/cost-summary.js"; export type CommandContext = { @@ -8,17 +7,6 @@ export type CommandContext = { startWorkflow?: (name: string) => string; /** Rename the active session (persisted as run.json task). */ renameSession?: (name: string) => string | undefined; - /** Goal mode operator surface. */ - goal?: { - get: () => GoalSnapshot | null; - set: (condition: string, opts?: GoalSetOpts) => GoalSnapshot; - pause: () => GoalSnapshot | null; - resume: (opts?: GoalResumeOpts) => GoalSnapshot | null; - clear: () => void; - /** Kick off a turn after set/resume so the agent starts working immediately. */ - /** Kick the agent after set/resume. phase defaults to set. */ - kickoff?: (condition: string, phase?: "set" | "resume") => void; - }; }; export type CommandResult = diff --git a/src/tui/gate-events.ts b/src/tui/gate-events.ts index 7e68ef108..08b5068a9 100644 --- a/src/tui/gate-events.ts +++ b/src/tui/gate-events.ts @@ -6,8 +6,8 @@ export type OperatorGateEvent = { options: string[]; resolve: (result: OperatorResult) => void; /** - * When set (goal mode active), auto-cancel if the operator has not answered - * within this many ms so an unattended goal cannot park on the modal forever. + * When set, auto-cancel if the operator has not answered within this many + * ms so an unattended auto-continue run cannot park on the modal forever. */ timeoutMs?: number; /** Override the agent-facing cancel message on timeout. */ @@ -24,8 +24,8 @@ export type PermissionGateEvent = { request: PermissionRequest; resolve: (outcome: ApprovalOutcome) => void; /** - * When set (goal mode active), auto-deny if the operator has not answered - * within this many ms so an unattended goal cannot park on the modal forever. + * When set, auto-deny if the operator has not answered within this many ms + * so an unattended auto-continue run cannot park on the modal forever. */ timeoutMs?: number; /** Override the agent-facing deny message on timeout. */ diff --git a/src/tui/request-approval.test.ts b/src/tui/request-approval.test.ts index 755af762a..1d83f7753 100644 --- a/src/tui/request-approval.test.ts +++ b/src/tui/request-approval.test.ts @@ -14,7 +14,7 @@ const request: PermissionRequest = { scopes: [], }; -const noGoal = () => undefined; +const noTimeout = () => undefined; describe("createGateRequestApproval", () => { test("denies immediately when no gate listener exists", async () => { @@ -26,7 +26,7 @@ describe("createGateRequestApproval", () => { async () => { const requestApproval = createGateRequestApproval({ emitGate: () => false, - goalTimeout: noGoal, + approvalTimeout: noTimeout, }); outcome = await requestApproval(request); const budget = getToolApprovalBudget(); @@ -54,7 +54,7 @@ describe("createGateRequestApproval", () => { captured = event; return true; }, - goalTimeout: noGoal, + approvalTimeout: noTimeout, }); const pending = requestApproval(request); // Longer than the budget — frozen while the prompt is open. @@ -72,18 +72,18 @@ describe("createGateRequestApproval", () => { expect(captured?.signal).toBeDefined(); }); - test("attaches goal timeout parameters to the gate event", async () => { + test("attaches auto-deny timeout parameters to the gate event", async () => { let captured: PermissionGateEvent | undefined; const requestApproval = createGateRequestApproval({ emitGate: (event) => { captured = event; return true; }, - goalTimeout: () => ({ timeoutMs: 15_000, timeoutMessage: "goal skip" }), + approvalTimeout: () => ({ timeoutMs: 15_000, timeoutMessage: "auto-deny skip" }), }); const pending = requestApproval(request); expect(captured?.timeoutMs).toBe(15_000); - expect(captured?.timeoutMessage).toBe("goal skip"); + expect(captured?.timeoutMessage).toBe("auto-deny skip"); captured?.resolve({ allow: false }); await pending; }); @@ -95,7 +95,7 @@ describe("createGateRequestApproval", () => { captured = event; return true; }, - goalTimeout: noGoal, + approvalTimeout: noTimeout, }); const pending = requestApproval(request); expect(captured?.signal).toBeUndefined(); diff --git a/src/tui/request-approval.ts b/src/tui/request-approval.ts index 255804c09..b41345183 100644 --- a/src/tui/request-approval.ts +++ b/src/tui/request-approval.ts @@ -7,8 +7,13 @@ import type { PermissionGateEvent } from "./gate-events.js"; export type CreateGateRequestApprovalArgs = { /** Emits the gate event to the UI; returns false when nothing is listening. */ emitGate: (event: PermissionGateEvent) => boolean; - /** Goal-mode auto-deny parameters, or undefined when no goal is active. */ - goalTimeout: () => { timeoutMs: number; timeoutMessage: string } | undefined; + /** + * Auto-deny timeout parameters for an unattended run, or undefined when + * nothing currently arms it. No caller supplies a non-undefined value today + * — the goal subsystem was the only arming condition and has been removed; + * a future generalized auto-continue mechanism owns re-arming this. + */ + approvalTimeout: () => { timeoutMs: number; timeoutMessage: string } | undefined; }; const logger = getLogger([LOG_NAMESPACE_ROOT, "tui", "permission"]); @@ -64,11 +69,11 @@ export function createGateRequestApproval(args: CreateGateRequestApprovalArgs): tool: request.tool, kind: "permission", }); - const goal = args.goalTimeout(); + const timeout = args.approvalTimeout(); const event: PermissionGateEvent = { request, resolve: finish, - ...(goal !== undefined ? goal : {}), + ...(timeout !== undefined ? timeout : {}), ...(signal !== undefined ? { signal } : {}), }; if (!args.emitGate(event)) { diff --git a/src/tui/runner.ts b/src/tui/runner.ts index b99819a0b..dae3d2656 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -120,20 +120,12 @@ import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/type import { createSessionOperationQueue } from "./session-operation-queue.js"; import { setAgentSourceUnlessClosed } from "./agent-source-sync.js"; import { createChatDirector, hydrateTasksFromTurns } from "../agent/director.js"; -import { createGoalGovernor } from "../agent/goal.js"; -import { createGoalEvaluator } from "../agent/goal-evaluator.js"; -import { loadGoalState, saveGoalState } from "../session/goal-state.js"; import { loadAgentProfiles, type AgentProfile } from "../agent/profiles.js"; import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; import { createPermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import { createPermissionsAdmin, type ScopedApproval } from "../permission/admin.js"; import type { GrantScope } from "../permission/types.js"; -import { - DEFAULT_GOAL_APPROVAL_TIMEOUT_MS, - goalApprovalTimeoutMessage, - isGoalApprovalTimeoutActive, -} from "../permission/goal-approval-timeout.js"; import { createAgentToolset, type MCPServerState, type OperatorResult } from "../agent/tools.js"; import { collectWebPlugins, resolveWebProviderFromPlugins, webBrand } from "../web/plugin-provider.js"; @@ -638,24 +630,17 @@ export async function runTUI(initialConfig: Config): Promise { err instanceof Error && err.name === "XaiAuthError"; const activeProviderModel = `${config.providerName}:${config.model}`; - // Goal governor is created after liveSource (evaluator closure); the gate - // holds a ref so requestApproval can arm a timeout once a goal is active. - const goalGovernorRef: { current: ReturnType | null } = { - current: null, - }; // Shared by the permission gate and every operator-gate emission site: an - // unattended goal-mode run must not park on any gate forever, whichever - // kind it is. - const goalTimeout = (): { timeoutMs: number; timeoutMessage: string } | undefined => { - const snap = goalGovernorRef.current?.get() ?? null; - return isGoalApprovalTimeoutActive(snap?.status) - ? { - timeoutMs: DEFAULT_GOAL_APPROVAL_TIMEOUT_MS, - timeoutMessage: goalApprovalTimeoutMessage(DEFAULT_GOAL_APPROVAL_TIMEOUT_MS), - } - : undefined; - }; + // unattended auto-continue run must not park on any gate forever, whichever + // kind it is. No caller arms this today — the goal subsystem was the only + // source of an auto-deny/auto-cancel deadline and has been removed. The + // timeout plumbing (gate-events.ts / request-approval.ts, and every + // OperatorGateEvent/PermissionGateEvent emission site below) stays for a + // future generalized auto-continue mechanism to re-arm by giving this a + // real body again. + const approvalTimeout = (): { timeoutMs: number; timeoutMessage: string } | undefined => + undefined; const seededApprovals = await loadSeededApprovals(config.cwd, sessionId); const permissionGate = createPermissionGate({ @@ -666,7 +651,7 @@ export async function runTUI(initialConfig: Config): Promise { model: config.model, requestApproval: createGateRequestApproval({ emitGate: (event) => emitter.emit("permission.gate", event), - goalTimeout, + approvalTimeout, }), persist: createApprovalPersist(config.cwd, activeProviderModel), interactive: true, @@ -675,7 +660,6 @@ export async function runTUI(initialConfig: Config): Promise { onGrant: (approval, covers) => emitter.emit("permission.grant", { approval, covers }), }); - const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd); // Track the active subagent provider so a live /agent switch (provider, model, @@ -1043,13 +1027,7 @@ export async function runTUI(initialConfig: Config): Promise { if (refreshed !== null) config = { ...config, settings: refreshed }; } } - // Loaded once, here, so both the wire tools array and the goal governor's - // restore (below) agree on the same snapshot — the file is only ever - // written for an active/paused/budget-limited goal, so non-null means the - // session starts with one. - const persistedGoalAtLaunch = await loadGoalState(config.cwd, sessionId); const toolAvailability: ToolAvailability = { - hasGoalAtLaunch: persistedGoalAtLaunch !== null, languageServerAvailable: detectLanguageServerAvailable(config.cwd), }; const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(liveSessionMode, toolAvailability); @@ -1070,7 +1048,6 @@ export async function runTUI(initialConfig: Config): Promise { toolWatchdog: liveToolWatchdog, getBlobReader: () => currentAgent.blobReader, isWorkflowActive: () => workflowControllerHolder.instance?.isActive() === true, - getGoalGovernor: () => goalGovernorRef.current, ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), onOperatorGate: (question, options) => new Promise((resolve) => { @@ -1078,12 +1055,12 @@ export async function runTUI(initialConfig: Config): Promise { tool: "ask_operator", kind: "operator", }); - const goal = goalTimeout(); + const timeout = approvalTimeout(); const event: OperatorGateEvent = { question, options, resolve: finish, - ...(goal !== undefined ? goal : {}), + ...(timeout !== undefined ? timeout : {}), ...(signal !== undefined ? { signal } : {}), }; emitter.emit("operator.gate", event); @@ -1100,7 +1077,7 @@ export async function runTUI(initialConfig: Config): Promise { tool: `mcp:${server.name}`, kind: "operator", }); - const goal = goalTimeout(); + const timeout = approvalTimeout(); const event: OperatorGateEvent = { question: `Trust local MCP server "${server.name}" for this project?` @@ -1111,7 +1088,7 @@ export async function runTUI(initialConfig: Config): Promise { : ""), options: ["Trust and connect", "Deny"], resolve: finish, - ...(goal !== undefined ? goal : {}), + ...(timeout !== undefined ? timeout : {}), ...(signal !== undefined ? { signal } : {}), }; emitter.emit("operator.gate", event); @@ -1205,7 +1182,6 @@ export async function runTUI(initialConfig: Config): Promise { }, provider: { providerName: config.providerName, model: config.model }, }); - d.setGoalGovernor(goalGovernor); directorHolder.instance = d; return d; }, @@ -1285,44 +1261,6 @@ export async function runTUI(initialConfig: Config): Promise { : buildOpenAICompatibleInitialSource(); } - // Goal governor survives director rebuilds; reattached in the factory below. - // Evaluator runs on the live session model. - const goalGovernor = createGoalGovernor({ - evaluate: createGoalEvaluator({ - getSource: () => liveSource, - deps: inferenceDeps, - }), - onChange: (snap) => { - emitter.emit("goal", snap.status === "inactive" || snap.status === "cleared" ? null : snap); - void saveGoalState( - config.cwd, - sessionId, - snap.status === "inactive" || snap.status === "cleared" - ? null - : { - status: snap.status, - condition: snap.condition, - brief: snap.brief, - criteria: snap.criteria, - startedAt: snap.startedAt, - ...(snap.completedAt !== undefined ? { completedAt: snap.completedAt } : {}), - turnBudget: snap.turnBudget, - turnsUsed: snap.turnsUsed, - ...(snap.tokenBudget !== undefined ? { tokenBudget: snap.tokenBudget } : {}), - mainTokens: snap.mainTokens, - evalTokens: snap.evalTokens, - ...(snap.lastReason !== undefined ? { lastReason: snap.lastReason } : {}), - }, - ); - }, - }); - goalGovernorRef.current = goalGovernor; - - // Resume restores condition as paused so autonomy is never silently re-armed. - if (persistedGoalAtLaunch !== null) { - goalGovernor.restore(persistedGoalAtLaunch); - } - // Compaction summarizer: produces a structured, workflow-aware handoff via a // one-shot call on the live model, falling back to the deterministic summary // on any failure. The workflow context is read at call time so a compaction @@ -1330,10 +1268,6 @@ export async function runTUI(initialConfig: Config): Promise { const compactionSummarize = createModelSummarizer({ getSource: () => liveSource, deps: inferenceDeps }); const summarizeForCompaction = (turns: Parameters[0]): Promise => { const status = workflowController.status(); - const goalSnap = goalGovernor.get(); - const goalActive = - goalSnap !== null && - (goalSnap.status === "active" || goalSnap.status === "paused" || goalSnap.status === "budget_limited"); return compactionSummarize(turns, { ...(status.active ? { @@ -1345,22 +1279,6 @@ export async function runTUI(initialConfig: Config): Promise { }, } : {}), - ...(goalActive - ? { - goal: { - condition: goalSnap.condition, - status: goalSnap.status, - brief: goalSnap.brief, - ...(goalSnap.criteria.length > 0 - ? { - criteriaSummary: goalSnap.criteria - .map((c) => `[${c.status}] ${c.title}`) - .join("; "), - } - : {}), - }, - } - : {}), }); }; @@ -1717,9 +1635,8 @@ export async function runTUI(initialConfig: Config): Promise { cycleRecorder.reset(); streamPromise = consumeStream(currentAgent.stream(), streamSink); await persistRunSnapshot("running"); - // A fresh session drops any active workflow and goal. + // A fresh session drops any active workflow. workflowController.reset(); - goalGovernor.clear(); fatalBuildError = null; } catch (err) { recordRunError(err); @@ -1824,13 +1741,6 @@ export async function runTUI(initialConfig: Config): Promise { void renameSession(config.cwd, sessionId, trimmed).then(() => persistRunSnapshot("running")); return undefined; }, - goal: { - get: () => goalGovernor.get(), - set: (condition, opts) => goalGovernor.set(condition, opts), - pause: () => goalGovernor.pause(), - resume: (opts) => goalGovernor.resume(opts), - clear: () => goalGovernor.clear(), - }, }; const systemRow = (text: string): void => { @@ -2102,7 +2012,6 @@ export async function runTUI(initialConfig: Config): Promise { dispatchCommand(commandName, rest.join(" ")); }, chrome: () => ({ - goal: goalGovernor.get(), tasks: directorHolder.instance?.getTasks() ?? null, agents: subAgentSessions.listForStrip().map((s) => ({ agentId: s.agentId, @@ -2116,11 +2025,9 @@ export async function runTUI(initialConfig: Config): Promise { }), subscribeChrome: (notify) => { const unsubscribeAgents = subAgentSessions.subscribe(notify); - emitter.on("goal", notify); emitter.on("tasks", notify); return () => { unsubscribeAgents(); - emitter.off("goal", notify); emitter.off("tasks", notify); }; }, diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 16905fc06..38ed0951e 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -35,8 +35,8 @@ describe("composer submit handler", () => { test("passes slash command arguments through", () => { const h = harness(); - h.submit("/goal 12 ship the feature"); - expect(h.dispatched).toEqual([{ name: "goal", args: "12 ship the feature" }]); + h.submit("/rename ship the feature"); + expect(h.dispatched).toEqual([{ name: "rename", args: "ship the feature" }]); expect(h.prompts).toEqual([]); }); diff --git a/tests/integration/vendored-carry.test.ts b/tests/integration/vendored-carry.test.ts index 129ad4c70..d8571fdd8 100644 --- a/tests/integration/vendored-carry.test.ts +++ b/tests/integration/vendored-carry.test.ts @@ -93,7 +93,7 @@ describe("integration — vendored feature carry", () => { const workdir = join(cwd, ".agent-state", "carry-session"); // Minimal director: every user message infers with an ephemeral nudge - // attached, exactly the shape the chat director and goal governor emit. + // attached, exactly the shape the chat director's terminal rewrites emit. const nudgeDirectorDef = defineDirector({ id: `${ID_PREFIX}/carry-nudge`, configSchema: type({}),