@sorenllm/opencode-forge 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/dist/index.js +108 -19
- 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
|
-
|
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
{
|
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
|
|
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})` : ""} —
|
|
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
|
-
|
|
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
|
|
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
|
|
15837
|
+
return sessionAnchor(context) || context.worktree;
|
|
15767
15838
|
}
|
|
15768
15839
|
function resolveActivePlan(state) {
|
|
15769
15840
|
if (state.planPath && existsSync3(state.planPath)) {
|
|
@@ -16417,7 +16488,7 @@ 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
|
|
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
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.",
|
|
@@ -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
|
|
16467
|
-
|
|
16468
|
-
|
|
16469
|
-
|
|
16470
|
-
|
|
16471
|
-
|
|
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}.`,
|
|
16472
16548
|
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low"}}}',
|
|
16473
16549
|
"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
|
-
]
|
|
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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|