@sorenllm/opencode-forge 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +123 -36
  2. package/dist/index.js +539 -108
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -150,8 +150,8 @@ and the plugin's command of that name is not registered.
150
150
  (otherwise the name lingers in the agent list).
151
151
  4. Done — all runtime injections (agents, tool hiding, commands) are gone
152
152
  with the plugin; nothing was ever written to your config
153
- (the hide was runtime-only). Your `.opencode/plan/` and `.opencode/goal/`
154
- files are yours; delete them yourself if you want.
153
+ (the hide was runtime-only). Your `.opencode/plan/`, `.opencode/goal/`,
154
+ and `.opencode/crew/` files are yours; delete them yourself if you want.
155
155
  5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
156
156
  job registry ledger, the watchdog ledger, and any leftover dispatch
157
157
  ledger from older versions — the file ledger table above lists
@@ -165,13 +165,14 @@ What this plugin touches, exhaustively:
165
165
  | --- | --- | --- |
166
166
  | `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
167
167
  | `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
168
+ | `<project>/.opencode/crew/*.md` | crew records (declared plan + arm/convert/abandon/close history; written at `crew_begin` registration, appended at each transition; record-only — never a resume mechanism) | user data — kept forever, uninstall never deletes |
168
169
  | merged config object (RAM only) | forge agent, static `forge-<id>` subagents, tool-partition injections (forge family shell hide, non-forge forge-tool hide, native build/plan/general/explore minimal entries), `command.plan`, `command.goal`, `command.crew`, goal permission keys, `permission.forge_shell`, `permission.crew_close` | vanishes when the plugin is removed; nothing is written to disk |
169
170
  | `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
170
171
  | `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
171
172
  | `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
172
173
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
173
174
  | `<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 |
174
- | the nearest `.opencode/forge.json` on the workspace's ancestor chain, `~/.config/opencode/forge.json` | your static subagent definitions (JSONC), two MERGED layers (project overrides global per agent id). **User data — the plugin only reads it, never writes or migrates it** | yours — version the project one, keep the global one out of sync tools if it holds machine-specific models |
175
+ | `.opencode/forge.json` files (the nearest on each host anchor's ancestor chain, every one in an anchor's subtree, and `~/.config/opencode/forge.json`) | your static subagent definitions (JSONC) — pool families MERGED over the global layer per agent id (primary anchor's root pool plain ids, others namespaced). **User data — the plugin only reads them, never writes or migrates them** | yours — version the project ones, keep the global one out of sync tools if it holds machine-specific models |
175
176
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
176
177
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
177
178
 
@@ -417,26 +418,60 @@ discovered by walking up:
417
418
 
418
419
  The two layers **merge**: agents combine, and on an id collision the project
419
420
  definition wins wholesale (one definition per id — no cross-layer field
420
- blending). A broken file empties only its own layer (error finding with the
421
- parse location); the other layer still applies. Both layers hot-apply —
422
- edits, and files appearing or disappearing on the ancestor chain, take
423
- effect without a host restart (new agents still need the restart to enter
424
- the task vocabulary; see the apply-timing table below).
421
+ blending). A broken file empties only its own layer/pool (error finding with
422
+ the parse location); everything else still applies. Both layers hot-apply —
423
+ edits, and files appearing or disappearing, take effect at the next config
424
+ hook without a host restart (see the apply-timing table below).
425
+
426
+ **Hierarchical pools on a shared host.** Agent frontends (paseo and friends)
427
+ routinely share ONE opencode host process across projects, re-initializing
428
+ plugins with different directories. The plugin therefore keeps an
429
+ **append-only anchor set**: every host initialization appends its directory
430
+ (first one = the *primary anchor*), and pools resolve from the union —
431
+ anchor flips can only ever ADD pools, never remove them. Per anchor the
432
+ family is:
433
+
434
+ - the **root pool** — the nearest forge.json walking up from that anchor
435
+ (merged over the global layer per agent id);
436
+ - every **sub-pool** — a `.opencode/forge.json` anywhere in the anchor's
437
+ subdirectories, discovered by a bounded breadth-first scan (lexicographic
438
+ sibling order; dependency dirs like `node_modules`/`.git`/`dist` are
439
+ skipped; 2,000-directory budget per anchor).
440
+
441
+ The **primary anchor's root pool keeps plain `forge-<id>` ids** (a
442
+ single-project host is unchanged); every other family materializes
443
+ **namespaced** as `forge-<ns>-<id>`. `ns` is the file's optional `pool`
444
+ field, else the sanitized directory name. Pool identity is the absolute file
445
+ path — the same file reached through two anchors materializes exactly once;
446
+ namespace and materialized-id collisions resolve deterministically with an
447
+ error finding, never by silent shadowing. The `/crew` roster and the
448
+ `crew_begin` registration output list the dispatchable agents **grouped by
449
+ pool** with each pool's origin path, the primary marked.
450
+
451
+ | axis | follows | survives restart? |
452
+ | --- | --- | --- |
453
+ | pools (the `forge-*` vocabulary) | host directories — the anchor set | the set re-seeds from the spawn anchor only |
454
+ | session artifacts (plan/goal/crew records) | the session's own workspace | files on disk are the source of truth |
425
455
 
426
- One host instance materializes one pool (from its launch anchor) — a
427
- workspace-local file that the host was not launched on is NOT silently
428
- ignored: `/crew` names it and tells you to relaunch there (or fold the
429
- agents into the global layer).
456
+ A forge.json in a directory NO host anchor covers is NOT silently ignored:
457
+ `/crew` names it as the residual mismatch and tells you a host
458
+ initialization in that workspace adds its pools (or fold the agents into the
459
+ global layer).
430
460
 
431
461
  **Id naming**: use plain role words (`research`, `coder`, …) — the plugin
432
- materializes every id as `forge-<id>` itself. A `forge-`-prefixed id in the
433
- file would double up (`forge-coder` → agent `forge-forge-coder`); the loader
434
- auto-strips the prefix (all repetitions) with a warning so existing files
435
- keep working, but rename the entries to silence it.
462
+ materializes every id as `forge-<id>` itself (namespaced pools:
463
+ `forge-<ns>-<id>`). A `forge-`-prefixed id in the file would double up
464
+ (`forge-coder` → agent `forge-forge-coder`); the loader auto-strips the
465
+ prefix (all repetitions) with a warning so existing files keep working, but
466
+ rename the entries to silence it. The namespace infix is never parsed back
467
+ out of a materialized id.
436
468
 
437
469
  ```jsonc
438
470
  {
439
- // forge subagents — definitions; see the restart table below for apply timing
471
+ // sub-pool files only: namespace for this family's ids (forge-<ns>-<id>).
472
+ // Short, stable, [a-z0-9-] up to 24 chars — ids survive directory renames.
473
+ // Invalid values degrade to a warning + the directory-derived namespace.
474
+ // "pool": "aa",
440
475
  "agents": {
441
476
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
442
477
  "research": {
@@ -496,12 +531,14 @@ A rejected entry costs only itself — its siblings materialize normally.
496
531
  > error finding naming the missing half. Fix per entry: add the missing
497
532
  > `thoughtLevel`, or delete `model` to make it an Auto worker.
498
533
 
499
- **What applies when** — edits take hold per field:
534
+ **What applies when** — file truth is hot; only the anchor set is frozen at
535
+ host start:
500
536
 
501
537
  | change | needs a host restart? |
502
538
  | --- | --- |
503
539
  | `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) |
