@sorenllm/opencode-forge 0.15.1 → 0.17.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.
Files changed (3) hide show
  1. package/README.md +178 -87
  2. package/dist/index.js +324 -134
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -195,7 +195,7 @@ What this plugin touches, exhaustively:
195
195
  | `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
196
196
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
197
197
  | `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger — **no longer written** (the dispatch engine was removed); only present as inert debris from older versions | runtime debris — delete freely |
198
- | `.opencode/forge.json` files (the nearest on each host anchor's ancestor chain, every one in an anchor's subtree, and `~/.config/opencode/forge.json`) | your static subagent definitions (JSONC) — pool families MERGED over the global layer per agent id (primary anchor's root pool plain ids, others namespaced). **User data — the plugin only reads them, never writes or migrates them** | yours — version the project ones, keep the global one out of sync tools if it holds machine-specific models |
198
+ | `.opencode/forge.json` (the NEAREST one walking up from the session's worktree — sole ownership) and `~/.config/opencode/forge.json` (global FALLBACK only, never merged) | your static subagent definitions (JSONC) — the workspace pool IS the session's whole roster; nested forge.json files are ignored. **User data — the plugin only reads them, never writes or migrates them** | yours — version the project ones, keep the global one out of sync tools if it holds machine-specific models |
199
199
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
200
200
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
201
201
 
@@ -429,58 +429,67 @@ subagent behavior is untouched. Onboarding is this README, for you the human.
429
429
 
430
430
  ### forge.json — the configuration
431
431
 
432
- A dedicated `forge.json` (JSONC — comments allowed), resolved exactly like
433
- opencode's own config language — **merged together, not replaced**, project
434
- discovered by walking up:
435
-
436
- 1. the project layer: the **nearest** `.opencode/forge.json` walking up from
437
- the workspace (session worktree → session directory → host launch
438
- directory as the anchor chain; first file found on the ancestor chain
439
- wins, nothing above it is consulted)
440
- 2. the global layer: `~/.config/opencode/forge.json`
441
-
442
- The two layers **merge**: agents combine, and on an id collision the project
443
- definition wins wholesale (one definition per id — no cross-layer field
444
- blending). A broken file empties only its own layer/pool (error finding with
445
- the parse location); everything else still applies. Both layers re-read at
446
- every real materialization — host boot, a first config build, or an
447
- opencode config reload — without a restart; a new session alone does
448
- not re-read them (see the apply-timing table below).
449
-
450
- **Hierarchical pools on a shared host.** Agent frontends (paseo and friends)
451
- routinely share ONE opencode host process across projects, re-initializing
452
- plugins with different directories. The plugin therefore keeps an
453
- **append-only anchor set**: every host initialization appends its directory
454
- (first one = the *primary anchor*), and pools resolve from the union —
455
- anchor flips can only ever ADD pools, never remove them. Per anchor the
456
- family is:
457
-
458
- - the **root pool** — the nearest forge.json walking up from that anchor
459
- (merged over the global layer per agent id);
460
- - every **sub-pool** — a `.opencode/forge.json` anywhere in the anchor's
461
- subdirectories, discovered by a bounded breadth-first scan (lexicographic
462
- sibling order; dependency dirs like `node_modules`/`.git`/`dist` are
463
- skipped; 2,000-directory budget per anchor).
464
-
465
- The **primary anchor's root pool keeps plain `forge-<id>` ids** (a
466
- single-project host is unchanged); every other family materializes
467
- **namespaced** as `forge-<ns>-<id>`. `ns` is the file's optional `pool`
468
- field, else the sanitized directory name. Pool identity is the absolute file
469
- path — the same file reached through two anchors materializes exactly once;
470
- namespace and materialized-id collisions resolve deterministically with an
471
- error finding, never by silent shadowing. The `/crew` roster and the
472
- `crew_begin` registration output list the dispatchable agents **grouped by
473
- pool** with each pool's origin path, the primary marked.
432
+ A dedicated `forge.json` (JSONC — comments allowed) with **workspace pool
433
+ ownership and a global fallback** (sole ownership — the same
434
+ workspace-ownership strictness as opencode's native MCP config):
435
+
436
+ 1. the **workspace pool**: the **nearest** `.opencode/forge.json` walking up
437
+ from the session's worktree (session worktree → session directory → host
438
+ launch directory as the anchor chain; first file found on the ancestor
439
+ chain wins, nothing above it is consulted). When it exists, it **IS the
440
+ session's whole dispatch roster**.
441
+ 2. the **global layer**: `~/.config/opencode/forge.json` — a **fallback
442
+ ONLY**: when the workspace has no pool file, the global layer serves as
443
+ the whole roster; when the workspace has one, the global layer is NOT
444
+ consulted for that session (**a fallback, not a merge** — no per-id
445
+ blending, global agents never ride a workspace pool).
446
+
447
+ `forge.json` files nested **deeper** in the workspace's subtree (a cloned
448
+ repo or subfolder carrying its own file) are **ignored entirely** — they
449
+ contribute no agents, split no pools, and extend nothing (a git checkout
450
+ does not change your roster). A broken file empties only its own pool
451
+ (error finding with the parse location); everything else still applies.
452
+ Both layers re-read at every real materialization — host boot, a first
453
+ config build, or an opencode config reload — without a restart; a new
454
+ session alone does not re-read them, and a **deleted pool file
455
+ un-materializes its agents** (deletion is real — no zombie pools).
456
+
457
+ **Pools on a shared host.** Agent frontends (paseo and friends) routinely
458
+ share ONE opencode host process across projects, re-initializing plugins
459
+ with different directories. The host therefore remembers every workspace
460
+ directory it was initialized in, and the **host-wide materialization set**
461
+ is the global layer plus each seen workspace's pool (one pool per
462
+ directory: its walk-up nearest file). Pool membership is re-derived from
463
+ disk truth on every resolve — a deleted pool file disappears at the next
464
+ config hook. The FIRST seen workspace's pool keeps **plain `forge-<id>`
465
+ ids** (a single-project host is unchanged); every other pool materializes
466
+ **namespaced** as `forge-<ns>-<id>` (`ns` = the file's optional `pool`
467
+ field, else the sanitized pool-directory name; the global fallback family
468
+ derives `global`). Namespace and materialized-id collisions resolve
469
+ deterministically with an error finding, never by silent shadowing.
470
+
471
+ **Dispatch is gated to the session's own pool.** Materializing every pool
472
+ host-wide is a vocabulary necessity (the task tool's agent list is
473
+ host-level); what a given session may actually DISPATCH is scoped
474
+ session-locally: a task call targeting an agent whose pool belongs to
475
+ ANOTHER workspace is refused with a finding naming the target's origin
476
+ pool path, stating it is *not dispatchable from this session*, and the
477
+ exits — open a session in that workspace, or configure the role into this
478
+ workspace's pool / the global layer. The `/crew` roster and the
479
+ `crew_begin` registration output lead with the session's dispatchable pool
480
+ (its forge.json origin — workspace pool, or the global layer marked as
481
+ fallback) and list every other host pool as NOT dispatchable with its
482
+ origin path.
474
483
 
475
484
  | axis | follows | survives restart? |
476
485
  | --- | --- | --- |
477
- | pools (the `forge-*` vocabulary) | host directories — the anchor set | the set re-seeds from the spawn anchor only |
486
+ | pools (the `forge-*` vocabulary) | workspaces — sole ownership + global fallback | the workspace registry re-seeds from the spawn anchor only |
478
487
  | session artifacts (plan/goal/crew records) | the session's own workspace | files on disk are the source of truth |
479
488
 
480
- A forge.json in a directory NO host anchor covers is NOT silently ignored:
481
- `/crew` names it as the residual mismatch and tells you a host
482
- initialization in that workspace adds its pools (or fold the agents into the
483
- global layer).
489
+ A forge.json in a workspace this host has never been initialized in is NOT
490
+ silently ignored: `/crew` names it as the residual mismatch and tells you
491
+ a host re-initialization in that workspace materializes it (or move the
492
+ agents into the global layer).
484
493
 
485
494
  **Id naming**: use plain role words (`research`, `coder`, …) — the plugin
