@guidobuilds/forge-ai 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,63 @@ Versions prior to 0.3.0 are not reconstructed here; see git history for earlier
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.8.0] - 2026-09-03
13
+
14
+ ### Added
15
+
16
+ - **`forge-ai list`** — prints every recorded install (the user-scope one, if any, plus one line per project) with Forge version, file count, platforms, and last-updated time. Answers "where is Forge installed and with what," which previously required manually reading manifest JSON files.
17
+ - `tests/fixtures/<platform>/<name>.golden` — committed golden-render snapshots (6 artifacts × 4 platforms) guarding against unintended drift in installed prose. Regenerate with `npm run generate-fixtures` after reviewing the diff.
18
+ - **Model selection.** `install`/`update` now offer an interactive model-selection step (per artifact × platform, gated on which platforms actually support a model — 3 of 4 silently drop it on skills) and accept `--model <id>` / `--model-map name=model,...` for non-interactive use (both require an explicit single `--platform`, since model ids are not portable across platforms). Choices persist in `model-preferences.json` next to the manifest and survive later `update` runs instead of silently resetting to the canonical default.
19
+ - **`forge-ai configure`** — change an already-installed agent's model without a full reinstall. Interactive by default; non-interactive via `--model`/`--model-map`. Rewrites only the affected files.
20
+ - **`forge-ai uninstall`** — the CLI's only removal path was previously "delete the files by hand"; now a manifest-driven uninstall mirrors `update`'s overwrite protection (a locally-edited file is backed up and requires `--yes`/`--force` or interactive confirmation before removal). Supports `--platform` to uninstall a single agent and `--dry-run` to preview.
21
+ - **OpenCode model discovery.** The per-agent model prompt (`install` and `configure`) shells out to `opencode models` (falling back to `opencode2 models` on v2) to offer exactly the models the user has real, credential-backed access to, instead of a generic list — OpenCode has no fixed model enum and its config file is not a reliable source of truth (env-var- and credential-connected providers appear in neither). Falls back to free-text entry if neither binary is available, or if a v2 install predates the `models` command (added to `opencode2` on 2026-08-06). `OPENCODE_UNKNOWN_MODEL` / `CODEX_UNKNOWN_MODEL` warnings added, matching the existing Claude/Grok pattern (OpenCode/Codex previously had no model validation at all).
22
+ - **OpenCode user-scope install now targets both v1 and v2, whichever actually exist.** v1 (`~/.config/opencode/`) and the v2 preview (`~/.opencode/`) read agents/skills from different directories — confirmed via `opencode2 debug config`, which doesn't list `~/.config/opencode` as a source at all. Installing only ever wrote v1's path, so a v2-only setup (no `opencode` v1 binary ever run) got files on disk that OpenCode itself would never read — `forge` silently missing from the agent list with no error. `install`/`update` now write to each generation's directory only if it already exists (`OPENCODE_USER_ROOT_NOT_FOUND` info diagnostic, defaulting to v1, if neither does yet); both get written if both exist. Project scope is unaffected — v1 and v2 already share `.opencode/` there.
23
+
24
+ ### Changed
25
+
26
+ - **`forge` installs as a real Claude Code subagent, not a skill.** Claude subagents can now spawn subagents up to depth 5 (v2.1.172+), so the historical blocker for a Claude-side orchestrator is gone. `forge` now has a structural `tools` allowlist (`Agent(forge-worker, forge-adversary)`, `TodoWrite`, `Skill`, `AskUserQuestion`) instead of being a fully advisory skill — it cannot Read/Write/Edit/Bash itself. It still keeps `AskUserQuestion` (confirmed: that tool is only stripped from bounded sub-task dispatches, not from an agent running as the session's main driver) and still loads `using-forge`/`forge-grill` via the `Skill` tool. To make it the automatic default for a project, add `{ "agent": "forge" }` to `.claude/settings.json` yourself — the installer does not write this for you (see README).
27
+ - **Codex agents declare an explicit `sandbox_mode`** — `read-only` for `forge` (it only dispatches), `workspace-write` for `forge-worker`/`forge-worker-leaf`/`forge-adversary` (they write files). Previously unset on all four.
28
+ - Grok and OpenCode installs now warn (`GROK_SKILL_DISCOVERABLE_UNENFORCED` / `OPENCODE_SKILL_DISCOVERABLE_UNENFORCED`) when installing a background-only skill (`using-forge`, `forge-grill`) that Claude can hide from direct invocation but Grok/OpenCode cannot — previously silent.
29
+ - **Per-user install state moved from `~/.forge-ai/` to `~/.forge/state/`.** Migrated automatically and transparently on first use of any command that touches it (`install`, `update`, `uninstall`, `list`) — the old directory is copied, never moved, and left in place with a `MIGRATED` note; nothing is deleted automatically. Nesting under `state/` means this can never collide with a project's own `.forge/<feature-slug>/` ledger, even in a repo where `$HOME` itself has been used as a Forge project.
30
+ - **The manifest now records the installed Forge version** (`forgeVersion`, both per-entry and per-manifest) and **merges across separate single-`--platform` runs instead of replacing** — `install --platform claude` followed by `install --platform opencode` no longer causes the first platform's files to reclassify as untracked/foreign. Existing `schemaVersion: 1` manifests are upgraded transparently on read (`forgeVersion: "unknown"`).
31
+ - Install/update/uninstall now warn if a legacy `~/.forge-ai/` manifest was updated more recently than the current `~/.forge/state/` one — a sign a cached old CLI is still writing to the old location.
32
+ - **Per-harness dispatch vocabulary in canonical artifact bodies is now composed per platform instead of hardcoded.** `forge-worker.md`, `forge.md`, and `using-forge.md` no longer spell out "via `Agent` (Claude), `task` (Grok), or `task` (OpenCode)" or a standalone "Codex fallback" section inline — a `{{snippet:id}}` reference is resolved per target platform at render time (`src/compose.ts`, `src/dispatch-snippets.ts`), failing loudly at build time if a platform has no text for a referenced snippet. This is a genuine prose change on Claude/Grok/OpenCode/Codex installs, not a no-op refactor — installed agent bodies now read slightly differently (equivalent meaning, phrased per platform instead of listing all four in one sentence). `BODY_OVER_BUDGET` is now measured on the composed, per-platform body rather than the shared canonical source.
33
+
34
+ ### Removed
35
+
36
+ - **Claude Code and Codex plugin marketplace distribution channels** (`forge-plugin/`, `plugins/forge/`, `.claude-plugin/`, `.agents/plugins/marketplace.json`, `src/adapters/claude-plugin.ts`, `src/adapters/codex-plugin.ts`, `src/build-plugin.ts`, the `forge-ai build-plugin` command, the plugin drift guard (`.githooks/`, `scripts/check-plugin-sync.mjs`, `scripts/verify-drift-guard.sh`), and 16 plugin-specific tests). The npm CLI (`npx @guidobuilds/forge-ai install`) is now the single supported distribution and install method for all four platforms, including Claude Code and Codex — both of which previously also had an alternative plugin-marketplace install path shipped in 0.7.0.
37
+ - `PluginFile` type and `writePluginFiles` (`src/model.ts`, `src/writer.ts`).
38
+ - Public export `export * from './adapters/codex-plugin.js'` from `src/index.ts` — anyone importing `@guidobuilds/forge-ai` programmatically for Codex plugin generation loses that entry point.
39
+ - `prepare`/`pretest` npm scripts, which existed solely to wire the now-removed pre-commit drift guard.
40
+
41
+ ### Fixed
42
+
43
+ - README no longer claims the Claude Code plugin channel is "Not yet live" (it had been live since 0.7.0; the line was never updated).
44
+
45
+ ### Security
46
+
47
+ An internal application-security audit (`report/api-report.md`, `report/api-fix.md`) found 15 findings (`A`, `B`, `C`, `M1`–`M12`); the two highest-severity ones were proven exploitable end-to-end in an isolated adversarial gate (`report/verification.md`). This release fixes the two proven findings (`A`, `B`) plus four of the supporting ones (`C`, `M1`, `M2`, `M12`), with dedicated regression tests (`tests/forge-cli.test.ts`, +22 tests). The remaining nine (`M3`–`M11`, correctness/hygiene polish — see `report/summary.md` §5) are lower-priority and not security-critical; they remain open.
48
+
49
+ - **Install-manifest entries are now scope-validated before any prune/uninstall delete or overwrite** (`src/paths.ts`, `src/manifest.ts`, `src/cli.ts`). `loadManifest` previously trusted any parseable JSON, and `pruneEntries`/`uninstall` deleted or overwrote `entry.path` with no check that it fell under a Forge-managed root — a poisoned `~/.forge/state/*.json` manifest was proven able to delete an arbitrary out-of-scope file, including via a symlinked path. Entries outside the per-scope allowlist (derived from the same `userBase`/`projectBase` roots as normal installs) are now rejected before any destructive operation.
50
+ - **Subprocess binaries are now resolved via an explicit `PATH` scan against an allowlist of trusted basenames, and repo-local shims are rejected** (`src/executable-resolution.ts`, `src/self-update.ts`, `src/opencode-discovery.ts`). Model discovery and self-update previously `spawnSync`'d bare command names (`opencode`, `pnpm`, `npm`, `forge-ai`) resolved from the ambient `PATH`, with model discovery additionally passing `cwd = process.cwd()` — a shim named `opencode` placed on `PATH` (e.g. via `node_modules/.bin`) was proven to execute with the user's privileges. Discovery no longer passes `cwd`, and every spawn now resolves to an absolute, allowlisted path first.
51
+ - **`self-update --to <version>` now allowlists its argument to a semver or `latest` spec** (`src/self-update.ts`, `src/cli.ts`) before it reaches `npm`/`pnpm install -g` — previously an arbitrary string was interpolated directly into the install command.
52
+ - **Manifest and model-preferences writes are now atomic** (temp-file + rename) (`src/manifest.ts`, `src/model-preferences.ts`), closing a TOCTOU window where an interrupted write could leave a corrupt state file.
53
+ - **Corrupt manifest/preferences files now fail safe instead of crashing the CLI with a raw stack trace** — malformed JSON or a non-object shape falls back to empty state with a diagnostic pointing at `forge-ai install` to regenerate it (`src/manifest.ts`, `src/model-preferences.ts`).
54
+ - **OpenCode adapter now validates `permissions` shape before emitting** (`src/adapters/opencode.ts`, `src/adapters/shared.ts`) instead of passing an unvalidated value straight through to frontmatter, matching the validation Claude/Grok/Codex already had.
55
+
56
+ ### Migration from 0.7.x
57
+
58
+ - If you installed Forge via `/plugin install forge@guidobuilds` (Claude Code) or `codex plugin add forge@guidobuilds-forge` (Codex), those marketplace sources no longer exist in this repository. Remove the plugin (`/plugin uninstall forge@guidobuilds` or `codex plugin remove forge@guidobuilds-forge`, then remove the marketplace) and install via the CLI instead: `npx @guidobuilds/forge-ai install --platform claude` or `--platform codex`.
59
+ - If you only ever used the CLI (`npx @guidobuilds/forge-ai install/update`), nothing changes for you.
60
+ - Programmatic consumers importing Codex plugin generation from `@guidobuilds/forge-ai`'s package export must vendor that logic themselves; it is no longer part of this package.
61
+
62
+ ## [0.7.0] - 2026-08-06
63
+
64
+ ### Added
65
+
66
+ - **Claude Code plugin distribution channel** — Forge can now be installed as a Claude Code plugin (`/plugin marketplace add guidobuilds/forge` then `/plugin install forge@guidobuilds`), generated from the same canonical `artifacts/` source as the CLI via the new `forge-ai build-plugin` command. Claude Code only — other platforms remain CLI-only (`npx @guidobuilds/forge-ai install`). Plugin skills are namespaced, so the orchestrator is invoked as `/forge:forge` instead of `/forge`.
67
+ - **Codex plugin distribution channel** — a standalone, skills-only Forge plugin is available through the repository marketplace `guidobuilds-forge`. `forge-ai build-plugin --target codex` generates its public orchestrator skill and private worker, leaf, adversary, routing, and grill contracts without relying on `.codex/agents`.
68
+
12
69
  ## [0.6.0] - 2026-07-05
13
70
 
14
71
  ### Added
package/README.md CHANGED
@@ -94,13 +94,22 @@ The same operating model is shared across all supported agents so the workflow s
94
94
 
95
95
  ## Installation
96
96
 
97
- The primary installer is the npm CLI:
97
+ Install with the npm CLI:
98
98
 
99
99
  ```sh
100
100
  npx @guidobuilds/forge-ai install
101
101
  ```
102
102
 
103
- The installer prompts for the target agent platform and whether Forge should be installed globally for your user or locally for the current project.
103
+ The installer prompts for the target agent platform, whether Forge should be installed globally for your user or locally for the current project, and — interactively — which model each agent should use: keep each agent's recommended default, or choose per agent. For OpenCode, the per-agent prompt shows the models you actually have configured (via `opencode models`, falling back to free-text entry if that command isn't available), not a generic list.
104
+
105
+ To choose models non-interactively (requires an explicit single `--platform` — model ids aren't portable across platforms):
106
+
107
+ ```sh
108
+ npx @guidobuilds/forge-ai install --platform claude --scope user --model opus --yes
109
+ npx @guidobuilds/forge-ai install --platform opencode --scope user --model-map forge=anthropic/claude-opus-4-1,forge-worker=anthropic/claude-sonnet-4-5 --yes
110
+ ```
111
+
112
+ `--model` applies to every agent that supports a model on the target platform; `--model-map name=model,...` sets it per artifact. Choices persist across `update` — they are not silently reset back to the canonical default. To change a model later without a full reinstall, see [Configuring models](#configuring-models) below.
104
113
 
105
114
  To update an existing install:
106
115
 
@@ -147,16 +156,22 @@ What changes per platform is the **kind** each piece is installed as, and theref
147
156
 
148
157
  | Piece | Installed as | How you invoke it |
149
158
  |---|---|---|
150
- | `forge` | skill (`model: opus`) | type `/forge` in the prompt the only Forge skill you invoke directly |
159
+ | `forge` | subagent (`model: opus`, structural `tools` allowlist) | mention it by name, `@forge`, or `claude --agent forge` |
151
160
  | `forge-grill` | skill (`model: sonnet`, not user-invocable) | loaded automatically by `forge` before non-trivial or risk-bearing builds |
152
161
  | `using-forge` | skill (`model: sonnet`, not user-invocable) | loaded automatically by `forge` before routing work |
153
- | `forge-worker` | subagent | coordinator; main thread delegates via `Agent` (or legacy `Task`) |
162
+ | `forge-worker` | subagent | coordinator; `forge` delegates via `Agent(forge-worker, forge-adversary)` |
154
163
  | `forge-worker-leaf` | subagent | terminal shard; spawned by `forge-worker` (or orchestrator on Codex) |
155
164
  | `forge-adversary` | subagent | the orchestrator delegates to it after build to gate risk-bearing work |
156
165
 
157
- Start a session by typing **`/forge`**. That loads the orchestrator role into your main Claude Code thread, which delegates each bounded task to `forge-worker`, which may spawn `forge-worker-leaf` for heavy inspect/build shards. Requires **Claude Code v2.1.172+** for nested sub-agents. `forge-grill` and `using-forge` are marked `user-invocable: false`: Claude still loads and runs them as part of `forge`'s routing, but they no longer appear in the `/` menu or run as standalone commands `forge` is the single entry point.
166
+ `forge` installs as a real Claude Code subagent with a structural `tools` allowlist (`Agent(forge-worker, forge-adversary)`, `TodoWrite`, `Skill`, `AskUserQuestion`) it cannot Read/Write/Edit/Bash itself, only dispatch. It loads `using-forge` and `forge-grill` via the `Skill` tool and can still ask you clarifying questions directly (`AskUserQuestion` is retained for an agent running as the session's main driver, unlike a bounded sub-task dispatch). Requires **Claude Code v2.1.172+**.
158
167
 
159
- > Why a skill and not an agent on Claude? The main thread retains `Agent`/`Task` and can delegate. A skill injects orchestrator behavior without replacing the main agent. The trade-off: "delegate, never do worker work inline" is followed by instruction at the orchestrator layer, not by tool restrictions — but `forge-worker` **can** structurally spawn leaves when given `Agent` in its tool list.
168
+ To make `forge` the automatic default for a project (equivalent to the old `/forge` auto-load), add to `.claude/settings.json`:
169
+
170
+ ```json
171
+ { "agent": "forge" }
172
+ ```
173
+
174
+ Without that, invoke it explicitly per session (mention it by name, `@forge`, or `claude --agent forge`). `forge-ai install`/`update`/`uninstall` manage `forge`'s own file; they do not write `settings.json` for you.
160
175
 
161
176
  ### OpenCode
162
177
 
@@ -174,10 +189,10 @@ Switch your primary agent to **`forge`**. Unlike Claude Code, the orchestrator h
174
189
 
175
190
  | Piece | Installed as | Location |
176
191
  |---|---|---|
177
- | `forge`, `forge-worker`, `forge-adversary` | agents (`.toml`) | `~/.codex/agents/` (or `.codex/agents/` per project) |
192
+ | `forge`, `forge-worker`, `forge-worker-leaf`, `forge-adversary` | agents (`.toml`) | `~/.codex/agents/` (or `.codex/agents/` per project) |
178
193
  | `using-forge`, `forge-grill` | skills | `~/.agents/skills/` (or `.agents/skills/` per project) |
179
194
 
180
- Codex support is the most partial of the three: Forge writes the agent `.toml` files but does not generate `AGENTS.md` or profiles, so wiring them into a Codex run may take manual steps. Treat Codex support as experimental.
195
+ The CLI writes agent `.toml` files but does not generate `AGENTS.md` or profiles.
181
196
 
182
197
  ### Project vs user scope
183
198
 
@@ -257,15 +272,33 @@ npx @guidobuilds/forge-ai@latest update
257
272
 
258
273
  Forge replaces its managed agent and skill definitions in your supported agent configuration directories.
259
274
 
260
- Forge records installed files in manifests under `~/.forge-ai/` so updates can safely remove files that are no longer bundled. `update` prunes stale managed files by default only when the current file still matches the recorded checksum; use `--no-prune` to keep stale managed files. `--dry-run` previews writes and deletes without changing files or manifests.
275
+ Forge records installed files in manifests under `~/.forge/state/` (migrated automatically and transparently from the earlier `~/.forge-ai/` on first run — the old directory is left in place, marked with a `MIGRATED` note, and never deleted automatically) so updates can safely remove files that are no longer bundled. `update` prunes stale managed files by default only when the current file still matches the recorded checksum; use `--no-prune` to keep stale managed files. `--dry-run` previews writes and deletes without changing files or manifests.
261
276
 
262
- Forge now routes a single canonical artifact to the right artifact kind per agent: the orchestrator installs as a Claude Code skill (`/forge`) but as an agent on OpenCode and Codex. If you installed an earlier version, run `update` (not `install`) so Forge prunes the now-stale `forge` agent and standalone `forge-worker` skill left by the previous layout.
277
+ Forge routes a single canonical artifact to the right artifact kind per agent: the orchestrator installs as a real subagent on Claude Code, OpenCode, and Codex, and as a skill on Grok. If you installed an earlier version where Claude's orchestrator was a skill, run `update` (not `install`) so Forge prunes the now-stale `~/.claude/skills/forge/SKILL.md` left by the previous layout.
263
278
 
264
279
  ## Uninstalling
265
280
 
266
- Remove Forge from the agent configuration directories for OpenCode, Codex, or Claude Code by deleting the installed Forge agent and skill entries.
281
+ ```sh
282
+ npx @guidobuilds/forge-ai uninstall
283
+ ```
284
+
285
+ This removes exactly the files Forge's manifest recorded for the given `--scope` (default: `user`), refusing to silently delete anything you edited locally — a locally-modified file is backed up and requires `--yes`/`--force` (or interactive confirmation) before it's removed, mirroring `update`'s overwrite protection. Pass `--platform` to uninstall a single agent instead of all of them, and `--dry-run` to preview what would be removed.
286
+
287
+ ## Listing installs
288
+
289
+ ```sh
290
+ npx @guidobuilds/forge-ai list
291
+ ```
292
+
293
+ Prints every recorded install — the user-scope one, if any, plus one line per project you've installed Forge into — with the Forge version, file count, platforms, and last-updated time for each.
294
+
295
+ ## Configuring models
296
+
297
+ ```sh
298
+ npx @guidobuilds/forge-ai configure --platform claude --scope user
299
+ ```
267
300
 
268
- If you installed Forge for multiple tools, repeat the removal for each one you no longer want to use.
301
+ Change which model an already-installed agent uses without a full reinstall. Interactively, it walks every agent on the given scope/platform that supports a model (using OpenCode's live `opencode models` output where applicable, same as `install`). Non-interactively, pass `--model <id>` or `--model-map name=model,...` (same rules as `install`: an explicit single `--platform`, no `--force`/`--yes` the point of the command is to make a choice, not skip one). Only the affected files are rewritten; the manifest and every other installed platform are left untouched.
269
302
 
270
303
  ## Project Status
271
304
 
@@ -3,11 +3,14 @@ name: forge
3
3
  description: Forge orchestrator with dynamic runtime routing and a single worker type
4
4
  kind: agent
5
5
  claude:
6
- kind: skill
7
6
  model: opus
8
- when_to_use: Use for any development or operational task in this repo — build a feature, fix a bug, investigate code, plan work, or verify a change. This is the default entry point; invoke explicitly with /forge or let Claude select it automatically for repo work.
7
+ permissions:
8
+ tools: ["Agent(forge-worker, forge-adversary)", TodoWrite, Skill, AskUserQuestion]
9
9
  grok:
10
10
  kind: skill
11
+ codex:
12
+ permissions:
13
+ sandbox_mode: read-only
11
14
  opencode:
12
15
  mode: primary
13
16
  permissions:
@@ -49,7 +52,7 @@ The `using-forge` skill owns runtime routing, operating principles, approval heu
49
52
 
50
53
  ## Worker model
51
54
  - `forge-worker` is the coordinator worker; route all build and operational work to it at `DISPATCH_DEPTH: 1`.
52
- - `forge-worker-leaf` is the terminal worker for bounded shards at `DISPATCH_DEPTH: 2`; coordinators spawn it — or you fan out leaves when a coordinator returns `DELEGATION_REQUESTS` (Codex).
55
+ - `forge-worker-leaf` is the terminal worker for bounded shards at `DISPATCH_DEPTH: 2`; {{snippet:who-spawns-leaf}}.
53
56
  - `forge-adversary` is a dedicated adversarial verification agent: dispatch it as the Definition-of-Done gate for risk-bearing work to break the build before it can reach `passing`. Never sub-delegate verify or adversary work.
54
57
  - You may launch one worker instance for a bounded task.
55
58
  - You may launch multiple `forge-worker` instances in sequence when one result should shape the next delegation.
@@ -89,7 +92,7 @@ QUESTIONS:
89
92
  1) <question>
90
93
  ```
91
94
 
92
- `QUESTIONS` appears only when `STATUS: blocked`. On `DELEGATION_REQUESTS`, fan out `forge-worker-leaf` dispatches (Codex fallback). Trust coordinator `SUB_RESULTS` unless `partial` or `blocked`.
95
+ `QUESTIONS` appears only when `STATUS: blocked`. {{snippet:orchestrator-delegation-requests-handling}} Trust coordinator `SUB_RESULTS` unless `partial` or `blocked`.
93
96
 
94
97
  If output is malformed:
95
98
  1) request one reformat retry with same task_id
@@ -8,6 +8,9 @@ claude:
8
8
  grok:
9
9
  permissions:
10
10
  tools: [todo_write, read_file, search_replace, run_terminal_cmd, grep_search, list_dir, web_fetch]
11
+ codex:
12
+ permissions:
13
+ sandbox_mode: workspace-write
11
14
  opencode:
12
15
  mode: subagent
13
16
  permissions:
@@ -8,6 +8,9 @@ claude:
8
8
  grok:
9
9
  permissions:
10
10
  tools: [todo_write, read_file, search_replace, run_terminal_cmd, grep_search, list_dir, web_fetch, task, get_task_output, kill_task]
11
+ codex:
12
+ permissions:
13
+ sandbox_mode: workspace-write
11
14
  opencode:
12
15
  mode: subagent
13
16
  permissions:
@@ -154,14 +157,10 @@ Spawn `forge-worker-leaf` when `DELEGATION: required`, or when **any** trigger f
154
157
  ### Spawn protocol
155
158
 
156
159
  1. Decompose into bounded leaf subgoals with disjoint `files_hint` paths.
157
- 2. Spawn `forge-worker-leaf` via `Agent` (Claude), `task` (Grok), or `task` (OpenCode). Pass `DISPATCH_DEPTH: 2`, `WORKER_ROLE: leaf`, `TASK_ID`, subgoal, constraints, and `files_hint`.
160
+ 2. {{snippet:worker-spawn-leaf-instruction}}
158
161
  3. Prefer **parallel** leaves for read-only `inspect`; prefer **sequential** leaves for `build` writes unless files are strictly disjoint.
159
162
  4. Synthesize: write durable detail to `.forge/<slug>/explore.md` or `build-log.md`; return ≤ 8 `SUMMARY` bullets plus compact `SUB_RESULTS`. Do not paste full child logs.
160
163
 
161
- ### Codex fallback
162
-
163
- On harnesses without spawn tools (Codex), return `DELEGATION_REQUESTS` for the orchestrator to fan out `forge-worker-leaf` dispatches. Omit `DELEGATION_REQUESTS` when you self-spawn.
164
-
165
164
  ## Concurrency discipline
166
165
 
167
166
  When the orchestrator or sibling leaves may be running in parallel:
@@ -8,6 +8,9 @@ claude:
8
8
  grok:
9
9
  permissions:
10
10
  tools: [todo_write, read_file, search_replace, run_terminal_cmd, grep_search, list_dir, web_fetch]
11
+ codex:
12
+ permissions:
13
+ sandbox_mode: workspace-write
11
14
  opencode:
12
15
  mode: subagent
13
16
  permissions:
@@ -87,7 +87,7 @@ Forge uses **two levels below the orchestrator** (`DISPATCH_DEPTH` 0 → 1 → 2
87
87
 
88
88
  ### Coordinator triggers
89
89
 
90
- `forge-worker` **must** spawn leaves (or return `DELEGATION_REQUESTS` on Codex) when any: ≥ 8 file reads, ≥ 6 searches, ≥ 20 tool calls, ≥ 5 files to edit, `DELEGATION: required`, or plan implies ≥ 3 shards. **Never** sub-delegate `verify` or adversary work.
90
+ `forge-worker` **must** {{snippet:worker-must-spawn-or-delegate}} when any: ≥ 8 file reads, ≥ 6 searches, ≥ 20 tool calls, ≥ 5 files to edit, `DELEGATION: required`, or plan implies ≥ 3 shards. **Never** sub-delegate `verify` or adversary work.
91
91
 
92
92
  ### Orchestrator dispatch hints
93
93
 
@@ -100,7 +100,7 @@ TASK_ID: <unique>
100
100
 
101
101
  - Unfamiliar-repo `inspect` → `DELEGATION: allowed`
102
102
  - Narrow bugfix → `DELEGATION: forbidden`
103
- - Codex: parse `DELEGATION_REQUESTS` and fan out `forge-worker-leaf` yourself
103
+ {{snippet:codex-orchestrator-dispatch-hint}}
104
104
 
105
105
  ## Routing rules
106
106
 
package/bin/forge-ai.mjs CHANGED
@@ -2,6 +2,6 @@
2
2
  import { main } from '../dist/src/cli.js';
3
3
 
4
4
  main().then((code) => { process.exitCode = code; }, (error) => {
5
- console.error(error);
5
+ console.error(error?.message ?? error);
6
6
  process.exitCode = 1;
7
7
  });
@@ -14,11 +14,15 @@ export const knownClaudeTools = new Set([
14
14
  'WebFetch',
15
15
  'WebSearch',
16
16
  'AskUserQuestion',
17
- 'ExitPlanMode'
17
+ 'ExitPlanMode',
18
+ 'Skill'
18
19
  ]);
19
20
  const mcpToolPattern = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_*-]+$/;
21
+ // `Agent(name, name2)` restricts which subagent types an agent running as the main
22
+ // session (via --agent/settings.json) is allowed to spawn — see sub-agents.md.
23
+ export const agentAllowlistPattern = /^Agent\([A-Za-z0-9_-]+(?:,\s*[A-Za-z0-9_-]+)*\)$/;
20
24
  export function isKnownClaudeTool(name) {
21
- return knownClaudeTools.has(name) || mcpToolPattern.test(name);
25
+ return knownClaudeTools.has(name) || mcpToolPattern.test(name) || agentAllowlistPattern.test(name);
22
26
  }
23
27
  export const knownClaudeModels = new Set([
24
28
  'sonnet',
@@ -0,0 +1,9 @@
1
+ // Codex's current model-id list was not verified against live docs at the time this was written
2
+ // (unlike Claude/Grok/OpenCode, which have a confirmed format). Rather than invent a shape we
3
+ // haven't checked, this accepts any non-empty, whitespace-free value and lets Codex itself be the
4
+ // real check — the same "don't guess" discipline this codebase applies elsewhere. Tighten this
5
+ // once Codex's actual model-id format is verified against its docs.
6
+ const codexModelPattern = /^\S+$/;
7
+ export function isKnownCodexModel(value) {
8
+ return codexModelPattern.test(value);
9
+ }
@@ -1,12 +1,17 @@
1
1
  import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
3
  import { isRecord, tomlString } from './shared.js';
4
+ import { isKnownCodexModel } from './codex-known.js';
4
5
  const safeSandboxModes = new Set(['read-only', 'workspace-write']);
5
6
  export function renderCodexAgent(artifact) {
6
7
  const diagnostics = [diagnostic('info', 'CODEX_PARTIAL_AGENT_SUPPORT', `Codex agent output is partial and does not generate AGENTS.md or profiles for ${artifact.name}`, { platform: 'codex' })];
7
8
  const lines = [`name = ${tomlString(artifact.name)}`, `description = ${tomlString(artifact.description)}`, `developer_instructions = ${tomlString(artifact.body)}`];
8
- if (artifact.codex?.model)
9
+ if (artifact.codex?.model) {
9
10
  lines.push(`model = ${tomlString(artifact.codex.model)}`);
11
+ if (!isKnownCodexModel(artifact.codex.model)) {
12
+ diagnostics.push(diagnostic('warning', 'CODEX_UNKNOWN_MODEL', `Unknown Codex model "${artifact.codex.model}" for ${artifact.name}`, { platform: 'codex' }));
13
+ }
14
+ }
10
15
  const permissions = artifact.codex?.permissions;
11
16
  if (isRecord(permissions) && typeof permissions.sandbox_mode === 'string') {
12
17
  if (safeSandboxModes.has(permissions.sandbox_mode))
@@ -37,5 +37,8 @@ export function renderGrokSkill(artifact) {
37
37
  if (artifact.grok?.model) {
38
38
  diagnostics.push(diagnostic('info', 'GROK_SKILL_MODEL_IGNORED', `Grok skill model is not emitted for ${artifact.name}`, { platform: 'grok' }));
39
39
  }
40
+ if (artifact.claude?.['user-invocable'] === false) {
41
+ diagnostics.push(diagnostic('warning', 'GROK_SKILL_DISCOVERABLE_UNENFORCED', `${artifact.name} is meant to be background-only, but Grok has no mechanism to hide a skill from direct invocation — it will be directly invocable here`, { platform: 'grok' }));
42
+ }
40
43
  return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
41
44
  }
@@ -0,0 +1,15 @@
1
+ // OpenCode has no fixed model enum — models.dev's catalog, filtered to whatever providers the
2
+ // user has credentials for (env var, stored auth, or a config/plugin-declared provider). The
3
+ // `provider/model-id` format is verified (opencode.ai/v2/docs/models: "Provider and model IDs are
4
+ // case-sensitive"); the model id itself MAY contain further slashes (e.g.
5
+ // `openrouter/openai/gpt-5-chat` is provider `openrouter`, model `openai/gpt-5-chat`), so this
6
+ // pattern only requires a first segment before the first `/`, not exactly two segments.
7
+ // OpenCode itself does not validate `model` at load time — a bad value fails only when the agent
8
+ // first runs (ModelNotFoundError) — so this pattern check is a genuine safety net, not redundant.
9
+ // Prefer live discovery (`opencode models`, see src/opencode-discovery.ts) when available; this is
10
+ // the fallback for when that isn't possible (non-interactive install, opencode not on PATH, a v2
11
+ // install predating the `models` command, etc).
12
+ const openCodeModelPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*\/.+$/;
13
+ export function isKnownOpenCodeModel(value) {
14
+ return openCodeModelPattern.test(value);
15
+ }
@@ -1,14 +1,27 @@
1
1
  import { stringifyYaml } from '../frontmatter.js';
2
2
  import { diagnostic } from '../diagnostics.js';
3
+ import { isKnownOpenCodeModel } from './opencode-known.js';
4
+ import { isOpenCodePermissions } from './shared.js';
3
5
  export function renderOpenCodeAgent(artifact) {
6
+ const diagnostics = [];
4
7
  const fm = { description: artifact.description };
5
8
  if (artifact.opencode?.mode)
6
9
  fm.mode = artifact.opencode.mode;
7
- if (artifact.opencode?.model)
10
+ if (artifact.opencode?.model) {
8
11
  fm.model = artifact.opencode.model;
9
- if (artifact.opencode?.permissions)
10
- fm.permission = artifact.opencode.permissions;
11
- return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics: [] };
12
+ if (!isKnownOpenCodeModel(artifact.opencode.model)) {
13
+ diagnostics.push(diagnostic('warning', 'OPENCODE_UNKNOWN_MODEL', `Unknown OpenCode model "${artifact.opencode.model}" for ${artifact.name}`, { platform: 'opencode' }));
14
+ }
15
+ }
16
+ if (artifact.opencode?.permissions) {
17
+ if (isOpenCodePermissions(artifact.opencode.permissions)) {
18
+ fm.permission = artifact.opencode.permissions;
19
+ }
20
+ else {
21
+ diagnostics.push(diagnostic('warning', 'OPENCODE_INVALID_PERMISSIONS', `opencode.permissions for ${artifact.name} is not a flat allow/deny/ask object (≤ 64 scalar-valued keys); the block was not emitted`, { platform: 'opencode' }));
22
+ }
23
+ }
24
+ return { content: `${stringifyYaml(fm)}${artifact.body}\n`, diagnostics };
12
25
  }
13
26
  export function renderOpenCodeSkill(artifact) {
14
27
  const diagnostics = [];
@@ -16,5 +29,8 @@ export function renderOpenCodeSkill(artifact) {
16
29
  diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_PERMISSIONS_IGNORED', `OpenCode skill permissions are not emitted for ${artifact.name}`, { platform: 'opencode' }));
17
30
  if (artifact.opencode?.model)
18
31
  diagnostics.push(diagnostic('info', 'OPENCODE_SKILL_MODEL_IGNORED', `OpenCode skill model is not emitted for ${artifact.name}`, { platform: 'opencode' }));
32
+ if (artifact.claude?.['user-invocable'] === false) {
33
+ diagnostics.push(diagnostic('warning', 'OPENCODE_SKILL_DISCOVERABLE_UNENFORCED', `${artifact.name} is meant to be background-only, but OpenCode has no mechanism to hide a skill from direct invocation — it will be directly invocable here`, { platform: 'opencode' }));
34
+ }
19
35
  return { content: `${stringifyYaml({ name: artifact.name, description: artifact.description })}${artifact.body}\n`, diagnostics };
20
36
  }
@@ -1,10 +1,11 @@
1
+ import { agentAllowlistPattern } from './claude-known.js';
1
2
  export function isRecord(value) {
2
3
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
3
4
  }
4
5
  const toolNamePattern = /^[A-Za-z0-9_*-]+$/;
5
6
  const patternBodyPattern = /^[^,\n]+$/;
6
7
  export function stringList(value) {
7
- return Array.isArray(value) && value.every((item) => typeof item === 'string' && toolNamePattern.test(item)) ? value : undefined;
8
+ return Array.isArray(value) && value.every((item) => typeof item === 'string' && (toolNamePattern.test(item) || agentAllowlistPattern.test(item))) ? value : undefined;
8
9
  }
9
10
  export function patternList(value) {
10
11
  return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0 && patternBodyPattern.test(item)) ? value : undefined;
@@ -12,3 +13,16 @@ export function patternList(value) {
12
13
  export function tomlString(value) {
13
14
  return JSON.stringify(value);
14
15
  }
16
+ const OPENCODE_PERMISSION_VALUES = new Set(['allow', 'deny', 'ask']);
17
+ const OPENCODE_PERMISSIONS_MAX_KEYS = 64;
18
+ export function isOpenCodePermissions(value) {
19
+ if (!isRecord(value))
20
+ return false;
21
+ const keys = Object.keys(value);
22
+ if (keys.length === 0 || keys.length > OPENCODE_PERMISSIONS_MAX_KEYS)
23
+ return false;
24
+ return keys.every((key) => {
25
+ const v = value[key];
26
+ return v === true || v === false || (typeof v === 'string' && OPENCODE_PERMISSION_VALUES.has(v));
27
+ });
28
+ }