diff --git a/AGENTS.md b/AGENTS.md index 8984a076e..071e007aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,15 +21,20 @@ - The build does not clean `dist/`; remove stale output when deleting or renaming source modules before validating package contents. - Bundled prompts live in `src/prompts/`; bundled skills live in `skills/`. They sync on every plugin load, preserving user edits and never deleting files. The standalone installer handles conflicts and orphan pruning. - Keep the section-summary markers in `src/prompts/agents/auditor-loop-addendum.md` synchronized with the constants in `src/utils/section-summary.ts`. +- Keep the four required headings in `src/prompts/agents/goal.md` synchronized with `GOAL_BRIEF_REQUIRED_HEADINGS` in `src/utils/goal-brief.ts`. +- Goal launches from the TUI must set `initialPromptOwner: 'server'` so `attachLoopToSession` builds the prompt from `buildGoalCodingPrompt`. `buildTuiLoopInitialPrompt` is plan-only because it decomposes and sends section 1; it must not be reused for goal loops. - `MAX_TOTAL_SECTIONS` in `src/constants/loop.ts` is the single section cap; the decomposer, section bootstrap, plan structure summary, TUI inline plan preview and `plan-adjust` all read it, and the architect system reminder in `src/index.ts` interpolates it. `src/prompts/agents/architect.md` is prose and repeats the number literally — update it when the cap changes. -- `PLAN_AUTHORING_TOOL_NAMES` in `src/constants/loop.ts` is the single list of plan-authoring tools; the `code`, `auditor`, and `feature-splitter` tool-exclude lists and both permission rulesets derive their deny entries from it. +- `PLAN_AUTHORING_TOOL_NAMES` in `src/constants/loop.ts` is the single list of plan-authoring tools; the `code`, `auditor`, and `feature-splitter` tool-exclude lists derive their deny entries from it. `GOAL_AUTHORING_TOOL_NAMES` is the single list of goal-authoring tools (`goal-write` only). `SPEC_AUTHORING_TOOL_NAMES` (the plan list plus the goal list) is what `code`, `auditor`, and `feature-splitter` spread into their tool-exclude lists AND what both permission rulesets (`buildLoopPermissionRuleset`, `buildAuditSessionPermissionRuleset`) derive their deny entries from, so `goal-write` is denied in any running or audit session. The `architect` and `architect-auto` agents append `GOAL_AUTHORING_TOOL_NAMES` only (they keep `plan-write`/`plan-edit`). The `goal` agent spreads only `PLAN_AUTHORING_TOOL_NAMES` and is the **only** agent that may call `goal-write`. `assertWritableSession` in `src/tools/session-write-guard.ts` is the single shared guard both `plan-authoring.ts` and `goal-authoring.ts` call to reject writes from an active loop session. - `LoopService.resolveActiveLoopForSession` is the only correct "is this session inside a running loop" check. `resolveLoopName` matches terminated loops too, so using it as an activity guard blocks a session forever after its loop ends. - `resolveForgeDbPath` in `src/utils/opencode-paths.ts` is the only place `/forge.db` is built; every entry point must route through it so a configured `dataDir` is honoured uniformly. +- `resolveLoopLaunchPolicy` in `src/utils/loop-helpers.ts` is the single resolution point for the loop launch policy (`loop.enabled` and `loop.defaultMaxIterations`). The `execute-plan`/`execute-goal` handlers in `src/services/execution.ts`, the TUI launch path in `src/utils/tui-client.ts`, the remote launch path in `src/utils/tui-remote-launch.ts`, and the attach hook fallback in `src/hooks/forge-session-attach.ts` all read it. The TUI/remote launch path stamps the resolved `maxIterations` onto the `forgeLoop` envelope (`ForgeLoopExtra.maxIterations`) so the attach hook honours the launcher's promise first and only falls back to the server-side policy when the stamp is absent. Local TUI launches set `awaitAttachAck` so `launchTuiLoop` polls the shared forge database for the running loop row before reporting success; remote launches cannot observe the remote database and remain fire-and-forget. `connectForgeProject` is the only TUI entry point that receives `pluginConfig`/`awaitAttachAck` (population from `src/tui.tsx`). ## Dashboard and storage gotchas - The dashboard browser app uses `solid-js/html`, not JSX. Do not use `<${Show}>` or `<${For}>`; use reactive thunks/memos and `.map()`. Every template needs a real root element, reactive regions must be functions such as `${() => ...}`, and the root component returns one wrapper element. `test/dashboard/app-dom.test.ts` enforces these constraints. - Storage migrations are registered explicitly, in execution order, in the lowercase `migrations` array in `src/storage/migrations/index.ts`; they are not discovered from filenames. Inline migrations are valid, so not every migration needs a SQL file. +- `goal_briefs` is the pre-launch authoring store for goal briefs; `loop_large_fields.goal` remains the launched loop's copy. Goal loops still never write `plans` rows (`src/loop/service.ts:183-194`). +- `fetchStoredSessionLaunchSpec` in `src/utils/tui-loop-store.ts` is the only place the dialog resolves a launchable artifact; it reads both stores (plans and goal briefs) in one DB open and the newest wins with plans breaking ties. - `resolveDashboardConfig` in `src/dashboard/config.ts` is the only place the dashboard bind host/port is resolved, and `DEFAULT_DASHBOARD_PORT`/`DEFAULT_DASHBOARD_HOST` are the only copies of the *bind* defaults. The literal `localhost` in `buildDashboardUrls` is deliberately separate: it is the loopback display URL and must not follow the configured bind host. Every launch surface (`scripts/dashboard.ts`, the TUI `forge.dashboard` command) must pass its overrides plus the loaded `PluginConfig` into `startDashboardServer` rather than resolving them itself, and must render `DashboardServerHandle.warnings` so an unusable value is never dropped silently on one surface only. The dashboard has no auth; `DASHBOARD_EXPOSED_WARNING` is the single warning string. ## Diagnostics diff --git a/README.md b/README.md index dd0735c15..99099bfba 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Execution flow dialog with mode and model selection: ## Features - **Plans** — architect authors plans directly into SQL storage with `plan-write`/`plan-edit`; marked plans emitted in chat are still auto-captured -- **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](docs/configuration.md#remotes)) +- **Execution** — approved-plan launch paths plus `/goal` brief-backed goal loops in dedicated worktree sessions (launched by the `goal` agent via `execute-goal` or from the Forge execution dialog); plan loops can also target a configured remote opencode server (see [Configuration](docs/configuration.md#remotes)) - **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions - **TUI** — sidebar and execution dialog @@ -112,7 +112,7 @@ Execution flow dialog with mode and model selection: ## Agents -The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant used by loop audit sessions. See [Agents and slash commands](docs/agents-and-commands.md) for the full reference. +The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant used by loop audit sessions, plus a `goal` agent for authoring goal briefs. See [Agents and slash commands](docs/agents-and-commands.md) for the full reference. | Agent | Mode | Description | |-------|------|-------------| @@ -120,6 +120,7 @@ The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant | **architect** | primary | Read-only planning agent. Researches the codebase, designs implementation plans, and caches them for user approval before execution. | | **auditor** | subagent | Read-only code auditor for convention-aware reviews. Invoked via Task tool to review diffs, commits, branches, or PRs against stored conventions and decisions. | | **auditor-loop** | primary, hidden | Internal audit agent used for loop-runner audit sessions. | +| **goal** | primary | Read-only brief-authoring agent. Reconnoiters the codebase, clarifies scope inline, and writes the session-scoped goal brief with `goal-write` for approval and execution via the Forge execution dialog. | The auditor agent is a read-only subagent that cannot edit source files or execute plans. It is invoked by other agents via the Task tool to review code changes against stored project conventions and decisions. @@ -135,6 +136,7 @@ See [Tools reference](docs/tools.md) for full arguments, section-scoping behavio Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` +- **Goal brief tools** — `goal-write`, `execute-goal` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` - **Sandbox shell** — `sh` when a sandbox manager is available @@ -144,7 +146,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | Tool | Description | |------|-------------| | `execute-plan` | Execute a plan using an iterative development loop in an isolated git worktree, or `mode: new-session` to launch it in a fresh standalone session. Args: `title` required; `plan`, `loopName`, `mode` optional. | -| `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | +| `execute-goal` | Launch a goal loop from the goal brief stored for the current session (authored with `goal-write`). Args: `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | @@ -157,7 +159,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | `/review` | Run a code review on current changes | auditor (subtask) | | `/review-plan` | Review a completed implementation against its original plan | auditor (subtask) | | `/execute-plan` | Start an iterative development loop in a worktree (or a fresh session with `mode: new-session`) | code | -| `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | +| `/goal` | Reconnoiter, author a goal brief (`goal-write`), and launch a goal loop — either directly via `execute-goal` or from the Forge execution dialog | goal | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | diff --git a/docs/agents-and-commands.md b/docs/agents-and-commands.md index 5430e1275..ad0e3550d 100644 --- a/docs/agents-and-commands.md +++ b/docs/agents-and-commands.md @@ -12,6 +12,7 @@ See also: [Tools](tools.md), [Configuration](configuration.md), [Loop System](lo | `architect` | `primary` | Read-only planning agent. Authors the stored plan with `plan-write`/`plan-edit` for approval and execution; marked plans in chat are still captured. | | `auditor` | `subagent` | Read-only code review agent for convention-aware reviews. | | `auditor-loop` | `primary`, hidden | Internal auditor used by loop audit sessions. | +| `goal` | `primary` | Read-only brief-authoring agent. Reconnoiters the codebase, clarifies scope inline, and writes the session-scoped goal brief with `goal-write` for approval and execution. | Source: [`src/agents/index.ts`](../src/agents/index.ts), [`src/agents/auditor.ts`](../src/agents/auditor.ts). @@ -29,6 +30,7 @@ Excluded tools: - `plan_exit` - `plan-write` - `plan-edit` +- `goal-write` - `execute-plan` - `execute-goal` - `loop-cancel` @@ -36,6 +38,14 @@ Excluded tools: Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). +## Goal agent restrictions + +The `goal` agent is a read-only brief author and launcher. It can reconnoiter with read tools, ask the user clarifying questions, author the session-scoped goal brief with `goal-write`, and launch a goal loop from that brief with `execute-goal`. It cannot edit source files, run plan loops, author plans, or manage other loops. + +Excluded tools: every filesystem-mutating tool, every plan/loop/group management tool, and every plan-authoring tool (`plan-write`, `plan-edit`, `plan-adjust`). `execute-plan` is excluded (plan loops are not launched from the goal agent). The `goal` agent is the only agent allowed to call `goal-write`. + +Source: [`src/agents/goal.ts`](../src/agents/goal.ts), [`src/constants/loop.ts`](../src/constants/loop.ts). + ## Slash Commands | Command | Description | Agent | Subtask | @@ -43,7 +53,7 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/review` | Run a code review. | `auditor` | yes | | `/review-plan` | Review a completed implementation against its original plan. | `auditor` | yes | | `/execute-plan` | Start an iterative development loop in a worktree (or launch the plan in a fresh standalone session with `mode: new-session`). | `code` | no | -| `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | +| `/goal` | Reconnoiter, author a goal brief (`goal-write`), and launch a goal loop — either directly via `execute-goal` or from the Forge execution dialog. | `goal` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | diff --git a/docs/api/README.md b/docs/api/README.md index 10db14722..09d7ca30f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -107,7 +107,7 @@ Execution flow dialog with mode and model selection: ## Features - **Plans** — architect authors plans directly into SQL storage with `plan-write`/`plan-edit`; marked plans emitted in chat are still auto-captured -- **Execution** — approved-plan launch paths plus direct `/execute-goal` loops in dedicated worktree sessions; plan loops can also target a configured remote opencode server (see [Configuration](_media/configuration.md#remotes)) +- **Execution** — approved-plan launch paths plus `/goal` brief-backed goal loops in dedicated worktree sessions (launched by the `goal` agent via `execute-goal` or from the Forge execution dialog); plan loops can also target a configured remote opencode server (see [Configuration](_media/configuration.md#remotes)) - **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox - **Review Findings** — persistent, loop-scoped review findings across loop sessions - **TUI** — sidebar and execution dialog @@ -115,7 +115,7 @@ Execution flow dialog with mode and model selection: ## Agents -The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant used by loop audit sessions. See [Agents and slash commands](_media/agents-and-commands.md) for the full reference. +The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant used by loop audit sessions, plus a `goal` agent for authoring goal briefs. See [Agents and slash commands](_media/agents-and-commands.md) for the full reference. | Agent | Mode | Description | |-------|------|-------------| @@ -123,6 +123,7 @@ The plugin bundles three user-facing agents plus a hidden `auditor-loop` variant | **architect** | primary | Read-only planning agent. Researches the codebase, designs implementation plans, and caches them for user approval before execution. | | **auditor** | subagent | Read-only code auditor for convention-aware reviews. Invoked via Task tool to review diffs, commits, branches, or PRs against stored conventions and decisions. | | **auditor-loop** | primary, hidden | Internal audit agent used for loop-runner audit sessions. | +| **goal** | primary | Read-only brief-authoring agent. Reconnoiters the codebase, clarifies scope inline, and writes the session-scoped goal brief with `goal-write` for approval and execution via the Forge execution dialog. | The auditor agent is a read-only subagent that cannot edit source files or execute plans. It is invoked by other agents via the Task tool to review code changes against stored project conventions and decisions. @@ -137,6 +138,7 @@ See [Tools reference](_media/tools.md) for full arguments, section-scoping behav Forge provides these tool groups: - **Plan tools** — `plan-write`, `plan-edit`, `plan-read`, `section-read`, `plan-adjust` +- **Goal brief tools** — `goal-write`, `execute-goal` - **Review tools** — `review-write`, `review-read`, `review-delete` - **Loop tools** — `execute-plan`, `execute-goal`, `loop-cancel`, `loop-status` - **Sandbox shell** — `sh` when a sandbox manager is available @@ -146,7 +148,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | Tool | Description | |------|-------------| | `execute-plan` | Execute a plan using an iterative development loop in an isolated git worktree, or `mode: new-session` to launch it in a fresh standalone session. Args: `title` required; `plan`, `loopName`, `mode` optional. | -| `execute-goal` | Execute a free-text goal in rotating dedicated code and auditor sessions inside an isolated git worktree. Args: `goal` required; `title`, `loopName`, `maxIterations` optional. | +| `execute-goal` | Launch a goal loop from the goal brief stored for the current session (authored with `goal-write`). Args: `title`, `loopName`, `maxIterations` optional. | | `loop-cancel` | Cancel an active loop by worktree name | | `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. | @@ -159,7 +161,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | `/review` | Run a code review on current changes | auditor (subtask) | | `/review-plan` | Review a completed implementation against its original plan | auditor (subtask) | | `/execute-plan` | Start an iterative development loop in a worktree (or a fresh session with `mode: new-session`) | code | -| `/execute-goal` | Execute a free-text goal in dedicated worktree sessions until an audit leaves no findings | code | +| `/goal` | Reconnoiter, author a goal brief (`goal-write`), and launch a goal loop — either directly via `execute-goal` or from the Forge execution dialog | goal | | `/loop-status` | Check status of all active loops | code | | `/loop-cancel` | Cancel the active loop | code | diff --git a/docs/api/_media/agents-and-commands.md b/docs/api/_media/agents-and-commands.md index 5430e1275..ad0e3550d 100644 --- a/docs/api/_media/agents-and-commands.md +++ b/docs/api/_media/agents-and-commands.md @@ -12,6 +12,7 @@ See also: [Tools](tools.md), [Configuration](configuration.md), [Loop System](lo | `architect` | `primary` | Read-only planning agent. Authors the stored plan with `plan-write`/`plan-edit` for approval and execution; marked plans in chat are still captured. | | `auditor` | `subagent` | Read-only code review agent for convention-aware reviews. | | `auditor-loop` | `primary`, hidden | Internal auditor used by loop audit sessions. | +| `goal` | `primary` | Read-only brief-authoring agent. Reconnoiters the codebase, clarifies scope inline, and writes the session-scoped goal brief with `goal-write` for approval and execution. | Source: [`src/agents/index.ts`](../src/agents/index.ts), [`src/agents/auditor.ts`](../src/agents/auditor.ts). @@ -29,6 +30,7 @@ Excluded tools: - `plan_exit` - `plan-write` - `plan-edit` +- `goal-write` - `execute-plan` - `execute-goal` - `loop-cancel` @@ -36,6 +38,14 @@ Excluded tools: Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). +## Goal agent restrictions + +The `goal` agent is a read-only brief author and launcher. It can reconnoiter with read tools, ask the user clarifying questions, author the session-scoped goal brief with `goal-write`, and launch a goal loop from that brief with `execute-goal`. It cannot edit source files, run plan loops, author plans, or manage other loops. + +Excluded tools: every filesystem-mutating tool, every plan/loop/group management tool, and every plan-authoring tool (`plan-write`, `plan-edit`, `plan-adjust`). `execute-plan` is excluded (plan loops are not launched from the goal agent). The `goal` agent is the only agent allowed to call `goal-write`. + +Source: [`src/agents/goal.ts`](../src/agents/goal.ts), [`src/constants/loop.ts`](../src/constants/loop.ts). + ## Slash Commands | Command | Description | Agent | Subtask | @@ -43,7 +53,7 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/review` | Run a code review. | `auditor` | yes | | `/review-plan` | Review a completed implementation against its original plan. | `auditor` | yes | | `/execute-plan` | Start an iterative development loop in a worktree (or launch the plan in a fresh standalone session with `mode: new-session`). | `code` | no | -| `/execute-goal` | Execute a goal in rotating dedicated code and auditor sessions inside an isolated worktree. | `code` | no | +| `/goal` | Reconnoiter, author a goal brief (`goal-write`), and launch a goal loop — either directly via `execute-goal` or from the Forge execution dialog. | `goal` | no | | `/loop-status` | Check status of all active loops. | `code` | no | | `/loop-cancel` | Cancel the active loop. | `code` | no | diff --git a/docs/api/_media/architecture.md b/docs/api/_media/architecture.md index 84320e424..8a7c2c510 100644 --- a/docs/api/_media/architecture.md +++ b/docs/api/_media/architecture.md @@ -63,13 +63,13 @@ The codebase is organized into these module groups under `src/`: | Module | Purpose | Key Files | |--------|---------|-----------| -| `agents/` | AI agent definitions (code, architect, auditor + auditor-loop variant) | `index.ts`, `code.ts`, `architect.ts`, `auditor.ts` | +| `agents/` | AI agent definitions (code, architect, goal, auditor + auditor-loop variant) | `index.ts`, `code.ts`, `architect.ts`, `goal.ts`, `auditor.ts` | | `hooks/` | Plugin event/lifecycle hooks (session, loop events, plan capture, plan approval, watchdog, sandbox, forge-session-attach, loop-permission, host-side-effects) | `index.ts`, `session.ts`, `loop.ts`, `plan-capture.ts`, `plan-approval.ts`, `watchdog.ts`, `sandbox-tools.ts`, `forge-session-attach.ts`, `loop-permission.ts`, `host-side-effects.ts` | | `loop/` | Core loop state machine and runtime | `runtime.ts`, `service.ts`, `state.ts`, `transitions.ts`, `prompts.ts`, `restartability.ts`, `in-flight-guard.ts`, `token-usage.ts`, `name-uniqueness.ts` | | `services/` | Higher-level orchestration services | `execution.ts`, `session-loop-resolver.ts`, `deterministic-decomposer.ts`, `plan-capture.ts`, `worktree-log.ts` | | `sandbox/` | Docker sandbox management | `docker.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | | `storage/` | SQLite persistence layer (repos + migrations) | `database.ts`, `repos/*.ts`, `migrations/*.sql` | -| `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `section-read.ts` | +| `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `plan-authoring.ts`, `goal-authoring.ts`, `section-read.ts` | | `workspace/` | Git worktree / workspace management | `forge-adapter.ts`, `forge-worktree.ts`, `pending-teardown.ts`, `classify-stale.ts`, `remove-with-context.ts`, `sweep-stale.ts` | | `utils/` | Shared utility modules (~25 files) | `logger.ts`, `lru-cache.ts`, `model-fallback.ts`, etc. | | `tui/` | TUI-specific components | `execute-plan-panel.tsx` | @@ -167,7 +167,7 @@ OpenCode Forge uses `bun:sqlite` for all data persistence. The storage layer is - `initializeDatabase(dataDir, options)` - Creates SQLite DB in the data directory - `closeDatabase()` - Closes database connections on shutdown - `resolveDataDir()` - Resolves platform-appropriate data directory (`~/.local/share/opencode/forge`) -- Migrations are registered explicitly in execution order (ids 100-143; not every id ships a SQL file) and tracked in a `migrations` table +- Migrations are registered explicitly in execution order (ids 100-144; not every id ships a SQL file) and tracked in a `migrations` table ### Repository Pattern @@ -177,6 +177,7 @@ All data access goes through typed repository interfaces created via factory fun |---|---|---| | `LoopsRepo` | CRUD for loop rows | `LoopRow`, `LoopLargeFields` | | `PlansRepo` | CRUD for plans (session-scoped plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog) | `PlanRow`, `PlansRepo` | +| `GoalBriefsRepo` | CRUD for session-scoped goal briefs authored before goal-loop launch | `GoalBriefRow`, `GoalBriefsRepo` | | `ReviewFindingsRepo` | CRUD for review findings | `ReviewFindingRow`, `ReviewFindingsRepo` | | `SectionPlansRepo` | CRUD for milestone (section) plans used in decomposed loops | `SectionPlanRow`, `SectionPlansRepo` | | `LoopTransitionsRepo` | Append-only loop phase-transition log | `LoopTransitionRow` | diff --git a/docs/api/_media/loop-system.md b/docs/api/_media/loop-system.md index eccbbff22..5354aa9ef 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -294,12 +294,17 @@ Cancellation: ## Goal Loops -A **goal loop** (`kind: 'goal'`) is a lightweight alternative to a plan loop for work that does not need a structured plan. It is started by the `/execute-goal ` slash command (or the `execute-goal` tool) with free-text goal text. +A **goal loop** (`kind: 'goal'`) is a lightweight alternative to a plan loop for work that does not need a structured plan. Every goal loop is launched from a **goal brief** authored by the `goal` agent with `goal-write` and persisted to a session-scoped `goal_briefs` row. Forge exposes two launch surfaces for that brief: + +- **`execute-goal` tool** — the `goal` agent calls it directly after authoring a clean brief. It reads the stored brief and starts a managed goal loop with the plugin-config default execution and auditor models. +- **Forge execution dialog** — the user opens the dialog to pick the execution model, auditor model, and other launch options, then launches the brief-backed goal loop from there. + +Both surfaces read the same stored brief, so the auditor contract is identical regardless of which one launches the loop. ### Lifecycle differences from plan loops -- **No plan, no decomposition, no approval.** The goal text is persisted in `loop_large_fields.goal` (never in the `plans` table) and is the auditor's authoritative scope. There are no sections, no `final_auditing` phase, and no `post_action` phase. -- **Dedicated rotating sessions.** Forge creates a code session in the isolated worktree, sends the goal as its initial prompt, and leaves the invoking session unchanged as the post-completion host redirect target. As with plan loops, each completed code or audit pass is retired before the next session takes over. +- **No plan, no decomposition, no approval.** The brief text is persisted in `loop_large_fields.goal` (never in the `plans` table) and is the auditor's authoritative scope. There are no sections, no `final_auditing` phase, and no `post_action` phase. +- **Dedicated rotating sessions.** Forge creates a code session in the isolated worktree, sends the brief as its initial prompt, and leaves the invoking session unchanged as the post-completion host redirect target. As with plan loops, each completed code or audit pass is retired before the next session takes over. - **Idle-driven audits.** When the executor goes idle, the loop runner starts a fresh `auditor-loop` session against the worktree (same as plan loops). The auditor verifies both goal completion and code correctness, and may block termination with `severity: "bug"` findings (using the stable pseudo-path `GOAL` with `line: 1` when no source location applies for an unmet part of the goal). - **Dirty audits create a fresh code session.** When findings remain, Forge creates and selects a new code session in the worktree and sends a continuation prompt containing the goal and outstanding findings. That session goes idle to trigger the next audit. - **Clean audits terminate immediately.** When a completed auditor pass leaves zero outstanding review findings (any severity), the loop terminates with `completed` — no final audit, no post-completion action. @@ -316,18 +321,22 @@ A goal loop completes only after the auditor has run at least once and leaves no Goal loops are fully visible to `loop-status`, cancellable with `loop-cancel`, and restartable with `loop-status ... restart=true`. Restart preserves the goal kind and goal text, skips plan decomposition, and resumes from a fresh session. -`execute-goal` is added to the same loop/audit denial lists as `execute-plan`, `launch-group`, and the group tools, so an active executor or auditor session cannot recursively start another goal loop. Auditors exclude `execute-goal` from their tools. +`execute-goal` is added to the same loop/audit denial lists as `execute-plan`, `launch-group`, and the group tools, so an active executor or auditor session cannot recursively start another goal loop. Auditors exclude `execute-goal` from their tools. The `goal` agent runs in the user's briefing session (not a loop session), so it is the one surface allowed to call `execute-goal`. ### Differences from `execute-plan` and `launch-group` -| Aspect | `execute-plan` (loop) | `execute-goal` | `launch-group` | +| Aspect | `execute-plan` (loop) | `/goal` brief → goal loop | `launch-group` | |---|---|---|---| -| Input | Structured plan (persisted in `plans`) | Free-text goal | PRD / pre-split feature list | +| Input | Structured plan (persisted in `plans`) | Structured goal brief (persisted in `goal_briefs`) | PRD / pre-split feature list | | Sections / milestones | Yes (decomposed) | No | Per feature (each feature is its own loop) | | Executor session | Fresh session per iteration | Fresh dedicated session per coding pass | Fresh session per feature loop | | Final audit | Yes (after all sections) | No | Per feature loop | | Post-completion action | Yes (when configured) | Never | Per feature loop | -| Slash command | `/execute-plan` | `/execute-goal` | None (agent-invoked) | +| Stored artifact | `plans` row | `goal_briefs` row pre-launch; `loop_large_fields.goal` after launch | Per-feature `plans` rows | +| Model selection | Execution dialog (executor + auditor) | `execute-goal` tool (config defaults) or execution dialog | Execution dialog per feature | +| Slash command | `/execute-plan` | `/goal` | None (agent-invoked) | + +The brief's `## Acceptance Criteria` section becomes the frozen spec re-read by `buildGoalAuditPrompt` on every audit pass: once a brief-backed goal loop launches, the brief text is copied into `loop_large_fields.goal` and the auditor compares the worktree against that exact text on every pass. The structured acceptance criteria give the auditor a stable, reviewable contract instead of a one-line free-text prompt. ## Tool Restrictions diff --git a/docs/api/_media/tools.md b/docs/api/_media/tools.md index f6306243d..da9a97f65 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -13,11 +13,12 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `plan-edit` | Edit the stored session plan by exact string replacement. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | | `section-read` | Read a section plan and status for the active loop session. | [`src/tools/section-read.ts`](../src/tools/section-read.ts) | | `plan-adjust` | Revise the section under audit and/or replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | +| `goal-write` | Write the session-scoped goal brief that seeds a goal loop. | [`src/tools/goal-authoring.ts`](../src/tools/goal-authoring.ts) | | `review-write` | Store a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-read` | Read review findings. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-delete` | Delete a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | | `execute-plan` | Start an iterative development loop in an isolated git worktree, or (with `mode: new-session`) launch the plan in a fresh standalone session. | [`src/tools/loop.ts`](../src/tools/loop.ts) | -| `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `execute-goal` | Launch a managed goal loop from the goal brief stored for the current session (`goal-write`). | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -66,6 +67,19 @@ Arguments: `plan-write` and `plan-edit` author the plan before execution; `plan-adjust` amends an already-running sectioned loop's plan during a section audit and is auditor-only. +### `goal-write` + +Writes the session-scoped **goal brief** that seeds a goal loop, the goal-mode counterpart of `plan-write`. Available to the `goal` agent only; denied in `code`, `auditor`, `auditor-loop`, `feature-splitter`, `architect`, and `architect-auto` sessions, and inside any running loop or audit session. Denied when the session owns a running loop, the same shared guard `plan-authoring.ts` uses. + +Reports missing required headings as warnings, and rejects `## Phase` headings or `` markers without writing. A brief with heading warnings still persists so the agent can inspect the report and correct it. Accepts `append` to grow the brief incrementally. On success the brief is persisted to the session-scoped `goal_briefs` row and the tool returns a structure report with line/character counts and warnings. + +Arguments: + +| Argument | Description | +|---|---| +| `content` | Goal brief markdown. Must contain the required headings; must not contain `## Phase` headings or `` markers. | +| `append` | Append to the existing stored brief instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the brief when none exists. | + ## Section Tools ### `section-read` @@ -141,22 +155,23 @@ Arguments: | `title` | Required short title for the session list. | | `plan` | Optional inline plan. If omitted, Forge reads the current session's stored plan. | | `loopName` | Optional loop name, slugified and uniquified. | -| `hostSessionId` | Optional host session ID for post-completion redirect. | | `mode` | Execution mode. `loop` (default) runs the iterative loop in an isolated git worktree. `new-session` launches the plan in a fresh standalone session running the code agent (no worktree, no loop, not tracked by `loop-status`/`loop-cancel`). | ### `execute-goal` -Starts a managed **goal loop** from free-text goal input, with no plan, decomposition, approval flow, final audit, or post-action. Forge creates a dedicated code session inside an isolated worktree and sends the goal as its initial prompt. When that coding pass goes idle, Forge replaces it with a fresh auditor session; a dirty audit then creates a fresh code session for remediation. The invoking session remains the host redirect target and is not warped into the worktree. +Launches a managed **goal loop** from the goal brief stored for the current session (authored with `goal-write`), with no plan, decomposition, approval flow, final audit, or post-action. There is no `goal` argument — the tool reads the stored brief, refuses to launch when the brief is missing or incomplete (missing required `##` headings), and dispatches the brief as the goal. Forge creates a dedicated code session inside an isolated worktree and sends the brief as its initial prompt. When that coding pass goes idle, Forge replaces it with a fresh auditor session; a dirty audit then creates a fresh code session for remediation. The invoking session remains the host redirect target and is not warped into the worktree. + +The `goal` agent calls this tool directly after authoring a clean brief; the Forge execution dialog is the alternate launch surface when the user wants to pick models. `execute-goal` uses the plugin-config default execution and auditor models. Arguments: | Argument | Description | |---|---| -| `goal` | Required. Non-empty free text describing the goal; the first line is used to derive a title/loop name when omitted. | -| `title` | Optional short title for the loop (derived from the goal when omitted). | +| `title` | Optional short title for the loop (derived from the brief's `## Goal` when omitted). | | `loopName` | Optional loop name, slugified and uniquified. | | `maxIterations` | Optional maximum loop iterations. Defaults to the plugin config `loop.defaultMaxIterations`; `0` means unlimited (run until auditor all-clear or cancellation). | -| `hostSessionId` | Optional host session ID for post-completion redirect; defaults to the invoking (`execute-goal`) session. | + +The invoking session is automatically used as the post-completion host redirect target; `execute-goal` does not expose a `hostSessionId` argument. Worktree/session behavior, auditor/finding completion rule, iteration cap, and differences from `execute-plan` and `launch-group` are documented in [Loop System → Goal Loops](loop-system.md#goal-loops). diff --git a/docs/architecture.md b/docs/architecture.md index 84320e424..8a7c2c510 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,13 +63,13 @@ The codebase is organized into these module groups under `src/`: | Module | Purpose | Key Files | |--------|---------|-----------| -| `agents/` | AI agent definitions (code, architect, auditor + auditor-loop variant) | `index.ts`, `code.ts`, `architect.ts`, `auditor.ts` | +| `agents/` | AI agent definitions (code, architect, goal, auditor + auditor-loop variant) | `index.ts`, `code.ts`, `architect.ts`, `goal.ts`, `auditor.ts` | | `hooks/` | Plugin event/lifecycle hooks (session, loop events, plan capture, plan approval, watchdog, sandbox, forge-session-attach, loop-permission, host-side-effects) | `index.ts`, `session.ts`, `loop.ts`, `plan-capture.ts`, `plan-approval.ts`, `watchdog.ts`, `sandbox-tools.ts`, `forge-session-attach.ts`, `loop-permission.ts`, `host-side-effects.ts` | | `loop/` | Core loop state machine and runtime | `runtime.ts`, `service.ts`, `state.ts`, `transitions.ts`, `prompts.ts`, `restartability.ts`, `in-flight-guard.ts`, `token-usage.ts`, `name-uniqueness.ts` | | `services/` | Higher-level orchestration services | `execution.ts`, `session-loop-resolver.ts`, `deterministic-decomposer.ts`, `plan-capture.ts`, `worktree-log.ts` | | `sandbox/` | Docker sandbox management | `docker.ts`, `manager.ts`, `context.ts`, `reconcile.ts` | | `storage/` | SQLite persistence layer (repos + migrations) | `database.ts`, `repos/*.ts`, `migrations/*.sql` | -| `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `section-read.ts` | +| `tools/` | Plugin tools callable by AI agents | `loop.ts`, `review.ts`, `plan-kv.ts`, `plan-authoring.ts`, `goal-authoring.ts`, `section-read.ts` | | `workspace/` | Git worktree / workspace management | `forge-adapter.ts`, `forge-worktree.ts`, `pending-teardown.ts`, `classify-stale.ts`, `remove-with-context.ts`, `sweep-stale.ts` | | `utils/` | Shared utility modules (~25 files) | `logger.ts`, `lru-cache.ts`, `model-fallback.ts`, etc. | | `tui/` | TUI-specific components | `execute-plan-panel.tsx` | @@ -167,7 +167,7 @@ OpenCode Forge uses `bun:sqlite` for all data persistence. The storage layer is - `initializeDatabase(dataDir, options)` - Creates SQLite DB in the data directory - `closeDatabase()` - Closes database connections on shutdown - `resolveDataDir()` - Resolves platform-appropriate data directory (`~/.local/share/opencode/forge`) -- Migrations are registered explicitly in execution order (ids 100-143; not every id ships a SQL file) and tracked in a `migrations` table +- Migrations are registered explicitly in execution order (ids 100-144; not every id ships a SQL file) and tracked in a `migrations` table ### Repository Pattern @@ -177,6 +177,7 @@ All data access goes through typed repository interfaces created via factory fun |---|---|---| | `LoopsRepo` | CRUD for loop rows | `LoopRow`, `LoopLargeFields` | | `PlansRepo` | CRUD for plans (session-scoped plan of record read by `plan-read`, the approval hook, `execute-plan`, and the TUI plan dialog) | `PlanRow`, `PlansRepo` | +| `GoalBriefsRepo` | CRUD for session-scoped goal briefs authored before goal-loop launch | `GoalBriefRow`, `GoalBriefsRepo` | | `ReviewFindingsRepo` | CRUD for review findings | `ReviewFindingRow`, `ReviewFindingsRepo` | | `SectionPlansRepo` | CRUD for milestone (section) plans used in decomposed loops | `SectionPlanRow`, `SectionPlansRepo` | | `LoopTransitionsRepo` | Append-only loop phase-transition log | `LoopTransitionRow` | diff --git a/docs/loop-system.md b/docs/loop-system.md index eccbbff22..5354aa9ef 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -294,12 +294,17 @@ Cancellation: ## Goal Loops -A **goal loop** (`kind: 'goal'`) is a lightweight alternative to a plan loop for work that does not need a structured plan. It is started by the `/execute-goal ` slash command (or the `execute-goal` tool) with free-text goal text. +A **goal loop** (`kind: 'goal'`) is a lightweight alternative to a plan loop for work that does not need a structured plan. Every goal loop is launched from a **goal brief** authored by the `goal` agent with `goal-write` and persisted to a session-scoped `goal_briefs` row. Forge exposes two launch surfaces for that brief: + +- **`execute-goal` tool** — the `goal` agent calls it directly after authoring a clean brief. It reads the stored brief and starts a managed goal loop with the plugin-config default execution and auditor models. +- **Forge execution dialog** — the user opens the dialog to pick the execution model, auditor model, and other launch options, then launches the brief-backed goal loop from there. + +Both surfaces read the same stored brief, so the auditor contract is identical regardless of which one launches the loop. ### Lifecycle differences from plan loops -- **No plan, no decomposition, no approval.** The goal text is persisted in `loop_large_fields.goal` (never in the `plans` table) and is the auditor's authoritative scope. There are no sections, no `final_auditing` phase, and no `post_action` phase. -- **Dedicated rotating sessions.** Forge creates a code session in the isolated worktree, sends the goal as its initial prompt, and leaves the invoking session unchanged as the post-completion host redirect target. As with plan loops, each completed code or audit pass is retired before the next session takes over. +- **No plan, no decomposition, no approval.** The brief text is persisted in `loop_large_fields.goal` (never in the `plans` table) and is the auditor's authoritative scope. There are no sections, no `final_auditing` phase, and no `post_action` phase. +- **Dedicated rotating sessions.** Forge creates a code session in the isolated worktree, sends the brief as its initial prompt, and leaves the invoking session unchanged as the post-completion host redirect target. As with plan loops, each completed code or audit pass is retired before the next session takes over. - **Idle-driven audits.** When the executor goes idle, the loop runner starts a fresh `auditor-loop` session against the worktree (same as plan loops). The auditor verifies both goal completion and code correctness, and may block termination with `severity: "bug"` findings (using the stable pseudo-path `GOAL` with `line: 1` when no source location applies for an unmet part of the goal). - **Dirty audits create a fresh code session.** When findings remain, Forge creates and selects a new code session in the worktree and sends a continuation prompt containing the goal and outstanding findings. That session goes idle to trigger the next audit. - **Clean audits terminate immediately.** When a completed auditor pass leaves zero outstanding review findings (any severity), the loop terminates with `completed` — no final audit, no post-completion action. @@ -316,18 +321,22 @@ A goal loop completes only after the auditor has run at least once and leaves no Goal loops are fully visible to `loop-status`, cancellable with `loop-cancel`, and restartable with `loop-status ... restart=true`. Restart preserves the goal kind and goal text, skips plan decomposition, and resumes from a fresh session. -`execute-goal` is added to the same loop/audit denial lists as `execute-plan`, `launch-group`, and the group tools, so an active executor or auditor session cannot recursively start another goal loop. Auditors exclude `execute-goal` from their tools. +`execute-goal` is added to the same loop/audit denial lists as `execute-plan`, `launch-group`, and the group tools, so an active executor or auditor session cannot recursively start another goal loop. Auditors exclude `execute-goal` from their tools. The `goal` agent runs in the user's briefing session (not a loop session), so it is the one surface allowed to call `execute-goal`. ### Differences from `execute-plan` and `launch-group` -| Aspect | `execute-plan` (loop) | `execute-goal` | `launch-group` | +| Aspect | `execute-plan` (loop) | `/goal` brief → goal loop | `launch-group` | |---|---|---|---| -| Input | Structured plan (persisted in `plans`) | Free-text goal | PRD / pre-split feature list | +| Input | Structured plan (persisted in `plans`) | Structured goal brief (persisted in `goal_briefs`) | PRD / pre-split feature list | | Sections / milestones | Yes (decomposed) | No | Per feature (each feature is its own loop) | | Executor session | Fresh session per iteration | Fresh dedicated session per coding pass | Fresh session per feature loop | | Final audit | Yes (after all sections) | No | Per feature loop | | Post-completion action | Yes (when configured) | Never | Per feature loop | -| Slash command | `/execute-plan` | `/execute-goal` | None (agent-invoked) | +| Stored artifact | `plans` row | `goal_briefs` row pre-launch; `loop_large_fields.goal` after launch | Per-feature `plans` rows | +| Model selection | Execution dialog (executor + auditor) | `execute-goal` tool (config defaults) or execution dialog | Execution dialog per feature | +| Slash command | `/execute-plan` | `/goal` | None (agent-invoked) | + +The brief's `## Acceptance Criteria` section becomes the frozen spec re-read by `buildGoalAuditPrompt` on every audit pass: once a brief-backed goal loop launches, the brief text is copied into `loop_large_fields.goal` and the auditor compares the worktree against that exact text on every pass. The structured acceptance criteria give the auditor a stable, reviewable contract instead of a one-line free-text prompt. ## Tool Restrictions diff --git a/docs/modules.md b/docs/modules.md index 0f3bfe715..bc2105580 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -73,13 +73,14 @@ Defines roles and system prompts for each AI agent used in the forge pipeline. | `code.ts` | Code execution agent | | `architect.ts` | Read-only planning/design agent | | `auditor.ts` | Code review agent + auditor-loop variant | +| `goal.ts` | Read-only brief-authoring agent (writes session-scoped goal briefs with `goal-write`) | ### Public API ```typescript buildAgents(): Record -type AgentRole = 'code' | 'architect' | 'auditor' | 'auditor-loop' +type AgentRole = 'code' | 'architect' | 'auditor' | 'auditor-loop' | 'architect-auto' | 'feature-splitter' | 'goal' ``` Source: [src/agents/index.ts](../src/agents/index.ts) @@ -346,10 +347,11 @@ Each created via `createXxxRepo(db)` factory with project-scoped queries: | `PlanAmendmentsRepo` | `plan_amendments` | `PlanAmendmentRow` — append-only plan-amendment audit trail | | `LoopSessionUsageRepo` | `loop_session_usage` | `LoopSessionUsageRow`, `LoopUsageAggregate` | | `TuiPrefsRepo` | `tui_preferences` | N/A | +| `GoalBriefsRepo` | `goal_briefs` | Session-scoped goal briefs authored with `goal-write` before launching a goal loop | ### Migrations -Migrations are registered explicitly, in execution order, in the `migrations` array (ids 100–143; inline migrations are valid, so not every id ships a `.sql` file) and tracked in a `migrations` table. +Migrations are registered explicitly, in execution order, in the `migrations` array (ids 100–144; inline migrations are valid, so not every id ships a `.sql` file) and tracked in a `migrations` table. See [storage/migrations/README.md](../src/storage/migrations/README.md) for migration details. @@ -373,8 +375,9 @@ Implements tools callable by AI agents during conversations. | `plan-read` | `plan-kv.ts` | Retrieve plans with pagination and pattern search | | `section-read` | `section-read.ts` | Retrieve a specific section of a plan | | `plan-adjust` | `plan-adjust.ts` | Auditor-only: revise the section under audit and/or replace the remaining sections of the active loop plan (logged as a plan amendment) | -| `execute-plan` | `loop.ts` | Execute a plan using an iterative development loop, or `mode: new-session` for a fresh standalone session. Args: `title` required; `plan`, `loopName`, `hostSessionId`, `mode` optional. | -| `execute-goal` | `loop.ts` | Execute a non-empty goal in a dedicated session inside a managed worktree. Args: `goal` required; `title`, `loopName`, `maxIterations`, `hostSessionId` optional. | +| `goal-write` | `goal-authoring.ts` | `goal` agent-only: validate, persist, or append the session-scoped goal brief; denied in any running loop or audit session. Returns a structure report. | +| `execute-plan` | `loop.ts` | Execute a plan using an iterative development loop, or `mode: new-session` for a fresh standalone session. Args: `title` required; `plan`, `loopName`, `mode` optional. | +| `execute-goal` | `loop.ts` | Launch a goal loop from the goal brief stored for the current session (authored with `goal-write`); refuses when no brief is stored or the brief is incomplete. Args: `title`, `loopName`, `maxIterations` optional. The invoking session is the implicit post-completion host redirect target; `execute-goal` does not expose `hostSessionId`. | | `loop-status` | `loop.ts` | List active/recent loops, show cumulative usage for detailed status, or restart loops with `restart`/`force` arguments | | `loop-cancel` | `loop.ts` | Cancel an active loop by worktree name | @@ -397,6 +400,7 @@ interface ToolContext { input: PluginInput sandboxManager: SandboxManager | null plansRepo: PlansRepo + goalBriefsRepo: GoalBriefsRepo reviewFindingsRepo: ReviewFindingsRepo loopsRepo: LoopsRepo sectionPlansRepo: SectionPlansRepo diff --git a/docs/tools.md b/docs/tools.md index f6306243d..da9a97f65 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,11 +13,12 @@ See also: [Agents and Slash Commands](agents-and-commands.md), [Configuration](c | `plan-edit` | Edit the stored session plan by exact string replacement. | [`src/tools/plan-authoring.ts`](../src/tools/plan-authoring.ts) | | `section-read` | Read a section plan and status for the active loop session. | [`src/tools/section-read.ts`](../src/tools/section-read.ts) | | `plan-adjust` | Revise the section under audit and/or replace the remaining (not yet started) sections of the active loop plan; auditor-only, logged as a plan amendment. | [`src/tools/plan-adjust.ts`](../src/tools/plan-adjust.ts) | +| `goal-write` | Write the session-scoped goal brief that seeds a goal loop. | [`src/tools/goal-authoring.ts`](../src/tools/goal-authoring.ts) | | `review-write` | Store a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-read` | Read review findings. | [`src/tools/review.ts`](../src/tools/review.ts) | | `review-delete` | Delete a review finding. | [`src/tools/review.ts`](../src/tools/review.ts) | | `execute-plan` | Start an iterative development loop in an isolated git worktree, or (with `mode: new-session`) launch the plan in a fresh standalone session. | [`src/tools/loop.ts`](../src/tools/loop.ts) | -| `execute-goal` | Start a managed goal loop in a dedicated code session inside an isolated Forge worktree. | [`src/tools/loop.ts`](../src/tools/loop.ts) | +| `execute-goal` | Launch a managed goal loop from the goal brief stored for the current session (`goal-write`). | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-cancel` | Cancel an active loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `loop-status` | List loops, inspect one loop, or restart a restartable loop. | [`src/tools/loop.ts`](../src/tools/loop.ts) | | `launch-group` | Launch a group of features (from a PRD or a pre-split list), each planned and run as its own loop, scheduled with a concurrency cap. | [`src/tools/group.ts`](../src/tools/group.ts) | @@ -66,6 +67,19 @@ Arguments: `plan-write` and `plan-edit` author the plan before execution; `plan-adjust` amends an already-running sectioned loop's plan during a section audit and is auditor-only. +### `goal-write` + +Writes the session-scoped **goal brief** that seeds a goal loop, the goal-mode counterpart of `plan-write`. Available to the `goal` agent only; denied in `code`, `auditor`, `auditor-loop`, `feature-splitter`, `architect`, and `architect-auto` sessions, and inside any running loop or audit session. Denied when the session owns a running loop, the same shared guard `plan-authoring.ts` uses. + +Reports missing required headings as warnings, and rejects `## Phase` headings or `` markers without writing. A brief with heading warnings still persists so the agent can inspect the report and correct it. Accepts `append` to grow the brief incrementally. On success the brief is persisted to the session-scoped `goal_briefs` row and the tool returns a structure report with line/character counts and warnings. + +Arguments: + +| Argument | Description | +|---|---| +| `content` | Goal brief markdown. Must contain the required headings; must not contain `## Phase` headings or `` markers. | +| `append` | Append to the existing stored brief instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the brief when none exists. | + ## Section Tools ### `section-read` @@ -141,22 +155,23 @@ Arguments: | `title` | Required short title for the session list. | | `plan` | Optional inline plan. If omitted, Forge reads the current session's stored plan. | | `loopName` | Optional loop name, slugified and uniquified. | -| `hostSessionId` | Optional host session ID for post-completion redirect. | | `mode` | Execution mode. `loop` (default) runs the iterative loop in an isolated git worktree. `new-session` launches the plan in a fresh standalone session running the code agent (no worktree, no loop, not tracked by `loop-status`/`loop-cancel`). | ### `execute-goal` -Starts a managed **goal loop** from free-text goal input, with no plan, decomposition, approval flow, final audit, or post-action. Forge creates a dedicated code session inside an isolated worktree and sends the goal as its initial prompt. When that coding pass goes idle, Forge replaces it with a fresh auditor session; a dirty audit then creates a fresh code session for remediation. The invoking session remains the host redirect target and is not warped into the worktree. +Launches a managed **goal loop** from the goal brief stored for the current session (authored with `goal-write`), with no plan, decomposition, approval flow, final audit, or post-action. There is no `goal` argument — the tool reads the stored brief, refuses to launch when the brief is missing or incomplete (missing required `##` headings), and dispatches the brief as the goal. Forge creates a dedicated code session inside an isolated worktree and sends the brief as its initial prompt. When that coding pass goes idle, Forge replaces it with a fresh auditor session; a dirty audit then creates a fresh code session for remediation. The invoking session remains the host redirect target and is not warped into the worktree. + +The `goal` agent calls this tool directly after authoring a clean brief; the Forge execution dialog is the alternate launch surface when the user wants to pick models. `execute-goal` uses the plugin-config default execution and auditor models. Arguments: | Argument | Description | |---|---| -| `goal` | Required. Non-empty free text describing the goal; the first line is used to derive a title/loop name when omitted. | -| `title` | Optional short title for the loop (derived from the goal when omitted). | +| `title` | Optional short title for the loop (derived from the brief's `## Goal` when omitted). | | `loopName` | Optional loop name, slugified and uniquified. | | `maxIterations` | Optional maximum loop iterations. Defaults to the plugin config `loop.defaultMaxIterations`; `0` means unlimited (run until auditor all-clear or cancellation). | -| `hostSessionId` | Optional host session ID for post-completion redirect; defaults to the invoking (`execute-goal`) session. | + +The invoking session is automatically used as the post-completion host redirect target; `execute-goal` does not expose a `hostSessionId` argument. Worktree/session behavior, auditor/finding completion rule, iteration cap, and differences from `execute-plan` and `launch-group` are documented in [Loop System → Goal Loops](loop-system.md#goal-loops). diff --git a/src/agents/architect-auto.ts b/src/agents/architect-auto.ts index 3d68f8434..46178ca02 100644 --- a/src/agents/architect-auto.ts +++ b/src/agents/architect-auto.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' +import { GOAL_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { return { @@ -9,7 +10,7 @@ export function buildArchitectAutoAgent(promptsDir?: string): AgentDefinition { mode: 'primary', hidden: true, tools: { - exclude: ['plan', 'plan_enter', 'plan_exit', 'question'], + exclude: ['plan', 'plan_enter', 'plan_exit', 'question', ...GOAL_AUTHORING_TOOL_NAMES], }, systemPrompt: loadPrompt(['agents', 'architect-auto.md'], promptsDir), } diff --git a/src/agents/architect.ts b/src/agents/architect.ts index 95428d78f..937e0ead3 100644 --- a/src/agents/architect.ts +++ b/src/agents/architect.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' +import { GOAL_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildArchitectAgent(promptsDir?: string): AgentDefinition { return { @@ -12,7 +13,7 @@ export function buildArchitectAgent(promptsDir?: string): AgentDefinition { question: 'allow', }, tools: { - exclude: ['plan', 'plan_enter', 'plan_exit'], + exclude: ['plan', 'plan_enter', 'plan_exit', ...GOAL_AUTHORING_TOOL_NAMES], }, systemPrompt: loadPrompt(['agents', 'architect.md'], promptsDir), } diff --git a/src/agents/auditor.ts b/src/agents/auditor.ts index 328c7ee0f..292dbe17e 100644 --- a/src/agents/auditor.ts +++ b/src/agents/auditor.ts @@ -1,7 +1,7 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' import { hasSectionSummaryMarkers } from '../utils/section-summary' -import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' +import { SPEC_AUTHORING_TOOL_NAMES } from '../constants/loop' const AUDITOR_TOOL_EXCLUDES = [ 'apply_patch', @@ -17,7 +17,7 @@ const AUDITOR_TOOL_EXCLUDES = [ 'launch-group', 'group-status', 'group-cancel', - ...PLAN_AUTHORING_TOOL_NAMES, + ...SPEC_AUTHORING_TOOL_NAMES, ] function buildBasePrompt(promptsDir?: string): string { diff --git a/src/agents/code.ts b/src/agents/code.ts index add6afb7b..acc7241d3 100644 --- a/src/agents/code.ts +++ b/src/agents/code.ts @@ -1,6 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' -import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' +import { SPEC_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildCodeAgent(promptsDir?: string): AgentDefinition { return { @@ -13,7 +13,7 @@ export function buildCodeAgent(promptsDir?: string): AgentDefinition { question: 'allow', }, tools: { - exclude: ['review-write','review-delete', 'plan', 'plan_enter', 'plan_exit', ...PLAN_AUTHORING_TOOL_NAMES] + exclude: ['review-write','review-delete', 'plan', 'plan_enter', 'plan_exit', ...SPEC_AUTHORING_TOOL_NAMES] }, systemPrompt: loadPrompt(['agents', 'code.md'], promptsDir), } diff --git a/src/agents/feature-splitter.ts b/src/agents/feature-splitter.ts index cd27c171c..a1734205d 100644 --- a/src/agents/feature-splitter.ts +++ b/src/agents/feature-splitter.ts @@ -1,6 +1,6 @@ import type { AgentDefinition } from './types' import { loadPrompt } from '../prompts/loader' -import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' +import { SPEC_AUTHORING_TOOL_NAMES } from '../constants/loop' export function buildFeatureSplitterAgent(promptsDir?: string): AgentDefinition { return { @@ -10,7 +10,7 @@ export function buildFeatureSplitterAgent(promptsDir?: string): AgentDefinition mode: 'primary', hidden: true, tools: { - exclude: ['plan', 'plan_enter', 'plan_exit', 'question', 'write', 'edit', 'patch', ...PLAN_AUTHORING_TOOL_NAMES], + exclude: ['plan', 'plan_enter', 'plan_exit', 'question', 'write', 'edit', 'patch', ...SPEC_AUTHORING_TOOL_NAMES], }, systemPrompt: loadPrompt(['agents', 'feature-splitter.md'], promptsDir), } diff --git a/src/agents/goal.ts b/src/agents/goal.ts new file mode 100644 index 000000000..372dcbcae --- /dev/null +++ b/src/agents/goal.ts @@ -0,0 +1,26 @@ +import type { AgentDefinition } from './types' +import { loadPrompt } from '../prompts/loader' +import { PLAN_AUTHORING_TOOL_NAMES } from '../constants/loop' + +export function buildGoalAgent(promptsDir?: string): AgentDefinition { + return { + role: 'goal', + id: 'opencode-goal', + displayName: 'goal', + mode: 'primary', + permission: { question: 'allow' }, + tools: { + exclude: [ + 'write', 'edit', 'multiedit', 'apply_patch', 'patch', + 'plan', 'plan_enter', 'plan_exit', + 'execute-plan', + 'launch-group', 'group-status', 'group-cancel', + 'loop-cancel', 'loop-status', + 'review-write', 'review-delete', + 'plan-adjust', + ...PLAN_AUTHORING_TOOL_NAMES, + ], + }, + systemPrompt: loadPrompt(['agents', 'goal.md'], promptsDir), + } +} diff --git a/src/agents/index.ts b/src/agents/index.ts index f7516edcd..1d1ce6005 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -4,6 +4,7 @@ import { buildArchitectAgent } from './architect' import { buildAuditorAgent, buildAuditorLoopAgent } from './auditor' import { buildArchitectAutoAgent } from './architect-auto' import { buildFeatureSplitterAgent } from './feature-splitter' +import { buildGoalAgent } from './goal' export function buildAgents(promptsDir?: string): Record { return { @@ -13,6 +14,7 @@ export function buildAgents(promptsDir?: string): Record template: loadPrompt(['commands','review-plan.md'], promptsDir) }, 'execute-plan': { description: 'Execute a plan in an iterative development loop, or a fresh standalone session', agent: 'code', subtask: false, template: loadPrompt(['commands','execute-plan.md'], promptsDir) }, - 'execute-goal': { description: 'Execute a goal in a dedicated session inside an isolated Forge worktree loop', agent: 'code', subtask: false, - template: loadPrompt(['commands','execute-goal.md'], promptsDir) }, + goal: { description: 'Research a goal, ask clarifying questions, author a goal brief, and launch a goal loop', agent: 'goal', subtask: false, + template: loadPrompt(['commands','goal.md'], promptsDir) }, 'launch-group': { description: 'Decompose a request into features and launch them as parallel planning + development loops', agent: 'code', subtask: false, template: loadPrompt(['commands','launch-group.md'], promptsDir) }, 'loop-status': { description: 'Check status of all active loops', agent: 'code', subtask: false, diff --git a/src/constants/loop.ts b/src/constants/loop.ts index af0fe82b4..0c4009b21 100644 --- a/src/constants/loop.ts +++ b/src/constants/loop.ts @@ -15,6 +15,10 @@ export const MAX_TOTAL_SECTIONS = 24 */ export const PLAN_AUTHORING_TOOL_NAMES = ['plan-write', 'plan-edit'] as const +export const GOAL_AUTHORING_TOOL_NAMES = ['goal-write'] as const + +export const SPEC_AUTHORING_TOOL_NAMES = [...PLAN_AUTHORING_TOOL_NAMES, ...GOAL_AUTHORING_TOOL_NAMES] as const + /** * Resolves the full set of external directories loop/audit sessions may access: the shared temp * directory (always, default `/tmp/oc-forge`) plus any user-configured `loop.allowExternalDirectories`. @@ -47,9 +51,8 @@ export interface LoopPermissionRulesetOptions { * layered on top. Both are added AFTER the blanket `external_directory` deny so last-match-wins * resolution grants access to these paths while all others stay denied. */ -/** Deny rules for every plan-authoring tool, derived from the shared name list. */ -function planAuthoringDenyRules(): PermissionRule[] { - return PLAN_AUTHORING_TOOL_NAMES.map((permission) => ({ permission, pattern: '*', action: 'deny' as const })) +function specAuthoringDenyRules(): PermissionRule[] { + return SPEC_AUTHORING_TOOL_NAMES.map((permission) => ({ permission, pattern: '*', action: 'deny' as const })) } function buildExternalDirectoryAllowRules(allowDirectories: string[] = []): PermissionRule[] { @@ -100,7 +103,7 @@ export function buildLoopPermissionRuleset(options: LoopPermissionRulesetOptions { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, - ...planAuthoringDenyRules(), + ...specAuthoringDenyRules(), { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, @@ -146,7 +149,7 @@ export function buildAuditSessionPermissionRuleset(options: LoopPermissionRulese { permission: 'plan', pattern: '*', action: 'deny' }, { permission: 'plan_enter', pattern: '*', action: 'deny' }, { permission: 'plan_exit', pattern: '*', action: 'deny' }, - ...planAuthoringDenyRules(), + ...specAuthoringDenyRules(), { permission: 'execute-plan', pattern: '*', action: 'deny' }, { permission: 'execute-goal', pattern: '*', action: 'deny' }, { permission: 'question', pattern: '*', action: 'deny' }, diff --git a/src/hooks/forge-session-attach.ts b/src/hooks/forge-session-attach.ts index 8ec8d38fa..de55181af 100644 --- a/src/hooks/forge-session-attach.ts +++ b/src/hooks/forge-session-attach.ts @@ -1,9 +1,10 @@ import type { Logger } from '../types' import type { ForgeClient } from '../client/port' import type { ForgeExecutionServiceDeps, ForgeLoopExtra, PlanSource } from '../services/execution' -import { attachLoopToSession } from '../services/execution' +import { attachLoopToSession, resolveForgeLoopExtraSpec } from '../services/execution' +import { resolveLoopLaunchPolicy } from '../utils/loop-helpers' import { resolveSandboxContextForLoop, isSandboxEnabled } from '../sandbox/context' -import { classifyForgeWorkspace, isPendingAttachWorkspace } from '../workspace/classify-stale' +import { classifyForgeWorkspace, isPendingAttachWorkspace, isGoalPendingAttachExpired } from '../workspace/classify-stale' import { removeForgeWorkspaceWithContext } from '../workspace/remove-with-context' import { getForgeWorkspaceLoopName } from '../workspace/forge-worktree' @@ -115,7 +116,7 @@ async function attachForgeSession( } const cfg = (ws.extra ?? {}).forgeLoop as - | (Partial & { maxIterations?: number }) + | Partial | undefined if (cfg?.initialPromptOwner === 'tui' && sendInitialPrompt) { @@ -123,6 +124,24 @@ async function attachForgeSession( return } + const spec = resolveForgeLoopExtraSpec(cfg) + if (!spec.ok) { + deps.logger.log( + `[forge-session-attach] skip session=${sessionId} workspace=${workspaceId} loop=${loopName} reason=invalid-forgeLoop-spec error=${spec.error}`, + ) + publishAttachFailureToast( + deps, + ws.directory ?? deps.directory, + `Forge loop "${loopName}"`, + 'Loop launch metadata is invalid. Re-run the launch from the TUI.', + ) + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-invalid-spec' }, + ) + return + } + // Build a synthetic entry for classification. If extra.projectDirectory is missing, // synthesize it from ws.directory so the classifier can still check the loop row. // This ensures the attach hook handles workspaces created by older code paths that @@ -153,8 +172,8 @@ async function attachForgeSession( const isTerminalNameConflict = action.action === 'keep' && action.reason === 'pending-start' && - cfg?.initialPromptOwner === 'tui' && - isPendingAttachWorkspace(classifyEntry) + ((cfg?.initialPromptOwner === 'tui' && isPendingAttachWorkspace(classifyEntry)) || + (cfg?.kind === 'goal' && cfg?.initialPromptOwner === 'server')) if (action.action === 'keep' && action.reason !== 'running' && action.reason !== 'pending-attach' && !isTerminalNameConflict) { // bad config or wrong-project — toast and bail, do not attach @@ -184,7 +203,11 @@ async function attachForgeSession( } return } - if (action.action === 'remove-fully' && cfg.initialPromptOwner === 'tui' && !isPendingAttachWorkspace(classifyEntry)) { + if ( + action.action === 'remove-fully' && + ((cfg.initialPromptOwner === 'tui' && !isPendingAttachWorkspace(classifyEntry)) || + isGoalPendingAttachExpired(classifyEntry)) + ) { await removeForgeWorkspaceWithContext( { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-expired-pending' }, @@ -241,26 +264,33 @@ async function attachForgeSession( ? cfg.hostSessionId : sessionId - const planSource: PlanSource = - cfg.planSource === 'inline' && cfg.planText - ? { kind: 'inline', planText: cfg.planText } - : { kind: 'stored', sessionId: resolvedHostSessionId } - let planText: string - if (planSource.kind === 'inline') { - planText = planSource.planText + let goalInput: { kind: 'goal'; goal: string; executorSessionId: string } | { kind: 'plan' } + if (spec.kind === 'goal') { + planText = '' + goalInput = { kind: 'goal', goal: spec.goal, executorSessionId: sessionId } } else { - const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) - if (!row) { - deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) - publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') - await removeForgeWorkspaceWithContext( - { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, - { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, - ) - return + goalInput = { kind: 'plan' } + const planSource: PlanSource = + cfg.planSource === 'inline' && cfg.planText + ? { kind: 'inline', planText: cfg.planText } + : { kind: 'stored', sessionId: resolvedHostSessionId } + + if (planSource.kind === 'inline') { + planText = planSource.planText + } else { + const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId) + if (!row) { + deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${loopName} workspace=${workspaceId}`) + publishAttachFailureToast(deps, ws.directory ?? deps.directory, `Forge loop "${loopName}"`, 'No stored plan found for this loop. Re-run "Execute → Loop" from a session that has a captured plan.') + await removeForgeWorkspaceWithContext( + { client: deps.client, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger }, + { workspaceId, loopName, action: 'remove-fully', reasonLabel: 'attach-no-plan' }, + ) + return + } + planText = row.content } - planText = row.content } try { @@ -281,10 +311,13 @@ async function attachForgeSession( auditorModel: cfg.auditorModel, executionVariant: cfg.executionVariant, auditorVariant: cfg.auditorVariant, - maxIterations: cfg.maxIterations ?? 50, + maxIterations: cfg.maxIterations ?? resolveLoopLaunchPolicy(deps.execDeps.config).maxIterations, sandboxEnabled: sandbox.enabled, sandboxContainer: sandbox.containerName, planText, + ...(goalInput.kind === 'goal' + ? { kind: 'goal' as const, goal: goalInput.goal, executorSessionId: goalInput.executorSessionId } + : {}), selectSession, selectSessionTiming: 'after-prompt', startWatchdog: true, diff --git a/src/index.ts b/src/index.ts index 9a35c3f37..3c3972f4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import type { ForgeClient, SessionGetParams } from './client/port' import { buildAgents } from './agents' import { createConfigHandler } from './config' import { createSessionHooks, createLoopEventHandler } from './hooks' -import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo } from './storage' +import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createGoalBriefsRepo } from './storage' import type { LoopChangeNotifier } from './loop' import { loadPluginConfig, resolveBundledContainerDir, resolvePromptsDir } from './setup' import { resolveLogPath } from './storage' @@ -336,6 +336,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { const featureGroupsRepo = createFeatureGroupsRepo(db) const loopTransitionsRepo = createLoopTransitionsRepo(db) const planAmendmentsRepo = createPlanAmendmentsRepo(db) + const goalBriefsRepo = createGoalBriefsRepo(db) // Mark any groups left in non-terminal status (extracting/planning/running) from a // prior process as interrupted. Do NOT auto-resume — user must restart via group-status. @@ -635,6 +636,7 @@ export function createForgePlugin(config: PluginConfig): Plugin { cleanup, sandboxManager, plansRepo, + goalBriefsRepo, reviewFindingsRepo, loopsRepo, sectionPlansRepo, diff --git a/src/loop/runtime-prompt.ts b/src/loop/runtime-prompt.ts index 87c6b6582..a41b2ca78 100644 --- a/src/loop/runtime-prompt.ts +++ b/src/loop/runtime-prompt.ts @@ -53,7 +53,10 @@ export function createPromptDispatch(deps: PromptDispatchDeps): PromptDispatch { worktreeDir: freshState.worktreeDir, workspaceId: freshState.workspaceId, prompt: promptText, - ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), + ...(model ? { auditorModel: model } : {}), + ...(input.variant && (model != null || (auditorModel == null && !freshState.modelFailed)) + ? { auditorVariant: input.variant } + : {}), }) return r.ok ? {} : { error: r.error } }, @@ -77,7 +80,10 @@ export function createPromptDispatch(deps: PromptDispatchDeps): PromptDispatch { ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), agent: 'code', parts: [{ type: 'text' as const, text: promptText }], - ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), + ...(model ? { model } : {}), + ...(input.variant && (model != null || (effectiveModel == null && !freshState.modelFailed)) + ? { variant: input.variant } + : {}), }) return {} } catch (err) { diff --git a/src/prompts/agents/goal.md b/src/prompts/agents/goal.md new file mode 100644 index 000000000..1e4dfe43a --- /dev/null +++ b/src/prompts/agents/goal.md @@ -0,0 +1,66 @@ +You are a goal-briefing agent. Your role is to research the codebase, ask clarifying questions inline, produce a **goal brief**, and launch a goal loop from that brief. You do not implement code, and you never produce a phased implementation plan. + +# Tone and style +Be concise, direct, and to the point. Your output is displayed on a CLI using GitHub-flavored markdown. +Minimize output tokens while maintaining quality. Do not add unnecessary preamble or postamble. +Prioritize technical accuracy over validating assumptions. Disagree when the evidence supports it. + +## General guidelines +- When exploring the codebase, prefer the Task tool with explore agents to reduce context usage and parallelize discovery. +- Launch up to 3 explore agents IN PARALLEL when the scope is uncertain or multiple areas are involved. +- Call multiple tools in a single response when they are independent. Batch tool calls for performance. +- Use specialized tools (Read, Glob, Grep) instead of bash equivalents (cat, find, grep). + +# Following conventions +When researching a goal, identify the existing code conventions that the implementation will need to match: +- Check how similar code is written before letting the brief reference patterns. +- Never assume a library or helper is available — verify it exists in the project first. +- Note framework choices, naming conventions, and typing patterns in the brief's `## Context` section. + +# Code references +When referencing code, use the pattern `file_path:line_number` for easy navigation. + +# File paths in the brief +All file references in your goal brief output MUST be repo-relative paths (e.g. `src/services/auth.ts`, `test/auth.test.ts`). Never include absolute host paths (paths starting with `/` such as `/Users/...`, `/home/...`, or `/private/...`) or home-relative paths (paths starting with `~/`) in Goal, Context, Constraints, or Acceptance Criteria. The brief is replayed verbatim into code/auditor sessions that may execute inside a git worktree at a different absolute path; absolute paths from the source checkout will not resolve there. Repo-relative paths work regardless of CWD. + +## Constraints + +You are in READ-ONLY mode **for file system operations**. You must NOT directly edit source files, run destructive commands, or make code changes. You may only read, search, and analyze the codebase. Authoring the goal brief through the `goal-write` tool is expected and is not a file edit. + +You MUST follow a gated briefing flow: +1. **Recon before drafting** — Inspect the codebase for what the goal touches: files and modules involved, existing helpers/patterns that already solve part of it, blast radius (callers, dependents, tests that will need updates), and any conflicting patterns that supersede the obvious approach. Do not start drafting the brief eagerly. +2. **Clarifying questions during research** — As ambiguities surface during recon, ask the user with the `question` tool right when they arise, not batched at the end. Prefer offering concrete options with a `(Recommended)` first option over open-ended questions. Ask multiple independent questions in a single `question` call when independent. Do not ask trivial questions whose answer is obvious from the codebase or conventions; answer those yourself first. +3. **Write the brief with `goal-write`** — Only after the goal, scope, constraints, and acceptance criteria are sufficiently clear, author the brief into storage with `goal-write`. You may call `goal-write` with `append: true` to incrementally add sections. Read the structure report that each call returns and fix any warnings (missing required headings) before finishing. + +## Goal Brief Storage + +You have access to two tools: + +- `goal-write`: Create, overwrite, or append (`append: true`) the goal brief stored for this session. The brief is the launch input for the goal loop. +- `execute-goal`: Launch a goal loop from the brief stored for this session. It creates an isolated Forge worktree, prompts a new dedicated code session with the brief, and starts the watchdog, using the plugin-config default execution and auditor models. + +Author the brief in one or a few `goal-write` calls. Do not emit the full brief in chat — it wastes tokens and gets truncated. Read every structure report returned by `goal-write`; it lists the line/char count, missing required `##` headings, and any plan-structure violations. Fix warnings by calling `goal-write` again (overwrite) before finishing. + +## Goal Brief Format + +The brief MUST contain exactly these four `##` headings, and MUST NOT contain any others: +- `## Goal` — What the user wants achieved and why it matters. The single, self-contained outcome statement the implementing loop will work from. +- `## Context` — What recon found: files/modules involved, existing helpers to reuse, prior art in the codebase, and any conflicting patterns that already exist. +- `## Constraints` — What must not change; compatibility requirements (breaking-change posture, migration ordering); and patterns to follow so the implementation matches existing conventions. +- `## Acceptance Criteria` — Verifiable conditions that prove the goal is met. Each item must be checkable by a test, a type/lint command, or a file/behavior assertion. + +The brief MUST NOT contain any of the following; `goal-write` rejects them: +- Plan section marker comments (the HTML comment the architect uses to delimit plan phases — `goal-write` rejects the brief if it contains one) +- `## Phase` or `### Phase` headings +- Ordered implementation steps, per-phase verification, or decomposition into phases + +The brief is a launch input, not a plan. It describes the destination, not the route. + +## After the brief is written + +Once `goal-write` returns a clean structure report (no missing headings, no plan-structure violations), ask the user with the `question` tool how to launch — one question, two options: + +- **Launch now** (Recommended) — call `execute-goal` immediately. It launches from the stored brief with the plugin-config default execution and auditor models. +- **Open the Forge execution dialog** — the user picks the execution model, auditor model, and other launch options, then launches from the dialog. Do **not** call `execute-goal` in this path; the dialog is the launch surface. + +Do not ask further approval questions after the launch decision is made. Do not edit files or attempt the goal in this session — that work happens in the new dedicated session the loop creates. diff --git a/src/prompts/commands/execute-goal.md b/src/prompts/commands/execute-goal.md deleted file mode 100644 index c943dd51d..000000000 --- a/src/prompts/commands/execute-goal.md +++ /dev/null @@ -1,29 +0,0 @@ -## Step 1: Validate the Goal - -Resolve the goal from `$ARGUMENTS` and the surrounding conversation. The user may have already established the goal earlier in the current session, so blank, whitespace-only, or referential arguments such as "do it" do not by themselves require clarification. - -If you are unsure what the goal is, or if its scope is ambiguous in a way that could materially change the implementation, use the `question` tool to ask a focused clarifying question and stop until the user answers. Do not guess consequential requirements or scope. Do not ask when the goal and scope are already clear from the conversation or can be resolved through normal repository inspection. - -Turn the resolved goal into a self-contained implementation request. Include the relevant requirements, constraints, file or issue references, and acceptance criteria already established in the conversation, but do not invent details or include unrelated context. When newer instructions conflict with earlier ones, follow the user's latest explicit instruction. - -Do NOT create a plan, decompose the goal into sections, or ask for approval. The goal is implemented directly by the loop. - -## Step 2: Start the Goal Loop - -Call the `execute-goal` tool with the full, self-contained goal text: -- goal: Required. The complete resolved goal, not necessarily the literal `$ARGUMENTS`. The new dedicated session does not inherit this conversation, so expand blank or referential arguments with the necessary context established above. -- title: Optional short title. Derived from the goal when omitted. -- loopName: Optional loop name. Forge slugifies it and auto-increments on collision. -- maxIterations: Optional maximum loop iterations. Defaults to the plugin config `loop.defaultMaxIterations`. - -This creates an isolated Forge worktree and a new dedicated code session inside it, sends the goal as that session's initial prompt, and starts the watchdog. Docker sandboxing is used automatically when configured and available. - -## Step 3: You Are Done - -The new session implements the goal — NOT this session. Do not edit files, run builds, or attempt the goal here. Just confirm to the user that the goal loop has been launched. - -The loop automatically audits the work when the session goes idle and rotates in fresh code sessions until an auditor pass leaves zero open findings, which completes the loop. - -Use `loop-status` to inspect progress or `loop-cancel` to stop early. Both work for goal loops exactly as they do for plan loops. - -$ARGUMENTS diff --git a/src/prompts/commands/goal.md b/src/prompts/commands/goal.md new file mode 100644 index 000000000..be2fa7811 --- /dev/null +++ b/src/prompts/commands/goal.md @@ -0,0 +1,24 @@ +## Step 1: Resolve the Goal + +Resolve the goal from `$ARGUMENTS` and the surrounding conversation. The user may have already established the goal earlier in the current session, so blank, whitespace-only, or referential arguments such as "do it" do not by themselves require clarification. + +If you are unsure what the goal is, or if its scope is ambiguous in a way that could materially change the brief, use the `question` tool to ask a focused clarifying question and stop until the user answers. Do not guess consequential requirements or scope. Do not ask when the goal and scope are already clear from the conversation or can be resolved through normal repository inspection. + +## Step 2: Recon + +Inspect the codebase for what the goal touches: files and modules involved, existing helpers/patterns that already solve part of it, blast radius (callers, dependents, tests that will need updates), and any conflicting patterns that supersede the obvious approach. Use direct inspection (Read/Grep/Glob) and parallel explore agents for broader research. As ambiguities surface, ask clarifying questions inline with the `question` tool — not batched at the end — offering concrete options with a `(Recommended)` first option. + +## Step 3: Write the Goal Brief + +Author the brief with `goal-write`. The brief MUST contain exactly these four `##` headings and no others: `## Goal`, `## Context`, `## Constraints`, `## Acceptance Criteria`. It MUST NOT contain plan section markers, `## Phase` headings, ordered implementation steps, or per-phase verification. Read the structure report returned by each `goal-write` call and fix any warnings (missing headings) before finishing. + +## Step 4: Launch + +Once `goal-write` returns a clean structure report, launch the goal loop. Offer the user two paths with the `question` tool (one question, two options): + +- **Launch now** (Recommended) — call the `execute-goal` tool immediately. It reads the stored brief and starts the goal loop with the plugin-config default execution and auditor models. +- **Open the Forge dialog** — the user picks the execution model, auditor model, and other launch options in the Forge execution dialog, then launches from there. Do **not** call `execute-goal` yourself in this path; the dialog is the launch surface. + +Whichever path is chosen, do not ask further approval questions after the launch decision is made. + +$ARGUMENTS diff --git a/src/services/execution.ts b/src/services/execution.ts index d56725f66..9f21dfbbe 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -36,6 +36,7 @@ import { loopBranchExists } from '../workspace/forge-naming' import { getWorktreeProjectPreconditionError } from '../workspace/forge-worktree' import { resolveHostSessionDirectory } from '../utils/resolve-project-root' import { resolvePostActionConfig, type ResolvedPostActionConfig } from '../loop/post-action-config' +import { resolveLoopLaunchPolicy } from '../utils/loop-helpers' /** * A freshly created + warped loop session can transiently report "Session not @@ -90,16 +91,44 @@ export interface ForgeLoopExtra { auditorModel?: string executionVariant?: string auditorVariant?: string - planSource: 'stored' | 'inline' + kind?: 'plan' | 'goal' + goal?: string + planSource?: 'stored' | 'inline' planText?: string initialPromptOwner?: 'server' | 'tui' pendingAttachStartedAt?: number + /** + * Maximum loop iterations as resolved at launch time by + * {@link resolveLoopLaunchPolicy}. Stamped on the envelope by the TUI/remote + * launch path so the attach hook honors the same policy without needing to + * re-resolve it from config (the attach hook's config view is the server's, + * which is consistent, but the envelope is the authority for what the + * launcher promised; absent stamp falls back to the server's policy). + */ + maxIterations?: number /** Whether the loop runs sandboxed. Written by the attach hook and remote launches; read on re-attach. */ sandboxEnabled?: boolean /** Docker container name when the loop runs sandboxed. */ sandboxContainer?: string } +export type ForgeLoopExtraSpec = + | { ok: true; kind: 'plan'; planText: string } + | { ok: true; kind: 'goal'; goal: string } + | { ok: false; error: string } + +export function resolveForgeLoopExtraSpec( + cfg: Partial | undefined, +): ForgeLoopExtraSpec { + if (cfg?.kind === 'goal') { + if (typeof cfg.goal !== 'string' || cfg.goal.trim().length === 0) { + return { ok: false, error: 'forgeLoop.kind is "goal" but forgeLoop.goal is missing or blank' } + } + return { ok: true, kind: 'goal', goal: cfg.goal.trim() } + } + return { ok: true, kind: 'plan', planText: cfg?.planText ?? '' } +} + export interface AttachLoopInput { sessionId: string workspaceId?: string @@ -794,6 +823,7 @@ export async function attachLoopToSession( agent: 'code', ...workspaceParam, ...(model ? { model } : {}), + ...(executionVariant && (model != null || loopModel == null) ? { variant: executionVariant } : {}), }) return {} } catch (err) { @@ -1099,8 +1129,9 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const resolvedExecutionVariant = command.executionVariant ?? deps.config.executionVariant const resolvedAuditorVariant = command.auditorVariant ?? deps.config.auditorVariant - // Resolve max iterations - const maxIterations = command.maxIterations ?? deps.config.loop?.defaultMaxIterations ?? 0 + // Resolve max iterations from the shared launch policy so the tool and + // TUI/remote launch surfaces cannot diverge. + const maxIterations = command.maxIterations ?? resolveLoopLaunchPolicy(deps.config).maxIterations // Track created resources for rollback let createdSessionId: string | null = null @@ -1346,7 +1377,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const baseName = command.loopName?.trim() ? slugify(command.loopName) : slugify(title) const uniqueLoopName = deps.loop.generateUniqueLoopName(baseName) - const maxIterations = command.maxIterations ?? deps.config.loop?.defaultMaxIterations ?? 0 + const maxIterations = command.maxIterations ?? resolveLoopLaunchPolicy(deps.config).maxIterations const resolvedExecutionModel = deps.config.executionModel const resolvedAuditorModel = deps.config.auditorModel const resolvedExecutionVariant = deps.config.executionVariant @@ -2060,7 +2091,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo directory: stoppedState.worktreeDir, parts: [{ type: 'text' as const, text: promptText }], agent: promptAgent, - ...(model ? { model, ...(restartVariant ? { variant: restartVariant } : {}) } : {}), + ...(model ? { model } : {}), + ...(restartVariant && (model != null || loopModel == null) + ? { variant: restartVariant } + : {}), ...workspaceParam, }) return {} diff --git a/src/storage/index.ts b/src/storage/index.ts index 65dd674c2..ebcac1ac9 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -23,3 +23,6 @@ export type { PlanRow } from './repos/plans-repo' export { createFeatureGroupsRepo } from './repos/feature-groups-repo' export type { FeatureGroupRow, GroupFeatureRow } from './repos/feature-groups-repo' + +export { createGoalBriefsRepo } from './repos/goal-briefs-repo' +export type { GoalBriefRow } from './repos/goal-briefs-repo' diff --git a/src/storage/migrations/144_create_goal_briefs.sql b/src/storage/migrations/144_create_goal_briefs.sql new file mode 100644 index 000000000..1f4d44a15 --- /dev/null +++ b/src/storage/migrations/144_create_goal_briefs.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS goal_briefs ( + project_id TEXT NOT NULL, + session_id TEXT NOT NULL, + content TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (project_id, session_id) +); diff --git a/src/storage/migrations/index.ts b/src/storage/migrations/index.ts index d0e840f05..11d0c4097 100644 --- a/src/storage/migrations/index.ts +++ b/src/storage/migrations/index.ts @@ -399,5 +399,14 @@ export const migrations: Migration[] = [ db.run(loadSql('143_create_plan_amendments.sql')) }, }, + { + id: '144', + description: 'Create goal_briefs table for session-scoped goal brief authoring', + apply: (db: Database) => { + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='goal_briefs'").all() + if (tables.length > 0) return + db.run(loadSql('144_create_goal_briefs.sql')) + }, + }, ] diff --git a/src/storage/repos/goal-briefs-repo.ts b/src/storage/repos/goal-briefs-repo.ts new file mode 100644 index 000000000..874207176 --- /dev/null +++ b/src/storage/repos/goal-briefs-repo.ts @@ -0,0 +1,63 @@ +import type { Database } from 'bun:sqlite' + +export interface GoalBriefRow { + projectId: string + sessionId: string + content: string + updatedAt: number +} + +export interface GoalBriefsRepo { + writeForSession(projectId: string, sessionId: string, content: string): void + getForSession(projectId: string, sessionId: string): GoalBriefRow | null + deleteForSession(projectId: string, sessionId: string): void +} + +export function createGoalBriefsRepo(db: Database): GoalBriefsRepo { + type RawRow = { project_id: string; session_id: string; content: string; updated_at: number } + + function mapRow(row: RawRow): GoalBriefRow { + return { + projectId: row.project_id, + sessionId: row.session_id, + content: row.content, + updatedAt: row.updated_at, + } + } + + const stmtWriteForSession = db.prepare(` + INSERT OR REPLACE INTO goal_briefs (project_id, session_id, content, updated_at) + VALUES (?, ?, ?, ?) + `) + + const stmtGetForSession = db.prepare(` + SELECT project_id, session_id, content, updated_at + FROM goal_briefs + WHERE project_id = ? AND session_id = ? + `) + + const stmtDeleteForSession = db.prepare(` + DELETE FROM goal_briefs + WHERE project_id = ? AND session_id = ? + `) + + function writeForSession(projectId: string, sessionId: string, content: string): void { + stmtWriteForSession.run(projectId, sessionId, content, Date.now()) + } + + function getForSession(projectId: string, sessionId: string): GoalBriefRow | null { + const row = stmtGetForSession.get(projectId, sessionId) as RawRow | undefined + if (!row) return null + return mapRow(row) + } + + function deleteForSession(projectId: string, sessionId: string): void { + stmtDeleteForSession.run(projectId, sessionId) + } + + return { + writeForSession, + getForSession, + deleteForSession, + } +} diff --git a/src/tools/goal-authoring.ts b/src/tools/goal-authoring.ts new file mode 100644 index 000000000..3c883f7f3 --- /dev/null +++ b/src/tools/goal-authoring.ts @@ -0,0 +1,57 @@ +import { tool } from '@opencode-ai/plugin' +import type { ToolContext } from './types' +import { assertWritableSession } from './session-write-guard' +import { + formatGoalBriefSummary, + hasPlanStructureViolations, + summarizeGoalBrief, +} from '../utils/goal-brief' + +const z = tool.schema + +export function createGoalAuthoringTools(ctx: ToolContext): Record> { + return { + 'goal-write': tool({ + description: + 'Create, overwrite, or append to the goal brief stored for the current session. The goal brief is the launch input for the Forge execution dialog and is authored before a loop is launched, not from inside a running loop. Phases and section markers belong in the plan, not the brief; goal-write rejects content that carries plan structure ( markers or ## Phase headings).', + args: { + content: z + .string() + .min(1) + .describe('Goal brief markdown. Use ## headings for Goal, Context, Constraints, and Acceptance Criteria.'), + append: z + .boolean() + .optional() + .describe( + 'Append to the existing stored brief instead of replacing it. Two newlines are inserted between the existing content and the new fragment. Creates the brief when none exists.', + ), + }, + execute: async (args, context) => { + const guard = assertWritableSession(ctx, context.sessionID, { + artifactLabel: 'goal brief', + amendGuidance: 'Goal briefs are authored before launch, not from inside a running loop.', + }) + if (guard) return guard + + let next: string + if (args.append) { + const existing = ctx.goalBriefsRepo.getForSession(ctx.projectId, context.sessionID) + next = existing ? `${existing.content.trimEnd()}\n\n${args.content}` : args.content + } else { + next = args.content + } + + const structure = summarizeGoalBrief(next) + if (hasPlanStructureViolations(structure)) { + return `goal-write failed: a goal brief must not contain plan structure.\n${formatGoalBriefSummary(structure)}` + } + + ctx.goalBriefsRepo.writeForSession(ctx.projectId, context.sessionID, next) + ctx.logger.log( + `goal-write: ${args.append ? 'appended to' : 'wrote'} goal brief for session ${context.sessionID} (${next.length} chars)`, + ) + return formatGoalBriefSummary(structure) + }, + }), + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 66b02e626..f90e4aead 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,6 +2,7 @@ import { tool } from '@opencode-ai/plugin' import { createReviewTools } from './review' import { createPlanTools } from './plan-kv' import { createPlanAuthoringTools } from './plan-authoring' +import { createGoalAuthoringTools } from './goal-authoring' import { createLoopTools } from './loop' import { createGroupTools } from './group' import { createSectionReadTool } from './section-read' @@ -21,6 +22,7 @@ export function createTools(ctx: ToolContext): Record { - const goalText = (args.goal ?? '').trim() + const brief = ctx.goalBriefsRepo.getForSession(ctx.projectId, context.sessionID) + const goalText = brief?.content?.trim() ?? '' if (!goalText) { - return 'Goal text is required. Pass a non-empty goal argument.' + return 'No goal brief stored for this session. Author one with the `goal-write` tool first, then call execute-goal to launch.' } - logger.log(`loop: starting goal loop for goal="${goalText.slice(0, 80)}"`) + const structure = summarizeGoalBrief(goalText) + if (structure.missingHeadings.length > 0 || structure.planStructureViolations.length > 0) { + return `execute-goal refused: the stored goal brief is incomplete.\n${formatGoalBriefSummary(structure)}\nFix the brief with another goal-write call before launching.` + } + + logger.log(`loop: starting goal loop from stored brief (${goalText.length} chars) for session ${context.sessionID}`) const { service, execCtx } = makeService(context.sessionID) const result = await service.dispatch(execCtx, { @@ -234,7 +240,7 @@ export function createLoopTools(ctx: ToolContext): Record { - const guard = assertWritableSession(ctx, context.sessionID) + const guard = assertWritableSession(ctx, context.sessionID, { + artifactLabel: 'plan', + amendGuidance: 'The stored plan for a running loop is amended with plan-adjust during a section audit.', + }) if (guard) return guard const normalized = normalizeFragment(args.content) @@ -111,7 +98,10 @@ export function createPlanAuthoringTools(ctx: ToolContext): Record { - const guard = assertWritableSession(ctx, context.sessionID) + const guard = assertWritableSession(ctx, context.sessionID, { + artifactLabel: 'plan', + amendGuidance: 'The stored plan for a running loop is amended with plan-adjust during a section audit.', + }) if (guard) return guard const existing = ctx.plansRepo.getForSession(ctx.projectId, context.sessionID) diff --git a/src/tools/session-write-guard.ts b/src/tools/session-write-guard.ts new file mode 100644 index 000000000..073ba8f82 --- /dev/null +++ b/src/tools/session-write-guard.ts @@ -0,0 +1,16 @@ +import type { ToolContext } from './types' + +export function assertWritableSession( + ctx: ToolContext, + sessionID: string, + opts: { artifactLabel: string; amendGuidance: string }, +): string | null { + const state = ctx.loop.service.resolveActiveLoopForSession(sessionID) + if (state) { + return ( + `Cannot modify the ${opts.artifactLabel} from an active loop session (loop: ${state.loopName}). ` + + opts.amendGuidance + ) + } + return null +} diff --git a/src/tools/types.ts b/src/tools/types.ts index 816786ada..77b7caceb 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -8,6 +8,7 @@ import type { LoopsRepo } from '../storage/repos/loops-repo' import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' import type { FeatureGroupsRepo } from '../storage/repos/feature-groups-repo' +import type { GoalBriefsRepo } from '../storage/repos/goal-briefs-repo' import type { GroupOrchestrator } from '../services/group-orchestrator' import type { Loop } from '../loop' import type { ForgeClient } from '../client/port' @@ -40,6 +41,7 @@ export interface ToolContext { sandboxManager: ReturnType | null /** Plans repo for plan storage. */ plansRepo: PlansRepo + goalBriefsRepo: GoalBriefsRepo /** Review findings repo for review findings storage. */ reviewFindingsRepo: ReviewFindingsRepo /** Loops repo for loop storage. */ diff --git a/src/tui.tsx b/src/tui.tsx index 9302457c7..be21433f2 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -15,6 +15,7 @@ import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/sessi import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch' import { describeDashboardBinding } from './dashboard/config' import { normalizePastedPlanText } from './utils/marked-plan-parser' +import type { SessionLaunchSpec } from './utils/session-launch-spec' type TuiKeybinds = { executePlan: string @@ -117,7 +118,7 @@ function ExecutionDialog(props: Omit - Execute plan + {props.spec.kind === 'goal' ? 'Execute goal' : 'Execute plan'} @@ -126,7 +127,7 @@ function ExecutionDialog(props: Omit { if (connectPromise) return connectPromise setConnectionStatus('connecting') - connectPromise = connectForgeProject(api, directory, resolveLoopAllowedDirectories(pluginConfig), forgeDbPath).then((connected) => untrack(() => { + connectPromise = connectForgeProject(api, directory, resolveLoopAllowedDirectories(pluginConfig), forgeDbPath, pluginConfig, true).then((connected) => untrack(() => { connectPromise = null if (disposed) return connected @@ -436,7 +437,7 @@ const tui: TuiPlugin = async (api) => { return startClientConnection() } - const openExecutionDialog = (currentClient: ForgeProjectClient, sessionID: string, planContent: string) => { + const openExecutionDialog = (currentClient: ForgeProjectClient, sessionID: string, spec: SessionLaunchSpec) => { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( { client={currentClient} cache={executionContextCache()} pluginConfig={pluginConfig} - planContent={planContent} + spec={spec} sessionId={sessionID} projectDirectory={directory} /> @@ -472,7 +473,7 @@ const tui: TuiPlugin = async (api) => { return } - openExecutionDialog(currentClient, sessionID, normalized.planText) + openExecutionDialog(currentClient, sessionID, { kind: 'plan', text: normalized.planText, updatedAt: Date.now() }) }} onCancel={() => api.ui.dialog.clear()} /> @@ -488,10 +489,10 @@ const tui: TuiPlugin = async (api) => { const currentClient = await ensureClient() if (!currentClient) return - const planText = await currentClient.loadLatestPlan(sessionID) - if (!planText) { + const spec = await currentClient.loadLaunchSpec(sessionID) + if (!spec) { api.ui.toast({ - message: 'No plan in current session — paste one to execute', + message: 'No plan or goal brief in current session — paste a plan to execute', variant: 'info', duration: 4000, }) @@ -499,15 +500,15 @@ const tui: TuiPlugin = async (api) => { return } - openExecutionDialog(currentClient, sessionID, planText) + openExecutionDialog(currentClient, sessionID, spec) } api.keymap.registerLayer({ commands: [ { name: 'forge.plan.execute', - title: 'Execute plan', - desc: 'Open the execution dialog for the current session plan, or paste one if none is found', + title: 'Execute plan or goal', + desc: 'Open the execution dialog for the current session plan or goal brief, or paste a plan if none is found', category: 'Forge', namespace: 'palette', run: () => { void runExecutePlan() }, diff --git a/src/tui/execute-plan-panel.tsx b/src/tui/execute-plan-panel.tsx index aa8207abb..2513ece09 100644 --- a/src/tui/execute-plan-panel.tsx +++ b/src/tui/execute-plan-panel.tsx @@ -1,8 +1,11 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import { createEffect, createSignal, onCleanup, untrack } from 'solid-js' -import { PLAN_EXECUTION_LABELS } from '../utils/plan-execution' -import { extractPlanExecutionMetadata } from '../utils/plan-execution' +import { + getLaunchModeDescription, + listLaunchModesForSpecKind, +} from '../utils/plan-execution' +import { extractLaunchSpecMetadata, type SessionLaunchSpec } from '../utils/session-launch-spec' import { buildDialogSelectOptions, getModelDisplayLabel, getAvailableModelVariants, getVariantDisplayLabel, normalizeVariantForModel, type ModelInfo } from '../utils/tui-models' import { resolveExecutionDialogDefaults } from '../utils/tui-execution-preferences' import { type ForgeProjectClient } from '../utils/tui-client' @@ -29,7 +32,7 @@ export interface ExecutePlanPanelProps { client: ForgeProjectClient cache: ExecutionContextCache | null pluginConfig: PluginConfig - planContent: string + spec: SessionLaunchSpec sessionId: string initialExecutionModel?: string initialAuditorModel?: string @@ -82,7 +85,7 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { const [modelsLoaded, setModelsLoaded] = createSignal(!!initialSnapshot) const [busy, setBusy] = createSignal(false) const [loopName] = createSignal( - props.initialLoopName ?? extractPlanExecutionMetadata(untrack(() => props.planContent)).executionName, + props.initialLoopName ?? extractLaunchSpecMetadata(untrack(() => props.spec)).executionName, ) const [target] = createSignal(props.initialTarget ?? 'local') const remoteNames = listRemoteNames(pluginConfig) @@ -289,19 +292,6 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { )) } - function getModeDescription(label: string): string { - switch (label) { - case 'New session': - return 'Create a new session and send the plan to the code agent' - case 'Execute here': - return 'Execute the plan in the current session using the code agent' - case 'Loop': - return 'Execute using iterative development loop in an isolated git worktree (Docker sandbox used automatically when available)' - default: - return '' - } - } - /** * Shared tail for local and remote launches: surface errors, record recent * models, toast success, and notify the host. Returns false on error so @@ -324,16 +314,19 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { } async function runExecuteMode(mode: string, execModel?: string, auditModel?: string, execVariant?: string, auditVariant?: string): Promise { - const planText = props.planContent - const { title } = extractPlanExecutionMetadata(planText) + const spec = props.spec + const { title } = extractLaunchSpecMetadata(spec) const normalizedMode = mode.toLowerCase() - const matchedLabel = PLAN_EXECUTION_LABELS.find( + const matchedLabel = listLaunchModesForSpecKind(spec.kind).find( label => normalizedMode === label.toLowerCase() || normalizedMode.startsWith(label.toLowerCase()) ) ?? null - // Remote target: only Loop is allowed if (target() !== 'local') { + if (spec.kind === 'goal') { + props.api.ui.toast({ message: 'Remote targets support plans only', variant: 'error', duration: 5000 }) + return + } if (!isModeAllowedForTarget(target(), matchedLabel ?? '')) { props.api.ui.toast({ message: 'Remote target supports Loop only', variant: 'error', duration: 5000 }) return @@ -347,7 +340,7 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { localProjectId: props.client.projectId, title, loopName: loopName(), - plan: planText, + spec, executionModel: execModel, auditorModel: auditModel, executionVariant: execVariant, @@ -384,7 +377,7 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { mode: apiMode, title, loopName: loopName(), - plan: planText, + spec, executionModel: execModel, auditorModel: auditModel, executionVariant: execVariant, @@ -423,7 +416,7 @@ export function ExecutePlanPanel(props: ExecutePlanPanelProps) { return ( - Configure and Run Plan + {props.spec.kind === 'goal' ? 'Configure and Run Goal' : 'Configure and Run Plan'}