486
495
  materializes every id as `forge-<id>` itself (namespaced pools:
@@ -492,7 +501,7 @@ out of a materialized id.
492
501
 
493
502
  ```jsonc
494
503
  {
495
- // sub-pool files only: namespace for this family's ids (forge-<ns>-<id>).
504
+ // non-primary pool files: namespace for this pool's ids (forge-<ns>-<id>).
496
505
  // Short, stable, [a-z0-9-] up to 24 chars — ids survive directory renames.
497
506
  // Invalid values degrade to a warning + the directory-derived namespace.
498
507
  // "pool": "aa",
@@ -502,7 +511,10 @@ out of a materialized id.
502
511
  "model": "zai-coding-plan/glm-5.3", // exact "provider/model" string
503
512
  "thoughtLevel": "low", // none/minimal/low/medium/high/xhigh/max, or any word — write lowercase
504
513
  // "prompt": "short role description — explicit prompts fully override the built-ins (only research/review ids have them; any other id without a prompt gets a generic one-liner)",
505
- // "shape": "write", // default readonly denies write/edit/bash
514
+ // "shape": "readonly", // absent shape defaults to WRITE (open
515
+ // permissions); "readonly" is the explicit seal
516
+ // denying write/edit/bash + the patch family
517
+ // (patch/applypatch/apply/multiedit)
506
518
  // "permission": { "bash": "deny" } // optional override; "task" is ALWAYS denied
507
519
  },
508
520
  // AUTO worker: set NEITHER — inherits the parent session's model at
@@ -545,10 +557,18 @@ A rejected entry costs only itself — its siblings materialize normally.
545
557
  says "inherits the parent session's model" instead of naming a brain),
546
558
  the worker discipline (workspace-relative paths only, verbatim reporting
547
559
  of tool refusals, evidence-bearing conclusions) around its role prompt,
548
- and a deny-style permission: `shape: "readonly"` (the default) denies
549
- `write`/`edit`/`bash`, `shape: "write"` allows them, an explicit
550
- `permission` map overrides the shape default — and `task` is ALWAYS
551
- denied (recursive spawning stays physically impossible).
560
+ and a deny-style permission: an ABSENT `shape` defaults to WRITE (open
561
+ permissions — the compatibility-preserving default for existing pools);
562
+ an explicit `shape: "readonly"` denies
563
+ the full mutating-tool family — `write`/`edit`/`bash` plus the host's
564
+ patch family (`patch`/`applypatch`/`apply`/`multiedit`) — `shape:
565
+ "write"` allows them (same as absent), an explicit `permission` map overrides the shape
566
+ default (lifting ANY member of that family off `deny` forfeits the
567
+ draft-phase readonly exemption) — and `task` is ALWAYS
568
+ denied (recursive spawning stays physically impossible). Readonly is a
569
+ restriction you ask for explicitly, never a surprise default — new pools
570
+ should give every agent an explicit `shape` (the `/crew` initialization
571
+ gate teaches the AI author exactly this).
552
572
 
553
573
  > **Upgrading from ≤ 0.5.0 (breaking):** agent entries that pinned `model`
554
574
  > without `thoughtLevel` used to be accepted; they are now SKIPPED with an
@@ -556,17 +576,17 @@ A rejected entry costs only itself — its siblings materialize normally.
556
576
  > `thoughtLevel`, or delete `model` to make it an Auto worker.
557
577
 
558
578
  **What applies when** — only a real materialization re-reads the pool
559
- files into the `forge-*` vocabulary; the anchor set itself only ever grows.
560
- Materialization triggers (2026-10-01 live finding): host boot (a freshly
561
- started CLI process reads the files as they are), the FIRST config build
562
- for a directory on this host, and opencode config file reloads. A NEW
563
- session on a long-lived shared host (serve-type) does NOT re-materialize:
579
+ files into the `forge-*` vocabulary. Materialization triggers (2026-10-01
580
+ live finding): host boot (a freshly started CLI process reads the files as
581
+ they are), the FIRST config build for a directory on this host, and
582
+ opencode config file reloads. A NEW session on a long-lived shared host
583
+ (serve-type) does NOT re-materialize:
564
584
 
565
585
  | change | needs a host restart? |
566
586
  | --- | --- |
567
587
  | `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) — depth lookups resolve the pool files fresh at every dispatch, independent of materialization |
568
- | agent added / removed / edited, any pool file created or deleted | no restart — but not at the next session either: the materialized `forge-*` set rebuilds only at the next REAL materialization (host boot, opencode config reload, or a first config build); on a long-lived shared host pool changes wait for that rebuild or a restart. The crew gate and depth lookups resolve the pool files fresh regardless |
569
- | a NEW directory joining the host (the first session of another project on the shared host) | no restart — that first config build itself re-initializes the plugin and appends the anchor and its pools (append-only; existing pools never flip) |
588
+ | agent added / removed / edited, any pool file created or deleted | no restart — but not at the next session either: the materialized `forge-*` set rebuilds only at the next REAL materialization (host boot, opencode config reload, or a first config build); on a long-lived shared host pool changes wait for that rebuild or a restart. The crew gate, the session-domain dispatch gate, and depth lookups resolve the pool files fresh regardless — a DELETED pool file un-materializes at that next resolve |
589
+ | a NEW directory joining the host (the first session of another project on the shared host) | no restart — that first config build itself re-initializes the plugin and registers that workspace and its pool (existing pools never flip; the first-seen workspace keeps plain ids) |
570
590
 
571
591
  ### Dispatching
572
592
 
@@ -585,6 +605,50 @@ providers can return a deterministic EMPTY response for any toolset-reduced
585
605
  attributable task failure; the mitigation is unchanged — use
586
606
  `shape: "write"` for such endpoints, or a keyed provider.
587
607
 
608
+ ### Readonly exec seal (the sealed supervised-exec channel)
609
+
610
+ The readonly shape used to seal only the TOOL face (write/edit/bash + the
611
+ patch family) — a readonly worker could still mutate the world through its
612
+ exec channel (`forge_shell`). The exec seal closes that hole: a session whose
613
+ running `forge-*` worker has an **explicit** `shape: "readonly"` (an absent
614
+ shape defaults to write and is **never** sealed) has its `forge_shell` calls
615
+ classified BEFORE spawn by a pure string classifier (`src/exec-classify.ts`,
616
+ no fs access at classify time). READ-CLASS commands (inspection, search,
617
+ read-only evals — `rg`, `git log`/`status`/`diff`, `node -e`/`python -c`
618
+ read evals, version/help queries) flow normally; **WRITE-CLASS commands are
619
+ denied** with a bounded finding (once per session per indicator class) that
620
+ names the readonly shape, names the classified write indicator, and points
621
+ at the remedy: `"shape": "write"` on that agent when its job legitimately
622
+ mutates. The worker is told to report the refusal verbatim, not work around
623
+ it. The primary forge session is never sealed; a session whose agent cannot
624
+ be resolved fails open (one-time disclosure). The seal composes with — never
625
+ replaces — the draft-phase ask/deny posture on the channel.
626
+
627
+ Covered WRITE-CLASS indicators:
628
+
629
+ | indicator | covers |
630
+ | --- | --- |
631
+ | `redirect` | output redirection to files — `>` `>>` and fd forms (`2>`, `1>`, `*>`); the PowerShell family uses the same operators |
632
+ | `mutating-cmdlet` | `Set-Content`, `Out-File`, `New-Item`, `Remove-Item`, `Move-Item`, `Copy-Item`, `Add-Content`, `Clear-Content` |
633
+ | `git-mutation` | `git add/commit/push/reset/checkout/rebase/merge/clean/rm` |
634
+ | `pkg-mutation` | `npm publish`, `npm install` (incl. `npm i`/`ci`/`add`), `bun add`/`install`, `pip`/`pip3 install` |
635
+ | `workspace-write` | output-target flags (`--out`, `--output`, `--out-file`/`--outfile`/`--outFile`, `--dest`, `--destination`, `-o`) whose parsed value is a workspace-relative path, or an absolute path under the session workspace |
636
+
637
+ **Honest positioning — this is belt-in-depth, NOT a sandbox.** A command
638
+ that fails to classify is treated as READ-CLASS (fail-open on the channel,
639
+ the same posture as every refusal belt). The known bypass surface, verbatim:
640
+ **indirect writes** (a script that writes internally — `node build.js` with
641
+ no redirect is indirection the classifier does not model), **`Start-Process`
642
+ writers** (spawning a writer process is explicitly out of scope), and
643
+ **encoded payloads** (`powershell -EncodedPayload …`, base64 blobs) are NOT
644
+ covered; they flow on the readonly channel. The seal narrows the exec hole
645
+ from "completely open" to "explicitly constructed indirection" — a true OS
646
+ level seal (write-time redirection, shadow copies, containers, win32+POSIX
647
+ dual implementations) was evaluated and rejected as a disproportionate
648
+ rewrite (see `openspec/changes/readonly-exec-seal/design.md`, option D3).
649
+ The tool-face deny remains the first belt; this is the second.
650
+
651
+
588
652
  ### The thoughtLevel word
589
653
 
590
654
  One word per agent: the canonical seven-word dictionary
@@ -594,22 +658,32 @@ name. **Write lowercase**: the value is normalized at the config boundary
594
658
  `xhigh`; normalization that changes what you wrote records one warn finding.
595
659
  Native casing on the wire is the catalog's business, never yours. Injection
596
660
  rides the `chat.params` hook keyed by agent name. The wire shape comes from
597
- data, not guessing: a model the models.dev catalog knows by
661
+ data, not guessing — with ONE family decided ahead of the catalog: for the
662
+ **anthropic family** the channel is read straight off the empirically
663
+ verified transport matrix — adaptive-era models (major > 4 or 4.7+) take
664
+ the adaptive effort key `options.effort` (the host transports it as the
665
+ adaptive-era native `output_config: {effort: <word>}` wire shape), legacy-era
666
+ models take the budget tier table below — and the models.dev catalog is
667
+ **not consulted** for this family: its effort claim cannot sound on the
668
+ current host generation's anthropic transport, which silently drops the
669
+ `reasoningEffort` key a catalog effort shape would select. For every other
670
+ family the catalog remains the shape authority: a model the models.dev
671
+ catalog knows by
598
672
  `reasoning_options` is translated per that catalog shape (effort word list /
599
673
  toggle / token budget); a catalog-silent model falls back to the
600
674
  provider-prefix family; a provider absent from both is treated as an effort
601
675
  **word channel** by default (custom relays and unknown endpoints — the most
602
- universal shape). Adaptive-era Anthropic models (major > 4 or 4.7+) receive
603
- the effort word directly instead of a budget number, on the empirically
604
- verified `effort` option key (the host transports it as the adaptive-era
605
- native `output_config: {effort: <word>}` wire shape; a `reasoningEffort`
606
- key is silently dropped by anthropic transports). **The dictionary is a
676
+ universal shape). **The dictionary is a
607
677
  dictionary, never a whitelist**: a word matching neither the dictionary nor
608
678
  the model's ladder passes through VERBATIM on word-shaped channels
609
679
  (`reasoningEffort = <word>`, or `effort = <word>` on the adaptive channel),
610
680
  disclosed as unverified — the provider is the
611
681
  final judge, and a provider-side rejection surfaces as the request's own
612
- error. Ladder matching is case-insensitive and emits the catalog's native
682
+ error. Known host-side limitation: the adaptive channel passes `none`
683
+ through verbatim per the dictionary axiom, but the current host generation's
684
+ anthropic transport refuses to send such a request (it silently never
685
+ reaches the wire) — documented here, deliberately not worked around. Ladder
686
+ matching is case-insensitive and emits the catalog's native
613
687
  casing (`xhigh` in, `XHigh` out when the catalog says so). **The only dead
