@sorenllm/opencode-forge 0.10.0 → 0.11.1

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 +24 -7
  2. package/dist/index.js +113 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -171,7 +171,7 @@ What this plugin touches, exhaustively:
171
171
  | `<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
172
  | `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
173
173
  | `<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
- | `<project>/.opencode/forge.json`, `~/.config/opencode/forge.json` | your static subagent definitions (JSONC). **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 |
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
175
  | `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
176
176
  | `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
177
177
 
@@ -405,11 +405,28 @@ subagent behavior is untouched. Onboarding is this README, for you the human.
405
405
 
406
406
  ### forge.json — the configuration
407
407
 
408
- A dedicated `forge.json` (JSONC — comments allowed), resolved by a two-level
409
- cascade with a single winning source (never merged):
410
-
411
- 1. `<project>/.opencode/forge.json` — project-level, versionable, team-shared
412
- 2. `~/.config/opencode/forge.json` — global
408
+ A dedicated `forge.json` (JSONC — comments allowed), resolved exactly like
409
+ opencode's own config language — **merged together, not replaced**, project
410
+ discovered by walking up:
411
+
412
+ 1. the project layer: the **nearest** `.opencode/forge.json` walking up from
413
+ the workspace (session worktree → session directory → host launch
414
+ directory as the anchor chain; first file found on the ancestor chain
415
+ wins, nothing above it is consulted)
416
+ 2. the global layer: `~/.config/opencode/forge.json`
417
+
418
+ The two layers **merge**: agents combine, and on an id collision the project
419
+ 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).
425
+
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).
413
430
 
414
431
  ```jsonc
