@sorenllm/opencode-forge 0.16.0 → 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 +146 -77
- package/dist/index.js +305 -120
- 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,8 +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": "
|
|
506
|
-
|
|
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)
|
|
507
518
|
// "permission": { "bash": "deny" } // optional override; "task" is ALWAYS denied
|
|
508
519
|
},
|
|
509
520
|
// AUTO worker: set NEITHER — inherits the parent session's model at
|
|
@@ -546,13 +557,18 @@ A rejected entry costs only itself — its siblings materialize normally.
|
|
|
546
557
|
says "inherits the parent session's model" instead of naming a brain),
|
|
547
558
|
the worker discipline (workspace-relative paths only, verbatim reporting
|
|
548
559
|
of tool refusals, evidence-bearing conclusions) around its role prompt,
|
|
549
|
-
and a deny-style permission: `shape
|
|
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
|
|
550
563
|
the full mutating-tool family — `write`/`edit`/`bash` plus the host's
|
|
551
564
|
patch family (`patch`/`applypatch`/`apply`/`multiedit`) — `shape:
|
|
552
|
-
"write"` allows them, an explicit `permission` map overrides the shape
|
|
565
|
+
"write"` allows them (same as absent), an explicit `permission` map overrides the shape
|
|
553
566
|
default (lifting ANY member of that family off `deny` forfeits the
|
|
554
567
|
draft-phase readonly exemption) — and `task` is ALWAYS
|
|
555
|
-
denied (recursive spawning stays physically impossible).
|
|
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).
|
|
556
572
|
|
|
557
573
|
> **Upgrading from ≤ 0.5.0 (breaking):** agent entries that pinned `model`
|
|
558
574
|
> without `thoughtLevel` used to be accepted; they are now SKIPPED with an
|
|
@@ -560,17 +576,17 @@ A rejected entry costs only itself — its siblings materialize normally.
|
|
|
560
576
|
> `thoughtLevel`, or delete `model` to make it an Auto worker.
|
|
561
577
|
|
|
562
578
|
**What applies when** — only a real materialization re-reads the pool
|
|
563
|
-
files into the `forge-*` vocabulary
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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:
|
|
568
584
|
|
|
569
585
|
| change | needs a host restart? |
|
|
570
586
|
| --- | --- |
|
|
571
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 |
|
|
572
|
-
| 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 |
|
|
573
|
-
| 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) |
|
|
574
590
|
|
|
575
591
|
### Dispatching
|
|
576
592
|
|
|
@@ -589,6 +605,50 @@ providers can return a deterministic EMPTY response for any toolset-reduced
|
|
|
589
605
|
attributable task failure; the mitigation is unchanged — use
|
|
590
606
|
`shape: "write"` for such endpoints, or a keyed provider.
|
|
591
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
|
+
|
|
592
652
|
### The thoughtLevel word
|
|
593
653
|
|
|
594
654
|
One word per agent: the canonical seven-word dictionary
|
|
@@ -721,18 +781,23 @@ itself (a rootish guard keeps degenerate global-project worktrees from
|
|
|
721
781
|
landing files on the drive root or the launch anchor). The anchor follows the
|
|
722
782
|
session's workspace, never the objective's scope.
|
|
723
783
|
|
|
724
|
-
**Not initialized?** `/crew` on a
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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).
|
|
736
801
|
|
|
737
802
|
### Debris note
|
|
738
803
|
|
|
@@ -812,16 +877,20 @@ The escape hatches are `plan_approve` and `/plan discard`, by design.
|
|
|
812
877
|
|
|
813
878
|
One carve-out, keyed on the target rather than the tool: during a draft the
|
|
814
879
|
`task` tool is gated by the dispatch target's **effective readonly shape**. A
|
|
815
|
-
dispatch to a forge worker whose shape is
|
|
880
|
+
dispatch to a forge worker whose shape is EXPLICITLY `readonly` AND whose
|
|
881
|
+
permission
|
|
816
882
|
overrides do not lift `write`/`edit`/`bash` — nor any host patch-family tool
|
|
817
883
|
(`patch`/`applypatch`/`apply`/`multiedit`) — off `deny` (`ask` counts as
|
|
818
884
|
lifted — fail-closed) passes as a reconnaissance dispatch: readonly scouts
|
|
819
885
|
may be spawned both before and during the draft, so breadth
|
|
820
|
-
evidence-gathering does not burn the planning session's context.
|
|
821
|
-
|
|
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
|
|
822
890
|
(including resume calls that carry no target) — is denied like any write-class
|
|
823
891
|
tool. The refusal names the currently dispatchable effective-readonly roster,
|
|
824
|
-
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
|
|
825
894
|
(an unconfigured host stays fully denied — same behavior as before, just more
|
|
826
895
|
actionable wording), and names the two exits (`plan_approve`, `/plan
|
|
827
896
|
discard`). Invariant strength is unchanged: the scout's materialized
|
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
|
|
|
@@ -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");
|
|
@@ -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) {
|
|
@@ -17762,6 +17942,7 @@ var server = async (input, options) => {
|
|
|
17762
17942
|
},
|
|
17763
17943
|
__forgeSubagentsTest: {
|
|
17764
17944
|
crews: () => crews,
|
|
17945
|
+
sealFindings: (sessionID) => [...sealFindings.get(sessionID) ?? []],
|
|
17765
17946
|
depthState: (sessionID) => sessionDepths.get(sessionID)?.translation ?? null,
|
|
17766
17947
|
depthFinding: (agentId) => depthFindingNotes.get(agentId) ?? null,
|
|
17767
17948
|
catalog: () => modelsDevCatalog,
|
|
@@ -17776,7 +17957,8 @@ var server = async (input, options) => {
|
|
|
17776
17957
|
poolResolution = null;
|
|
17777
17958
|
},
|
|
17778
17959
|
anchors: () => [...hostAnchors],
|
|
17779
|
-
resolveNow: () => resolvePools()
|
|
17960
|
+
resolveNow: () => resolvePools(),
|
|
17961
|
+
workspacePoolOf: (anchor) => sessionPoolFileOf(anchor)
|
|
17780
17962
|
},
|
|
17781
17963
|
"permission.ask": async (input2, output) => {
|
|
17782
17964
|
const meta = input2.metadata ?? {};
|
|
@@ -17824,6 +18006,9 @@ var server = async (input, options) => {
|
|
|
17824
18006
|
jobManager.onSessionEnd(info.id);
|
|
17825
18007
|
sessionDepths.delete(info.id);
|
|
17826
18008
|
sessionAgents.delete(info.id);
|
|
18009
|
+
sealFindings.delete(info.id);
|
|
18010
|
+
sealUnknownNotes.delete(info.id);
|
|
18011
|
+
poolGateUnknownNotes.delete(info.id);
|
|
17827
18012
|
}
|
|
17828
18013
|
}
|
|
17829
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",
|