614
688
  ends are the two shapes with no word channel**: budget-style (legacy
615
689
  Anthropic-style) models have a published thinking tier instead
@@ -619,7 +693,13 @@ consensus number; `low → 8192`, `medium → 16384`, `high → 24576`,
619
693
  `none → off`. **This tier table is this plugin's selection — there is no
620
694
  community-standard tier table**), and pure toggle endpoints (zai/GLM: a level
621
695
  word has no toggle counterpart) — there the plugin injects nothing and
622
- records one bounded finding naming both vocabularies. No interpolation
696
+ records one bounded finding naming both vocabularies. **Host-transport
697
+ caveat (observed on the opencode 1.18.34 generation):** the anthropic
698
+ transport rewrites the thinking `budget_tokens` value this plugin injects
699
+ to 1024 regardless of the selected tier (characterized by four paired
700
+ probe experiments). The tier table above is the plugin-side truth —
701
+ verifiable with a `FORGE_DEPTH_PROBE=1` trace — while the value that
702
+ actually reaches the wire is the host's to rewrite. No interpolation
623
703
  anywhere; the session is never broken by a config word. A session's depth is
624
704
  frozen at its first request (editing forge.json mid-session never rewrites a
625
705
  running session).
@@ -701,18 +781,23 @@ itself (a rootish guard keeps degenerate global-project worktrees from
701
781
  landing files on the drive root or the launch anchor). The anchor follows the
702
782
  session's workspace, never the objective's scope.
703
783
 
704
- **Not initialized?** `/crew` on a host with ZERO pools across its whole
705
- anchor set refuses with one-time setup guidance: the two file paths, a
706
- copy-paste template, and the choice of configuring it yourself OR having the
707
- session AI do it — the latter only on your explicit go-ahead in that
708
- conversation, through the normal visible write path (when the AI writes a
709
- sub-pool file it also proposes a short stable `pool` namespace so ids
710
- survive directory renames). This is the plugin's only configuration
711
- invitation. Saved files apply at the next real materialization — no
712
- restart needed; on a shared host, the first session in a NEW workspace adds
713
- that anchor and its pools automatically (a new session in an existing
714
- workspace does not re-read the files — see the apply-timing table
715
- above).
784
+ **Not initialized?** `/crew` on a session whose roster is EMPTY (neither
785
+ its workspace pool nor the global fallback materializes an agent) refuses
786
+ with one-time setup guidance: the two file paths and their sole-ownership
787
+ relationship (the workspace pool IS the whole roster; the global layer is
788
+ fallback-only, never merged; nested forge.json files ignored), a
789
+ copy-paste template, and the choice of configuring it yourself OR having
790
+ the session AI do it — the latter only on your explicit go-ahead in that
791
+ conversation, through the normal visible write path (when the AI writes
792
+ the file every agent gets an explicit `shape` — `readonly` for read-only
793
+ roles, `write` for implementers — and it also proposes a short stable
794
+ `pool` namespace so ids survive directory renames). This is the plugin's
795
+ only configuration invitation. Saved files apply at the next real
796
+ materialization — no restart needed, and a deleted pool file
797
+ un-materializes its agents; on a shared host, the first session in a NEW
798
+ workspace registers that workspace and its pool automatically (a new
799
+ session in an existing workspace does not re-read the files — see the
800
+ apply-timing table above).
716
801
 
717
802
  ### Debris note
718
803
 
@@ -792,19 +877,25 @@ The escape hatches are `plan_approve` and `/plan discard`, by design.
792
877
 
793
878
  One carve-out, keyed on the target rather than the tool: during a draft the
794
879
  `task` tool is gated by the dispatch target's **effective readonly shape**. A
