@sorenllm/opencode-forge 0.14.0 → 0.15.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 +81 -21
  2. package/dist/index.js +146 -49
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,11 +43,13 @@ OpenSpec spec workflows remain a third, separate lane.
43
43
  counts read the Task List only — and a superseded plan's Task List
44
44
  stays the frozen decision snapshot: closure IS the supersession, and
45
45
  crew/goal results never tick it.
46
- - While a plan is in draft, `write` / `edit` / `bash` / `task` are **denied
47
- at the permission layer** — including your own `allow` config, and for
48
- every agent in that session (the draft protects shared session state; see
49
- Agent partition below). The only exits are approval and discard. This is
50
- deliberate; see Design stance.
46
+ - While a plan is in draft, `write` / `edit` / `bash` are **denied at the
47
+ permission layer** — including your own `allow` config, and for every agent
48
+ in that session (the draft protects shared session state; see Agent
49
+ partition below) — and `task` is **shape-gated**: dispatch to an
50
+ effective-readonly forge scout passes, every other target is denied (see
51
+ Design stance). The only exits are approval and discard. This is deliberate;
52
+ see Design stance.
51
53
  - `plan_approve` / `plan_close` are pinned to a confirmation dialog: the
52
54
  model can never flip the state itself.
53
55
  - Goal files: `.opencode/goal/<date>-<slug>.md`, state machine
@@ -80,7 +82,17 @@ entirely (the hide is withdrawn in the same stroke). The forge family is
80
82
  prompted to route every shell command through `forge_shell` — on hosts that
81
83
  ignore injected tools maps the builtin tool stays visible but is refused at
82
84
  call time, and the channel mandate is what keeps the model from trying it