415
432
  {
@@ -419,7 +436,7 @@ cascade with a single winning source (never merged):
419
436
  "research": {
420
437
  "model": "zai-coding-plan/glm-5.3", // exact "provider/model" string
421
438
  "thoughtLevel": "low", // none/low/medium/high/max or a native level name
422
- // "prompt": "optional role prompt; built-in research/review defaults exist",
439
+ // "prompt": "short role description — explicit prompts fully override the built-ins (only research/review ids have them; any other id without a prompt gets a generic one-liner)",
423
440
  // "shape": "write", // default readonly denies write/edit/bash
424
441
  // "permission": { "bash": "deny" } // optional override; "task" is ALWAYS denied
425
442
  },
package/dist/index.js CHANGED
@@ -15429,6 +15429,24 @@ function forgeConfigPaths(opts) {
15429
15429
  const global = opts.homeDir ? `${opts.homeDir}${sep2}.config${sep2}opencode${sep2}forge.json` : "";
15430
15430
  return { project, global };
15431
15431
  }
15432
+ function isFsRoot(p) {
15433
+ return p === "" || p === "/" || p === "\\" || /^[A-Za-z]:[\\/]?$/.test(p);
15434
+ }
15435
+ function parentDirOf(p) {
15436
+ let s = p;
15437
+ while (s.endsWith("/") || s.endsWith("\\")) {
15438
+ if (isFsRoot(s))
15439
+ return null;
15440
+ s = s.slice(0, -1);
15441
+ if (isFsRoot(s))
15442
+ return null;
15443
+ }
15444
+ const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
15445
+ if (i < 0)
15446
+ return null;
15447
+ const parent = s.slice(0, i + 1);
15448
+ return parent === "" ? null : parent;
15449
+ }
15432
15450
  function createForgeConfigLoader(deps) {
15433
15451
  const stat = deps.stat ?? ((path) => {
15434
15452
  try {
@@ -15439,7 +15457,7 @@ function createForgeConfigLoader(deps) {
15439
15457
  }
15440
15458
  });
15441
15459
  const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
15442
- const paths = forgeConfigPaths({ projectDir: deps.projectDir, homeDir: deps.homeDir });
15460
+ const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
15443
15461
  const cache = new Map;
15444
15462
  const readCandidate = (path, source) => {
15445
15463
  const st = stat(path);
@@ -15458,11 +15476,12 @@ function createForgeConfigLoader(deps) {
15458
15476
  agents: {},
15459
15477
  source,
15460
15478
  path,
15479
+ projectPath: source === "project" ? path : null,
15461
15480
  findings: [
15462
15481
  {
15463
15482
  level: "error",
15464
15483
  code: "config-parse-error",
15465
- message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — the agent set is empty until the file parses`
15484
+ message: `${path}: ${parsed.error.message}${parsed.error.line !== undefined ? ` (line ${parsed.error.line}, column ${parsed.error.column})` : ""} — this layer contributes no agents until the file parses`
15466
15485
  }
15467
15486
  ]
15468
15487
  };
@@ -15473,25 +15492,55 @@ function createForgeConfigLoader(deps) {
15473
15492
  agents: {},
15474
15493
  source,
15475
15494
  path,
15476
- findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — the agent set is empty` }]
15495
+ projectPath: source === "project" ? path : null,
15496
+ findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this layer contributes no agents` }]
15477
15497
  };
15478
15498
  } else {
15479
15499
  const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15480
- result = { agents: validated.agents, source, path, findings: validated.findings };
15500
+ result = { agents: validated.agents, source, path, projectPath: source === "project" ? path : null, findings: validated.findings };
15481
15501
  }
15482
15502
  }
15483
15503
  cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
15484
15504
  return result;
15485
15505
  };
15506
+ const candidateAt = (dir) => {
15507
+ const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
15508
+ const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
15509
+ return `${base}.opencode${sep2}forge.json`;
15510
+ };
15511
+ const discoverProjectFile = (anchor) => {
15512
+ let dir = (anchor ?? "").trim();
15513
+ if (!dir)
15514
+ return null;
15515
+ for (;; ) {
15516
+ const candidate = candidateAt(dir);
15517
+ if (stat(candidate) !== null)
15518
+ return candidate;
15519
+ const parent = parentDirOf(dir);
15520
+ if (parent === null || parent === dir)
15521
+ return null;
15522
+ dir = parent;
15523
+ }
15524
+ };
15486
15525
  return {
15487
15526
  load() {
15488
- const project = paths.project ? readCandidate(paths.project, "project") : null;
15527
+ const projectPath = discoverProjectFile(deps.projectDir ?? "");
15528
+ const project = projectPath ? readCandidate(projectPath, "project") : null;
15529
+ const global = globalPath ? readCandidate(globalPath, "global") : null;
15530
+ if (project && global) {
15531
+ return {
15532
+ agents: { ...global.agents, ...project.agents },
15533
+ source: "project+global",
15534
+ path: project.path,
15535
+ projectPath: project.path,
15536
+ findings: [...global.findings, ...project.findings]
15537
+ };
15538
+ }
15489
15539
  if (project)
15490
- return project;
15491
- const global = paths.global ? readCandidate(paths.global, "global") : null;
15540
+ return { agents: project.agents, source: "project", path: project.path, projectPath: project.path, findings: project.findings };
15492
15541
  if (global)
15493
- return global;
15494
- return { agents: {}, source: "none", path: null, findings: [] };
15542
+ return { agents: global.agents, source: "global", path: global.path, projectPath: null, findings: global.findings };
15543
+ return { agents: {}, source: "none", path: null, projectPath: null, findings: [] };
15495
15544
  }
15496
15545
  };
15497
15546
  }
@@ -15609,6 +15658,22 @@ function noteToolAgent(context) {
15609
15658
  var forgeDisabled = false;
15610
15659
  var forgeLoader = null;
15611
15660
  var forgeLoaderDir = null;
15661
+ var sessionAnchorLoaders = new Map;
15662
+ function loaderForAnchor(anchor) {
15663
+ const key = anchor || "-";
15664
+ let l = sessionAnchorLoaders.get(key);
15665
+ if (!l) {
15666
+ l = createForgeConfigLoader({ projectDir: anchor, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
15667
+ sessionAnchorLoaders.set(key, l);
15668
+ while (sessionAnchorLoaders.size > 8) {
15669
+ const oldest = sessionAnchorLoaders.keys().next().value;
15670
+ if (oldest === undefined)
15671
+ break;
15672
+ sessionAnchorLoaders.delete(oldest);
15673
+ }
15674
+ }
15675
+ return l;
15676
+ }
15612
15677
  var crews = new Map;
15613
15678
  var modelsDevCatalog = null;
15614
15679
  var depthFindingNotes = new Map;
@@ -15761,9 +15826,15 @@ function ensureSession(sessionID, worktree) {
15761
15826
  sessions.set(sessionID, state);
15762
15827
  return state;
15763
15828
  }
15829
+ function sessionAnchor(context) {
15830
+ const viaDir = effectiveWorktree(context.worktree, context.directory);
15831
+ if (viaDir && !isRootish(viaDir))
15832
+ return viaDir;
15833
+ return hostWorktree || (context.worktree ?? "").trim() || (context.directory ?? "").trim();
15834
+ }
15764
15835
  function worktreeFor(context) {
15765
15836
  noteToolAgent(context);
15766
- return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
15837
+ return sessionAnchor(context) || context.worktree;
15767
15838
  }
15768
15839
  function resolveActivePlan(state) {
15769
15840
  if (state.planPath && existsSync3(state.planPath)) {
@@ -16417,12 +16488,12 @@ var CREW_INIT_TEMPLATE = [
16417
16488
  "",
16418
16489
  "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:",
16419
16490
  "",
16420
- "1. The user configures it themselves: create ONE of these files (project-level wins, never merged):",
16491
+ "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):",
16421
16492
  "{paths}",
16422
16493
  "",
16423
- "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.",
16494
+ "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: 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.",
16424
16495
  "",
16425
- "Template (JSONC — comments allowed): `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.",
16496
+ "Template (JSONC — comments allowed): `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).",
16426
16497
  "{template}",
16427
16498
  "",
16428
16499
  "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."
@@ -16463,15 +16534,24 @@ var crewBeginTool = tool({
16463
16534
  if (active && active.doc.status === "draft") {
16464
16535
  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.");
16465
16536
  }
16466
- const loaded = forgeLoader?.load();
16467
- if (!loaded || Object.keys(loaded.agents).length === 0) {
16468
- const paths = forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16469
- throw new Error([
16470
- "[forge:crew] CREW IS NOT INITIALIZED: no forge subagents are configured. Crew orchestration is unavailable until the user sets it up.",
16471
- `Create ONE of these files (project-level wins): ${paths.project} OR ${paths.global}`,
16472
- 'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
16473
- "The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. Newly added agents need a host restart; thoughtLevel edits apply without one."
16474
- ].join(`
16537
+ const hostLoaded = forgeLoader?.load();
16538
+ const hostIds = hostLoaded ? Object.keys(hostLoaded.agents) : [];
16539
+ const anchor = sessionAnchor(context);
16540
+ const hostAnchorDir = forgeLoaderDir ?? "";
16541
+ const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
16542
+ const sessionProject = sessionLoaded?.projectPath ?? null;
16543
+ if (hostIds.length === 0) {
16544
+ const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16545
+ const lines = [
16546
+ "[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
16547
+ `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}.`,
16548
+ 'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}',
16549
+ "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."
16550
+ ];
16551
+ if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
16552
+ 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.`);
16553
+ }
16554
+ throw new Error(lines.join(`
16475
16555
  `));
16476
16556
  }
16477
16557
  if (!args.objective?.trim())
@@ -16497,13 +16577,22 @@ var crewBeginTool = tool({
16497
16577
  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.`);
16498
16578
  }
16499
16579
  crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
16580
+ let disclosure = "";
16581
+ if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
16582
+ const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
16583
+ if (localOnly.length > 0) {
16584
+ disclosure = `
16585
+ [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.`;
16586
+ }
16587
+ }
16500
16588
  return {
16501
16589
  title: `crew registered: ${args.objective.trim().slice(0, 60)}`,
16502
16590
  output: [
16503
16591
  `[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
16504
16592
  `Declared plan (${subtasks.length}):`,
16505
16593
  ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16506
- "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."
16594
+ "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.",
16595
+ ...disclosure ? [disclosure] : []
16507
16596
  ].join(`
16508
16597
  `)
16509
16598
  };
@@ -16961,7 +17050,7 @@ var server = async (input, options) => {
16961
17050
  if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
16962
17051
  const agentIds = Object.keys(loaded.agents);
16963
17052
  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}
16964
- - ${forgeConfigPaths({ projectDir: forgeLoaderDir ?? undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ agents: { research: { model: "provider/model", thoughtLevel: "low" } } }, null, 2).split(`
17053
+ - ${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(`
16965
17054
  `).map((l) => " " + l).join(`
16966
17055
  `));
16967
17056
  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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
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",