795
- dispatch to a forge worker whose shape is not `write` AND whose permission
796
- overrides do not lift `write`/`edit`/`bash` off `deny` (`ask` counts as
880
+ dispatch to a forge worker whose shape is EXPLICITLY `readonly` AND whose
881
+ permission
882
+ overrides do not lift `write`/`edit`/`bash` — nor any host patch-family tool
883
+ (`patch`/`applypatch`/`apply`/`multiedit`) — off `deny` (`ask` counts as
797
884
  lifted — fail-closed) passes as a reconnaissance dispatch: readonly scouts
798
885
  may be spawned both before and during the draft, so breadth
799
- evidence-gathering does not burn the planning session's context. Every other
800
- target — write-shaped workers, native agents, absent or unresolvable targets
886
+ evidence-gathering does not burn the planning session's context. A worker
887
+ with NO shape field is not exempt (absent defaults to write). Every other
888
+ target — write-shaped and shape-less workers, native agents, absent or
889
+ unresolvable targets
801
890
  (including resume calls that carry no target) — is denied like any write-class
802
891
  tool. The refusal names the currently dispatchable effective-readonly roster,
803
- points to defining a readonly worker in forge.json when the roster is empty
892
+ points to defining a readonly worker in forge.json (explicit
893
+ `"shape": "readonly"`) when the roster is empty
804
894
  (an unconfigured host stays fully denied — same behavior as before, just more
805
895
  actionable wording), and names the two exits (`plan_approve`, `/plan
806
896
  discard`). Invariant strength is unchanged: the scout's materialized
807
- permission hard-denies write/edit/bash (and `task`, always — no chained
897
+ permission hard-denies the mutating-tool family — write/edit/bash and the
898
+ patch family (and `task`, always — no chained
808
899
  spawning), the scout's own session is ambient-bound to the same draft write
809
900
  ban, and its `forge_shell` calls ride the same per-call ask gate the primary
810
901
  agent faces. The `permission.ask` belt does not follow the exemption: on a
package/dist/index.js CHANGED
@@ -12335,7 +12335,7 @@ function tool(input) {
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
12337
  import { execFileSync as execFileSync3 } from "node:child_process";
12338
- import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12338
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12339
12339
  import { isAbsolute, join as join6, relative } from "node:path";
12340
12340
  import { tmpdir as tmpdir3, homedir } from "node:os";
12341
12341
 
@@ -15042,7 +15042,7 @@ function materializeAgentPrompt(input) {
15042
15042
  }
15043
15043
 
15044
15044
  // src/dispatch-tiers.ts
15045
- var MUTATING_TOOLS = ["write", "edit", "bash"];
15045
+ var MUTATING_TOOLS = ["write", "edit", "bash", "patch", "applypatch", "apply", "multiedit"];
15046
15046
  var ROLE_PROMPTS = {
15047
15047
  research: "You are a research worker: search, read, and run read-only checks to answer the assigned question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
15048
15048
  review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
@@ -15053,13 +15053,13 @@ function rolePromptFor(agentId, def) {
15053
15053
  return ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
15054
15054
  }
15055
15055
  function agentDescriptionFor(agentId, def) {
15056
- const shape = def.shape === "write" ? "write" : "readonly";
15056
+ const shape = def.shape === "readonly" ? "readonly" : "write";
15057
15057
  const brain = def.model !== undefined ? `pinned ${def.model}` : "auto — inherits the parent session's model at dispatch";
15058
15058
  return `forge subagent "${agentId}" (${shape}, ${brain}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
15059
15059
  }
15060
15060
  function forgeAgentDef(agentId, def) {
15061
15061
  const permission = { task: "deny" };
15062
- const shape = def.shape ?? "readonly";
15062
+ const shape = def.shape ?? "write";
15063
15063
  if (shape === "readonly") {
15064
15064
  for (const t of MUTATING_TOOLS)
15065
15065
  permission[t] = "deny";
@@ -15082,7 +15082,7 @@ function forgeAgentDef(agentId, def) {
15082
15082
  return entry;
15083
15083
  }
15084
15084
  function effectiveReadonlyShape(def) {
15085
- if (def.shape === "write")
15085
+ if (def.shape !== "readonly")
15086
15086
  return false;
15087
15087
  const perm = def.permission ?? {};
15088
15088
  for (const t of MUTATING_TOOLS) {
@@ -15093,6 +15093,165 @@ function effectiveReadonlyShape(def) {
15093
15093
  return true;
15094
15094
  }
15095
15095
 
15096
+ // src/exec-classify.ts
15097
+ var READ_CLASS = "READ";
15098
+ var WRITE_CLASS = "WRITE";
15099
+ var REDIRECT_INDICATOR = "redirect";
15100
+ var MUTATING_CMDLETS = [
15101
+ "Set-Content",
15102
+ "Out-File",
15103
+ "New-Item",
15104
+ "Remove-Item",
15105
+ "Move-Item",
15106
+ "Copy-Item",
15107
+ "Add-Content",
15108
+ "Clear-Content"
15109
+ ];
15110
+ var GIT_MUTATING_SUBCOMMANDS = [
15111
+ "add",
15112
+ "commit",
15113
+ "push",
15114
+ "reset",
15115
+ "checkout",
15116
+ "rebase",
15117
+ "merge",
15118
+ "clean",
15119
+ "rm"
15120
+ ];
15121
+ var PKG_MUTATING_COMMANDS = ["npm publish", "npm install", "bun add", "pip install"];
15122
+ var PKG_MUTATING_PATTERNS = [
15123
+ /\bnpm\s+(?:publish|install|i|ci|add)\b/i,
15124
+ /\bbun\s+(?:add|install)\b/i,
15125
+ /\bpip3?\s+install\b/i
15126
+ ];
15127
+ var WRITE_TARGET_FLAGS = [
15128
+ "--out",
15129
+ "--output",
15130
+ "--out-file",
15131
+ "--outfile",
15132
+ "--outFile",
15133
+ "--dest",
15134
+ "--destination",
15135
+ "-o"
15136
+ ];
15137
+ var EXEC_SEAL_INDICATORS = [
15138
+ { id: REDIRECT_INDICATOR, covers: "`>` `>>` and fd forms (`2>` `*>`) — bash and the PowerShell family share the operators" },
15139
+ { id: "mutating-cmdlet", covers: MUTATING_CMDLETS.join(", ") },
15140
+ { id: "git-mutation", covers: `git ${GIT_MUTATING_SUBCOMMANDS.join("/")}` },
15141
+ { id: "pkg-mutation", covers: PKG_MUTATING_COMMANDS.join(", ") + " (with common aliases: npm i/ci/add, bun install, pip3 install)" },
15142
+ { id: "workspace-write", covers: `output-target flags ${WRITE_TARGET_FLAGS.join(" ")} whose parsed value is a workspace-relative path (or an absolute path under the provided workspaceRoot)` }
15143
+ ];
15144
+ function stripQuoted(cmd) {
15145
+ let out = "";
15146
+ let quote = null;
15147
+ for (const ch of cmd) {
15148
+ if (quote) {
15149
+ if (ch === quote)
15150
+ quote = null;
15151
+ continue;
15152
+ }
15153
+ if (ch === '"' || ch === "'") {
15154
+ quote = ch;
15155
+ continue;
15156
+ }
15157
+ out += ch;
15158
+ }
15159
+ return out;
15160
+ }
15161
+ function redirectMatch(cmd) {
15162
+ const bare = stripQuoted(cmd).replace(/\r?\n/g, " ");
15163
+ const m = /(?:^|[^>&])>{1,2}(?!&)[^\s;&|]*/.exec(bare);
15164
+ if (m)
15165
+ return { detail: m[0].trim() };
15166
+ return null;
15167
+ }
15168
+ function wordMatch(cmd, needle) {
15169
+ const re = new RegExp(`(^|[\\s;&|(])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
15170
+ const m = re.exec(cmd);
15171
+ if (m)
15172
+ return { detail: m[0].trim() };
15173
+ return null;
15174
+ }
15175
+ function gitMutationMatch(cmd) {
15176
+ const bare = stripQuoted(cmd);
15177
+ for (const sub of GIT_MUTATING_SUBCOMMANDS) {
15178
+ const m = new RegExp(`(?:^|[\\s;&|(])git\\s+${sub}\\b`, "i").exec(bare);
15179
+ if (m)
15180
+ return { detail: m[0].trim() };
15181
+ }
15182
+ return null;
15183
+ }
15184
+ function pkgMutationMatch(cmd) {
15185
+ const bare = stripQuoted(cmd);
15186
+ for (const re of PKG_MUTATING_PATTERNS) {
15187
+ const m = re.exec(bare);
15188
+ if (m)
15189
+ return { detail: m[0].trim() };
15190
+ }
15191
+ return null;
15192
+ }
15193
+ function isDriveRelative(p) {
15194
+ return /^[a-zA-Z]:/.test(p);
15195
+ }
15196
+ function pathLike(p) {
15197
+ return /^[^;&|<>]+$/.test(p) && /[./\\]/.test(p);
15198
+ }
15199
+ function insideWorkspace(value, workspaceRoot) {
15200
+ const norm = (s) => s.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
15201
+ if (!isDriveRelative(value) && !value.startsWith("/") && !value.startsWith("\\\\")) {
15202
+ return true;
15203
+ }
15204
+ if (workspaceRoot === undefined)
15205
+ return false;
15206
+ return norm(value).startsWith(`${norm(workspaceRoot)}/`) || norm(value) === norm(workspaceRoot);
15207
+ }
15208
+ function workspaceWriteMatch(cmd, workspaceRoot) {
15209
+ const tokens = stripQuoted(cmd).split(/\s+/).filter(Boolean);
15210
+ for (let i = 0;i < tokens.length; i++) {
15211
+ const tok = tokens[i];
15212
+ let flag = "";
15213
+ let value;
15214
+ const eq = tok.indexOf("=");
15215
+ if (eq > 0 && tok.startsWith("--")) {
15216
+ flag = tok.slice(0, eq);
15217
+ value = tok.slice(eq + 1);
15218
+ } else if (WRITE_TARGET_FLAGS.includes(tok)) {
15219
+ flag = tok;
15220
+ value = tokens[i + 1];
15221
+ }
15222
+ if (!flag || value === undefined)
15223
+ continue;
15224
+ if (value.startsWith("-"))
15225
+ continue;
15226
+ if (pathLike(value) && insideWorkspace(value, workspaceRoot)) {
15227
+ return { detail: `${flag} ${value}` };
15228
+ }
15229
+ }
15230
+ return null;
15231
+ }
15232
+ function classifyExecCommand(cmd, opts) {
15233
+ if (typeof cmd !== "string" || cmd.length === 0)
15234
+ return { klass: READ_CLASS };
15235
+ const r = redirectMatch(cmd);
15236
+ if (r)
15237
+ return { klass: WRITE_CLASS, indicator: REDIRECT_INDICATOR, detail: r.detail };
15238
+ for (const cmdlet of MUTATING_CMDLETS) {
15239
+ const m = wordMatch(cmd, cmdlet);
15240
+ if (m)
15241
+ return { klass: WRITE_CLASS, indicator: "mutating-cmdlet", detail: m.detail };
15242
+ }
15243
+ const g = gitMutationMatch(cmd);
15244
+ if (g)
15245
+ return { klass: WRITE_CLASS, indicator: "git-mutation", detail: g.detail };
15246
+ const p = pkgMutationMatch(cmd);
15247
+ if (p)
15248
+ return { klass: WRITE_CLASS, indicator: "pkg-mutation", detail: p.detail };
15249
+ const w = workspaceWriteMatch(cmd, opts?.workspaceRoot);
15250
+ if (w)
15251
+ return { klass: WRITE_CLASS, indicator: "workspace-write", detail: w.detail };
15252
+ return { klass: READ_CLASS };
15253
+ }
15254
+
15096
15255
  // src/dispatch-depth.ts
15097
15256
  var META_DEPTHS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
15098
15257
  var BUDGET_TIERS = {
@@ -15176,7 +15335,7 @@ function resolvePinnedDepth(thoughtLevel, channel) {
15176
15335
  }
15177
15336
 
15178
15337
  // src/forge-config.ts
15179
- import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15338
+ import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15180
15339
  function sanitizeJsonc(text) {
15181
15340
  const out = [];
15182
15341
  let i = 0;
@@ -15630,8 +15789,6 @@ function createForgeConfigLoader(deps) {
15630
15789
  }
15631
15790
  };
15632
15791
  }
15633
- var POOL_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "target", ".cache", "venv", ".venv", "__pycache__", "coverage"]);
15634
- var POOL_SCAN_BUDGET = 2000;
15635
15792
  function sanitizeNamespace(name) {
15636
15793
  const collapsed = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
15637
15794
  return collapsed || "pool";
@@ -15664,14 +15821,6 @@ function createPoolResolver(deps) {
15664
15821
  }
15665
15822
  });
15666
15823
  const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
15667
- const readdir = deps.readdir ?? ((dir) => {
15668
- try {
15669
- return readdirSync4(dir, { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
15670
- } catch {
15671
- return null;
15672
- }
15673
- });
15674
- const budget = typeof deps.scanBudget === "number" && deps.scanBudget > 0 ? deps.scanBudget : POOL_SCAN_BUDGET;
15675
15824
  const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
15676
15825
  const fileCache = new Map;
15677
15826
  const readPool = (path) => {
@@ -15708,56 +15857,14 @@ function createPoolResolver(deps) {
15708
15857
  fileCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, parsed });
15709
15858
  return parsed;
15710
15859
  };
15711
- const dirCache = new Map;
15712
- const listDir = (dir) => {
15713
- const st = stat(dir);
15714
- if (st === null) {
15715
- dirCache.delete(dir);
15860
+ const workspacePoolOf = (anchor) => {
15861
+ const dir = (anchor ?? "").trim();
15862
+ if (!dir)
15716
15863
  return null;
15717
- }
15718
- const hit = dirCache.get(dir);
15719
- if (hit && hit.mtimeMs === st.mtimeMs)
15720
- return hit;
15721
- const listing = readdir(dir);
15722
- const children = listing === null ? null : [...listing].sort((a, b) => codepointCompare(a.name, b.name));
15723
- let poolFile = null;
15724
- const candidate = forgeCandidateAt(dir);
15725
- if (stat(candidate) !== null)
15726
- poolFile = candidate;
15727
- const entry = { mtimeMs: st.mtimeMs, children, poolFile };
15728
- dirCache.set(dir, entry);
15729
- return entry;
15730
- };
15731
- const scanSubtree = (anchor) => {
15732
- const files = [];
15733
- const seen = new Set;
15734
- let opened = 0;
15735
- const sep2 = anchor.includes("\\") && !anchor.includes("/") ? "\\" : "/";
15736
- const joinDir = (parent, name) => parent.endsWith("/") || parent.endsWith("\\") ? `${parent}${name}` : `${parent}${sep2}${name}`;
15737
- const queue = [anchor];
15738
- while (queue.length > 0 && opened < budget) {
15739
- const dir = queue.shift();
15740
- if (seen.has(dir))
15741
- continue;
15742
- seen.add(dir);
15743
- const entry = listDir(dir);
15744
- opened++;
15745
- if (entry === null || entry.children === null)
15746
- continue;
15747
- const isAnchor = dir === anchor;
15748
- if (!isAnchor && entry.poolFile !== null)
15749
- files.push(entry.poolFile);
15750
- for (const child of entry.children) {
15751
- if (!child.dir)
15752
- continue;
15753
- if (POOL_SKIP_DIRS.has(child.name) || child.name === ".opencode")
15754
- continue;
15755
- queue.push(joinDir(dir, child.name));
15756
- }
15757
- }
15758
- return files;
15864
+ return discoverProjectFileWith(stat, dir);
15759
15865
  };
15760
15866
  return {
15867
+ workspacePoolOf,
15761
15868
  resolve(anchorsInput) {
15762
15869
  const anchors = [];
15763
15870
  for (const a of anchorsInput) {
@@ -15769,20 +15876,17 @@ function createPoolResolver(deps) {
15769
15876
  const globalParsed = globalPath ? readPool(globalPath) : null;
15770
15877
  if (globalParsed)
15771
15878
  findings.push(...globalParsed.findings);
15772
- const primaryRootFile = anchors.length > 0 ? discoverProjectFileWith(stat, anchors[0]) : null;
15773
15879
  const uniqueFiles = new Set;
15774
- if (primaryRootFile)
15775
- uniqueFiles.add(primaryRootFile);
15776
15880
  for (const anchor of anchors) {
15777
- for (const f of scanSubtree(anchor))
15881
+ const f = workspacePoolOf(anchor);
15882
+ if (f)
15778
15883
  uniqueFiles.add(f);
15779
- if (anchor !== anchors[0]) {
15780
- const rootFile = discoverProjectFileWith(stat, anchor);
15781
- if (rootFile)
15782
- uniqueFiles.add(rootFile);
15783
- }
15784
15884
  }
15785
- const plainPoolFile = primaryRootFile ?? (globalParsed !== null && globalPath ? globalPath : null);
15885
+ const anyWorkspacePool = uniqueFiles.size > 0;
15886
+ if (globalPath && globalParsed)
15887
+ uniqueFiles.add(globalPath);
15888
+ const primaryFile = anchors.length > 0 ? workspacePoolOf(anchors[0]) : null;
15889
+ const plainPoolFile = primaryFile ?? (!anyWorkspacePool && globalParsed !== null && globalPath ? globalPath : null);
15786
15890
  const orderedFiles = [plainPoolFile, ...[...uniqueFiles].filter((f) => f !== plainPoolFile).sort(codepointCompare)].filter((f) => f !== null);
15787
15891
  const families = [];
15788
15892
  for (const file2 of orderedFiles) {
@@ -15811,7 +15915,7 @@ function createPoolResolver(deps) {
15811
15915
  for (const f of families) {
15812
15916
  if (f.primary)
15813
15917
  continue;
15814
- const base = f.declaredPool ?? sanitizeNamespace(dirBasename(parentDirLabel(parentDirLabel(f.file))));
15918
+ const base = f.declaredPool ?? (f.file === globalPath ? "global" : sanitizeNamespace(dirBasename(parentDirLabel(parentDirLabel(f.file)))));
15815
15919
  let ns = base;
15816
15920
  let suffix = 2;
15817
15921
  while ((usedNs.has(ns) || ns !== base && allDeclared.has(ns)) && suffix < 100) {
@@ -15827,16 +15931,16 @@ function createPoolResolver(deps) {
15827
15931
  const agents = {};
15828
15932
  const origins = [];
15829
15933
  for (const f of families) {
15830
- const merged = { ...globalParsed?.agents ?? {}, ...f.parsed?.agents ?? {} };
15934
+ const own = f.parsed?.agents ?? {};
15831
15935
  const ids = [];
15832
- for (const rawId of Object.keys(merged)) {
15936
+ for (const rawId of Object.keys(own)) {
15833
15937
  const matId = f.ns === null ? rawId : `${f.ns}-${rawId}`;
15834
15938
  const existing = agents[matId];
15835
15939
  if (existing !== undefined) {
15836
15940
  findings.push({ level: "error", code: "materialized-id-collision", message: `materialized agent forge-${matId} from ${f.file} collides with the one from ${existing.file} — the earlier pool in resolution order wins, this agent is skipped` });
15837
15941
  continue;
15838
15942
  }
15839
- agents[matId] = { materializedId: matId, def: merged[rawId], ns: f.ns, file: f.file };
15943
+ agents[matId] = { materializedId: matId, def: own[rawId], ns: f.ns, file: f.file };
15840
15944
  ids.push(matId);
15841
15945
  }
15842
15946
  origins.push({ file: f.file, ns: f.ns, declaredPool: f.declaredPool, primary: f.primary, materializedIds: ids });
@@ -15948,6 +16052,8 @@ function effectiveWorktree(worktree, fallback) {
15948
16052
  }
15949
16053
  var sessions = new Map;
15950
16054
  var sessionAgents = new Map;
16055
+ var sealFindings = new Map;
16056
+ var sealUnknownNotes = new Set;
15951
16057
  function isForgeFamilyAgent(name) {
15952
16058
  if (typeof name !== "string")
15953
16059
  return false;
@@ -15968,6 +16074,11 @@ var forgeDisabled = false;
15968
16074
  var hostAnchors = [];
15969
16075
  var poolResolver = null;
15970
16076
  var poolResolution = null;
16077
+ var poolGateUnknownNotes = new Set;
16078
+ var foldPath = (p) => process.platform === "win32" ? p.replace(/\//g, "\\").toLowerCase() : p;
16079
+ function globalPoolPathOf() {
16080
+ return forgeConfigPaths({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global;
16081
+ }
15971
16082
  function normalizeAnchorDir(dir) {
15972
16083
  let p = (dir ?? "").trim();
15973
16084
  if (!p)
@@ -15999,7 +16110,7 @@ function loaderForAnchor(anchor) {
15999
16110
  const key = anchor || "-";
16000
16111
  let l = sessionAnchorLoaders.get(key);
16001
16112
  if (!l) {
16002
- l = createForgeConfigLoader({ projectDir: anchor, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16113
+ l = createForgeConfigLoader({ projectDir: anchor });
16003
16114
  sessionAnchorLoaders.set(key, l);
16004
16115
  while (sessionAnchorLoaders.size > 8) {
16005
16116
  const oldest = sessionAnchorLoaders.keys().next().value;
@@ -16010,6 +16121,11 @@ function loaderForAnchor(anchor) {
16010
16121
  }
16011
16122
  return l;
16012
16123
  }
16124
+ function sessionPoolFileOf(anchor) {
16125
+ if (poolResolver)
16126
+ return poolResolver.workspacePoolOf(anchor);
16127
+ return loaderForAnchor(anchor).load().projectPath;
16128
+ }
16013
16129
  var crews = new Map;
16014
16130
  function crewRecordPathFor(worktree, objective) {
16015
16131
  const dir = join6(worktree, ".opencode", "crew");
@@ -16091,6 +16207,9 @@ function isAdaptiveAnthropic(modelID) {
16091
16207
  return major > 4 || major === 4 && minor >= 7;
16092
16208
  }
16093
16209
  function resolveDepthChannel(providerID, modelID) {
16210
+ if (dispatchProviderFamily(providerID) === "anthropic") {
16211
+ return isAdaptiveAnthropic(modelID) ? { kind: "adaptive-effort" } : { kind: "budget" };
16212
+ }
16094
16213
  const shape = modelDepthShape(modelsDevCatalog, `${providerID}/${modelID}`);
16095
16214
  if (shape) {
16096
16215
  if (shape.shape === "effort")
@@ -16104,9 +16223,6 @@ function resolveDepthChannel(providerID, modelID) {
16104
16223
  return { kind: "effort", ladder: null };
16105
16224
  if (family === "zai")
16106
16225
  return { kind: "toggle" };
16107
- if (family === "anthropic") {
16108
- return isAdaptiveAnthropic(modelID) ? { kind: "adaptive-effort" } : { kind: "budget" };
16109
- }
16110
16226
  return { kind: "effort", ladder: null };
16111
16227
  }
16112
16228
  var jobsMode = "auto";
@@ -16231,7 +16347,7 @@ function readPlanDir(worktree) {
16231
16347
  const dir = planDirOf(worktree);
16232
16348
  if (!existsSync3(dir))
16233
16349
  return [];
16234
- return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16350
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16235
16351
  }
16236
16352
  function ensureSession(sessionID, worktree) {
16237
16353
  const existing = sessions.get(sessionID);
@@ -16298,7 +16414,7 @@ var planWriteTool = tool({
16298
16414
  } else {
16299
16415
  const dir = planDirOf(state.worktree);
16300
16416
  mkdirSync5(dir, { recursive: true });
16301
- path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md"))));
16417
+ path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
16302
16418
  mode = "created";
16303
16419
  }
16304
16420
  const text = renderPlan(args, now, created);
@@ -16434,7 +16550,7 @@ function readGoalDir(worktree) {
16434
16550
  const dir = goalDirOf(worktree);
16435
16551
  if (!existsSync3(dir))
16436
16552
  return [];
16437
- return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16553
+ return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16438
16554
  }
16439
16555
  function resolveSessionGoal(state) {
16440
16556
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -16543,7 +16659,7 @@ var goalWriteTool = tool({
16543
16659
  }
16544
16660
  const dir = goalDirOf(state.worktree);
16545
16661
  mkdirSync5(dir, { recursive: true });
16546
- const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md")));
16662
+ const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
16547
16663
  const path = join6(dir, name);
16548
16664
  const text = renderGoal(input, {
16549
16665
  now,
@@ -16915,44 +17031,48 @@ var CREW_INIT_TEMPLATE = [
16915
17031
  "",
16916
17032
  "FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
16917
17033
  "",
16918
- "CREW IS NOT INITIALIZED: no forge subagents are materialized across the whole host anchor set (zero pools). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
17034
+ "CREW IS NOT INITIALIZED: no forge subagent is materialized for this session's roster (neither the workspace pool nor the global fallback yields an agent). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
16919
17035
  "",
16920
- "1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root; a .opencode/forge.json in any anchor subdirectory additionally forms a namespaced sub-pool family, its ids materializing as forge-<ns>-<id>):",
17036
+ "1. The user configures it themselves — create either layer, SOLE OWNERSHIP (the same workspace-ownership strictness as opencode's native MCP config): the workspace pool is the NEAREST .opencode/forge.json walking up from this session's worktree, and when it exists it IS the session's whole roster; the global layer serves only as the fallback when the workspace has none — a fallback, NOT a merge (a workspace with a pool never sees global agents; nested forge.json files deeper in the workspace subtree are ignored entirely):",
16921
17037
  "{paths}",
16922
17038
  "",
16923
- "Two axes, distinct: POOLS follow host directories (the host's anchor set); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in a directory no host anchor covers is not dispatchable on this host.",
17039
+ "Two axes, distinct: POOLS follow workspaces (sole ownership above — dispatch is gated to the session's own pool / the global fallback); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in another workspace is not dispatchable from this session.",
16924
17040
  "",
16925
- '2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead. When you configure it: name ids as PLAIN role words WITHOUT the `forge-` prefix — the plugin materializes every id as `forge-<id>` automatically (a `forge-`-prefixed id doubles up as `forge-forge-coder` and is auto-stripped with a warning). Give EVERY agent a SHORT prompt derived from the role the user asked for — one or two sentences describing its job (the user trims or extends from there); write a long prompt ONLY when the user explicitly asks for that agent. For sub-pool files, ALSO include a proposed short stable `pool` namespace (a [a-z0-9-] word up to 24 chars — e.g. "aa" for a devAa specialist pool) so the materialized ids survive directory renames.',
17041
+ '2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead. When you configure it: name ids as PLAIN role words WITHOUT the `forge-` prefix — the plugin materializes every id as `forge-<id>` automatically (a `forge-`-prefixed id doubles up as `forge-forge-coder` and is auto-stripped with a warning). Give EVERY agent a SHORT prompt derived from the role the user asked for — one or two sentences describing its job (the user trims or extends from there); write a long prompt ONLY when the user explicitly asks for that agent. Give EVERY agent an EXPLICIT `shape`: `readonly` for read-only roles (reviewer/scout/planner — denies write/edit/bash and the patch family), `write` for implementers and build drivers. ALSO include a proposed short stable `pool` namespace (a [a-z0-9-] word up to 24 chars — e.g. "aa" for a specialist pool) so the materialized ids survive directory renames.',
16926
17042
  "",
16927
- "Template (JSONC — comments allowed): ids are plain role words (research, coder, ...) — the plugin adds the `forge-` prefix at materialization; `model` + `thoughtLevel` are an ATOMIC PAIR — set BOTH to pin the brain and depth (model = exact provider/model identity; thoughtLevel = none/low/medium/high/max or a native level name), or NEITHER for an Auto worker that inherits the parent session's model; exactly one of the two is rejected. Optional per agent: `prompt` — a short role description; an explicit prompt FULLY overrides the built-in role (only the ids `research` and `review` carry built-ins; any other id without a prompt gets a generic one-liner and will not know its job). Optional top level: `pool` — the file's namespace.",
17043
+ "Template (JSONC — comments allowed): ids are plain role words (research, coder, ...) — the plugin adds the `forge-` prefix at materialization; `model` + `thoughtLevel` are an ATOMIC PAIR — set BOTH to pin the brain and depth (model = exact provider/model identity; thoughtLevel = none/low/medium/high/max or a native level name), or NEITHER for an Auto worker that inherits the parent session's model; exactly one of the two is rejected. Optional per agent: `prompt` — a short role description; an explicit prompt FULLY overrides the built-in role (only the ids `research` and `review` carry built-ins; any other id without a prompt gets a generic one-liner and will not know its job). Optional per agent: `shape` — an ABSENT shape defaults to WRITE (open permissions — the compatibility-preserving default for existing pools); `shape: \"readonly\"` is the explicit seal that denies the mutating-tool family (write/edit/bash + patch/applypatch/apply/multiedit) for read-only roles. Optional top level: `pool` — the file's namespace.",
16928
17044
  "{template}",
16929
17045
  "",
16930
- "After the file is saved: file edits, creations, and deletions apply at the next config hook — NO host restart is needed. Only the anchor set itself is fixed at host start: host re-initializations add anchors and pools, they never remove them."
17046
+ "After the file is saved: file edits, creations, and deletions apply at the next config hook — NO host restart is needed, and a DELETED pool file un-materializes its agents (deletion is real — no zombie pools survive on disk truth)."
16931
17047
  ].join(`
16932
17048
  `);
16933
17049
  function crewOrchestrationTemplate(resolution) {
16934
17050
  const families = resolution?.families ?? [];
16935
- const familyLines = families.map((f) => {
16936
- const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
16937
- return ` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`;
17051
+ const globalPath = forgeConfigPaths({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global;
17052
+ const otherLines = families.map((f) => {
17053
+ const label = f.primary ? "primary pool (plain forge-<id> ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
17054
+ return ` - ${label} — origin ${f.file} — NOT dispatchable from this session (unless this session's worktree is owned by that pool / it is the global fallback): ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`;
16938
17055
  });
16939
17056
  return [
16940
17057
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
16941
17058
  "",
16942
17059
  "FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
16943
17060
  "",
16944
- "Configured forge subagents (task-tool vocabulary), grouped by pool:",
16945
- ...familyLines.length > 0 ? familyLines : [" (none)"],
17061
+ "Configured forge subagents (task-tool vocabulary). ROSTER OWNERSHIP (sole ownership): THIS session's dispatchable roster is its WORKSPACE pool — the nearest .opencode/forge.json walking up from this session's worktree — or, when the workspace has none, the GLOBAL fallback layer (a fallback, not a merge; forge.json files nested deeper in the workspace subtree are ignored, same as opencode's native MCP config). Check this workspace's pool:",
17062
+ ' - this workspace pool (when the path prints, its agents ARE the roster): !`ls .opencode/forge.json 2>/dev/null || echo "(no workspace pool — the global fallback applies)"`',
17063
+ ` - global fallback layer: ${globalPath} — dispatchable ONLY when the workspace has no pool of its own`,
17064
+ ...otherLines.length > 0 ? ["Other pools materialized on this host — each NOT dispatchable from this session (open a session in that workspace, or configure the role into this workspace's pool / the global layer):", ...otherLines] : [],
16946
17065
  "",
16947
17066
  "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). The macro contract above the crew may come from an approved plan, a spec change, or inline text — the discipline is identical in all three cases and never requires a plan. Follow it exactly:",
16948
17067
  "",
16949
17068
  '1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). When the macro contract descends from a plan artifact, pass its path as the optional `lineage` argument (e.g. lineage: ".opencode/plan/2026-10-01-my-plan.md") — the descent is recorded as a first-class line in the crew record header; lineage is never inferred. Reconnaissance BEFORE registration is free (task calls included). If crew_begin refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew. A wrong plan is fixed by abandoning and re-crewing (crew_close {abandon: true, reason}), never by improvising outside the declared plan.',
16950
17069
  `2. THE PAUSE: registration enters a PENDING crew — execution has NOT started. STOP and present the user the three execution-mode choices (supervised waves now / convert to a goal contract / standby), then END YOUR TURN. Arm per the user's choice: waves → crew_begin {execution: "waves"}; conversion → crew_begin {execution: "goal"} followed by goal_write (arm=true — its dialog is the user's gate) folding the declared subtasks into criteria/checks; standby → no call. EXCEPTION: while a live active goal governs this session, register with execution: "waves" in the SAME call — the crew is born armed (the goal contract already authorized orchestration) and crew_close / abandon stay ask-free under that goal.`,
16951
17070
  "3. WAVES, NEVER FLOODS (armed crews only): execute subtasks in waves of parallel native `task` calls (subagent_type = the matching forge-* agent; a wave's results return within the calling turn). Launch the next wave ONLY after the previous wave's results are in. GUI / computer-use subtasks are mutually exclusive within a wave (the desktop is a singleton resource — at most ONE in flight; non-GUI subtasks still parallelize freely), and GUI walkthroughs use the `computer` tool rather than DIY shell screenshot pipelines.",
16952
- "4. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
16953
- "5. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
16954
- "6. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
16955
- "7. CLOSE: when every declared subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks the report against the declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog (ask-free while a goal governs the session)."
17071
+ "4. CONTRACT RELAY (lineage-descended crews): when the macro contract descends from an on-disk artifact (the lineage names a plan artifact, a spec change directory, or a goal contract), every subtask's dispatch prompt MUST explicitly carry that artifact's path — or the exact slice the subtask works from (a delta file path, a quoted criteria section) — so the dispatched worker reads the contract itself, never your paraphrase of it. A large artifact is relayed by its exact slice; it is NEVER dropped for being too long to inline. Every close-report evidence statement MUST name the contract (or slice) the subtask worked against.",
17072
+ "5. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
17073
+ "6. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
17074
+ "7. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
17075
+ "8. CLOSE: when every declared subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks the report against the declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog (ask-free while a goal governs the session)."
16956
17076
  ].join(`
16957
17077
  `);
16958
17078
  }
@@ -16975,26 +17095,28 @@ var crewBeginTool = tool({
16975
17095
  throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Three exits: " + "plan_approve puts the plan to user approval (an APPROVED plan does not block /crew — only a draft does); " + "/plan discard abandons the plan outright; " + '/plan discard {supersede: "<successor path or id>"} moves the work into this crew (the plan becomes superseded, the successor recorded).');
16976
17096
  }
16977
17097
  const resolution = resolvePools();
16978
- const hostIds = resolution ? Object.keys(resolution.agents) : [];
16979
17098
  const anchor = sessionAnchor(context);
17099
+ if (anchor)
17100
+ ensureSession(context.sessionID, anchor);
16980
17101
  const sessionLoaded = anchor ? loaderForAnchor(anchor).load() : null;
16981
17102
  const sessionProject = sessionLoaded?.projectPath ?? null;
16982
- const foldPath = (p) => process.platform === "win32" ? p.replace(/\//g, "\\").toLowerCase() : p;
16983
- const sessionCovered = sessionProject === null || (resolution?.families.some((f) => foldPath(f.file) === foldPath(sessionProject)) ?? false);
16984
- if (hostIds.length === 0) {
16985
- const primaryAnchorDir = hostAnchors[0] ?? hostWorktree;
16986
- const anchorNote = hostAnchors.length > 1 ? `host anchors: ${hostAnchors.join(", ")}` : `launched from ${primaryAnchorDir || "an unresolvable directory"}`;
16987
- const paths = forgeConfigPaths({ projectDir: primaryAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
17103
+ const globalPoolPath = forgeConfigPaths({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global;
17104
+ const sessionPool = sessionProject;
17105
+ const sessionFamily = resolution?.families.find((f) => foldPath(f.file) === foldPath(sessionPool ?? globalPoolPath)) ?? null;
17106
+ const sessionCovered = sessionPool === null || sessionFamily !== null;
17107
+ const sessionRosterIds = sessionFamily ? sessionFamily.materializedIds : [];
17108
+ if (sessionRosterIds.length === 0) {
17109
+ const paths = forgeConfigPaths({ projectDir: anchor || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16988
17110
  const lines2 = [
16989
- `[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized across the whole anchor set (${anchorNote}). Crew orchestration is unavailable until the user sets it up.`,
16990
- `Two layers, MERGED (the project layer overrides the global one per agent id): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}. Sub-pool files (.opencode/forge.json in an anchor's subdirectories) contribute additional namespaced families.`,
16991
- "Two axes, distinct: POOLS follow host directories (the anchor set above); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in an unanchored directory is not dispatchable on this host.",
16992
- `Template (JSONC): top-level optional "pool" names this file's namespace (short stable [a-z0-9-] up to 24 chars — sub-pool ids materialize as forge-<ns>-<id>); {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}`,
17111
+ `[forge:crew] CREW IS NOT INITIALIZED: no forge subagent is materialized for this session's roster (neither the workspace pool nor the global fallback yields an agent). Crew orchestration is unavailable until the user sets it up.`,
17112
+ `Two layers, SOLE OWNERSHIP: the workspace pool — the NEAREST .opencode/forge.json walking up from this session's worktree (${paths.project}) — IS the whole roster when it exists; the global layer (${paths.global}) serves only as the fallback when the workspace has none (a fallback, NOT a merge; forge.json files nested deeper in the workspace subtree are ignored — the same workspace-ownership strictness as opencode's native MCP config).`,
17113
+ "Two axes, distinct: POOLS follow workspaces (sole ownership above — dispatch is gated to the session's own pool / the global fallback); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in another workspace is not dispatchable from this session.",
17114
+ `Template (JSONC): top-level optional "pool" names this file's namespace (short stable [a-z0-9-] up to 24 chars — non-primary pools materialize as forge-<ns>-<id>); {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "shape": "readonly", "prompt": "short role description"}}} — an absent shape defaults to WRITE (open permissions); "shape": "readonly" is the explicit seal denying write/edit/bash + the patch family for read-only roles.`,
16993
17115
  "Agent ids are plain role words WITHOUT the `forge-` prefix — the plugin materializes each id as `forge-<id>` (namespaced pools: `forge-<ns>-<id>`); a prefixed id is auto-stripped with a warning.",
16994
- "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. When you write it: give every agent a SHORT prompt from the role the user asked for (one or two sentences; long only on explicit request), and INCLUDE a proposed short stable `pool` namespace for sub-pool files so ids survive directory renames. File edits, creations, and deletions apply at the next config hook — no host restart; only the anchor set itself is fixed at host start (re-initializations add pools, never remove)."
17116
+ "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. When you write it: give every agent a SHORT prompt from the role the user asked for (one or two sentences; long only on explicit request), give every agent an EXPLICIT shape (readonly for read-only roles like reviewer/scout/planner; write for implementers and build drivers), and INCLUDE a proposed short stable `pool` namespace so ids survive directory renames. File edits, creations, and deletions apply at the next config hook — no host restart needed, and a DELETED pool file un-materializes its agents (deletion is real)."
16995
17117
  ];
16996
17118
  if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject && !sessionCovered) {
16997
- lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but NO host anchor covers it (${anchorNote}). A host initialization in that workspace adds its pools; or fold the agents into the global layer.`);
17119
+ lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but this host has NOT materialized it (no initialization in that workspace). Re-initialize the host in that workspace, or move the agents into the global layer (${globalPoolPath}).`);
16998
17120
  }
16999
17121
  throw new Error(lines2.join(`
17000
17122
  `));
@@ -17095,24 +17217,32 @@ var crewBeginTool = tool({
17095
17217
  const localOnly = Object.keys(sessionLoaded?.agents ?? {});
17096
17218
  if (localOnly.length > 0) {
17097
17219
  disclosure = `
17098
- [forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-<ns>-${id} (namespaced by its pool)`).join(", ")} — NOT dispatchable on this host (no anchor covers it; anchors: ${hostAnchors.join(", ") || "none"}). A host initialization in that workspace adds its pools, or fold the agents into the global layer.`;
17220
+ [forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-<ns>-${id} (namespaced by its pool)`).join(", ")} — NOT dispatchable from this session (this host has not materialized that file). Re-initialize the host in that workspace, or move the agents into the global layer (${globalPoolPath}).`;
17099
17221
  }
17100
17222
  }
17101
- const familyLines = [];
17102
17223
  const families = resolution?.families ?? [];
17224
+ const familyLines = [];
17225
+ if (sessionFamily) {
17226
+ familyLines.push(` - DISPATCHABLE from this session — ${sessionPool ? "workspace pool" : "global fallback"}: ${sessionFamily.file} — ${sessionFamily.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`);
17227
+ }
17103
17228
  for (const f of families.slice(0, 8)) {
17104
- const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
17105
- familyLines.push(` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`);
17229
+ if (f === sessionFamily)
17230
+ continue;
17231
+ const label = f.primary ? "primary pool (plain forge-<id> ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
17232
+ familyLines.push(` - NOT dispatchable from this session — ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`);
17106
17233
  }
17107
17234
  if (families.length > 8)
17108
17235
  familyLines.push(` - …and ${families.length - 8} more pools`);
17109
17236
  const lines = [
17110
17237
  ...recordNote ? [recordNote] : [],
17111
- ...lineage ? [`Lineage: ${lineage}`] : [],
17238
+ ...lineage ? [
17239
+ `Lineage: ${lineage}`,
17240
+ `RELAY RULE: this crew descends from ${lineage} — every dispatch prompt must explicitly carry the artifact path (or its exact slice), and every close-report evidence statement must name the contract it worked against.`
17241
+ ] : [],
17112
17242
  governed ? `[forge:crew] Crew registered UNDER THE GOVERNING GOAL — born armed, never pends. Objective: ${objectiveText}` : `[forge:crew] Crew registered PENDING for this session (execution has NOT started). Objective: ${objectiveText}`,
17113
17243
  `Declared plan (${subtasks.length}):`,
17114
17244
  ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
17115
- `Dispatchable roster origin — ${families.length} pool${families.length === 1 ? "" : "s"} across the host anchor set${hostAnchors.length > 0 ? ` (anchors: ${hostAnchors.join(", ")})` : ""}:`,
17245
+ `Session roster origins — sole ownership (dispatch gated to this session's workspace pool / the global fallback); ${families.length} pool${families.length === 1 ? "" : "s"} materialized on this host:`,
17116
17246
  ...familyLines,
17117
17247
  ...disclosure ? [disclosure] : []
17118
17248
  ];
@@ -17602,7 +17732,7 @@ var server = async (input, options) => {
17602
17732
  const agentCount = Object.keys(resolution?.agents ?? {}).length;
17603
17733
  const primaryAnchorDir = hostAnchors[0] ?? undefined;
17604
17734
  const template = agentCount > 0 ? crewOrchestrationTemplate(resolution ?? null) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
17605
- - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ pool: "short-stable-namespace (optional, [a-z0-9-] up to 24 chars — only meaningful for sub-pool files)", agents: { research: { model: "provider/model", thoughtLevel: "low", prompt: "short role description — what this agent does" } } }, null, 2).split(`
17735
+ - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ pool: "short-stable-namespace (optional, [a-z0-9-] up to 24 chars — non-primary pools materialize as forge-<ns>-<id>)", agents: { research: { model: "provider/model", thoughtLevel: "low", shape: "readonly", prompt: "short role description — what this agent does" }, coder: { model: "provider/model", thoughtLevel: "low", shape: "write", prompt: "short role description — what this agent does" } } }, null, 2).split(`
17606
17736
  `).map((l) => " " + l).join(`
17607
17737
  `));
17608
17738
  const description = agentCount > 0 ? "forge crew orchestration: register a declared subtask plan, execute it in waves of parallel native task calls, close with an evidence-checked report" : "forge crew orchestration — NOT INITIALIZED: no forge subagents configured; invoking it shows the one-time setup guidance";
@@ -17632,14 +17762,35 @@ var server = async (input, options) => {
17632
17762
  }
17633
17763
  }
17634
17764
  if (input2.tool === "task") {
17765
+ const a = output?.args ?? {};
17766
+ const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
17635
17767
  const speaker = sessionAgents.get(input2.sessionID);
17636
17768
  if (speaker !== undefined && !isForgeFamilyAgent(speaker)) {
17637
- const a = output?.args ?? {};
17638
- const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
17639
17769
  if (target && target.toLowerCase().startsWith("forge-")) {
17640
17770
  throw new Error(`[forge:partition] Dispatch refused: "${target}" is a forge-family worker and the current agent ("${speaker}") is outside the forge family. Use the native general/explore subagents, or switch to the forge agent to dispatch forge-* workers.`);
17641
17771
  }
17642
17772
  }
17773
+ if (target && target.toLowerCase().startsWith("forge-") && input2.sessionID) {
17774
+ const resolution = resolvePools();
17775
+ const agentId = target.slice("forge-".length);
17776
+ const entry = resolution?.agents[agentId];
17777
+ if (resolution && entry) {
17778
+ const bound = sessions.get(input2.sessionID);
17779
+ const anchor = bound?.worktree ?? "";
17780
+ if (!anchor) {
17781
+ if (!poolGateUnknownNotes.has(input2.sessionID)) {
17782
+ poolGateUnknownNotes.add(input2.sessionID);
17783
+ console.warn(`[forge:pool] session ${input2.sessionID}: task dispatch to ${target} passed an UNGATED pool check — the session's workspace could not be established (fail-open belt posture; pool ownership unscoped).`);
17784
+ }
17785
+ } else {
17786
+ const sessionPool = sessionPoolFileOf(anchor);
17787
+ const allowed = sessionPool ?? globalPoolPathOf();
17788
+ if (allowed === null || foldPath(entry.file) !== foldPath(allowed)) {
17789
+ throw new Error(`[forge:pool] Dispatch refused: "${target}" belongs to the pool ${entry.file} — not dispatchable from this session. ` + `This session's dispatchable roster is ${sessionPool ? `its workspace pool (${sessionPool})` : `the global fallback (${globalPoolPathOf()})`}. ` + `Exits: open a session in that pool's workspace, or configure the role into this workspace's pool (its .opencode/forge.json), or into the global layer (${globalPoolPathOf()}).`);
17790
+ }
17791
+ }
17792
+ }
17793
+ }
17643
17794
  const pendingCrew = input2.sessionID ? crews.get(input2.sessionID) : undefined;
17644
17795
  if (pendingCrew && pendingCrew.mode === "pending") {
17645
17796
  throw new Error(`[forge:crew] Dispatch refused: the crew "${pendingCrew.objective}" is PENDING (registered, not armed). Present the execution-mode choice to the user — supervised waves now / convert to a goal contract / standby — then arm per their choice with crew_begin {execution: "waves" | "goal"}. Reconnaissance task calls are free only BEFORE registration.`);
@@ -17678,13 +17829,42 @@ var server = async (input, options) => {
17678
17829
  roster.push(`forge-${id}`);
17679
17830
  }
17680
17831
  roster.sort();
17681
- throw new Error(`[forge] A plan is in draft; dispatch is denied for this target. ` + (roster.length > 0 ? `Readonly recon scouts ARE dispatchable during draft: ${roster.join(", ")}. ` : `No readonly worker is configured — define one in forge.json (shape defaults to readonly) to enable draft-phase reconnaissance. `) + `Write-shaped workers and native agents wait for approval (plan_approve); /plan discard to abandon the plan.`);
17832
+ throw new Error(`[forge] A plan is in draft; dispatch is denied for this target. ` + (roster.length > 0 ? `Readonly recon scouts ARE dispatchable during draft: ${roster.join(", ")}. ` : `No readonly worker is configured — define one in forge.json with an explicit "shape": "readonly" to enable draft-phase reconnaissance. `) + `Write-shaped workers and native agents wait for approval (plan_approve); /plan discard to abandon the plan.`);
17682
17833
  }
17683
17834
  } else {
17684
17835
  throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
17685
17836
  }
17686
17837
  }
17687
17838
  }
17839
+ if (input2.tool === "forge_shell" && input2.sessionID) {
17840
+ const a = output?.args ?? {};
17841
+ const cmd = typeof a.command === "string" ? a.command : undefined;
17842
+ if (cmd) {
17843
+ const speaker = sessionAgents.get(input2.sessionID);
17844
+ const workerId = speaker !== undefined && isForgeFamilyAgent(speaker) && speaker.toLowerCase() !== FORGE_AGENT ? speaker.slice("forge-".length) : undefined;
17845
+ if (workerId !== undefined) {
17846
+ const def = resolvePools()?.agents[workerId]?.def;
17847
+ if (def !== undefined && effectiveReadonlyShape(def)) {
17848
+ const verdict = classifyExecCommand(cmd);
17849
+ if (verdict.klass === WRITE_CLASS) {
17850
+ const seen = sealFindings.get(input2.sessionID) ?? new Set;
17851
+ if (!seen.has(verdict.indicator)) {
17852
+ seen.add(verdict.indicator);
17853
+ sealFindings.set(input2.sessionID, seen);
17854
+ console.warn(`[forge:seal] session ${input2.sessionID} (readonly worker "${speaker}"): write-class forge_shell command denied — indicator "${verdict.indicator}" (${verdict.detail ?? cmd.slice(0, 60)}). Remedy: "shape": "write" on the agent when its job legitimately mutates.`);
17855
+ }
17856
+ throw new Error(`[forge:seal] Exec refused: this session runs the readonly worker "${speaker}" (explicit "shape": "readonly") and the command was classified WRITE-CLASS — indicator "${verdict.indicator}"${verdict.detail ? ` (${verdict.detail})` : ""}. READ-CLASS commands (inspection, search, read-only evals) flow normally. If this worker's job legitimately mutates, set "shape": "write" on its forge.json entry. Report this refusal VERBATIM — do not work around it.`);
17857
+ }
17858
+ }
17859
+ } else if (speaker === undefined) {
17860
+ const verdict = classifyExecCommand(cmd);
17861
+ if (verdict.klass === WRITE_CLASS && !sealUnknownNotes.has(input2.sessionID)) {
17862
+ sealUnknownNotes.add(input2.sessionID);
17863
+ console.warn(`[forge:seal] session ${input2.sessionID}: a WRITE-CLASS forge_shell command (${verdict.indicator}) flowed UNSEALED — the session's agent is unknown (fail-open; the exec seal binds explicit-readonly forge-* workers only).`);
17864
+ }
17865
+ }
17866
+ }
17867
+ }
17688
17868
  if (input2.sessionID && typeof input2.tool === "string") {
17689
17869
  const speaker = sessionAgents.get(input2.sessionID);
17690
17870
  if (speaker !== undefined) {
@@ -17725,7 +17905,14 @@ var server = async (input, options) => {
17725
17905
  return;
17726
17906
  const frozen = sessionDepths.get(sessionID);
17727
17907
  if (frozen) {
17728
- applyDepthTranslation(output.options ?? (output.options = {}), frozen.translation);
17908
+ if (process.env.FORGE_DEPTH_PROBE) {
17909
+ try {
17910
+ appendFileSync(join6(tmpdir3(), "forge-depth-probe.log"), `${new Date().toISOString()} sid=${sessionID} agent=${agentName} frozen-apply translation=${JSON.stringify(frozen.translation) ?? "null"}
17911
+ `);
17912
+ } catch {}
17913
+ }
17914
+ if (frozen.translation)
17915
+ applyDepthTranslation(output.options ?? (output.options = {}), frozen.translation);
17729
17916
  return;
17730
17917
  }
17731
17918
  const agentId = agentName.slice("forge-".length);
@@ -17737,6 +17924,13 @@ var server = async (input, options) => {
17737
17924
  const modelID = typeof modelObj?.modelID === "string" ? modelObj.modelID : typeof modelObj?.id === "string" ? modelObj.id : "";
17738
17925
  const channel = resolveDepthChannel(providerID, modelID);
17739
17926
  const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, channel);
17927
+ sessionDepths.set(sessionID, { translation });
17928
+ if (translation) {
17929
+ applyDepthTranslation(output.options ?? (output.options = {}), translation);
17930
+ } else if (findingMessage && !depthFindingNotes.has(agentId)) {
17931
+ depthFindingNotes.set(agentId, findingMessage);
17932
+ console.warn(`[forge:depth] agent forge-${agentId}: ${findingMessage}`);
17933
+ }
17740
17934
  if (process.env.FORGE_DEPTH_PROBE) {
17741
17935
  try {
17742
17936
  appendFileSync(join6(tmpdir3(), "forge-depth-probe.log"), `${new Date().toISOString()} sid=${sessionID} agent=${agentName} prov=${providerID} model=${modelID} depth=${def.thoughtLevel} channel=${JSON.stringify(channel)} translation=${JSON.stringify(translation) ?? "null"} finding=${findingMessage ?? "-"}
@@ -17745,18 +17939,10 @@ var server = async (input, options) => {
17745
17939
  `);
17746
17940
  } catch {}
17747
17941
  }
17748
- if (!translation) {
17749
- if (findingMessage && !depthFindingNotes.has(agentId)) {
17750
- depthFindingNotes.set(agentId, findingMessage);
17751
- console.warn(`[forge:depth] agent forge-${agentId}: ${findingMessage}`);
17752
- }
17753
- return;
17754
- }
17755
- sessionDepths.set(sessionID, { translation });
17756
- applyDepthTranslation(output.options ?? (output.options = {}), translation);
17757
17942
  },
17758
17943
  __forgeSubagentsTest: {
17759
17944
  crews: () => crews,
17945
+ sealFindings: (sessionID) => [...sealFindings.get(sessionID) ?? []],
17760
17946
  depthState: (sessionID) => sessionDepths.get(sessionID)?.translation ?? null,
17761
17947
  depthFinding: (agentId) => depthFindingNotes.get(agentId) ?? null,
17762
17948
  catalog: () => modelsDevCatalog,
@@ -17771,7 +17957,8 @@ var server = async (input, options) => {
17771
17957
  poolResolution = null;
17772
17958
  },
17773
17959
  anchors: () => [...hostAnchors],
17774
- resolveNow: () => resolvePools()
17960
+ resolveNow: () => resolvePools(),
17961
+ workspacePoolOf: (anchor) => sessionPoolFileOf(anchor)
17775
17962
  },
17776
17963
  "permission.ask": async (input2, output) => {
17777
17964
  const meta = input2.metadata ?? {};
@@ -17819,6 +18006,9 @@ var server = async (input, options) => {
17819
18006
  jobManager.onSessionEnd(info.id);
17820
18007
  sessionDepths.delete(info.id);
17821
18008
  sessionAgents.delete(info.id);
18009
+ sealFindings.delete(info.id);
18010
+ sealUnknownNotes.delete(info.id);
18011
+ poolGateUnknownNotes.delete(info.id);
17822
18012
  }
17823
18013
  }
17824
18014
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "Single general-purpose forge agent for opencode: static worker subagents defined in forge.json (pinned model + thoughtLevel pair, or an Auto worker inheriting the parent model; dispatched through the native task tool — TUI-visible), a plan harness (file-backed plans with approve/close confirmation gates and a hard draft write-ban), a goal harness (autonomous, host-verified objectives), a /crew orchestration command (declared-plan waves with an evidence-checked close gate), a job supervisor (forge_shell with exit-bound completion, idle/success early return, background jobs with poll/log/kill/handoff), and a builtin-shell hang watchdog.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",