@sorenllm/opencode-forge 0.14.1 → 0.15.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 +70 -20
  2. package/dist/index.js +152 -50
  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
@@ -498,7 +500,7 @@ out of a materialized id.
498
500
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
499
501
  "research": {
500
502
  "model": "zai-coding-plan/glm-5.3", // exact "provider/model" string
501
- "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
502
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)",
503
505
  // "shape": "write", // default readonly denies write/edit/bash
504
506
  // "permission": { "bash": "deny" } // optional override; "task" is ALWAYS denied
@@ -572,8 +574,10 @@ Say "dispatch a subagent..." in a forge session and the model picks from the
572
574
  task tool vocabulary: a configured `forge-*` agent whose description matches
573
575
  runs on its pinned brain; if none matches, forge is instructed to say so
574
576
  plainly and fall back to the native task channel. During a plan draft the
575
- native `task` tool is already denied by the plan harness — no plugin-side
576
- 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.
577
581
 
578
582
  **Known pitfall — keyless endpoints × readonly agents**: free/keyless
579
583
  providers can return a deterministic EMPTY response for any toolset-reduced
@@ -583,18 +587,42 @@ attributable task failure; the mitigation is unchanged — use
583
587
 
584
588
  ### The thoughtLevel word
585
589
 
586
- One word per agent: the canonical `none | low | medium | high | max`, or the
587
- model's native level name verbatim (e.g. Qwen's `XHigh`). Injection rides the
588
- `chat.params` hook keyed by agent name, translated per provider family:
589
- effort-style (OpenAI-compatible) receives `reasoningEffort = <word>`;
590
- budget-style (Anthropic-style) receives a published thinking tier
591
- (`low → 8192`, `medium → 16384`, `high → 24576`, `max → 32768`,
592
- `none → off`); toggle-style (zai/GLM) receives thinking on/off. Rules:
593
- verbatim-first, no interpolation, unknown family injects nothing. A word the
594
- model does not natively offer injects NOTHING and records one finding naming
595
- both vocabularies — the session is never broken by a config word. A
596
- session's depth is frozen at its first request (editing forge.json
597
- 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).
598
626
 
599
627
  ## Crew workflow (`/crew`)
600
628
 
@@ -762,6 +790,28 @@ read/grep/glob; if you genuinely need a shell command to decide the plan,
762
790
  approve the plan first (revising after approval is allowed via a new `/plan`).
763
791
  The escape hatches are `plan_approve` and `/plan discard`, by design.
764
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
+
765
815
  Separately, the builtin shell is hidden on the forge agent on purpose —
766
816
  `forge_shell` is the exec surface there (see Agent partition and the job
767
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
  }
@@ -15855,7 +15904,7 @@ var PLAN_COMMAND_TEMPLATE = [
15855
15904
  "",
15856
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.",
15857
15906
  "",
15858
- "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.",
15859
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.",
15860
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.`,
15861
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).",
@@ -16032,6 +16081,34 @@ function dispatchProviderFamily(providerID) {
16032
16081
  return "zai";
16033
16082
  return null;
16034
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
+ }
16035
16112
  var jobsMode = "auto";
16036
16113
  var jobsKeepBuiltinShell = false;
16037
16114
  var jobsSurviveMode = "never";
@@ -17587,7 +17664,25 @@ var server = async (input, options) => {
17587
17664
  const state = stateForBan(input2.sessionID);
17588
17665
  const active = state ? resolveActivePlan(state) : null;
17589
17666
  if (active && active.doc.status === "draft") {
17590
- 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
+ }
17591
17686
  }
17592
17687
  }
17593
17688
  if (input2.sessionID && typeof input2.tool === "string") {
@@ -17637,12 +17732,19 @@ var server = async (input, options) => {
17637
17732
  const def = resolvePools()?.agents[agentId]?.def;
17638
17733
  if (!def?.thoughtLevel)
17639
17734
  return;
17735
+ const modelObj = raw.model;
17640
17736
  const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
17641
- const modelID = typeof raw.model?.modelID === "string" ? raw.model.modelID : "";
17642
- const family = dispatchProviderFamily(providerID);
17643
- if (!family)
17644
- return;
17645
- const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, family, nativeLadder(modelsDevCatalog, `${providerID}/${modelID}`));
17737
+ const modelID = typeof modelObj?.modelID === "string" ? modelObj.modelID : typeof modelObj?.id === "string" ? modelObj.id : "";
17738
+ const channel = resolveDepthChannel(providerID, modelID);
17739
+ const { translation, findingMessage } = resolvePinnedDepth(def.thoughtLevel, channel);
17740
+ if (process.env.FORGE_DEPTH_PROBE) {
17741
+ try {
17742
+ appendFileSync(join6(tmpdir3(), "forge-depth-probe.log"), `${new Date().toISOString()} sid=${sessionID} agent=${agentName} prov=${providerID} model=${modelID} depth=${def.thoughtLevel} channel=${JSON.stringify(channel)} translation=${JSON.stringify(translation) ?? "null"} finding=${findingMessage ?? "-"}
17743
+ ` + ` input=${JSON.stringify(raw).slice(0, 600)}
17744
+ ` + ` optionsAfter=${JSON.stringify(output.options ?? {})}
17745
+ `);
17746
+ } catch {}
17747
+ }
17646
17748
  if (!translation) {
17647
17749
  if (findingMessage && !depthFindingNotes.has(agentId)) {
17648
17750
  depthFindingNotes.set(agentId, findingMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.14.1",
3
+ "version": "0.15.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",