@sorenllm/opencode-forge 0.12.0 → 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 +68 -28
  2. package/dist/index.js +355 -83
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -172,7 +172,7 @@ What this plugin touches, exhaustively:
172
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 |
173
173
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
174
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 |
175
- | 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 |
176
176
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
177
177
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
178
178
 
@@ -418,26 +418,60 @@ discovered by walking up:
418
418
 
419
419
  The two layers **merge**: agents combine, and on an id collision the project
420
420
  definition wins wholesale (one definition per id — no cross-layer field
421
- blending). A broken file empties only its own layer (error finding with the
422
- parse location); the other layer still applies. Both layers hot-apply —
423
- edits, and files appearing or disappearing on the ancestor chain, take
424
- effect without a host restart (new agents still need the restart to enter
425
- 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 |
426
455
 
427
- One host instance materializes one pool (from its launch anchor) — a
428
- workspace-local file that the host was not launched on is NOT silently
429
- ignored: `/crew` names it and tells you to relaunch there (or fold the
430
- 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).
431
460
 
432
461
  **Id naming**: use plain role words (`research`, `coder`, …) — the plugin
433
- materializes every id as `forge-<id>` itself. A `forge-`-prefixed id in the
434
- file would double up (`forge-coder` → agent `forge-forge-coder`); the loader
435
- auto-strips the prefix (all repetitions) with a warning so existing files
436
- 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.
437
468
 
438
469
  ```jsonc
439
470
  {
440
- // 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",
441
475
  "agents": {
442
476
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
443
477
  "research": {
@@ -497,12 +531,14 @@ A rejected entry costs only itself — its siblings materialize normally.
497
531
  > error finding naming the missing half. Fix per entry: add the missing
498
532
  > `thoughtLevel`, or delete `model` to make it an Auto worker.
499
533
 
500
- **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:
501
536
 
502
537
  | change | needs a host restart? |
503
538
  | --- | --- |
504
539
  | `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) |
505
- | 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 |
506
542
 
507
543
  ### Dispatching
508
544
 
@@ -545,10 +581,11 @@ requires a plan. The flow:
545
581
  1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
546
582
  titled subtask each, optionally naming the intended `forge-*` agent.
547
583
  Registration enters a **PENDING** crew: execution has not started. The
548
- registration output confirms the roster **with its origin** (which
549
- forge.json layer the dispatchable set materialized from), points at the
550
- crew record file, presents the three execution-mode choices, and stops the
551
- turn. Reconnaissance task calls are free BEFORE registration.
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.
552
589
  2. **The pause is mechanical**: while the crew pends, every `task` dispatch is
553
590
  refused by an interception belt. The user chooses:
554
591
  - **supervised waves now** — `crew_begin {execution: "waves"}` lifts the
@@ -601,13 +638,16 @@ itself (a rootish guard keeps degenerate global-project worktrees from
601
638
  landing files on the drive root or the launch anchor). The anchor follows the
602
639
  session's workspace, never the objective's scope.
603
640
 
604
- **Not initialized?** `/crew` on a host with zero usable forge agents refuses
605
- with one-time setup guidance: the two file paths, a copy-paste template, and
606
- the choice of configuring it yourself OR having the session AI do it — the
607
- latter only on your explicit go-ahead in that conversation, through the
608
- normal visible write path. This is the plugin's only configuration
609
- invitation. After saving the file, restart opencode to materialize the
610
- agents.
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.
611
651
 
612
652
  ### Debris note
613
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 || "-";
@@ -15874,7 +16123,7 @@ function readPlanDir(worktree) {
15874
16123
  const dir = planDirOf(worktree);
15875
16124
  if (!existsSync3(dir))
15876
16125
  return [];
15877
- 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") }));
15878
16127
  }