83
- first (the belt refusal remains the backstop). Mixed sessions (Tab
85
+ first (the belt refusal remains the backstop). On such unfiltered hosts the
86
+ fallback belt family is a three-part enumeration of hard refusals at the
87
+ tool layer: a forge-family builtin `shell`/`bash` call is refused with a
88
+ pointer to `forge_shell`; a non-forge agent's call of any forge tool is
89
+ refused with the partition guidance; and a `forge-*` worker (any family
90
+ member whose agent name is not exactly `forge`) calling a harness state tool
91
+ (`plan_*`, `goal_*`, `crew_begin`, `crew_close`) is refused as primary-only.
92
+ That refusal fires before any execution or user confirmation dialog, so a
93
+ worker's state-tool call never reaches a gate, and the error names the way
94
+ out: report findings back to the orchestrating session — the primary forge
95
+ agent alone calls the state tools. Mixed sessions (Tab
84
96
  between agents in one session): harness state (plans/goals) stays
85
97
  session-bound, tool surfaces follow the current speaker, and the goal loop
86
98
  parks instead of driving a non-forge turn. One deliberate exception: a
@@ -488,7 +500,7 @@ out of a materialized id.
488
500
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
489
501
  "research": {
490
502
  "model": "zai-coding-plan/glm-5.3", // exact "provider/model" string
491
- "thoughtLevel": "low", // none/low/medium/high/max or a native level name
503
+ "thoughtLevel": "low", // none/minimal/low/medium/high/xhigh/max, or any word — write lowercase
492
504
  // "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)",
493
505
  // "shape": "write", // default readonly denies write/edit/bash
494
506
  // "permission": { "bash": "deny" } // optional override; "task" is ALWAYS denied
@@ -562,8 +574,10 @@ Say "dispatch a subagent..." in a forge session and the model picks from the
562
574
  task tool vocabulary: a configured `forge-*` agent whose description matches
563
575
  runs on its pinned brain; if none matches, forge is instructed to say so
564
576
  plainly and fall back to the native task channel. During a plan draft the
565
- native `task` tool is already denied by the plan harness — no plugin-side
566
- rule needed.
577
+ `task` tool is gated by the dispatch target's effective readonly shape:
578
+ readonly scout dispatch passes as reconnaissance (breadth evidence without
579
+ burning the planning session's context), write-shaped workers and native
580
+ agents are denied with the dispatchable roster and the two exits.
567
581
 
568
582
  **Known pitfall — keyless endpoints × readonly agents**: free/keyless
569
583
  providers can return a deterministic EMPTY response for any toolset-reduced
@@ -573,18 +587,42 @@ attributable task failure; the mitigation is unchanged — use
573
587
 
574
588
  ### The thoughtLevel word
575
589
 
576
- One word per agent: the canonical `none | low | medium | high | max`, or the
577
- model's native level name verbatim (e.g. Qwen's `XHigh`). Injection rides the
578
- `chat.params` hook keyed by agent name, translated per provider family:
579
- effort-style (OpenAI-compatible) receives `reasoningEffort = <word>`;
580
- budget-style (Anthropic-style) receives a published thinking tier
581
- (`low → 8192`, `medium → 16384`, `high → 24576`, `max → 32768`,
582
- `none → off`); toggle-style (zai/GLM) receives thinking on/off. Rules:
583
- verbatim-first, no interpolation, unknown family injects nothing. A word the
584
- model does not natively offer injects NOTHING and records one finding naming
585
- both vocabularies — the session is never broken by a config word. A
586
- session's depth is frozen at its first request (editing forge.json
587
- mid-session never rewrites a running session).
590
+ One word per agent: the canonical seven-word dictionary
591
+ `none | minimal | low | medium | high | xhigh | max`, or any native level
592
+ name. **Write lowercase**: the value is normalized at the config boundary
593
+ (trimmed and lowercased), so `High` and `XHigh` behave exactly as `high` and
594
+ `xhigh`; normalization that changes what you wrote records one warn finding.
595
+ Native casing on the wire is the catalog's business, never yours. Injection
596
+ rides the `chat.params` hook keyed by agent name. The wire shape comes from
597
+ data, not guessing: a model the models.dev catalog knows by
598
+ `reasoning_options` is translated per that catalog shape (effort word list /
599
+ toggle / token budget); a catalog-silent model falls back to the
600
+ provider-prefix family; a provider absent from both is treated as an effort
601
+ **word channel** by default (custom relays and unknown endpoints — the most
602
+ universal shape). Adaptive-era Anthropic models (major > 4 or 4.7+) receive
603
+ the effort word directly instead of a budget number, on the empirically
604
+ verified `effort` option key (the host transports it as the adaptive-era
605
+ native `output_config: {effort: <word>}` wire shape; a `reasoningEffort`
606
+ key is silently dropped by anthropic transports). **The dictionary is a
607
+ dictionary, never a whitelist**: a word matching neither the dictionary nor
608
+ the model's ladder passes through VERBATIM on word-shaped channels
609
+ (`reasoningEffort = <word>`, or `effort = <word>` on the adaptive channel),
610
+ disclosed as unverified — the provider is the
611
+ final judge, and a provider-side rejection surfaces as the request's own
612
+ error. Ladder matching is case-insensitive and emits the catalog's native
613
+ casing (`xhigh` in, `XHigh` out when the catalog says so). **The only dead
614
+ ends are the two shapes with no word channel**: budget-style (legacy
615
+ Anthropic-style) models have a published thinking tier instead
616
+ (`minimal → 1024` — the Anthropic API hard floor, the only cross-project
617
+ consensus number; `low → 8192`, `medium → 16384`, `high → 24576`,
618
+ `xhigh → 28672` — a half step between high and max; `max → 32768`;
619
+ `none → off`. **This tier table is this plugin's selection — there is no
620
+ community-standard tier table**), and pure toggle endpoints (zai/GLM: a level
621
+ word has no toggle counterpart) — there the plugin injects nothing and
622
+ records one bounded finding naming both vocabularies. No interpolation
623
+ anywhere; the session is never broken by a config word. A session's depth is
624
+ frozen at its first request (editing forge.json mid-session never rewrites a
625
+ running session).
588
626
 
589
627
  ## Crew workflow (`/crew`)
590
628
 
@@ -752,6 +790,28 @@ read/grep/glob; if you genuinely need a shell command to decide the plan,
752
790
  approve the plan first (revising after approval is allowed via a new `/plan`).
753
791
  The escape hatches are `plan_approve` and `/plan discard`, by design.
754
792
 
793
+ One carve-out, keyed on the target rather than the tool: during a draft the
794
+ `task` tool is gated by the dispatch target's **effective readonly shape**. A
795
+ dispatch to a forge worker whose shape is not `write` AND whose permission
796
+ overrides do not lift `write`/`edit`/`bash` off `deny` (`ask` counts as
797
+ lifted — fail-closed) passes as a reconnaissance dispatch: readonly scouts
798
+ may be spawned both before and during the draft, so breadth
799
+ evidence-gathering does not burn the planning session's context. Every other
800
+ target — write-shaped workers, native agents, absent or unresolvable targets
801
+ (including resume calls that carry no target) — is denied like any write-class
802
+ tool. The refusal names the currently dispatchable effective-readonly roster,
803
+ points to defining a readonly worker in forge.json when the roster is empty
804
+ (an unconfigured host stays fully denied — same behavior as before, just more
805
+ actionable wording), and names the two exits (`plan_approve`, `/plan
806
+ discard`). Invariant strength is unchanged: the scout's materialized
807
+ permission hard-denies write/edit/bash (and `task`, always — no chained
808
+ spawning), the scout's own session is ambient-bound to the same draft write
809
+ ban, and its `forge_shell` calls ride the same per-call ask gate the primary
810
+ agent faces. The `permission.ask` belt does not follow the exemption: on a
811
+ host configured with `task: "ask"` a draft-phase dispatch is still denied
812
+ (fail-closed degradation by design — the side belt does not resolve target
813
+ shapes).
814
+
755
815
  Separately, the builtin shell is hidden on the forge agent on purpose —
756
816
  `forge_shell` is the exec surface there (see Agent partition and the job
757
817
  supervisor chapter above). The hide is unconditional: it covers a
package/dist/index.js CHANGED
@@ -14877,7 +14877,7 @@ function parseIdentity(identity) {
14877
14877
  return null;
14878
14878
  return { provider: identity.slice(0, slash), model: identity.slice(slash + 1) };
14879
14879
  }
14880
- function nativeLadder(catalog, identity) {
14880
+ function modelDepthShape(catalog, identity) {
14881
14881
  if (!catalog)
14882
14882
  return null;
14883
14883
  const parsed = parseIdentity(identity);
@@ -14886,8 +14886,13 @@ function nativeLadder(catalog, identity) {
14886
14886
  const entry = catalog.providers?.[parsed.provider]?.models?.[parsed.model];
14887
14887
  if (!entry)
14888
14888
  return null;
14889
- const options = entry.reasoningOptions;
14890
- return Array.isArray(options) ? [...options] : null;
14889
+ if (entry.reasoningShape === "toggle")
14890
+ return { shape: "toggle" };
14891
+ if (entry.reasoningShape === "budget_tokens")
14892
+ return { shape: "budget_tokens" };
14893
+ if (entry.reasoningShape === "effort")
14894
+ return { shape: "effort", ladder: entry.reasoningOptions ? [...entry.reasoningOptions] : [] };
14895
+ return null;
14891
14896
  }
14892
14897
  function modelsDevCacheDir(base) {
14893
14898
  return join5(base ?? join5(tmpdir2(), "opencode-forge"), "models-dev");
@@ -14906,6 +14911,16 @@ function namedLevels(rows) {
14906
14911
  }
14907
14912
  return out;
14908
14913
  }
14914
+ function shapeOf(rows) {
14915
+ for (const r of rows) {
14916
+ if (r && typeof r === "object") {
14917
+ const t = r.type;
14918
+ if (t === "effort" || t === "toggle" || t === "budget_tokens")
14919
+ return t;
14920
+ }
14921
+ }
14922
+ return;
14923
+ }
14909
14924
  function reduceModelsDev(raw) {
14910
14925
  const catalog = { providers: {} };
14911
14926
  const providers = raw?.constructor === Object ? raw : null;
@@ -14918,10 +14933,13 @@ function reduceModelsDev(raw) {
14918
14933
  const out = { models: {} };
14919
14934
  for (const [modelID, model] of Object.entries(models)) {
14920
14935
  const m = model;
14921
- const options = Array.isArray(m.reasoning_options) ? namedLevels(m.reasoning_options) : undefined;
14936
+ const rows = Array.isArray(m.reasoning_options) ? m.reasoning_options : undefined;
14937
+ const shape = rows !== undefined ? shapeOf(rows) : undefined;
14938
+ const options = rows !== undefined && shape === "effort" ? namedLevels(rows) : undefined;
14922
14939
  const cost = m.cost && typeof m.cost === "object" ? m.cost : undefined;
14923
14940
  out.models[modelID] = {
14924
- ...options ? { reasoningOptions: options } : {},
14941
+ ...shape !== undefined ? { reasoningShape: shape } : {},
14942
+ ...options && options.length > 0 ? { reasoningOptions: options } : {},
14925
14943
  ...cost ? { cost } : {}
14926
14944
  };
14927
14945
  }
@@ -15063,71 +15081,97 @@ function forgeAgentDef(agentId, def) {
15063
15081
  entry.model = def.model;
15064
15082
  return entry;
15065
15083
  }
15084
+ function effectiveReadonlyShape(def) {
15085
+ if (def.shape === "write")
15086
+ return false;
15087
+ const perm = def.permission ?? {};
15088
+ for (const t of MUTATING_TOOLS) {
15089
+ const v = perm[t];
15090
+ if (v !== undefined && v !== "deny")
15091
+ return false;
15092
+ }
15093
+ return true;
15094
+ }
15066
15095
 
15067
15096
  // src/dispatch-depth.ts
15068
- var META_DEPTHS = ["none", "low", "medium", "high", "max"];
15097
+ var META_DEPTHS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
15069
15098
  var BUDGET_TIERS = {
15099
+ minimal: 1024,
15070
15100
  low: 8192,
15071
15101
  medium: 16384,
15072
15102
  high: 24576,
15103
+ xhigh: 28672,
15073
15104
  max: 32768
15074
15105
  };
15075
- function noMapping(depth, family, nativeVocabulary, metaAvailable) {
15106
+ function noMapping(depth, channel, shapeVocabulary) {
15076
15107
  return {
15077
15108
  kind: "no-mapping",
15078
- family,
15079
- message: `depth "${depth}" has no native counterpart on this model — the plugin never interpolates. Native levels: ${nativeVocabulary.join(", ") || "(none named)"}. Meta words this model accepts: ${metaAvailable.join(", ") || "(none)"}.`,
15080
- nativeVocabulary,
15081
- metaAvailable
15109
+ channel,
15110
+ message: `depth "${depth}" has no channel on this shape (${channel} — the endpoint cannot express it). Shape vocabulary: ${shapeVocabulary.join(", ") || "(none)"}. Dictionary words: ${META_DEPTHS.join(", ")}. The plugin never interpolates; fix the pinned thoughtLevel or the model's wire shape.`,
15111
+ shapeVocabulary,
15112
+ dictionary: [...META_DEPTHS]
15082
15113
  };
15083
15114
  }
15084
- function translateDepth(depth, family, ladder) {
15085
- if (family === "unknown") {
15086
- return { kind: "not-injected", family, disclose: "not injected (unknown provider shape)" };
15087
- }
15088
- if (family === "anthropic") {
15089
- if (depth === "none")
15090
- return { kind: "off", family, disclose: "canonical none → native thinking:off" };
15091
- const budget = BUDGET_TIERS[depth];
15092
- if (budget !== undefined) {
15093
- return { kind: "tiered", family, budgetTokens: budget, disclose: `canonical ${depth} → native thinking budget:${budget}` };
15094
- }
15095
- return noMapping(depth, family, [], [...META_DEPTHS]);
15096
- }
15097
- if (family === "zai") {
15098
- if (depth === "none")
15099
- return { kind: "off", family, disclose: "canonical none → native thinking:off" };
15100
- if (META_DEPTHS.includes(depth)) {
15101
- return { kind: "toggle-on", family, disclose: `canonical ${depth} → native thinking:on (this provider shape takes a toggle, not a level)` };
15115
+ function ladderWord(ladder, depth) {
15116
+ if (ladder === null)
15117
+ return null;
15118
+ for (const w of ladder)
15119
+ if (w.toLowerCase() === depth)
15120
+ return w;
15121
+ return null;
15122
+ }
15123
+ function translateDepth(depth, channel) {
15124
+ const d = depth.trim().toLowerCase();
15125
+ if (channel.kind === "effort" || channel.kind === "adaptive-effort") {
15126
+ const native = channel.kind === "effort" ? ladderWord(channel.ladder, d) : null;
15127
+ if (native !== null) {
15128
+ return {
15129
+ kind: "verbatim",
15130
+ channel: channel.kind,
15131
+ word: native,
15132
+ confirmed: true,
15133
+ disclose: `canonical ${d} → native "${native}" (catalog-confirmed)`
15134
+ };
15102
15135
  }
15103
- return noMapping(depth, family, ["thinking on/off"], [...META_DEPTHS]);
15136
+ const why = channel.kind === "effort" && channel.ladder !== null ? `catalog ladder does not list "${d}"` : "no catalog ladder for this endpoint";
15137
+ return {
15138
+ kind: "verbatim",
15139
+ channel: channel.kind,
15140
+ word: d,
15141
+ confirmed: false,
15142
+ disclose: `verbatim (unverified — ${why}; the provider judges)`
15143
+ };
15104
15144
  }
15105
- const native = ladder ?? null;
15106
- if (native !== null && native.includes(depth)) {
15107
- return { kind: "verbatim", family: "openai", word: depth, disclose: META_DEPTHS.includes(depth) ? "verbatim" : `verbatim (native word "${depth}")` };
15145
+ if (channel.kind === "toggle") {
15146
+ if (d === "none")
15147
+ return { kind: "off", channel: "toggle", disclose: "canonical none → native thinking:off" };
15148
+ return noMapping(d, "toggle", ["none (thinking on/off only)"]);
15108
15149
  }
15109
- if (native !== null && native.length > 0 && META_DEPTHS.includes(depth)) {
15110
- return noMapping(depth, family, native, META_DEPTHS.filter((w) => native.includes(w)));
15150
+ if (d === "none")
15151
+ return { kind: "off", channel: "budget", disclose: "canonical none → native thinking:off" };
15152
+ const budget = BUDGET_TIERS[d];
15153
+ if (budget !== undefined) {
15154
+ return { kind: "tiered", channel: "budget", budgetTokens: budget, disclose: `canonical ${d} → native thinking budget:${budget}` };
15111
15155
  }
15112
- return { kind: "verbatim", family: "openai", word: depth, disclose: `verbatim (unverified — ${native === null ? "catalog does not know this model" : `catalog ladder does not list "${depth}"`}; the provider judges)` };
15156
+ return noMapping(d, "budget", ["none", ...Object.keys(BUDGET_TIERS)]);
15113
15157
  }
15114
15158
  function applyDepthTranslation(options, t) {
15115
15159
  if (t.kind === "verbatim") {
15116
- options.reasoningEffort = t.word;
15160
+ if (t.channel === "adaptive-effort") {
15161
+ options.effort = t.word;
15162
+ } else {
15163
+ options.reasoningEffort = t.word;
15164
+ }
15117
15165
  } else if (t.kind === "tiered") {
15118
15166
  options.thinking = { type: "enabled", budget_tokens: t.budgetTokens };
15119
15167
  } else if (t.kind === "off") {
15120
15168
  options.thinking = { type: "disabled" };
15121
- } else if (t.kind === "toggle-on") {
15122
- options.thinking = { type: "enabled" };
15123
15169
  }
15124
15170
  }
15125
- function resolvePinnedDepth(thoughtLevel, family, ladder) {
15126
- const t = translateDepth(thoughtLevel, family, ladder);
15171
+ function resolvePinnedDepth(thoughtLevel, channel) {
15172
+ const t = translateDepth(thoughtLevel, channel);
15127
15173
  if (t.kind === "no-mapping")
15128
15174
  return { translation: null, findingMessage: t.message };
15129
- if (t.kind === "not-injected")
15130
- return { translation: null };
15131
15175
  return { translation: t };
15132
15176
  }
15133
15177
 
@@ -15420,6 +15464,11 @@ function validateAgentSet(raw) {
15420
15464
  if (entry.thoughtLevel !== undefined) {
15421
15465
  if (nonEmptyString(entry.thoughtLevel)) {
15422
15466
  thoughtLevel = entry.thoughtLevel.trim();
15467
+ const normalized = thoughtLevel.toLowerCase();
15468
+ if (normalized !== thoughtLevel) {
15469
+ findings.push({ level: "warn", code: "thoughtlevel-normalized", message: `${label}.thoughtLevel "${thoughtLevel}" normalized to "${normalized}" (config is lowercase; the catalog decides wire casing)` });
15470
+ }
15471
+ thoughtLevel = normalized;
15423
15472
  } else {
15424
15473
  findings.push({ level: "warn", code: "thoughtlevel-invalid", message: `${label}.thoughtLevel must be a non-empty string — field ignored (counts as absent for the model/thoughtLevel pair)` });
15425
15474
  }
@@ -15824,6 +15873,7 @@ var FORGE_STATE_TOOLS = [
15824
15873
  var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
15825
15874
  var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
15826
15875
  var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
15876
+ var FORGE_STATE_TOOL_SET = new Set(FORGE_STATE_TOOLS);
15827
15877
  var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
15828
15878
  var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
15829
15879
 
@@ -15854,7 +15904,7 @@ var PLAN_COMMAND_TEMPLATE = [
15854
15904
  "",
15855
15905
  "Plans are short-horizon, single-task-goal documents (.opencode/plan/<date>-<slug>.md). The harness enforces the hard parts (draft write-ban, approval/close dialogs); you supply the engineering judgment. Never edit or create plan files by hand — every change goes through the plan_* tools.",
15856
15906
  "",
15857
- "1. Reconnaissance and alignment (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
15907
+ "1. Reconnaissance and alignment (read-only): explore with read/grep/glob, and dispatch readonly-shape forge scouts (task tool) for breadth evidence — reconnaissance dispatch to effective-readonly workers keeps this session's context lean and is allowed both BEFORE and DURING the draft. All write tools and bash are DENIED while a draft exists, and so is dispatching write-shaped workers or native agents; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
15858
15908
  "2. Draft (plan_write): call plan_write with structured fields; the tool renders and validates the fixed sections, so a malformed plan cannot exist. Quality bar: goal = one line, the outcome not the activity; context = verified findings with file:line evidence, including what you ruled out and why; approach = the chosen approach AND at least one rejected alternative with the reason (a plan with no considered alternative is a guess); tasks = 3-8 concrete, independently verifiable steps, each doable in one sitting ('improve the code' is invalid; 'extract the timeout constant into config.ts and default it to 3000' is valid); risks = what could break, blast radius, rollback path; acceptance = criteria verifiable by a command, a file, or an observable behavior (vague criteria will fail the close); nonGoals = explicit out-of-scope items. To revise after feedback, call plan_write again — while in draft it overwrites the same file.",
15859
15909
  `3. User review, then approval (plan_approve): after plan_write, present the plan in chat — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then STOP: end your turn and wait. The user owns the review, at their own pace: feedback → revise with plan_write, re-present, and wait again; rejection → /plan discard (or re-plan together); explicit go-ahead (e.g. "execute", "批准", "looks good") → call plan_approve — its confirmation dialog is the final hard gate, and the user's Allow starts execution. Never call plan_approve in the same turn that presents a draft, and never implement before approval succeeds.`,
15860
15910
  "4. Execution (tick as you go): work tasks in order; call plan_tick with the task number immediately after EACH task's work is actually done — never batch ticks, never tick ahead of reality (tick timestamps are an audit trail). If the plan turns out wrong mid-execution, do not silently improvise: tell the user what changed and either finish the affected task or ask about revising (/plan with the same goal re-enters planning).",
@@ -16031,6 +16081,34 @@ function dispatchProviderFamily(providerID) {
16031
16081
  return "zai";
16032
16082
  return null;
16033
16083
  }
16084
+ function isAdaptiveAnthropic(modelID) {
16085
+ const id = modelID.toLowerCase();
16086
+ const m = /^claude-(?:(?:opus|sonnet|haiku)[-.])?(\d+)(?:[.-](\d+))?/.exec(id);
16087
+ if (!m)
16088
+ return false;
16089
+ const major = Number(m[1]);
16090
+ const minor = m[2] !== undefined ? Number(m[2]) : 0;
16091
+ return major > 4 || major === 4 && minor >= 7;
16092
+ }
16093
+ function resolveDepthChannel(providerID, modelID) {
16094
+ const shape = modelDepthShape(modelsDevCatalog, `${providerID}/${modelID}`);
16095
+ if (shape) {
16096
+ if (shape.shape === "effort")
16097
+ return { kind: "effort", ladder: shape.ladder };
16098
+ if (shape.shape === "toggle")
16099
+ return { kind: "toggle" };
16100
+ return { kind: "budget" };
16101
+ }
16102
+ const family = dispatchProviderFamily(providerID);
16103
+ if (family === "openai")
16104
+ return { kind: "effort", ladder: null };
16105
+ if (family === "zai")
16106
+ return { kind: "toggle" };
16107
+ if (family === "anthropic") {
16108
+ return isAdaptiveAnthropic(modelID) ? { kind: "adaptive-effort" } : { kind: "budget" };
16109
+ }
16110
+ return { kind: "effort", ladder: null };
16111
+ }
16034
16112
  var jobsMode = "auto";
16035
16113
  var jobsKeepBuiltinShell = false;
16036
16114
  var jobsSurviveMode = "never";
@@ -17586,7 +17664,25 @@ var server = async (input, options) => {
17586
17664
  const state = stateForBan(input2.sessionID);
17587
17665
  const active = state ? resolveActivePlan(state) : null;
17588
17666
  if (active && active.doc.status === "draft") {
17589
- throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
17667
+ if (input2.tool === "task") {
17668
+ const a = output?.args ?? {};
17669
+ const target = typeof a.subagent_type === "string" ? a.subagent_type : typeof a.agent === "string" ? a.agent : undefined;
17670
+ const agentId = target !== undefined && target.toLowerCase().startsWith("forge-") ? target.slice("forge-".length) : undefined;
17671
+ const def = agentId !== undefined ? resolvePools()?.agents[agentId]?.def : undefined;
17672
+ if (def !== undefined && effectiveReadonlyShape(def)) {} else {
17673
+ const roster = [];
17674
+ const pools = resolvePools();
17675
+ if (pools) {
17676
+ for (const [id, entry] of Object.entries(pools.agents))
17677
+ if (effectiveReadonlyShape(entry.def))
17678
+ roster.push(`forge-${id}`);
17679
+ }
17680
+ roster.sort();
17681
+ throw new Error(`[forge] A plan is in draft; dispatch is denied for this target. ` + (roster.length > 0 ? `Readonly recon scouts ARE dispatchable during draft: ${roster.join(", ")}. ` : `No readonly worker is configured — define one in forge.json (shape defaults to readonly) to enable draft-phase reconnaissance. `) + `Write-shaped workers and native agents wait for approval (plan_approve); /plan discard to abandon the plan.`);
17682
+ }
17683
+ } else {
17684
+ throw new Error(`[forge] A plan is in draft; write operations are denied (${input2.tool}). Switch back to the forge agent to present it and complete plan approval (plan_approve), or run /plan discard to abandon the plan.`);
17685
+ }
17590
17686
  }
17591
17687
  }
17592
17688
  if (input2.sessionID && typeof input2.tool === "string") {
@@ -17598,6 +17694,9 @@ var server = async (input, options) => {
17598
17694
  if (isForgeFamilyAgent(speaker) && (input2.tool === "shell" || input2.tool === "bash") && !jobsKeepBuiltinShell && jobStage() < 2) {
17599
17695
  throw new Error(`[forge:partition] Builtin shell refused: the forge family executes through forge_shell (jobs.keepBuiltinShell or jobs.mode: "native" restores the builtin shell). Re-issue the command through forge_shell.`);
17600
17696
  }
17697
+ if (isForgeFamilyAgent(speaker) && speaker.toLowerCase() !== FORGE_AGENT && FORGE_STATE_TOOL_SET.has(input2.tool)) {
17698
+ throw new Error(`[forge:partition] Tool refused: "${input2.tool}" is a harness state tool reserved for the primary forge agent; "${speaker}" is a forge worker. Report your findings back to the orchestrating session — the primary agent calls ${input2.tool} at the gate.`);
17699
+ }
17601
17700
  }
17602
17701
  }
17603
17702
  },
@@ -17635,10 +17734,8 @@ var server = async (input, options) => {
17635
17734
  return;
17636
17735
  const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
17637
17736
  const modelID = typeof raw.model?.modelID === "string" ? raw.model.modelID : "";
17638
- const family = dispatchProviderFamily(providerID);
17639
- if (!family)
17640
- return;
17641
- const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, family, nativeLadder(modelsDevCatalog, `${providerID}/${modelID}`));
17737
+ const channel = resolveDepthChannel(providerID, modelID);
17738
+ const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, channel);
17642
17739
  if (!translation) {
17643
17740
  if (findingMessage && !depthFindingNotes.has(agentId)) {
17644
17741
  depthFindingNotes.set(agentId, findingMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",