From b457641bdd3d2f6913821bc4e41326b919d41294 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:56:25 -0400 Subject: [PATCH 1/2] feat: add goal-brief launch flow with server-owned prompt and goal-authoring tools --- AGENTS.md | 7 +- README.md | 5 +- docs/agents-and-commands.md | 11 + docs/api/README.md | 5 +- docs/api/_media/agents-and-commands.md | 11 + docs/api/_media/architecture.md | 7 +- docs/api/_media/loop-system.md | 25 +- docs/api/_media/tools.md | 18 +- docs/architecture.md | 7 +- docs/loop-system.md | 25 +- docs/modules.md | 12 +- docs/tools.md | 18 +- src/agents/architect-auto.ts | 3 +- src/agents/architect.ts | 3 +- src/agents/auditor.ts | 4 +- src/agents/code.ts | 4 +- src/agents/feature-splitter.ts | 4 +- src/agents/goal.ts | 26 + src/agents/index.ts | 2 + src/agents/types.ts | 2 +- src/config.ts | 2 + src/constants/loop.ts | 13 +- src/hooks/forge-session-attach.ts | 81 ++- src/index.ts | 4 +- src/loop/runtime-prompt.ts | 10 +- src/prompts/agents/goal.md | 59 +++ src/prompts/commands/goal.md | 19 + src/services/execution.ts | 44 +- src/storage/index.ts | 3 + .../migrations/144_create_goal_briefs.sql | 7 + src/storage/migrations/index.ts | 9 + src/storage/repos/goal-briefs-repo.ts | 63 +++ src/tools/goal-authoring.ts | 57 ++ src/tools/index.ts | 2 + src/tools/plan-authoring.ts | 28 +- src/tools/session-write-guard.ts | 16 + src/tools/types.ts | 2 + src/tui.tsx | 27 +- src/tui/execute-plan-panel.tsx | 47 +- src/utils/goal-brief.ts | 62 +++ src/utils/loop-helpers.ts | 23 + src/utils/plan-execution.ts | 63 ++- src/utils/session-launch-spec.ts | 46 ++ src/utils/tui-client.ts | 197 +++++-- src/utils/tui-loop-store.ts | 43 +- src/utils/tui-remote-launch.ts | 8 +- src/workspace/classify-stale.ts | 35 +- test/agents.test.ts | 58 +- test/config-commands.test.ts | 8 + test/config.test.ts | 20 +- test/constants/loop.test.ts | 23 +- test/goal-brief-launch-flow.test.ts | 496 ++++++++++++++++++ test/goal-briefs-repo.test.ts | 97 ++++ test/hooks/forge-session-attach.test.ts | 276 +++++++++- test/loop-helpers.test.ts | 25 +- test/loop-permission-ruleset.test.ts | 4 +- test/loop/runtime.test.ts | 84 ++- test/plan-execution.test.ts | 38 ++ test/prompts/loader.test.ts | 17 + test/services/attach-loop.test.ts | 38 +- .../execution.forge-loop-extra.test.ts | 81 +++ test/storage-migrations.test.ts | 41 ++ test/tools/goal-authoring.test.ts | 291 ++++++++++ test/tui-client.loop-error.test.ts | 8 +- test/utils/goal-brief.test.ts | 120 +++++ test/utils/session-launch-spec.test.ts | 77 +++ .../utils/tui-client-loop-inline-plan.test.ts | 6 +- test/utils/tui-client-stored-plan.test.ts | 45 +- test/utils/tui-client-warp-flow.test.ts | 123 ++++- test/utils/tui-remote-launch.test.ts | 42 +- test/utils/tui-stored-plan.test.ts | 159 +++++- 71 files changed, 3069 insertions(+), 277 deletions(-) create mode 100644 src/agents/goal.ts create mode 100644 src/prompts/agents/goal.md create mode 100644 src/prompts/commands/goal.md create mode 100644 src/storage/migrations/144_create_goal_briefs.sql create mode 100644 src/storage/repos/goal-briefs-repo.ts create mode 100644 src/tools/goal-authoring.ts create mode 100644 src/tools/session-write-guard.ts create mode 100644 src/utils/goal-brief.ts create mode 100644 src/utils/session-launch-spec.ts create mode 100644 test/goal-brief-launch-flow.test.ts create mode 100644 test/goal-briefs-repo.test.ts create mode 100644 test/services/execution.forge-loop-extra.test.ts create mode 100644 test/tools/goal-authoring.test.ts create mode 100644 test/utils/goal-brief.test.ts create mode 100644 test/utils/session-launch-spec.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8984a076ef..071e007aa2 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 dd0735c150..6fde49d943 100644 --- a/README.md +++ b/README.md @@ -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` - **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 @@ -158,6 +160,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | `/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 and author a goal brief (`goal-write`), then launch a goal loop via 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 5430e12759..324333e905 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. It can reconnoiter with read tools and ask the user clarifying questions, but cannot edit source files, run loops, or author plans. Its only write tool is `goal-write`. + +Excluded tools: every filesystem-mutating tool, every plan/loop/group management tool, and every plan-authoring tool (`plan-write`, `plan-edit`, `plan-adjust`). 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 | @@ -44,6 +54,7 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/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 and author a goal brief (`goal-write`), then launch a goal loop via 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 10db147221..cb4f455346 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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` - **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 @@ -160,6 +162,7 @@ Loops always run in an isolated git worktree; Docker sandbox is used automatical | `/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 and author a goal brief (`goal-write`), then launch a goal loop via 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 5430e12759..324333e905 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. It can reconnoiter with read tools and ask the user clarifying questions, but cannot edit source files, run loops, or author plans. Its only write tool is `goal-write`. + +Excluded tools: every filesystem-mutating tool, every plan/loop/group management tool, and every plan-authoring tool (`plan-write`, `plan-edit`, `plan-adjust`). 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 | @@ -44,6 +54,7 @@ Source: [`AUDITOR_TOOL_EXCLUDES`](../src/agents/auditor.ts). | `/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 and author a goal brief (`goal-write`), then launch a goal loop via 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 84320e4247..8a7c2c510f 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 eccbbff22f..1d02e6f6df 100644 --- a/docs/api/_media/loop-system.md +++ b/docs/api/_media/loop-system.md @@ -294,7 +294,10 @@ 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. Forge exposes two launch paths: + +- **`/execute-goal` (or the `execute-goal` tool)** — immediate. Free-text goal input is passed straight to a managed goal loop with config-default models. No stored artifact is authored first; the goal text lives only in `loop_large_fields.goal` after launch. +- **`/goal`** — interactive. The `goal` agent reconnoiters the codebase, clarifies scope inline, and authors a structured **goal brief** with the `goal-write` tool, persisted to a session-scoped `goal_briefs` row. The brief is then handed to the Forge execution dialog, which lets the user pick execution and auditor models before launching a goal loop. ### Lifecycle differences from plan loops @@ -320,14 +323,18 @@ Goal loops are fully visible to `loop-status`, cancellable with `loop-cancel`, a ### Differences from `execute-plan` and `launch-group` -| Aspect | `execute-plan` (loop) | `execute-goal` | `launch-group` | -|---|---|---|---| -| Input | Structured plan (persisted in `plans`) | Free-text goal | 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) | +| Aspect | `execute-plan` (loop) | `execute-goal` | `/goal` brief → goal loop | `launch-group` | +|---|---|---|---|---| +| Input | Structured plan (persisted in `plans`) | Free-text goal | Structured goal brief (persisted in `goal_briefs`) | PRD / pre-split feature list | +| Sections / milestones | Yes (decomposed) | No | No | Per feature (each feature is its own loop) | +| Executor session | Fresh session per iteration | Fresh dedicated session per coding pass | Fresh dedicated session per coding pass | Fresh session per feature loop | +| Final audit | Yes (after all sections) | No | No | Per feature loop | +| Post-completion action | Yes (when configured) | Never | Never | Per feature loop | +| Stored artifact | `plans` row | None (goal only in `loop_large_fields.goal`) | `goal_briefs` row pre-launch; `loop_large_fields.goal` after launch | Per-feature `plans` rows | +| Model selection | Execution dialog (executor + auditor) | Config defaults | Execution dialog (executor + auditor) | Execution dialog per feature | +| Slash command | `/execute-plan` | `/execute-goal` | `/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. That is why briefs are preferred over bare `/execute-goal` goals for any work spanning multiple iterations — 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 f6306243d1..8aad865595 100644 --- a/docs/api/_media/tools.md +++ b/docs/api/_media/tools.md @@ -13,6 +13,7 @@ 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) | @@ -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,7 +155,6 @@ 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` @@ -156,7 +169,8 @@ Arguments: | `title` | Optional short title for the loop (derived from the 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 84320e4247..8a7c2c510f 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 eccbbff22f..1d02e6f6df 100644 --- a/docs/loop-system.md +++ b/docs/loop-system.md @@ -294,7 +294,10 @@ 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. Forge exposes two launch paths: + +- **`/execute-goal` (or the `execute-goal` tool)** — immediate. Free-text goal input is passed straight to a managed goal loop with config-default models. No stored artifact is authored first; the goal text lives only in `loop_large_fields.goal` after launch. +- **`/goal`** — interactive. The `goal` agent reconnoiters the codebase, clarifies scope inline, and authors a structured **goal brief** with the `goal-write` tool, persisted to a session-scoped `goal_briefs` row. The brief is then handed to the Forge execution dialog, which lets the user pick execution and auditor models before launching a goal loop. ### Lifecycle differences from plan loops @@ -320,14 +323,18 @@ Goal loops are fully visible to `loop-status`, cancellable with `loop-cancel`, a ### Differences from `execute-plan` and `launch-group` -| Aspect | `execute-plan` (loop) | `execute-goal` | `launch-group` | -|---|---|---|---| -| Input | Structured plan (persisted in `plans`) | Free-text goal | 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) | +| Aspect | `execute-plan` (loop) | `execute-goal` | `/goal` brief → goal loop | `launch-group` | +|---|---|---|---|---| +| Input | Structured plan (persisted in `plans`) | Free-text goal | Structured goal brief (persisted in `goal_briefs`) | PRD / pre-split feature list | +| Sections / milestones | Yes (decomposed) | No | No | Per feature (each feature is its own loop) | +| Executor session | Fresh session per iteration | Fresh dedicated session per coding pass | Fresh dedicated session per coding pass | Fresh session per feature loop | +| Final audit | Yes (after all sections) | No | No | Per feature loop | +| Post-completion action | Yes (when configured) | Never | Never | Per feature loop | +| Stored artifact | `plans` row | None (goal only in `loop_large_fields.goal`) | `goal_briefs` row pre-launch; `loop_large_fields.goal` after launch | Per-feature `plans` rows | +| Model selection | Execution dialog (executor + auditor) | Config defaults | Execution dialog (executor + auditor) | Execution dialog per feature | +| Slash command | `/execute-plan` | `/execute-goal` | `/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. That is why briefs are preferred over bare `/execute-goal` goals for any work spanning multiple iterations — 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 0f3bfe715c..45513310d9 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` | Execute a non-empty goal in a dedicated session inside a managed worktree. Args: `goal` required; `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 f6306243d1..8aad865595 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,6 +13,7 @@ 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) | @@ -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,7 +155,6 @@ 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` @@ -156,7 +169,8 @@ Arguments: | `title` | Optional short title for the loop (derived from the 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 3d68f8434a..46178ca02a 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 95428d78fd..937e0ead30 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 328c7ee0f1..292dbe17ec 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 add6afb7b9..acc7241d34 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 cd27c171cb..a1734205da 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 0000000000..5f53569315 --- /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', 'execute-goal', + '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 f7516edcdf..1d1ce6005b 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','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, and author a goal brief for launching from the Forge dialog', 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 af0fe82b4d..0c4009b213 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 8ec8d38fae..de55181af4 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 9a35c3f37c..3c3972f4b8 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 87c6b65827..a41b2ca784 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 0000000000..952fabfc64 --- /dev/null +++ b/src/prompts/agents/goal.md @@ -0,0 +1,59 @@ +You are a goal-briefing agent. Your role is to research the codebase, ask clarifying questions inline, and produce a **goal brief** that the user will launch from the Forge execution dialog. 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 one tool for managing the goal brief: +- `goal-write`: Create, overwrite, or append (`append: true`) the goal brief stored for this session. This brief is the launch input for the Forge execution dialog. + +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), tell the user to **open the Forge execution dialog** to choose the execution model, auditor model, and other launch options, then launch from the dialog. Do **not** call `execute-goal` yourself. Do not call the `question` tool for approval — the dialog is the approval surface. diff --git a/src/prompts/commands/goal.md b/src/prompts/commands/goal.md new file mode 100644 index 0000000000..27d19a995f --- /dev/null +++ b/src/prompts/commands/goal.md @@ -0,0 +1,19 @@ +## 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: Tell the User to Launch + +Once `goal-write` returns a clean structure report, tell the user to open the Forge execution dialog to choose the execution model, auditor model, and other launch options, then launch from the dialog. Do **not** call `execute-goal` yourself. The dialog is the approval surface. + +$ARGUMENTS diff --git a/src/services/execution.ts b/src/services/execution.ts index d56725f66f..9f21dfbbef 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 65dd674c29..ebcac1ac99 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 0000000000..1f4d44a153 --- /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 d0e840f054..11d0c40971 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 0000000000..874207176e --- /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 0000000000..3c883f7f32 --- /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 66b02e626b..f90e4aeadf 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 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 0000000000..073ba8f823 --- /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 816786adaa..77b7cacebe 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 9302457c71..be21433f2c 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 aa8207abbe..2513ece094 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'}