15879
16128
  function ensureSession(sessionID, worktree) {
15880
16129
  const existing = sessions.get(sessionID);
@@ -15941,7 +16190,7 @@ var planWriteTool = tool({
15941
16190
  } else {
15942
16191
  const dir = planDirOf(state.worktree);
15943
16192
  mkdirSync5(dir, { recursive: true });
15944
- 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"))));
15945
16194
  mode = "created";
15946
16195
  }
15947
16196
  const text = renderPlan(args, now, created);
@@ -16065,7 +16314,7 @@ function readGoalDir(worktree) {
16065
16314
  const dir = goalDirOf(worktree);
16066
16315
  if (!existsSync3(dir))
16067
16316
  return [];
16068
- 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") }));
16069
16318
  }
16070
16319
  function resolveSessionGoal(state) {
16071
16320
  if (state.goalPath && existsSync3(state.goalPath)) {
@@ -16174,7 +16423,7 @@ var goalWriteTool = tool({
16174
16423
  }
16175
16424
  const dir = goalDirOf(state.worktree);
16176
16425
  mkdirSync5(dir, { recursive: true });
16177
- 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")));
16178
16427
  const path = join6(dir, name);
16179
16428
  const text = renderGoal(input, {
16180
16429
  now,
@@ -16546,26 +16795,34 @@ var CREW_INIT_TEMPLATE = [
16546
16795
  "",
16547
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.",
16548
16797
  "",
16549
- "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:",
16550
16799
  "",
16551
- "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>):",
16552
16801
  "{paths}",
16553
16802
  "",
16554
- "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.",
16804
+ "",
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.',
16555
16806
  "",
16556
- "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).",
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.",
16557
16808
  "{template}",
16558
16809
  "",
16559
- "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."
16560
16811
  ].join(`
16561
16812
  `);
16562
- 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
+ });
16563
16819
  return [
16564
16820
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
16565
16821
  "",
16566
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.",
16567
16823
  "",
16568
- `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)"],
16569
16826
  "",
16570
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:",
16571
16828
  "",
@@ -16596,23 +16853,27 @@ var crewBeginTool = tool({
16596
16853
  if (active && active.doc.status === "draft") {
16597
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.");
16598
16855
  }
16599
- const hostLoaded = forgeLoader?.load();
16600
- const hostIds = hostLoaded ? Object.keys(hostLoaded.agents) : [];
16856
+ const resolution = resolvePools();
16857
+ const hostIds = resolution ? Object.keys(resolution.agents) : [];
16601
16858
  const anchor = sessionAnchor(context);
16602
- const hostAnchorDir = forgeLoaderDir ?? "";
16603
- const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
16859
+ const sessionLoaded = anchor ? loaderForAnchor(anchor).load() : null;
16604
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);
16605
16863
  if (hostIds.length === 0) {
16606
- const paths2 = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
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() });
16607
16867
  const lines2 = [
16608
- "[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
16609
- `Two layers, MERGED (the project layer overrides the global one per agent id): ${paths2.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths2.global}.`,
16610
- 'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}',
16611
- "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).",
16612
- "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."
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)."
16613
16874
  ];
16614
- if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
16615
- lines2.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.`);
16616
16877
  }
16617
16878
  throw new Error(lines2.join(`
16618
16879
  `));
@@ -16708,20 +16969,27 @@ var crewBeginTool = tool({
16708
16969
  crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
16709
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}` : ""}`));
16710
16971
  let disclosure = "";
16711
- if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
16712
- const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
16972
+ if (sessionProject && !sessionCovered) {
16973
+ const localOnly = Object.keys(sessionLoaded?.agents ?? {});
16713
16974
  if (localOnly.length > 0) {
16714
16975
  disclosure = `
16715
- [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.`;
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.`;
16716
16977
  }
16717
16978
  }
16718
- const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16719
- const originDesc = hostLoaded?.projectPath ? `the project layer ${hostLoaded.projectPath}${hostLoaded.source === "project+global" ? ` merged with the global layer (${paths.global})` : ""}` : `the global layer (${paths.global})`;
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`);
16720
16987
  const lines = [
16721
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}`,
16722
16989
  `Declared plan (${subtasks.length}):`,
16723
16990
  ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16724
- `Dispatchable roster origin: ${originDesc} — materialized by this host instance${hostAnchorDir ? ` (launched from ${hostAnchorDir})` : ""}.`,
16991
+ `Dispatchable roster origin — ${families.length} pool${families.length === 1 ? "" : "s"} across the host anchor set${hostAnchors.length > 0 ? ` (anchors: ${hostAnchors.join(", ")})` : ""}:`,
16992
+ ...familyLines,
16725
16993
  ...recordNote ? [recordNote] : [],
16726
16994
  ...disclosure ? [disclosure] : []
16727
16995
  ];
@@ -17040,7 +17308,9 @@ function scheduleIdleContinuation(client, sessionID) {
17040
17308
  }, IDLE_DEBOUNCE_MS));
17041
17309
  }
17042
17310
  var server = async (input, options) => {
17043
- hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
17311
+ const effectiveInitDir = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
17312
+ hostWorktree = effectiveInitDir;
17313
+ rememberHostAnchor(effectiveInitDir);
17044
17314
  const client = input.client;
17045
17315
  jobClient = input.client;
17046
17316
  const jobsOpts = options?.jobs;
@@ -17151,23 +17421,17 @@ var server = async (input, options) => {
17151
17421
  if (permSection[gateKey] !== "deny")
17152
17422
  permSection[gateKey] = "ask";
17153
17423
  }
17154
- if (!forgeLoader || forgeLoaderDir !== hostWorktree) {
17155
- forgeLoader = createForgeConfigLoader({
17156
- projectDir: hostWorktree,
17157
- homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
17158
- });
17159
- forgeLoaderDir = hostWorktree;
17160
- }
17161
- const loaded = forgeLoader.load();
17162
- for (const finding of loaded.findings) {
17424
+ const resolution = resolvePools();
17425
+ poolResolution = resolution;
17426
+ for (const finding of resolution?.findings ?? []) {
17163
17427
  console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
17164
17428
  }
17165
17429
  materializedSnapshot = {};
17166
- for (const [agentId, def] of Object.entries(loaded.agents)) {
17167
- const id = `forge-${agentId}`;
17430
+ for (const [matId, agent] of Object.entries(resolution?.agents ?? {})) {
17431
+ const id = `forge-${matId}`;
17168
17432
  if (agentSection[id] !== undefined)
17169
17433
  continue;
17170
- const entry = forgeAgentDef(agentId, def);
17434
+ const entry = forgeAgentDef(matId, agent.def);
17171
17435
  agentSection[id] = entry;
17172
17436
  materializedSnapshot[id] = entry;
17173
17437
  }
@@ -17212,12 +17476,13 @@ var server = async (input, options) => {
17212
17476
  cfg.command ??= {};
17213
17477
  const existingCrew = cfg.command["crew"];
17214
17478
  if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
17215
- const agentIds = Object.keys(loaded.agents);
17216
- 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}
17217
- - ${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(`
17218
17483
  `).map((l) => " " + l).join(`
17219
17484
  `));
17220
- 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";
17221
17486
  cfg.command["crew"] = { template, description };
17222
17487
  crewCommandSnapshot = { template, description };
17223
17488
  }
@@ -17320,7 +17585,7 @@ var server = async (input, options) => {
17320
17585
  return;
17321
17586
  }
17322
17587
  const agentId = agentName.slice("forge-".length);
17323
- const def = forgeLoader?.load().agents[agentId];
17588
+ const def = resolvePools()?.agents[agentId]?.def;
17324
17589
  if (!def?.thoughtLevel)
17325
17590
  return;
17326
17591
  const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
@@ -17348,7 +17613,14 @@ var server = async (input, options) => {
17348
17613
  modelsDevCatalog = c;
17349
17614
  },
17350
17615
  crewCommand: () => crewCommandSnapshot,
17351
- materialized: () => materializedSnapshot
17616
+ materialized: () => materializedSnapshot,
17617
+ resetAnchors: () => {
17618
+ hostAnchors.length = 0;
17619
+ poolResolver = null;
17620
+ poolResolution = null;
17621
+ },
17622
+ anchors: () => [...hostAnchors],
17623
+ resolveNow: () => resolvePools()
17352
17624
  },
17353
17625
  "permission.ask": async (input2, output) => {
17354
17626
  const meta = input2.metadata ?? {};
@@ -17514,20 +17786,20 @@ async function v2Setup(ctx) {
17514
17786
  });
17515
17787
  }
17516
17788
  try {
17517
- const loader = createForgeConfigLoader({ projectDir: process.cwd(), homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
17518
- const loaded = loader.load();
17519
- for (const [agentId, def] of Object.entries(loaded.agents)) {
17520
- 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}`;
17521
17793
  if (draft.get(id) !== undefined)
17522
17794
  continue;
17523
- const v1def = forgeAgentDef(agentId, def);
17524
- draft.update(id, (agent) => {
17525
- agent.description = v1def.description;
17526
- agent.system = v1def.prompt;
17527
- 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;
17528
17800
  if (v1def.model !== undefined)
17529
- agent.model = v1def.model;
17530
- agent.permission = v1def.permission;
17801
+ agentDraft.model = v1def.model;
17802
+ agentDraft.permission = v1def.permission;
17531
17803
  });
17532
17804
  }
17533
17805
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.12.0",
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",