504
- | agent added / removed, or `model` / `prompt` / `permission` changed | yes (materialization runs on the config hook) |
540
+ | agent added / removed / edited, any pool file created or deleted | no restart — applies at the next config hook (materialization re-runs there; the crew gate and depth lookups always resolve fresh) |
541
+ | a NEW directory joining the host (another project's session on the shared host) | no restart — the re-initialization itself adds the anchor and its pools |
505
542
 
506
543
  ### Dispatching
507
544
 
@@ -536,31 +573,81 @@ mid-session never rewrites a running session).
536
573
  ## Crew workflow (`/crew`)
537
574
 
538
575
  `/crew <objective>` is the third state-activation command next to `/plan` and
539
- `/goal`: it enters crew orchestration discipline for the session. The flow:
576
+ `/goal`: it enters crew orchestration discipline for the session. The macro
577
+ contract above the crew may come from an approved plan, an OpenSpec change, or
578
+ inline text — the discipline is identical in all three cases and never
579
+ requires a plan. The flow:
540
580
 
541
581
  1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
542
582
  titled subtask each, optionally naming the intended `forge-*` agent.
543
- 2. **Execute in waves** of parallel native `task` calls (results return
544
- in-turn; the next wave launches only after the previous wave's results).
545
- 3. **Missing role?** Run the subtask through a native task call anyway and
583
+ Registration enters a **PENDING** crew: execution has not started. The
584
+ registration output confirms the roster **grouped by pool with its
585
+ origins** (which forge.json families the dispatchable set materialized
586
+ from, the primary marked), points at the crew record file, presents the
587
+ three execution-mode choices, and stops the turn. Reconnaissance task
588
+ calls are free BEFORE registration.
589
+ 2. **The pause is mechanical**: while the crew pends, every `task` dispatch is
590
+ refused by an interception belt. The user chooses:
591
+ - **supervised waves now** — `crew_begin {execution: "waves"}` lifts the
592
+ belt and the wave discipline runs;
593
+ - **convert to a goal contract** — `crew_begin {execution: "goal"}` ends
594
+ the crew as a conversion record and the session drafts `goal_write`
595
+ (arm=true) folding the declared subtasks into criteria/checks; the arm
596
+ dialog is the user's gate;
597
+ - **standby** — no call; the crew waits until armed or abandoned.
598
+ 3. **Execute in waves** (armed crews only) of parallel native `task` calls
599
+ (results return in-turn; the next wave launches only after the previous
600
+ wave's results). GUI / computer-use subtasks are mutually exclusive within
601
+ a wave (the desktop is a singleton; non-GUI subtasks still parallelize
602
+ freely), and GUI walkthroughs use the `computer` tool rather than DIY
603
+ shell screenshot pipelines.
604
+ 4. **Missing role?** Run the subtask through a native task call anyway and
546
605
  tell the user — suggest configuring the missing role.
547
- 4. **Verify** each subtask against its acceptance-evidence statement;
606
+ 5. **Verify** each subtask against its acceptance-evidence statement;
548
607
  retry a failure at most once.
549
- 5. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
608
+ 6. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
550
609
  report against the DECLARED plan: every declared subtask needs a
551
610
  PASS/FAIL verdict with evidence; an undeclared subtask in the report
552
611
  refuses the close (fold discoveries into an existing verdict, or discard
553
- and re-crew); a FAIL needs both failure reports. The close output is the
554
- crew's record — crew state is in-memory and a host restart ends it
555
- honestly.
556
-
557
- **Not initialized?** `/crew` on a host with zero usable forge agents refuses
558
- with one-time setup guidance: the two file paths, a copy-paste template, and
559
- the choice of configuring it yourself OR having the session AI do it — the
560
- latter only on your explicit go-ahead in that conversation, through the
561
- normal visible write path. This is the plugin's only configuration
562
- invitation. After saving the file, restart opencode to materialize the
563
- agents.
612
+ and re-crew); a FAIL needs both failure reports.
613
+ 7. **Re-shard exit**: `crew_close {abandon: true, reason}` abandons the crew
614
+ — ask-gated like the close, no verdict requirements, usable from PENDING
615
+ (standby cancel) and EXECUTING (wrong decomposition) alike. The session is
616
+ immediately free for a fresh `crew_begin` with the corrected plan.
617
+
618
+ **Crew record**: registration writes `.opencode/crew/<date>-<slug>.md` under
619
+ the session anchor with the declared plan; arm / convert / abandon / close
620
+ append to it. It is inspectable history and a compaction-recovery anchor —
621
+ NOT a resume mechanism: crew state is in-memory and a host restart ends it
622
+ honestly (a stale record is inert).
623
+
624
+ **Goal-delegated orchestration**: while a live ACTIVE goal governs the
625
+ session, the pause folds into the goal's own authorization —
626
+ `crew_begin {objective, subtasks, execution: "waves"}` in ONE call is legal
627
+ (born armed, never pends), `crew_close` (report and abandon) are ask-free
628
+ (the goal's arm/complete/budget gates are the user boundary), and
629
+ `execution: "goal"` is refused (already governed). The goal continuation
630
+ brief carries the own-the-crew-layer directive, so an armed loop can crew,
631
+ re-shard (`abandon` + fresh one-call registration), and close without a human
632
+ turn. Neither harness requires its slash command — the ask dialogs are the
633
+ authorization surfaces; the templates are discipline carriers.
634
+
635
+ **Artifact anchoring** (plan / goal / crew alike): a git-repo session anchors
636
+ artifacts at the repo root; a plain-folder session anchors at the folder
637
+ itself (a rootish guard keeps degenerate global-project worktrees from
638
+ landing files on the drive root or the launch anchor). The anchor follows the
639
+ session's workspace, never the objective's scope.
640
+
641
+ **Not initialized?** `/crew` on a host with ZERO pools across its whole
642
+ anchor set refuses with one-time setup guidance: the two file paths, a
643
+ copy-paste template, and the choice of configuring it yourself OR having the
644
+ session AI do it — the latter only on your explicit go-ahead in that
645
+ conversation, through the normal visible write path (when the AI writes a
646
+ sub-pool file it also proposes a short stable `pool` namespace so ids
647
+ survive directory renames). This is the plugin's only configuration
648
+ invitation. Saved files apply at the next config hook — no restart needed;
649
+ on a shared host, opening a session in a new workspace adds that anchor and
650
+ its pools automatically.
564
651
 
565
652
  ### Debris note
566
653
 
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 readdirSync4, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12338
+ import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12339
12339
  import { isAbsolute, join as join6, relative } from "node:path";
12340
12340
  import { tmpdir as tmpdir3, homedir } from "node:os";
12341
12341
 
@@ -15104,7 +15104,7 @@ function resolvePinnedDepth(thoughtLevel, family, ladder) {
15104
15104
  }
15105
15105
 
15106
15106
  // src/forge-config.ts
15107
- import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15107
+ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
15108
15108
  function sanitizeJsonc(text) {
15109
15109
  const out = [];
15110
15110
  let i = 0;
@@ -15456,6 +15456,25 @@ function parentDirOf(p) {
15456
15456
  const parent = s.slice(0, i + 1);
15457
15457
  return parent === "" ? null : parent;
15458
15458
  }
15459
+ function forgeCandidateAt(dir) {
15460
+ const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
15461
+ const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
15462
+ return `${base}.opencode${sep2}forge.json`;
15463
+ }
15464
+ function discoverProjectFileWith(stat, anchor) {
15465
+ let dir = (anchor ?? "").trim();
15466
+ if (!dir)
15467
+ return null;
15468
+ for (;; ) {
15469
+ const candidate = forgeCandidateAt(dir);
15470
+ if (stat(candidate) !== null)
15471
+ return candidate;
15472
+ const parent = parentDirOf(dir);
15473
+ if (parent === null || parent === dir)
15474
+ return null;
15475
+ dir = parent;
15476
+ }
15477
+ }
15459
15478
  function createForgeConfigLoader(deps) {
15460
15479
  const stat = deps.stat ?? ((path) => {
15461
15480
  try {
@@ -15512,28 +15531,9 @@ function createForgeConfigLoader(deps) {
15512
15531
  cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
15513
15532
  return result;
15514
15533
  };
15515
- const candidateAt = (dir) => {
15516
- const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
15517
- const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
15518
- return `${base}.opencode${sep2}forge.json`;
15519
- };
15520
- const discoverProjectFile = (anchor) => {
15521
- let dir = (anchor ?? "").trim();
15522
- if (!dir)
15523
- return null;
15524
- for (;; ) {
15525
- const candidate = candidateAt(dir);
15526
- if (stat(candidate) !== null)
15527
- return candidate;
15528
- const parent = parentDirOf(dir);
15529
- if (parent === null || parent === dir)
15530
- return null;
15531
- dir = parent;
15532
- }
15533
- };
15534
15534
  return {
15535
15535
  load() {
15536
- const projectPath = discoverProjectFile(deps.projectDir ?? "");
15536
+ const projectPath = discoverProjectFileWith(stat, deps.projectDir ?? "");
15537
15537
  const project = projectPath ? readCandidate(projectPath, "project") : null;
15538
15538
  const global = globalPath ? readCandidate(globalPath, "global") : null;
15539
15539
  if (project && global) {
@@ -15553,6 +15553,228 @@ function createForgeConfigLoader(deps) {
15553
15553
  }
15554
15554
  };
15555
15555
  }
15556
+ var POOL_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "target", ".cache", "venv", ".venv", "__pycache__", "coverage"]);
15557
+ var POOL_SCAN_BUDGET = 2000;
15558
+ function sanitizeNamespace(name) {
15559
+ const collapsed = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
15560
+ return collapsed || "pool";
15561
+ }
15562
+ function validatePoolField(v) {
15563
+ if (typeof v !== "string")
15564
+ return { ok: false };
15565
+ const trimmed = v.trim();
15566
+ if (trimmed.length < 1 || trimmed.length > 24 || !/^[a-z0-9-]+$/.test(trimmed))
15567
+ return { ok: false };
15568
+ return { ok: true, ns: trimmed };
15569
+ }
15570
+ function dirBasename(dir) {
15571
+ let s = dir;
15572
+ while (s.length > 1 && (s.endsWith("/") || s.endsWith("\\")))
15573
+ s = s.slice(0, -1);
15574
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
15575
+ return i < 0 ? s : s.slice(i + 1);
15576
+ }
15577
+ function codepointCompare(a, b) {
15578
+ return a < b ? -1 : a > b ? 1 : 0;
15579
+ }
15580
+ function createPoolResolver(deps) {
15581
+ const stat = deps.stat ?? ((path) => {
15582
+ try {
15583
+ const s = statSync4(path);
15584
+ return { mtimeMs: s.mtimeMs, size: s.size };
15585
+ } catch {
15586
+ return null;
15587
+ }
15588
+ });
15589
+ const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
15590
+ const readdir = deps.readdir ?? ((dir) => {
15591
+ try {
15592
+ return readdirSync4(dir, { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
15593
+ } catch {
15594
+ return null;
15595
+ }
15596
+ });
15597
+ const budget = typeof deps.scanBudget === "number" && deps.scanBudget > 0 ? deps.scanBudget : POOL_SCAN_BUDGET;
15598
+ const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
15599
+ const fileCache = new Map;
15600
+ const readPool = (path) => {
15601
+ const st = stat(path);
15602
+ if (st === null) {
15603
+ fileCache.delete(path);
15604
+ return null;
15605
+ }
15606
+ const hit = fileCache.get(path);
15607
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
15608
+ return hit.parsed;
15609
+ let parsed = null;
15610
+ const text = readFile(path);
15611
+ const doc2 = parseForgeJsonc(text);
15612
+ if (!doc2.ok) {
15613
+ parsed = {
15614
+ agents: {},
15615
+ declaredPool: null,
15616
+ findings: [
15617
+ {
15618
+ level: "error",
15619
+ code: "config-parse-error",
15620
+ message: `${path}: ${doc2.error.message}${doc2.error.line !== undefined ? ` (line ${doc2.error.line}, column ${doc2.error.column})` : ""} — this pool contributes no agents until the file parses`
15621
+ }
15622
+ ]
15623
+ };
15624
+ } else if (doc2.config !== undefined && !isRecord(doc2.config)) {
15625
+ parsed = { agents: {}, declaredPool: null, findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this pool contributes no agents` }] };
15626
+ } else {
15627
+ const top = doc2.config === undefined ? undefined : doc2.config;
15628
+ const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15629
+ parsed = { agents: validated.agents, declaredPool: top === undefined ? null : top.pool, findings: validated.findings };
15630
+ }
15631
+ fileCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, parsed });
15632
+ return parsed;
15633
+ };
15634
+ const dirCache = new Map;
15635
+ const listDir = (dir) => {
15636
+ const st = stat(dir);
15637
+ if (st === null) {
15638
+ dirCache.delete(dir);
15639
+ return null;
15640
+ }
15641
+ const hit = dirCache.get(dir);
15642
+ if (hit && hit.mtimeMs === st.mtimeMs)
15643
+ return hit;
15644
+ const listing = readdir(dir);
15645
+ const children = listing === null ? null : [...listing].sort((a, b) => codepointCompare(a.name, b.name));
15646
+ let poolFile = null;
15647
+ const candidate = forgeCandidateAt(dir);
15648
+ if (stat(candidate) !== null)
15649
+ poolFile = candidate;
15650
+ const entry = { mtimeMs: st.mtimeMs, children, poolFile };
15651
+ dirCache.set(dir, entry);
15652
+ return entry;
15653
+ };
15654
+ const scanSubtree = (anchor) => {
15655
+ const files = [];
15656
+ const seen = new Set;
15657
+ let opened = 0;
15658
+ const sep2 = anchor.includes("\\") && !anchor.includes("/") ? "\\" : "/";
15659
+ const joinDir = (parent, name) => parent.endsWith("/") || parent.endsWith("\\") ? `${parent}${name}` : `${parent}${sep2}${name}`;
15660
+ const queue = [anchor];
15661
+ while (queue.length > 0 && opened < budget) {
15662
+ const dir = queue.shift();
15663
+ if (seen.has(dir))
15664
+ continue;
15665
+ seen.add(dir);
15666
+ const entry = listDir(dir);
15667
+ opened++;
15668
+ if (entry === null || entry.children === null)
15669
+ continue;
15670
+ const isAnchor = dir === anchor;
15671
+ if (!isAnchor && entry.poolFile !== null)
15672
+ files.push(entry.poolFile);
15673
+ for (const child of entry.children) {
15674
+ if (!child.dir)
15675
+ continue;
15676
+ if (POOL_SKIP_DIRS.has(child.name) || child.name === ".opencode")
15677
+ continue;
15678
+ queue.push(joinDir(dir, child.name));
15679
+ }
15680
+ }
15681
+ return files;
15682
+ };
15683
+ return {
15684
+ resolve(anchorsInput) {
15685
+ const anchors = [];
15686
+ for (const a of anchorsInput) {
15687
+ const t = (a ?? "").trim();
15688
+ if (t && !anchors.includes(t))
15689
+ anchors.push(t);
15690
+ }
15691
+ const findings = [];
15692
+ const globalParsed = globalPath ? readPool(globalPath) : null;
15693
+ if (globalParsed)
15694
+ findings.push(...globalParsed.findings);
15695
+ const primaryRootFile = anchors.length > 0 ? discoverProjectFileWith(stat, anchors[0]) : null;
15696
+ const uniqueFiles = new Set;
15697
+ if (primaryRootFile)
15698
+ uniqueFiles.add(primaryRootFile);
15699
+ for (const anchor of anchors) {
15700
+ for (const f of scanSubtree(anchor))
15701
+ uniqueFiles.add(f);
15702
+ if (anchor !== anchors[0]) {
15703
+ const rootFile = discoverProjectFileWith(stat, anchor);
15704
+ if (rootFile)
15705
+ uniqueFiles.add(rootFile);
15706
+ }
15707
+ }
15708
+ const plainPoolFile = primaryRootFile ?? (globalParsed !== null && globalPath ? globalPath : null);
15709
+ const orderedFiles = [plainPoolFile, ...[...uniqueFiles].filter((f) => f !== plainPoolFile).sort(codepointCompare)].filter((f) => f !== null);
15710
+ const families = [];
15711
+ for (const file2 of orderedFiles) {
15712
+ const parsed = readPool(file2);
15713
+ if (parsed && file2 !== globalPath)
15714
+ findings.push(...parsed.findings);
15715
+ const isPrimary = plainPoolFile !== null && file2 === plainPoolFile;
15716
+ let declaredPool = null;
15717
+ if (!isPrimary && parsed) {
15718
+ const v = validatePoolField(parsed.declaredPool);
15719
+ if (parsed.declaredPool !== undefined && parsed.declaredPool !== null) {
15720
+ if (v.ok) {
15721
+ declaredPool = v.ns;
15722
+ } else {
15723
+ findings.push({ level: "warn", code: "pool-field-invalid", message: `${file2}: invalid "pool" field ${JSON.stringify(String(parsed.declaredPool))} (expected [a-z0-9-], 1-24 chars) — falling back to the directory-derived namespace` });
15724
+ }
15725
+ }
15726
+ }
15727
+ families.push({ file: file2, parsed, ns: null, declaredPool, primary: isPrimary });
15728
+ }
15729
+ const allDeclared = new Set;
15730
+ for (const f of families)
15731
+ if (f.declaredPool)
15732
+ allDeclared.add(f.declaredPool);
15733
+ const usedNs = new Map;
15734
+ for (const f of families) {
15735
+ if (f.primary)
15736
+ continue;
15737
+ const base = f.declaredPool ?? sanitizeNamespace(dirBasename(parentDirLabel(parentDirLabel(f.file))));
15738
+ let ns = base;
15739
+ let suffix = 2;
15740
+ while ((usedNs.has(ns) || ns !== base && allDeclared.has(ns)) && suffix < 100) {
15741
+ ns = `${base}-${suffix++}`;
15742
+ }
15743
+ const owner = usedNs.get(base);
15744
+ if (owner !== undefined && owner !== f.file) {
15745
+ findings.push({ level: "error", code: "pool-namespace-collision", message: `namespace "${base}" is already used by ${owner} — ${f.file} materializes under "${ns}" instead` });
15746
+ }
15747
+ f.ns = ns;
15748
+ usedNs.set(ns, f.file);
15749
+ }
15750
+ const agents = {};
15751
+ const origins = [];
15752
+ for (const f of families) {
15753
+ const merged = { ...globalParsed?.agents ?? {}, ...f.parsed?.agents ?? {} };
15754
+ const ids = [];
15755
+ for (const rawId of Object.keys(merged)) {
15756
+ const matId = f.ns === null ? rawId : `${f.ns}-${rawId}`;
15757
+ const existing = agents[matId];
15758
+ if (existing !== undefined) {
15759
+ 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` });
15760
+ continue;
15761
+ }
15762
+ agents[matId] = { materializedId: matId, def: merged[rawId], ns: f.ns, file: f.file };
15763
+ ids.push(matId);
15764
+ }
15765
+ origins.push({ file: f.file, ns: f.ns, declaredPool: f.declaredPool, primary: f.primary, materializedIds: ids });
15766
+ }
15767
+ return { families: origins, agents, findings };
15768
+ }
15769
+ };
15770
+ }
15771
+ function parentDirLabel(filePath) {
15772
+ let s = filePath;
15773
+ while (s.length > 1 && (s.endsWith("/") || s.endsWith("\\")))
15774
+ s = s.slice(0, -1);
15775
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
15776
+ return i < 0 ? s : s.slice(0, i);
15777
+ }
15556
15778
 
15557
15779
  // plugin.ts
15558
15780
  var FORGE_AGENT = "forge";
@@ -15665,8 +15887,35 @@ function noteToolAgent(context) {
15665
15887
  }
15666
15888
  }
15667
15889
  var forgeDisabled = false;
15668
- var forgeLoader = null;
15669
- var forgeLoaderDir = null;
15890
+ var hostAnchors = [];
15891
+ var poolResolver = null;
15892
+ var poolResolution = null;
15893
+ function normalizeAnchorDir(dir) {
15894
+ let p = (dir ?? "").trim();
15895
+ if (!p)
15896
+ return "";
15897
+ try {
15898
+ p = realpathSync(p);
15899
+ } catch {}
15900
+ if (process.platform === "win32")
15901
+ return p.replace(/\//g, "\\").toLowerCase();
15902
+ return p;
15903
+ }
15904
+ function rememberHostAnchor(dir) {
15905
+ const normalized = normalizeAnchorDir(dir);
15906
+ if (!normalized)
15907
+ return;
15908
+ if (!hostAnchors.includes(normalized))
15909
+ hostAnchors.push(normalized);
15910
+ if (!poolResolver) {
15911
+ poolResolver = createPoolResolver({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
15912
+ }
15913
+ }
15914
+ function resolvePools() {
15915
+ if (!poolResolver || hostAnchors.length === 0)
15916
+ return null;
15917
+ return poolResolver.resolve(hostAnchors);
15918
+ }
15670
15919
  var sessionAnchorLoaders = new Map;
15671
15920
  function loaderForAnchor(anchor) {
15672
15921
  const key = anchor || "-";
@@ -15684,6 +15933,57 @@ function loaderForAnchor(anchor) {
15684
15933
  return l;
15685
15934
  }
15686
15935
  var crews = new Map;
15936
+ function crewRecordPathFor(worktree, objective) {
15937
+ const dir = join6(worktree, ".opencode", "crew");
15938
+ const date5 = localDateNow();
15939
+ const slug = slugifyGoal(objective) || "crew";
15940
+ let name = `${date5}-${slug}.md`;
15941
+ let n = 2;
15942
+ while (existsSync3(join6(dir, name)))
15943
+ name = `${date5}-${slug}-${n++}.md`;
15944
+ return join6(dir, name);
15945
+ }
15946
+ function writeCrewRecord(path, objective, anchor, subtasks) {
15947
+ mkdirSync5(join6(path, ".."), { recursive: true });
15948
+ writeFileSync5(path, [
15949
+ `# Crew Record: ${objective}`,
15950
+ "",
15951
+ `- registered: ${nowIso()}`,
15952
+ `- anchor: ${anchor}`,
15953
+ "",
15954
+ `## Declared plan (${subtasks.length})`,
15955
+ ...subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
15956
+ ""
15957
+ ].join(`
15958
+ `) + `
15959
+ `, "utf8");
15960
+ }
15961
+ function appendCrewRecord(path, heading, lines) {
15962
+ if (!path)
15963
+ return;
15964
+ try {
15965
+ appendFileSync(path, [`## ${nowIso()} — ${heading}`, ...lines, ""].join(`
15966
+ `) + `
15967
+ `, "utf8");
15968
+ } catch {}
15969
+ }
15970
+ function governingGoalFor(sessionID) {
15971
+ if (!sessionID)
15972
+ return false;
15973
+ const state = stateForBan(sessionID);
15974
+ if (!state)
15975
+ return false;
15976
+ const goal = resolveLiveGoal(state);
15977
+ return Boolean(goal && goal.doc.status === "active" && goal.doc.session === sessionID);
15978
+ }
15979
+ function crewPendingChoiceLines() {
15980
+ return [
15981
+ "[forge:crew] The crew is PENDING — execution has NOT started. Present the execution-mode choice to the user and END YOUR TURN (every task dispatch is refused while the crew pends):",
15982
+ ` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline;`,
15983
+ ` 2. convert to a goal contract — call crew_begin {execution: "goal"} (the crew ends as a conversion record; no extra dialog here), then draft goal_write with arm=true folding the declared subtasks into criteria/checks — the arm dialog is the user's gate;`,
15984
+ ` 3. standby — no call; the crew waits until armed or abandoned.`
15985
+ ];
15986
+ }
15687
15987
  var modelsDevCatalog = null;
15688
15988
  var depthFindingNotes = new Map;
15689
15989
  var crewCommandSnapshot = null;
@@ -15823,7 +16123,7 @@ function readPlanDir(worktree) {
15823
16123
  const dir = planDirOf(worktree);
15824
16124
  if (!existsSync3(dir))
15825
16125
  return [];
15826
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16126
+ return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
15827
16127
  }
15828
16128
  function ensureSession(sessionID, worktree) {
15829
16129
  const existing = sessions.get(sessionID);
@@ -15890,7 +16190,7 @@ var planWriteTool = tool({
15890
16190
  } else {
15891
16191
  const dir = planDirOf(state.worktree);
15892
16192
  mkdirSync5(dir, { recursive: true });
15893
- path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
16193
+ path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md"))));
15894
16194
  mode = "created";
15895
16195
  }
15896
16196
  const text = renderPlan(args, now, created);
@@ -16014,7 +16314,7 @@ function readGoalDir(worktree) {
16014
16314
  const dir = goalDirOf(worktree);
16015
16315
  if (!existsSync3(dir))
16016
16316
  return [];
16017
- return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16317
+ return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
16018
16318
  }
16019
16319
  function resolveSessionGoal(state) {
16020
16320
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -16123,7 +16423,7 @@ var goalWriteTool = tool({
16123
16423
  }
16124
16424
  const dir = goalDirOf(state.worktree);
16125
16425
  mkdirSync5(dir, { recursive: true });
16126
- const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
16426
+ const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md")));
16127
16427
  const path = join6(dir, name);
16128
16428
  const text = renderGoal(input, {
16129
16429
  now,
@@ -16495,42 +16795,52 @@ var CREW_INIT_TEMPLATE = [
16495
16795
  "",
16496
16796
  "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.",
16497
16797
  "",
16498
- "CREW IS NOT INITIALIZED: no forge subagents are configured (no usable forge.json agents). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
16798
+ "CREW IS NOT INITIALIZED: no forge subagents are materialized across the whole host anchor set (zero pools). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
16499
16799
  "",
16500
- "1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root):",
16800
+ "1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root; a .opencode/forge.json in any anchor subdirectory additionally forms a namespaced sub-pool family, its ids materializing as forge-<ns>-<id>):",
16501
16801
  "{paths}",
16502
16802
  "",
16503
- "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.",
16803
+ "Two axes, distinct: POOLS follow host directories (the host's anchor set); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in a directory no host anchor covers is not dispatchable on this host.",
16504
16804
  "",
16505
- "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).",
16805
+ '2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead. When you configure it: name ids as PLAIN role words WITHOUT the `forge-` prefix — the plugin materializes every id as `forge-<id>` automatically (a `forge-`-prefixed id doubles up as `forge-forge-coder` and is auto-stripped with a warning). Give EVERY agent a SHORT prompt derived from the role the user asked for — one or two sentences describing its job (the user trims or extends from there); write a long prompt ONLY when the user explicitly asks for that agent. For sub-pool files, ALSO include a proposed short stable `pool` namespace (a [a-z0-9-] word up to 24 chars — e.g. "aa" for a devAa specialist pool) so the materialized ids survive directory renames.',
16806
+ "",
16807
+ "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.",
16506
16808
  "{template}",
16507
16809
  "",
16508
- "After the file is saved: NEW agents require a host restart to appear in the task tool; thoughtLevel edits on existing agents apply without restart. Until then, crew stays unavailable."
16810
+ "After the file is saved: file edits, creations, and deletions apply at the next config hook — NO host restart is needed. Only the anchor set itself is fixed at host start: host re-initializations add anchors and pools, they never remove them."
16509
16811
  ].join(`
16510
16812
  `);
16511
- function crewOrchestrationTemplate(agentIds) {
16813
+ function crewOrchestrationTemplate(resolution) {
16814
+ const families = resolution?.families ?? [];
16815
+ const familyLines = families.map((f) => {
16816
+ const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
16817
+ return ` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`;
16818
+ });
16512
16819
  return [
16513
16820
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
16514
16821
  "",
16515
16822
  "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.",
16516
16823
  "",
16517
- `Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
16824
+ "Configured forge subagents (task-tool vocabulary), grouped by pool:",
16825
+ ...familyLines.length > 0 ? familyLines : [" (none)"],
16518
16826
  "",
16519
- "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
16827
+ "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:",
16520
16828
  "",
16521
- "1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). 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. Reconnaissance first if you need it to decompose well; a wrong plan is fixed by discarding and re-crewing, never by improvising outside the declared plan.",
16522
- "2. WAVES, NEVER FLOODS: 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.",
16523
- "3. 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.",
16524
- "4. 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.",
16525
- "5. 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.",
16526
- "6. 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 your 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."
16829
+ "1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). 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.",
16830
+ `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.`,
16831
+ "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.",
16832
+ "4. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
16833
+ "5. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
16834
+ "6. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
16835
+ "7. CLOSE: when every declared subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks the report against the declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog (ask-free while a goal governs the session)."
16527
16836
  ].join(`
16528
16837
  `);
16529
16838
  }
16530
16839
  var crewBeginTool = tool({
16531
- description: "Register this session's crew (from /crew) with its DECLARED subtask plan. Refuses when a crew is already active in the session, while a plan draft is active, or when subtasks are missing/duplicated. Crew state is in-memory: a host restart ends it honestly.",
16840
+ description: `Register this session's crew (from /crew) with its DECLARED subtask plan — the crew starts PENDING: the execution-mode choice (waves / convert to goal / standby) is the user's. Dual-purpose: called with {execution} ONLY (no objective/subtasks) it ARMS the pending crew — "waves" lifts the dispatch belt, "goal" ends the crew as a conversion record (the following goal_write arm dialog is the user's gate). One-call register+arm ({objective, subtasks, execution:"waves"}) is legal ONLY while a live active goal governs the session (born armed, never pends). Refuses: active crew, plan draft, missing/duplicated subtasks. Crew state is in-memory: a host restart ends it honestly.`,
16532
16841
  args: {
16533
- objective: tool.schema.string().describe("One-line crew objective (from /crew)"),
16842
+ objective: tool.schema.string().optional().describe("One-line crew objective (from /crew) — required on registration, ABSENT on the arm call"),
16843
+ execution: tool.schema.string().optional().describe('Arm action: "waves" (supervised waves — lifts the belt) or "goal" (convert to a goal contract). Alone = arm the pending crew; with objective+subtasks = one-call born-armed registration, legal only under a governing active goal.'),
16534
16844
  subtasks: tool.schema.array(tool.schema.object({
16535
16845
  title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
16536
16846
  agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
@@ -16543,30 +16853,80 @@ var crewBeginTool = tool({
16543
16853
  if (active && active.doc.status === "draft") {
16544
16854
  throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
16545
16855
  }
16546
- const hostLoaded = forgeLoader?.load();
16547
- const hostIds = hostLoaded ? Object.keys(hostLoaded.agents) : [];
16856
+ const resolution = resolvePools();
16857
+ const hostIds = resolution ? Object.keys(resolution.agents) : [];
16548
16858
  const anchor = sessionAnchor(context);
16549
- const hostAnchorDir = forgeLoaderDir ?? "";
16550
- const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
16859
+ const sessionLoaded = anchor ? loaderForAnchor(anchor).load() : null;
16551
16860
  const sessionProject = sessionLoaded?.projectPath ?? null;
16861
+ const foldPath = (p) => process.platform === "win32" ? p.replace(/\//g, "\\").toLowerCase() : p;
16862
+ const sessionCovered = sessionProject === null || (resolution?.families.some((f) => foldPath(f.file) === foldPath(sessionProject)) ?? false);
16552
16863
  if (hostIds.length === 0) {
16553
- const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16554
- const lines = [
16555
- "[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
16556
- `Two layers, MERGED (the project layer overrides the global one per agent id): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}.`,
16557
- 'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}',
16558
- "Agent ids are plain role words WITHOUT the `forge-` prefix — the plugin materializes each id as `forge-<id>` (a prefixed id is auto-stripped with a warning).",
16559
- "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) — without a prompt, custom ids get a generic one-liner and will not know their job. Newly added agents need a host restart; thoughtLevel edits apply without one."
16864
+ const primaryAnchorDir = hostAnchors[0] ?? hostWorktree;
16865
+ const anchorNote = hostAnchors.length > 1 ? `host anchors: ${hostAnchors.join(", ")}` : `launched from ${primaryAnchorDir || "an unresolvable directory"}`;
16866
+ const paths = forgeConfigPaths({ projectDir: primaryAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16867
+ const lines2 = [
16868
+ `[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized across the whole anchor set (${anchorNote}). Crew orchestration is unavailable until the user sets it up.`,
16869
+ `Two layers, MERGED (the project layer overrides the global one per agent id): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}. Sub-pool files (.opencode/forge.json in an anchor's subdirectories) contribute additional namespaced families.`,
16870
+ "Two axes, distinct: POOLS follow host directories (the anchor set above); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in an unanchored directory is not dispatchable on this host.",
16871
+ `Template (JSONC): top-level optional "pool" names this file's namespace (short stable [a-z0-9-] up to 24 chars — sub-pool ids materialize as forge-<ns>-<id>); {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}`,
16872
+ "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.",
16873
+ "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. When you write it: give every agent a SHORT prompt from the role the user asked for (one or two sentences; long only on explicit request), and INCLUDE a proposed short stable `pool` namespace for sub-pool files so ids survive directory renames. File edits, creations, and deletions apply at the next config hook — no host restart; only the anchor set itself is fixed at host start (re-initializations add pools, never remove)."
16560
16874
  ];
16561
- if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
16562
- lines.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but this host instance was launched from ${hostAnchorDir || "a different directory"}, so they are not in the task vocabulary. Relaunch opencode in that workspace, or add the agents to the global layer.`);
16875
+ if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject && !sessionCovered) {
16876
+ lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but NO host anchor covers it (${anchorNote}). A host initialization in that workspace adds its pools; or fold the agents into the global layer.`);
16563
16877
  }
16564
- throw new Error(lines.join(`
16878
+ throw new Error(lines2.join(`
16565
16879
  `));
16566
16880
  }
16567
- if (!args.objective?.trim())
16568
- throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
16881
+ const execution = typeof args.execution === "string" ? args.execution.trim().toLowerCase() : "";
16882
+ if (execution && execution !== "waves" && execution !== "goal") {
16883
+ throw new Error('[forge:crew] execution must be "waves" or "goal".');
16884
+ }
16885
+ const objectiveText = String(args.objective ?? "").trim();
16569
16886
  const raw = Array.isArray(args.subtasks) ? args.subtasks : [];
16887
+ if (execution && !objectiveText && raw.length === 0) {
16888
+ const crew = crews.get(context.sessionID);
16889
+ if (!crew) {
16890
+ throw new Error("[forge:crew] No active crew to arm (crew state is in-memory and dies on host restart). Register one first: crew_begin {objective, subtasks}.");
16891
+ }
16892
+ if (crew.mode !== "pending") {
16893
+ throw new Error(`[forge:crew] This crew is already armed (${crew.mode}) — execute "${crew.objective}" instead of re-arming.`);
16894
+ }
16895
+ if (execution === "goal" && governingGoalFor(context.sessionID)) {
16896
+ throw new Error('[forge:crew] execution:"goal" is refused under a governing goal — this session already runs inside an active goal contract. Run supervised waves (arm or one-call register), or pause/discard the goal first if conversion is truly intended.');
16897
+ }
16898
+ if (execution === "goal") {
16899
+ crews.delete(context.sessionID);
16900
+ appendCrewRecord(crew.recordPath, "converted to goal", [
16901
+ "The crew ends here as a conversion record; the objective moves to a goal contract.",
16902
+ ...crew.subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`)
16903
+ ]);
16904
+ return {
16905
+ title: `crew converted: ${crew.objective.slice(0, 60)}`,
16906
+ output: [
16907
+ `[forge:crew] Crew ended as a CONVERSION record: ${crew.objective}`,
16908
+ "Now draft the goal contract: goal_write with arm=true, folding the declared subtasks below into its criteria and verification checks. The arm confirmation dialog is the user's gate for the whole autonomous path — do not ask again here.",
16909
+ `Declared subtasks to fold in (${crew.subtasks.length}):`,
16910
+ ...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`)
16911
+ ].join(`
16912
+ `)
16913
+ };
16914
+ }
16915
+ crew.mode = "executing";
16916
+ appendCrewRecord(crew.recordPath, "armed (waves)", ["The user chose supervised waves; the dispatch belt is lifted."]);
16917
+ return {
16918
+ title: `crew armed: ${crew.objective.slice(0, 60)}`,
16919
+ output: [
16920
+ `[forge:crew] Crew ARMED for supervised waves: ${crew.objective}`,
16921
+ `Declared plan (${crew.subtasks.length}):`,
16922
+ ...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16923
+ "Proceed with the wave discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask."
16924
+ ].join(`
16925
+ `)
16926
+ };
16927
+ }
16928
+ if (!objectiveText)
16929
+ throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
16570
16930
  if (raw.length === 0)
16571
16931
  throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
16572
16932
  const seen = new Set;
@@ -16586,43 +16946,99 @@ var crewBeginTool = tool({
16586
16946
  if (existing) {
16587
16947
  throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
16588
16948
  }
16589
- crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
16949
+ if (execution === "waves" && !governingGoalFor(context.sessionID)) {
16950
+ throw new Error("[forge:crew] crew_begin cannot self-arm outside a governing goal: register with {objective, subtasks} only — the pause is the user's decision point, armed by a follow-up crew_begin {execution} call per their choice.");
16951
+ }
16952
+ if (execution === "goal") {
16953
+ throw new Error('[forge:crew] execution:"goal" converts an already-registered PENDING crew — register the plan first (no execution), then convert. Under a governing goal, only "waves" one-call arming is legal (the loop already owns the goal).');
16954
+ }
16955
+ const governed = execution === "waves";
16956
+ const anchorDir = anchor || hostWorktree;
16957
+ let recordPath = null;
16958
+ let recordNote = "";
16959
+ if (anchorDir) {
16960
+ try {
16961
+ recordPath = crewRecordPathFor(anchorDir, objectiveText);
16962
+ writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks);
16963
+ recordNote = `Crew record (inspectable history; NOT a resume mechanism): ${relFrom(anchorDir, recordPath)}`;
16964
+ } catch {
16965
+ recordPath = null;
16966
+ recordNote = "Crew record could not be written (fail-soft; the crew still runs).";
16967
+ }
16968
+ }
16969
+ crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
16970
+ appendCrewRecord(recordPath, governed ? "registered (born armed under a governing goal)" : "registered (pending)", subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`));
16590
16971
  let disclosure = "";
16591
- if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
16592
- const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
16972
+ if (sessionProject && !sessionCovered) {
16973
+ const localOnly = Object.keys(sessionLoaded?.agents ?? {});
16593
16974
  if (localOnly.length > 0) {
16594
16975
  disclosure = `
16595
- [forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-${id}`).join(", ")} — NOT dispatchable on this host (launched from ${hostAnchorDir || "a different directory"}). Relaunch opencode in that workspace to use them, or fold them into the global layer.`;
16596
- }
16976
+ [forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-<ns>-${id} (namespaced by its pool)`).join(", ")} — NOT dispatchable on this host (no anchor covers it; anchors: ${hostAnchors.join(", ") || "none"}). A host initialization in that workspace adds its pools, or fold the agents into the global layer.`;
16977
+ }
16978
+ }
16979
+ const familyLines = [];
16980
+ const families = resolution?.families ?? [];
16981
+ for (const f of families.slice(0, 8)) {
16982
+ const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
16983
+ familyLines.push(` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`);
16984
+ }
16985
+ if (families.length > 8)
16986
+ familyLines.push(` - …and ${families.length - 8} more pools`);
16987
+ const lines = [
16988
+ 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}`,
16989
+ `Declared plan (${subtasks.length}):`,
16990
+ ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16991
+ `Dispatchable roster origin — ${families.length} pool${families.length === 1 ? "" : "s"} across the host anchor set${hostAnchors.length > 0 ? ` (anchors: ${hostAnchors.join(", ")})` : ""}:`,
16992
+ ...familyLines,
16993
+ ...recordNote ? [recordNote] : [],
16994
+ ...disclosure ? [disclosure] : []
16995
+ ];
16996
+ if (governed) {
16997
+ lines.push("Goal-delegated orchestration: the governing goal contract already authorized this crew — run the wave discipline NOW (crew gates stay internal to the loop; crew_close and abandon are ask-free under this goal), and re-shard via crew_close {abandon: true, reason} + a fresh one-call crew_begin when the decomposition proves wrong.", "Wave discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask.");
16998
+ } else {
16999
+ lines.push(...crewPendingChoiceLines());
16597
17000
  }
16598
17001
  return {
16599
- title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
16600
- output: [
16601
- `[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
16602
- `Declared plan (${subtasks.length}):`,
16603
- ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16604
- "Proceed with the discipline: waves of parallel native task calls (next wave after the previous results) → per-subtask evidence verdicts → at most one retry → crew_close with the full report covering every declared subtask.",
16605
- ...disclosure ? [disclosure] : []
16606
- ].join(`
17002
+ title: `crew registered: ${objectiveText.slice(0, 60)}`,
17003
+ output: lines.join(`
16607
17004
  `)
16608
17005
  };
16609
17006
  }
16610
17007
  });
16611
17008
  var crewCloseTool = tool({
16612
- description: "Close this session's crew with the final report (ask-level user confirmation). The gate cross-checks the report against the declared subtask plan: every declared subtask needs a verdict (PASS/FAIL) and an evidence statement; an undeclared subtask in the report refuses the close; FAIL requires both failure reports (attempt + the one retry). On success the crew ends and the summary is the tool output (there is no persistent crew record).",
17009
+ description: "Close this session's crew. Two paths: (a) final report — the gate cross-checks the report against the declared subtask plan (every declared subtask needs a verdict and an evidence statement; an undeclared subtask refuses the close; FAIL requires both failure reports); (b) abandon {abandon: true, reason} — the re-shard exit: no verdict requirements, ends the crew with an abandonment record, frees the session for a fresh crew_begin. Both paths are ask-gated UNLESS a live active goal governs the session (then ask-free — the goal's own gates are the user boundary).",
16613
17010
  args: {
16614
17011
  report: tool.schema.array(tool.schema.object({
16615
17012
  title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
16616
17013
  verdict: tool.schema.string().describe("PASS or FAIL"),
16617
17014
  evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
16618
17015
  attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
16619
- })).describe("The full crew report — one entry per declared subtask")
17016
+ })).optional().describe("The full crew report — one entry per declared subtask (not needed when abandoning)"),
17017
+ abandon: tool.schema.boolean().optional().describe("true = abandon the crew (re-shard / standby-cancel): ends it with an abandonment record, no verdicts required"),
17018
+ reason: tool.schema.string().optional().describe("Why the crew is abandoned (recorded in the crew record)")
16620
17019
  },
16621
17020
  execute: async (args, context) => {
16622
17021
  noteToolAgent(context);
16623
17022
  const crew = crews.get(context.sessionID);
16624
17023
  if (!crew)
16625
17024
  throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
17025
+ if (args.abandon === true) {
17026
+ const reason = String(args.reason ?? "").trim() || "(no reason given)";
17027
+ if (!governingGoalFor(context.sessionID))
17028
+ await gate(context.ask, "crew_close", `Abandon crew: ${crew.objective}`);
17029
+ crews.delete(context.sessionID);
17030
+ appendCrewRecord(crew.recordPath, "abandoned", [`mode at abandon: ${crew.mode}`, `reason: ${reason}`, "The session is free for a fresh crew_begin (re-shard or new objective)."]);
17031
+ return {
17032
+ title: `crew abandoned: ${crew.objective.slice(0, 60)}`,
17033
+ output: [
17034
+ `[forge:crew] Crew ABANDONED: ${crew.objective}`,
17035
+ `mode at abandon: ${crew.mode}`,
17036
+ `reason: ${reason}`,
17037
+ "The session is free for a fresh crew_begin — register the corrected plan next (it starts PENDING again unless a governing goal arms it in the same call)."
17038
+ ].join(`
17039
+ `)
17040
+ };
17041
+ }
16626
17042
  const report = args.report ?? [];
16627
17043
  const check2 = validateCrewReport(report, crew.subtasks);
16628
17044
  if (!check2.ok) {
@@ -16631,16 +17047,21 @@ ${check2.gaps.map((g) => `- ${g}`).join(`
16631
17047
  `)}
16632
17048
  The crew stays active; complete or correct the report and call crew_close again.`);
16633
17049
  }
16634
- await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
17050
+ if (!governingGoalFor(context.sessionID))
17051
+ await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
16635
17052
  crews.delete(context.sessionID);
16636
17053
  const fails = report.filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
17054
+ appendCrewRecord(crew.recordPath, "closed", [
17055
+ `subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
17056
+ ...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`)
17057
+ ]);
16637
17058
  return {
16638
17059
  title: `crew closed: ${crew.objective.slice(0, 60)}`,
16639
17060
  output: [
16640
17061
  `[forge:crew] Crew closed: ${crew.objective}`,
16641
17062
  `subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
16642
17063
  ...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
16643
- "This output is the crew's record (crew state is in-memory; nothing persists)."
17064
+ crew.recordPath ? `Crew record (history): ${crew.recordPath}` : "This output is the crew's record (crew state is in-memory; nothing persists)."
16644
17065
  ].join(`
16645
17066
  `)
16646
17067
  };
@@ -16723,6 +17144,7 @@ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\``
16723
17144
  `)}`,
16724
17145
  d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
16725
17146
  `Budget: ${d.turnsUsed}/${d.maxTurns} turns used. Work the criteria now; call goal_check when you believe they hold, then goal_complete (it re-runs every check itself and asks the user). If genuinely blocked, call goal_pause with the blocker — do not spin.`,
17147
+ `Crew layer: you own it under this goal — when parallel decomposition serves a criterion, register crews with crew_begin {objective, subtasks, execution: "waves"} in ONE call (born armed; no pause mid-loop), dispatch forge-* waves, and re-shard a wrong decomposition via crew_close {abandon: true, reason} plus a fresh registration. Crew gates stay internal to this loop.`,
16726
17148
  `Goal file: ${relFrom(state.worktree, goal.path)}`
16727
17149
  ].filter((s) => s.length > 0).join(`
16728
17150
  `);
@@ -16886,7 +17308,9 @@ function scheduleIdleContinuation(client, sessionID) {
16886
17308
  }, IDLE_DEBOUNCE_MS));
16887
17309
  }
16888
17310
  var server = async (input, options) => {
16889
- hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
17311
+ const effectiveInitDir = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
17312
+ hostWorktree = effectiveInitDir;
17313
+ rememberHostAnchor(effectiveInitDir);
16890
17314
  const client = input.client;
16891
17315
  jobClient = input.client;
16892
17316
  const jobsOpts = options?.jobs;
@@ -16997,23 +17421,17 @@ var server = async (input, options) => {
16997
17421
  if (permSection[gateKey] !== "deny")
16998
17422
  permSection[gateKey] = "ask";
16999
17423
  }
17000
- if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
17001
- forgeLoader = createForgeConfigLoader({
17002
- projectDir: hostWorktree,
17003
- homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
17004
- });
17005
- forgeLoaderDir = hostWorktree;
17006
- }
17007
- const loaded = forgeLoader.load();
17008
- for (const finding of loaded.findings) {
17424
+ const resolution = resolvePools();
17425
+ poolResolution = resolution;
17426
+ for (const finding of resolution?.findings ?? []) {
17009
17427
  console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
17010
17428
  }
17011
17429
  materializedSnapshot = {};
17012
- for (const [agentId, def] of Object.entries(loaded.agents)) {
17013
- const id = `forge-${agentId}`;
17430
+ for (const [matId, agent] of Object.entries(resolution?.agents ?? {})) {
17431
+ const id = `forge-${matId}`;
17014
17432
  if (agentSection[id] !== undefined)
17015
17433
  continue;
17016
- const entry = forgeAgentDef(agentId, def);
17434
+ const entry = forgeAgentDef(matId, agent.def);
17017
17435
  agentSection[id] = entry;
17018
17436
  materializedSnapshot[id] = entry;
17019
17437
  }
@@ -17058,12 +17476,13 @@ var server = async (input, options) => {
17058
17476
  cfg.command ??= {};
17059
17477
  const existingCrew = cfg.command["crew"];
17060
17478
  if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
17061
- const agentIds = Object.keys(loaded.agents);
17062
- const template = agentIds.length > 0 ? crewOrchestrationTemplate(agentIds) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
17063
- - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ agents: { research: { model: "provider/model", thoughtLevel: "low", prompt: "short role description — what this agent does" } } }, null, 2).split(`
17479
+ const agentCount = Object.keys(resolution?.agents ?? {}).length;
17480
+ const primaryAnchorDir = hostAnchors[0] ?? undefined;
17481
+ const template = agentCount > 0 ? crewOrchestrationTemplate(resolution ?? null) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
17482
+ - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ pool: "short-stable-namespace (optional, [a-z0-9-] up to 24 chars — only meaningful for sub-pool files)", agents: { research: { model: "provider/model", thoughtLevel: "low", prompt: "short role description — what this agent does" } } }, null, 2).split(`
17064
17483
  `).map((l) => " " + l).join(`
17065
17484
  `));
17066
- const description = agentIds.length > 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";
17485
+ 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";
17067
17486
  cfg.command["crew"] = { template, description };
17068
17487
  crewCommandSnapshot = { template, description };
17069
17488
  }
@@ -17098,6 +17517,10 @@ var server = async (input, options) => {
17098
17517
  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.`);
17099
17518
  }
17100
17519
  }
17520
+ const pendingCrew = input2.sessionID ? crews.get(input2.sessionID) : undefined;
17521
+ if (pendingCrew && pendingCrew.mode === "pending") {
17522
+ 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.`);
17523
+ }
17101
17524
  }
17102
17525
  if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
17103
17526
  const speaker = sessionAgents.get(input2.sessionID);
@@ -17162,7 +17585,7 @@ var server = async (input, options) => {
17162
17585
  return;
17163
17586
  }
17164
17587
  const agentId = agentName.slice("forge-".length);
17165
- const def = forgeLoader?.load().agents[agentId];
17588
+ const def = resolvePools()?.agents[agentId]?.def;
17166
17589
  if (!def?.thoughtLevel)
17167
17590
  return;
17168
17591
  const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
@@ -17190,7 +17613,14 @@ var server = async (input, options) => {
17190
17613
  modelsDevCatalog = c;
17191
17614
  },
17192
17615
  crewCommand: () => crewCommandSnapshot,
17193
- materialized: () => materializedSnapshot
17616
+ materialized: () => materializedSnapshot,
17617
+ resetAnchors: () => {
17618
+ hostAnchors.length = 0;
17619
+ poolResolver = null;
17620
+ poolResolution = null;
17621
+ },
17622
+ anchors: () => [...hostAnchors],
17623
+ resolveNow: () => resolvePools()
17194
17624
  },
17195
17625
  "permission.ask": async (input2, output) => {
17196
17626
  const meta = input2.metadata ?? {};
@@ -17284,7 +17714,8 @@ var server = async (input, options) => {
17284
17714
  return;
17285
17715
  const crewActive = crews.get(input2.sessionID);
17286
17716
  if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
17287
- output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
17717
+ const modeText = crewActive.mode === "pending" ? "PENDING (present the execution-mode choice; every task dispatch is refused until armed)" : "armed (executing)";
17718
+ output.system.push(`[forge:crew-active] A crew is ${modeText} in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict, or crew_close {abandon: true, reason} to re-shard.`);
17288
17719
  }
17289
17720
  if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
17290
17721
  output.system.push(jobsKeepBuiltinShell ? "[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion." : "[forge:job-guidance] Every shell command — quick ones included — goes through forge_shell: the builtin shell/bash tools are refused on this agent, so never try them first. forge_shell returns on exit/success/idle with a jobId instead of blocking indefinitely; long-running or non-exiting commands use run_in_background. Manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
@@ -17355,20 +17786,20 @@ async function v2Setup(ctx) {
17355
17786
  });
17356
17787
  }
17357
17788
  try {
17358
- const loader = createForgeConfigLoader({ projectDir: process.cwd(), homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
17359
- const loaded = loader.load();
17360
- for (const [agentId, def] of Object.entries(loaded.agents)) {
17361
- const id = `forge-${agentId}`;
17789
+ const resolver = createPoolResolver({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
17790
+ const resolution = resolver.resolve([process.cwd()]);
17791
+ for (const [matId, agent] of Object.entries(resolution.agents)) {
17792
+ const id = `forge-${matId}`;
17362
17793
  if (draft.get(id) !== undefined)
17363
17794
  continue;
17364
- const v1def = forgeAgentDef(agentId, def);
17365
- draft.update(id, (agent) => {
17366
- agent.description = v1def.description;
17367
- agent.system = v1def.prompt;
17368
- agent.mode = v1def.mode;
17795
+ const v1def = forgeAgentDef(matId, agent.def);
17796
+ draft.update(id, (agentDraft) => {
17797
+ agentDraft.description = v1def.description;
17798
+ agentDraft.system = v1def.prompt;
17799
+ agentDraft.mode = v1def.mode;
17369
17800
  if (v1def.model !== undefined)
17370
- agent.model = v1def.model;
17371
- agent.permission = v1def.permission;
17801
+ agentDraft.model = v1def.model;
17802
+ agentDraft.permission = v1def.permission;
17372
17803
  });
17373
17804
  }
17374
17805
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.11.2",
3
+ "version": "0.13.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",