@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.
- package/README.md +178 -87
- package/dist/index.js +324 -134
- 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`
|
|
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)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
1. the
|
|
437
|
-
the
|
|
438
|
-
directory as the anchor chain; first file found on the ancestor
|
|
439
|
-
wins, nothing above it is consulted)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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) |
|
|
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
|
|
481
|
-
`/crew` names it as the residual mismatch and tells you
|
|
482
|
-
initialization in that workspace
|
|
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
|
-
//
|
|
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": "
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
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
|
|
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).
|
|
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.
|
|
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.
|
|
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
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
796
|
-
|
|
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.
|
|
800
|
-
|
|
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
|
|
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
|
|
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
|
|
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 === "
|
|
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 ?? "
|
|
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
|
|
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 {
|
|
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
|
|
15712
|
-
|
|
15713
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
15934
|
+
const own = f.parsed?.agents ?? {};
|
|
15831
15935
|
const ids = [];
|
|
15832
|
-
for (const rawId of Object.keys(
|
|
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:
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
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),
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
16936
|
-
|
|
16937
|
-
|
|
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),
|
|
16945
|
-
|
|
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.
|
|
16953
|
-
"5.
|
|
16954
|
-
"6.
|
|
16955
|
-
"7.
|
|
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
|
|
16983
|
-
const
|
|
16984
|
-
|
|
16985
|
-
|
|
16986
|
-
|
|
16987
|
-
|
|
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
|
|
16990
|
-
`Two layers,
|
|
16991
|
-
"Two axes, distinct: POOLS follow
|
|
16992
|
-
`Template (JSONC): top-level optional "pool" names this file's namespace (short stable [a-z0-9-] up to 24 chars —
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
17105
|
-
|
|
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 ? [
|
|
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
|
-
`
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|