@sorenllm/opencode-forge 0.19.0 → 0.20.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 +75 -18
  2. package/dist/index.js +228 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -566,6 +566,11 @@ out of a materialized id.
566
566
  // dispatches are blocked until the field is added). Invalid values
567
567
  // degrade to a warning + the directory-derived namespace.
568
568
  // "pool": "aa",
569
+ // OPTIONAL pool-level shared prompt segment — written ONCE at the top of
570
+ // the file, inherited by EVERY agent this file materializes (project
571
+ // conventions, repo layout, standing rules). A plain string, no
572
+ // interpolation. See "Pool-level shared prompt segment" below.
573
+ // "shared": "Project X conventions: use bun; cite file:line evidence.",
569
574
  "agents": {
570
575
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
571
576
  "research": {
@@ -612,6 +617,26 @@ A rejected entry costs only itself — its siblings materialize normally.
612
617
  Findings surface in the plugin diagnostics log.
613
618
  - **`depths` is deprecated**: the array is ignored with a warning finding.
614
619
  Pin ONE `thoughtLevel` per agent instead.
620
+ - **Pool-level `shared` prompt segment**: an optional top-level `"shared"`
621
+ string — the pool's common base, written once and inherited by EVERY agent
622
+ the file materializes (no per-agent opt-out; an agent that must escape it
623
+ gets its own pool file). The layering contract is a strict prepend: the
624
+ materialized role prompt = `shared` + a blank line + the EXISTING
625
+ three-layer chain (explicit `prompt` > built-in `research`/`review`
626
+ defaults > generic one-liner), byte-for-byte unchanged beneath it —
627
+ `shared` never suppresses and never overrides anything, and an explicit
628
+ per-agent prompt keeps its priority over role defaults. Validation is
629
+ field-level fail-soft: a non-string or whitespace-only `shared` records
630
+ exactly ONE warn finding (`shared-invalid`, naming the file) and is
631
+ ignored — every agent of the pool still materializes with the unshared
632
+ chain. Scope is PER FILE: agents from two different pool files each get
633
+ their OWN file's `shared` (no cross-pool bleed), and the GLOBAL fallback
634
+ file's `shared` inherits to its family normally — the rule that ignores
635
+ the global file's `pool` namespace field does not extend to `shared`
636
+ (content is not namespace). Hot-apply rides the existing mtime cache:
637
+ editing `shared` re-materializes at the next config hook exactly like any
638
+ pool edit. With no `shared` declared anywhere, materialized prompts are
639
+ byte-identical to the pre-`shared` build.
615
640
  - Each agent materializes as `forge-<ns>-<id>` — a subagent-mode agent
616
641
  visible in the task tool's vocabulary (never hidden), whose description
617
642
  names its **pool provenance** (namespace + origin forge.json path +
@@ -720,7 +745,7 @@ Covered WRITE-CLASS indicators:
720
745
  | `mutating-cmdlet` | `Set-Content`, `Out-File`, `New-Item`, `Remove-Item`, `Move-Item`, `Copy-Item`, `Add-Content`, `Clear-Content` |
721
746
  | `git-mutation` | `git add/commit/push/reset/checkout/rebase/merge/clean/rm` |
722
747
  | `pkg-mutation` | `npm publish`, `npm install` (incl. `npm i`/`ci`/`add`), `bun add`/`install`, `pip`/`pip3 install` |
723
- | `workspace-write` | output-target flags (`--out`, `--output`, `--out-file`/`--outfile`/`--outFile`, `--dest`, `--destination`, `-o`) whose parsed value is a workspace-relative path, or an absolute path under the session workspace |
748
+ | `workspace-write` | output-target flags (`--out`, `--output`, `--out-file`/`--outfile`/`--outFile`, `--dest`, `--destination`, `-o`) whose parsed value is a workspace-relative path, or an absolute path under the session workspace — the short `-o` counts only when the head binary is a known `-o` writer (`gcc`, `g++`, `cc`, `c++`, `clang`, `clang++`, `ld`, `rustc`, `go`, `curl`, `sort`, `psql`, `dotnet`); on every other binary it flows |
724
749
 
725
750
  **Honest positioning — this is belt-in-depth, NOT a sandbox.** A command
726
751
  that fails to classify is treated as READ-CLASS (fail-open on the channel,
@@ -729,7 +754,9 @@ the same posture as every refusal belt). The known bypass surface, verbatim:
729
754
  no redirect is indirection the classifier does not model), **`Start-Process`
730
755
  writers** (spawning a writer process is explicitly out of scope), and
731
756
  **encoded payloads** (`powershell -EncodedPayload …`, base64 blobs) are NOT
732
- covered; they flow on the readonly channel. The seal narrows the exec hole
757
+ covered; they flow on the readonly channel. The short `-o` on a writer
758
+ binary outside the known set flows (fail-open, disclosed). The seal narrows
759
+ the exec hole
733
760
  from "completely open" to "explicitly constructed indirection" — a true OS
734
761
  level seal (write-time redirection, shadow copies, containers, win32+POSIX
735
762
  dual implementations) was evaluated and rejected as a disproportionate
@@ -834,22 +861,52 @@ requires a plan. The flow:
834
861
  (arm=true) folding the declared subtasks into criteria/checks; the arm
835
862
  dialog is the user's gate;
836
863
  - **standby** — no call; the crew waits until armed or abandoned.
837
- 3. **Execute in waves** (armed crews only) of parallel native `task` calls
838
- (results return in-turn; the next wave launches only after the previous
839
- wave's results). GUI / computer-use subtasks are mutually exclusive within
840
- a wave (the desktop is a singleton; non-GUI subtasks still parallelize
841
- freely), and GUI walkthroughs use the `computer` tool rather than DIY
842
- shell screenshot pipelines.
843
- 4. **Missing role?** Run the subtask through a native task call anyway and
844
- tell the user — suggest configuring the missing role.
845
- 5. **Verify** each subtask against its acceptance-evidence statement;
846
- retry a failure at most once.
847
- 6. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
848
- report against the DECLARED plan: every declared subtask needs a
849
- PASS/FAIL verdict with evidence; an undeclared subtask in the report
850
- refuses the close (fold discoveries into an existing verdict, or discard
851
- and re-crew); a FAIL needs both failure reports.
852
- 7. **Re-shard exit**: `crew_close {abandon: true, reason}` abandons the crew
864
+ 3. **Execute in waves** (armed crews only) of parallel native `task` calls
865
+ (results return in-turn; the next wave launches only after the previous
866
+ wave's results). GUI / computer-use subtasks are mutually exclusive within
867
+ a wave (the desktop is a singleton; non-GUI subtasks still parallelize
868
+ freely), and GUI walkthroughs use the `computer` tool rather than DIY
869
+ shell screenshot pipelines.
870
+ 4. **Auto-advance on idle** (armed crews only): when the session goes idle
871
+ between waves and no live active goal governs it, the plugin's
872
+ idle-continuation engine sends a `[forge:crew-continue]` brief (objective
873
+ + declared subtask titles + wave discipline) after the shared debounce —
874
+ waves advance autonomously until `crew_close`. A PENDING crew is never
875
+ auto-continued (that pause is the user's decision seam), a goal-governed
876
+ session belongs to the goal engine exclusively, and a non-forge speaker is
877
+ never continued. The engine keeps its OWN activity counter (task
878
+ dispatches and write-family calls count); after 3 consecutive
879
+ activity-free continuation turns it STOPS with one bounded console
880
+ disclosure — the crew itself stays registered (no auto-abandon, no
881
+ auto-close; nudge, close, or re-shard), and repeated transport failures
882
+ stop it the same way. A fresh `crew_begin` clears the stall; no disk
883
+ state is added (the crew record is unchanged).
884
+ 5. **Missing role?** Run the subtask through a native task call anyway and
885
+ tell the user — suggest configuring the missing role.
886
+ 6. **Native fallback lane** (crew-native-fallback): when the pool offers no
887
+ forge worker fitting a subtask, or `forge-*` dispatch is blocked by a
888
+ degraded (non-compliant) pool file, the armed crew dispatches the subtask
889
+ through the NATIVE task channel (e.g. the `general` subagent) with the
890
+ SAME relay contract as a forge dispatch — the change/artifact path for
891
+ authoritative guides, the scoped task prompt, and the evidence demands.
892
+ The fallback is recorded, never silent: the crew record's wave notes name
893
+ it, and the subtask's `crew_close` evidence statement says it ran native
894
+ (free text — the close gate's cross-check is unchanged; titles, verdicts
895
+ and evidence statements govern exactly as before, no new field). The
896
+ mid-crew compliance refusal names this exit while the pool is repaired —
897
+ the belt gates `forge-*` dispatch only. No guard changed: the
898
+ registration pre-flight still refuses a non-compliant pool outright
899
+ (D3), cross-pool refusals keep their copy, the pending belt stands, and
900
+ native targets were never refused for forge-family sessions and still
901
+ are not.
902
+ 7. **Verify** each subtask against its acceptance-evidence statement;
903
+ retry a failure at most once.
904
+ 8. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
905
+ report against the DECLARED plan: every declared subtask needs a
906
+ PASS/FAIL verdict with evidence; an undeclared subtask in the report
907
+ refuses the close (fold discoveries into an existing verdict, or discard
908
+ and re-crew); a FAIL needs both failure reports.
909
+ 9. **Re-shard exit**: `crew_close {abandon: true, reason}` abandons the crew
853
910
  — ask-gated like the close, no verdict requirements, usable from PENDING
854
911
  (standby cancel) and EXECUTING (wrong decomposition) alike. The session is
855
912
  immediately free for a fresh `crew_begin` with the corrected plan.
package/dist/index.js CHANGED
@@ -15048,10 +15048,11 @@ var ROLE_PROMPTS = {
15048
15048
  research: "You are a research worker: search, read, and run read-only checks to answer the assigned question. Report findings as a compact brief with sources and file:line evidence; state plainly what could not be verified.",
15049
15049
  review: "You are a review worker: read the diff or files under review, run read-only checks/tests, and report findings as a prioritized list, each with file:line evidence and a concrete fix suggestion. End with a single-line verdict."
15050
15050
  };
15051
- function rolePromptFor(agentId, def) {
15052
- if (def?.prompt && def.prompt.trim().length > 0)
15053
- return def.prompt;
15054
- return ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
15051
+ function rolePromptFor(agentId, def, shared) {
15052
+ const chain = def?.prompt && def.prompt.trim().length > 0 ? def.prompt : ROLE_PROMPTS[agentId] ?? `You are a ${agentId} worker. Execute the assigned task exactly as scoped.`;
15053
+ return shared === undefined ? chain : `${shared}
15054
+
15055
+ ${chain}`;
15055
15056
  }
15056
15057
  function poolProvenanceLine(provenance) {
15057
15058
  if ("global" in provenance) {
@@ -15065,7 +15066,7 @@ function agentDescriptionFor(agentId, def, provenance) {
15065
15066
  const base = `forge subagent "${agentId}" (${shape}, ${brain}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
15066
15067
  return provenance === undefined ? base : `${base} ${poolProvenanceLine(provenance)}`;
15067
15068
  }
15068
- function forgeAgentDef(agentId, def, provenance) {
15069
+ function forgeAgentDef(agentId, def, provenance, sharedSeg) {
15069
15070
  const permission = { task: "deny" };
15070
15071
  const shape = def.shape ?? "write";
15071
15072
  if (shape === "readonly") {
@@ -15080,7 +15081,7 @@ function forgeAgentDef(agentId, def, provenance) {
15080
15081
  prompt: materializeAgentPrompt({
15081
15082
  agent: agentId,
15082
15083
  shape,
15083
- role: rolePromptFor(agentId, def)
15084
+ role: rolePromptFor(agentId, def, sharedSeg)
15084
15085
  }),
15085
15086
  permission,
15086
15087
  tools: { shell: false, bash: false }
@@ -15142,12 +15143,27 @@ var WRITE_TARGET_FLAGS = [
15142
15143
  "--destination",
15143
15144
  "-o"
15144
15145
  ];
15146
+ var O_FLAG_WRITER_BINARIES = [
15147
+ "gcc",
15148
+ "g++",
15149
+ "cc",
15150
+ "c++",
15151
+ "clang",
15152
+ "clang++",
15153
+ "ld",
15154
+ "rustc",
15155
+ "go",
15156
+ "curl",
15157
+ "sort",
15158
+ "psql",
15159
+ "dotnet"
15160
+ ];
15145
15161
  var EXEC_SEAL_INDICATORS = [
15146
15162
  { id: REDIRECT_INDICATOR, covers: "`>` `>>` and fd forms (`2>` `*>`) — bash and the PowerShell family share the operators" },
15147
15163
  { id: "mutating-cmdlet", covers: MUTATING_CMDLETS.join(", ") },
15148
15164
  { id: "git-mutation", covers: `git ${GIT_MUTATING_SUBCOMMANDS.join("/")}` },
15149
15165
  { id: "pkg-mutation", covers: PKG_MUTATING_COMMANDS.join(", ") + " (with common aliases: npm i/ci/add, bun install, pip3 install)" },
15150
- { id: "workspace-write", covers: `output-target flags ${WRITE_TARGET_FLAGS.join(" ")} whose parsed value is a workspace-relative path (or an absolute path under the provided workspaceRoot)` }
15166
+ { id: "workspace-write", covers: `output-target flags ${WRITE_TARGET_FLAGS.join(" ")} whose parsed value is a workspace-relative path (or an absolute path under the provided workspaceRoot); the short -o counts only when the head binary is a known -o writer (${O_FLAG_WRITER_BINARIES.join(", ")})` }
15151
15167
  ];
15152
15168
  function stripQuoted(cmd) {
15153
15169
  let out = "";
@@ -15166,6 +15182,57 @@ function stripQuoted(cmd) {
15166
15182
  }
15167
15183
  return out;
15168
15184
  }
15185
+ function tokenizeCommand(cmd) {
15186
+ const tokens = [];
15187
+ let text = "";
15188
+ let quoted = false;
15189
+ let mode = null;
15190
+ let sawAny = false;
15191
+ const flush = () => {
15192
+ if (sawAny)
15193
+ tokens.push({ text, quoted });
15194
+ text = "";
15195
+ quoted = false;
15196
+ sawAny = false;
15197
+ };
15198
+ for (const ch of cmd) {
15199
+ if (mode) {
15200
+ if (ch === mode) {
15201
+ mode = null;
15202
+ continue;
15203
+ }
15204
+ text += ch;
15205
+ sawAny = true;
15206
+ continue;
15207
+ }
15208
+ if (ch === '"' || ch === "'") {
15209
+ mode = ch;
15210
+ quoted = true;
15211
+ sawAny = true;
15212
+ continue;
15213
+ }
15214
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === `
15215
+ `) {
15216
+ flush();
15217
+ continue;
15218
+ }
15219
+ text += ch;
15220
+ sawAny = true;
15221
+ }
15222
+ flush();
15223
+ return tokens;
15224
+ }
15225
+ function resolveHeadBinary(tokens) {
15226
+ let i = tokens.length > 0 && !tokens[0].quoted && tokens[0].text === "&" ? 1 : 0;
15227
+ const head = tokens[i];
15228
+ if (!head)
15229
+ return "";
15230
+ const parts = head.text.split(/[\\/]/);
15231
+ let bin = parts[parts.length - 1].toLowerCase();
15232
+ if (bin.endsWith(".exe"))
15233
+ bin = bin.slice(0, -4);
15234
+ return bin;
15235
+ }
15169
15236
  function redirectMatch(cmd) {
15170
15237
  const bare = stripQuoted(cmd).replace(/\r?\n/g, " ");
15171
15238
  const m = /(?:^|[^>&])>{1,2}(?!&)[^\s;&|]*/.exec(bare);
@@ -15214,25 +15281,33 @@ function insideWorkspace(value, workspaceRoot) {
15214
15281
  return norm(value).startsWith(`${norm(workspaceRoot)}/`) || norm(value) === norm(workspaceRoot);
15215
15282
  }
15216
15283
  function workspaceWriteMatch(cmd, workspaceRoot) {
15217
- const tokens = stripQuoted(cmd).split(/\s+/).filter(Boolean);
15284
+ const tokens = tokenizeCommand(cmd);
15285
+ const headBinary = resolveHeadBinary(tokens);
15286
+ const headIsWriter = O_FLAG_WRITER_BINARIES.includes(headBinary);
15218
15287
  for (let i = 0;i < tokens.length; i++) {
15219
15288
  const tok = tokens[i];
15289
+ if (tok.quoted)
15290
+ continue;
15220
15291
  let flag = "";
15221
- let value;
15222
- const eq = tok.indexOf("=");
15223
- if (eq > 0 && tok.startsWith("--")) {
15224
- flag = tok.slice(0, eq);
15225
- value = tok.slice(eq + 1);
15226
- } else if (WRITE_TARGET_FLAGS.includes(tok)) {
15227
- flag = tok;
15228
- value = tokens[i + 1];
15229
- }
15230
- if (!flag || value === undefined)
15292
+ let valueText;
15293
+ const text = tok.text;
15294
+ const eq = text.indexOf("=");
15295
+ if (eq > 0 && text.startsWith("--")) {
15296
+ flag = text.slice(0, eq);
15297
+ valueText = text.slice(eq + 1);
15298
+ } else if (WRITE_TARGET_FLAGS.includes(text)) {
15299
+ if (text === "-o" && !headIsWriter)
15300
+ continue;
15301
+ flag = text;
15302
+ const next = tokens[i + 1];
15303
+ valueText = next?.text;
15304
+ }
15305
+ if (!flag || valueText === undefined)
15231
15306
  continue;
15232
- if (value.startsWith("-"))
15307
+ if (valueText.startsWith("-"))
15233
15308
  continue;
15234
- if (pathLike(value) && insideWorkspace(value, workspaceRoot)) {
15235
- return { detail: `${flag} ${value}` };
15309
+ if (pathLike(valueText) && insideWorkspace(valueText, workspaceRoot)) {
15310
+ return { detail: `${flag} ${valueText}` };
15236
15311
  }
15237
15312
  }
15238
15313
  return null;
@@ -15857,6 +15932,7 @@ function createPoolResolver(deps) {
15857
15932
  parsed = {
15858
15933
  agents: {},
15859
15934
  declaredPool: null,
15935
+ shared: null,
15860
15936
  findings: [
15861
15937
  {
15862
15938
  level: "error",
@@ -15866,11 +15942,20 @@ function createPoolResolver(deps) {
15866
15942
  ]
15867
15943
  };
15868
15944
  } else if (doc2.config !== undefined && !isRecord(doc2.config)) {
15869
- parsed = { agents: {}, declaredPool: null, findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this pool contributes no agents` }] };
15945
+ parsed = { agents: {}, declaredPool: null, shared: null, findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this pool contributes no agents` }] };
15870
15946
  } else {
15871
15947
  const top = doc2.config === undefined ? undefined : doc2.config;
15872
15948
  const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15873
- parsed = { agents: validated.agents, declaredPool: top === undefined ? null : top.pool, findings: validated.findings };
15949
+ const sharedRaw = top === undefined ? undefined : top.shared;
15950
+ let shared = null;
15951
+ if (sharedRaw !== undefined && sharedRaw !== null) {
15952
+ if (typeof sharedRaw === "string" && sharedRaw.trim().length > 0) {
15953
+ shared = sharedRaw.trim();
15954
+ } else {
15955
+ validated.findings.push({ level: "warn", code: "shared-invalid", message: `${path}: the top-level "shared" field must be a non-empty string — ignored` });
15956
+ }
15957
+ }
15958
+ parsed = { agents: validated.agents, declaredPool: top === undefined ? null : top.pool, shared, findings: validated.findings };
15874
15959
  }
15875
15960
  fileCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, parsed });
15876
15961
  return parsed;
@@ -15925,7 +16010,7 @@ function createPoolResolver(deps) {
15925
16010
  }
15926
16011
  }
15927
16012
  }
15928
- families.push({ file: file2, parsed, ns: "", declaredPool });
16013
+ families.push({ file: file2, parsed, ns: "", declaredPool, shared: parsed?.shared ?? null });
15929
16014
  }
15930
16015
  const allDeclared = new Set;
15931
16016
  for (const f of families)
@@ -15958,10 +16043,10 @@ function createPoolResolver(deps) {
15958
16043
  findings.push({ level: "error", code: "materialized-id-collision", message: `materialized agent forge-${matId} from ${f.file} collides with the one from ${existing.file} — the earlier pool in resolution order wins, this agent is skipped` });
15959
16044
  continue;
15960
16045
  }
15961
- agents[matId] = { materializedId: matId, def: own[rawId], ns: f.ns, file: f.file };
16046
+ agents[matId] = { materializedId: matId, def: own[rawId], ns: f.ns, file: f.file, shared: f.shared };
15962
16047
  ids.push(matId);
15963
16048
  }
15964
- origins.push({ file: f.file, ns: f.ns, declaredPool: f.declaredPool, materializedIds: ids });
16049
+ origins.push({ file: f.file, ns: f.ns, declaredPool: f.declaredPool, shared: f.shared, materializedIds: ids });
15965
16050
  }
15966
16051
  return { families: origins, agents, findings };
15967
16052
  }
@@ -16363,6 +16448,8 @@ function crewPoolComplianceRefusal(anchor) {
16363
16448
  const suggested = fam?.ns ?? sanitizeNamespace(basename2(dirname4(dirname4(poolFile))));
16364
16449
  return `[forge:crew] Pool compliance refused: this session's workspace pool file (${poolFile}) is non-compliant — ${verdict.violation}. ` + `Minimal fix: add or repair the top-level "pool": "${suggested}" field (a [a-z0-9-] string, 1-24 chars — REQUIRED for crew-governed dispatch). ` + `The gate lifts hot-apply: the next resolve after the repair admits registration/dispatch; no host restart.`;
16365
16450
  }
16451
+ var CREW_NATIVE_FALLBACK_RULE = "Native fallback: when the pool offers no forge worker fitting a subtask (or forge-* dispatch is blocked by a degraded pool), dispatch the native general subagent with the same relay contract (artifact path, scoped prompt, evidence demands) and RECORD the fallback (wave notes + the subtask's close evidence says it ran native).";
16452
+ var CREW_COMPLIANCE_NATIVE_EXIT = " Native exit: while the pool is being repaired, the affected subtask may dispatch through the NATIVE task channel with the same relay contract — this compliance belt gates forge-* dispatch only, never the crew's existence.";
16366
16453
  var crews = new Map;
16367
16454
  function crewRecordPathFor(worktree, objective) {
16368
16455
  const dir = join6(worktree, ".opencode", "crew");
@@ -16412,7 +16499,7 @@ function crewPendingChoiceLines() {
16412
16499
  return [
16413
16500
  "[forge:crew] The crew is PENDING — execution has NOT started. Present the execution-mode choice to the user and END YOUR TURN (every task dispatch is refused while the crew pends):",
16414
16501
  `RELAY FIRST: tell the user the crew record path (it leads this output) alongside this choice — the record is on disk from the registration moment, and the user must hear it.`,
16415
- ` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline;`,
16502
+ ` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline; waves auto-advance on session idle until crew_close (the engine stops after a stall — nudge or re-shard);`,
16416
16503
  ` 2. convert to a goal contract — call crew_begin {execution: "goal"} (the crew ends as a conversion record; no extra dialog here), then draft goal_write with arm=true folding the declared subtasks into criteria/checks — the arm dialog is the user's gate;`,
16417
16504
  ` 3. standby — no call; the crew waits until armed or abandoned.`
16418
16505
  ];
@@ -17405,8 +17492,8 @@ function crewOrchestrationTemplate(resolution) {
17405
17492
  "You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). The macro contract above the crew may come from an approved plan, a spec change, or inline text — the discipline is identical in all three cases and never requires a plan. Follow it exactly:",
17406
17493
  "",
17407
17494
  '1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). When the macro contract descends from a plan artifact, pass its path as the optional `lineage` argument (e.g. lineage: ".opencode/plan/2026-10-01-my-plan.md") — the descent is recorded as a first-class line in the crew record header; lineage is never inferred. Reconnaissance BEFORE registration is free (task calls included). If crew_begin refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew. A wrong plan is fixed by abandoning and re-crewing (crew_close {abandon: true, reason}), never by improvising outside the declared plan.',
17408
- `2. THE PAUSE: registration enters a PENDING crew — execution has NOT started. STOP and present the user the three execution-mode choices (supervised waves now / convert to a goal contract / standby), then END YOUR TURN. Arm per the user's choice: waves → crew_begin {execution: "waves"}; conversion → crew_begin {execution: "goal"} followed by goal_write (arm=true — its dialog is the user's gate) folding the declared subtasks into criteria/checks; standby → no call. EXCEPTION: while a live active goal governs this session, register with execution: "waves" in the SAME call — the crew is born armed (the goal contract already authorized orchestration) and crew_close / abandon stay ask-free under that goal.`,
17409
- "3. WAVES, NEVER FLOODS (armed crews only): execute subtasks in waves of parallel native `task` calls (subagent_type = the matching forge-* agent; a wave's results return within the calling turn). Launch the next wave ONLY after the previous wave's results are in. GUI / computer-use subtasks are mutually exclusive within a wave (the desktop is a singleton resource — at most ONE in flight; non-GUI subtasks still parallelize freely), and GUI walkthroughs use the `computer` tool rather than DIY shell screenshot pipelines.",
17495
+ `2. THE PAUSE: registration enters a PENDING crew — execution has NOT started. STOP and present the user the three execution-mode choices (supervised waves now / convert to a goal contract / standby), then END YOUR TURN. Arm per the user's choice: waves → crew_begin {execution: "waves"} (waves then auto-advance on session idle until crew_close — the engine stops after a stall, nudge or re-shard); conversion → crew_begin {execution: "goal"} followed by goal_write (arm=true — its dialog is the user's gate) folding the declared subtasks into criteria/checks; standby → no call. EXCEPTION: while a live active goal governs this session, register with execution: "waves" in the SAME call — the crew is born armed (the goal contract already authorized orchestration) and crew_close / abandon stay ask-free under that goal.`,
17496
+ "3. WAVES, NEVER FLOODS (armed crews only): execute subtasks in waves of parallel native `task` calls (subagent_type = the matching forge-* agent; a wave's results return within the calling turn). Launch the next wave ONLY after the previous wave's results are in. GUI / computer-use subtasks are mutually exclusive within a wave (the desktop is a singleton resource — at most ONE in flight; non-GUI subtasks still parallelize freely), and GUI walkthroughs use the `computer` tool rather than DIY shell screenshot pipelines. Native fallback: when the pool offers no forge worker fitting a subtask (or forge-* dispatch is blocked by a degraded pool), dispatch the native general subagent with the same relay contract (artifact path, scoped prompt, evidence demands) and RECORD the fallback (wave notes + the subtask's close evidence says it ran native).",
17410
17497
  "4. CONTRACT RELAY (lineage-descended crews): when the macro contract descends from an on-disk artifact (the lineage names a plan artifact, a spec change directory, or a goal contract), every subtask's dispatch prompt MUST explicitly carry that artifact's path — or the exact slice the subtask works from (a delta file path, a quoted criteria section) — so the dispatched worker reads the contract itself, never your paraphrase of it. A large artifact is relayed by its exact slice; it is NEVER dropped for being too long to inline. Every close-report evidence statement MUST name the contract (or slice) the subtask worked against.",
17411
17498
  "5. MISSING ROLE: when a subtask needs a role no configured forge-* agent matches, run that subtask through a native task call anyway AND tell the user about the gap — suggest configuring the missing role in forge.json.",
17412
17499
  "6. VERIFY: judge each subtask strictly against its acceptance-evidence statement from the task result text. A result that does not meet the statement is a failure even if the worker sounded confident.",
@@ -17500,9 +17587,11 @@ var crewBeginTool = tool({
17500
17587
  title: `crew armed: ${crew.objective.slice(0, 60)}`,
17501
17588
  output: [
17502
17589
  `[forge:crew] Crew ARMED for supervised waves: ${crew.objective}`,
17590
+ `Waves auto-advance on session idle until crew_close (the engine stops after a stall — nudge or re-shard).`,
17503
17591
  `Declared plan (${crew.subtasks.length}):`,
17504
17592
  ...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
17505
- "Proceed with the wave 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."
17593
+ "Proceed with the wave 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.",
17594
+ CREW_NATIVE_FALLBACK_RULE
17506
17595
  ].join(`
17507
17596
  `)
17508
17597
  };
@@ -17554,6 +17643,7 @@ var crewBeginTool = tool({
17554
17643
  recordNote = "Crew record could not be written (fail-soft; the crew still runs).";
17555
17644
  }
17556
17645
  }
17646
+ crewEngineForgetSession(context.sessionID);
17557
17647
  crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
17558
17648
  appendCrewRecord(recordPath, governed ? "registered (born armed under a governing goal)" : "registered (pending)", subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`));
17559
17649
  let disclosure = "";
@@ -17591,7 +17681,7 @@ var crewBeginTool = tool({
17591
17681
  ...disclosure ? [disclosure] : []
17592
17682
  ];
17593
17683
  if (governed) {
17594
- lines.push("Goal-delegated orchestration: the governing goal contract already authorized this crew — run the wave discipline NOW (crew gates stay internal to the loop; crew_close and abandon are ask-free under this goal), and re-shard via crew_close {abandon: true, reason} + a fresh one-call crew_begin when the decomposition proves wrong.", "Wave 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.");
17684
+ lines.push("Goal-delegated orchestration: the governing goal contract already authorized this crew — run the wave discipline NOW (crew gates stay internal to the loop; crew_close and abandon are ask-free under this goal), and re-shard via crew_close {abandon: true, reason} + a fresh one-call crew_begin when the decomposition proves wrong.", "This crew is driven by the goal engine's own continuations (no separate crew briefs); standalone waves auto-advance on session idle until crew_close (the engine stops after a stall — nudge or re-shard).", "Wave 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.", CREW_NATIVE_FALLBACK_RULE);
17595
17685
  } else {
17596
17686
  lines.push(...crewPendingChoiceLines());
17597
17687
  }
@@ -17731,6 +17821,91 @@ function engineForgetAll() {
17731
17821
  for (const id of [...idleTimers.keys()])
17732
17822
  engineForgetSession(id);
17733
17823
  }
17824
+ var CREW_NO_PROGRESS_LIMIT = 3;
17825
+ var crewPendingTurn = new Set;
17826
+ var crewTurnActivity = new Map;
17827
+ var crewNoProgress = new Map;
17828
+ var crewStalled = new Set;
17829
+ function crewEngineForgetSession(sessionID) {
17830
+ crewPendingTurn.delete(sessionID);
17831
+ crewTurnActivity.delete(sessionID);
17832
+ crewNoProgress.delete(sessionID);
17833
+ crewStalled.delete(sessionID);
17834
+ }
17835
+ function crewContinueBrief(crew) {
17836
+ return [
17837
+ `[forge:crew-continue] The crew "${crew.objective}" is armed (waves) — continue the wave discipline.`,
17838
+ `Declared plan (every subtask needs a verdict at close):`,
17839
+ ...crew.subtasks.map((s, i) => `${i + 1}. ${s.title}`),
17840
+ `Next: dispatch the next wave (parallel task calls — forge-* workers from this session's pool, or the native general subagent when no forge worker fits), collect per-subtask evidence verdicts, at most one retry per subtask, then crew_close with the full report. Stalled or wrong decomposition? crew_close {abandon: true, reason} to re-shard.`
17841
+ ].join(`
17842
+ `);
17843
+ }
17844
+ async function continueCrewIfEligible(client, state, sessionID) {
17845
+ const crew = crews.get(sessionID);
17846
+ if (!crew || crew.mode !== "executing") {
17847
+ goalProbe(`crew skip: no executing crew session=${sessionID}`);
17848
+ return;
17849
+ }
17850
+ if (crewStalled.has(sessionID)) {
17851
+ goalProbe(`crew skip: stalled session=${sessionID}`);
17852
+ return;
17853
+ }
17854
+ if (!sessionIsForgeFamily(sessionID)) {
17855
+ goalProbe(`crew skip: agent switched to non-forge before send session=${sessionID}`);
17856
+ return;
17857
+ }
17858
+ if (crewPendingTurn.has(sessionID)) {
17859
+ crewPendingTurn.delete(sessionID);
17860
+ const act = crewTurnActivity.get(sessionID);
17861
+ if (act && (act.dispatches > 0 || act.writes > 0)) {
17862
+ crewNoProgress.delete(sessionID);
17863
+ } else {
17864
+ const n = (crewNoProgress.get(sessionID) ?? 0) + 1;
17865
+ crewNoProgress.set(sessionID, n);
17866
+ goalProbe(`crew no-progress session=${sessionID} streak=${n}`);
17867
+ if (n >= CREW_NO_PROGRESS_LIMIT) {
17868
+ crewStalled.add(sessionID);
17869
+ crewTurnActivity.delete(sessionID);
17870
+ console.warn(`[forge:crew-continue] stalled: crew "${crew.objective.slice(0, 80)}" produced ${n} activity-free continuation turns — the engine stops continuing it (the crew stays registered; nudge the session, crew_close the report, or crew_close {abandon: true, reason} to re-shard).`);
17871
+ return;
17872
+ }
17873
+ }
17874
+ }
17875
+ if (typeof client.session.status === "function") {
17876
+ try {
17877
+ const st = await client.session.status({ path: { id: sessionID } });
17878
+ const t = st?.[sessionID]?.type;
17879
+ if (t && t !== "idle") {
17880
+ goalProbe(`crew skip: session busy again session=${sessionID} status=${t}`);
17881
+ return;
17882
+ }
17883
+ } catch (err) {
17884
+ goalProbe(`crew skip: status check failed session=${sessionID} err=${String(err)}`);
17885
+ return;
17886
+ }
17887
+ }
17888
+ try {
17889
+ await client.session.prompt({
17890
+ path: { id: sessionID },
17891
+ body: { parts: [{ type: "text", text: crewContinueBrief(crew) }] }
17892
+ });
17893
+ transportFails.delete(sessionID);
17894
+ crewPendingTurn.add(sessionID);
17895
+ crewTurnActivity.set(sessionID, { dispatches: 0, writes: 0 });
17896
+ goalProbe(`crew continued session=${sessionID} objective="${crew.objective.slice(0, 60)}"`);
17897
+ } catch (err) {
17898
+ const n = (transportFails.get(sessionID) ?? 0) + 1;
17899
+ transportFails.set(sessionID, n);
17900
+ goalProbe(`crew transport failure session=${sessionID} count=${n} err=${String(err)}`);
17901
+ if (n >= TRANSPORT_FAILURE_LIMIT) {
17902
+ crewStalled.add(sessionID);
17903
+ crewPendingTurn.delete(sessionID);
17904
+ crewTurnActivity.delete(sessionID);
17905
+ console.warn(`[forge:crew-continue] stopped: ${n} consecutive delivery failures for crew "${crew.objective.slice(0, 80)}" — the engine stops continuing it (the crew stays registered; nudge the session or crew_close when back).`);
17906
+ }
17907
+ }
17908
+ }
17734
17909
  function goalBriefText(state, goal) {
17735
17910
  const d = goal.doc;
17736
17911
  return [
@@ -17743,7 +17918,7 @@ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\``
17743
17918
  `)}`,
17744
17919
  d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
17745
17920
  `Budget: ${d.turnsUsed}/${d.maxTurns} turns used. Work the criteria now; call goal_check when you believe they hold, then goal_complete (it re-runs every check itself and asks the user). If genuinely blocked, call goal_pause with the blocker — do not spin.`,
17746
- `Crew layer: you own it under this goal — when parallel decomposition serves a criterion, register crews with crew_begin {objective, subtasks, execution: "waves"} in ONE call (born armed; no pause mid-loop), dispatch forge-* waves, and re-shard a wrong decomposition via crew_close {abandon: true, reason} plus a fresh registration. Crew gates stay internal to this loop.`,
17921
+ `Crew layer: you own it under this goal — when parallel decomposition serves a criterion, register crews with crew_begin {objective, subtasks, execution: "waves"} in ONE call (born armed; no pause mid-loop), dispatch forge-* waves, and re-shard a wrong decomposition via crew_close {abandon: true, reason} plus a fresh registration. Crew gates stay internal to this loop (your goal turns drive the crew — no separate crew idle briefs fire under this goal; standalone waves auto-advance on session idle until crew_close).`,
17747
17922
  `Goal file: ${relFrom(state.worktree, goal.path)}`
17748
17923
  ].filter((s) => s.length > 0).join(`
17749
17924
  `);
@@ -17817,12 +17992,9 @@ async function continueIfEligible(client, sessionID) {
17817
17992
  }
17818
17993
  }
17819
17994
  const goal = resolveLiveGoal(state);
17820
- if (!goal || goal.doc.status !== "active") {
17821
- goalProbe(`skip: no live active goal session=${sessionID}`);
17822
- return;
17823
- }
17824
- if (!goal.doc.session || goal.doc.session !== sessionID) {
17825
- goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session || "(none)"}`);
17995
+ if (!goal || goal.doc.status !== "active" || !goal.doc.session || goal.doc.session !== sessionID) {
17996
+ goalProbe(`no live active goal owned by session=${sessionID} — crew branch`);
17997
+ await continueCrewIfEligible(client, state, sessionID);
17826
17998
  return;
17827
17999
  }
17828
18000
  if (!sessionIsForgeFamily(sessionID)) {
@@ -18045,7 +18217,7 @@ var server = async (input, options) => {
18045
18217
  if (agentSection[id] !== undefined)
18046
18218
  continue;
18047
18219
  const provenance = foldPath(agent.file) === foldPath(globalMaterializationPath) ? { global: true } : { ns: agent.ns, file: agent.file };
18048
- const entry = forgeAgentDef(matId, agent.def, provenance);
18220
+ const entry = forgeAgentDef(matId, agent.def, provenance, agent.shared ?? undefined);
18049
18221
  agentSection[id] = entry;
18050
18222
  materializedSnapshot[id] = entry;
18051
18223
  }
@@ -18162,7 +18334,7 @@ var server = async (input, options) => {
18162
18334
  if (anchor) {
18163
18335
  const complianceRefusal = crewPoolComplianceRefusal(anchor);
18164
18336
  if (complianceRefusal)
18165
- throw new Error(complianceRefusal);
18337
+ throw new Error(complianceRefusal + CREW_COMPLIANCE_NATIVE_EXIT);
18166
18338
  }
18167
18339
  }
18168
18340
  }
@@ -18380,6 +18552,8 @@ var server = async (input, options) => {
18380
18552
  sealFindings.delete(info.id);
18381
18553
  sealUnknownNotes.delete(info.id);
18382
18554
  poolGateUnknownNotes.delete(info.id);
18555
+ crews.delete(info.id);
18556
+ crewEngineForgetSession(info.id);
18383
18557
  }
18384
18558
  }
18385
18559
  },
@@ -18412,12 +18586,19 @@ var server = async (input, options) => {
18412
18586
  if (typeof input2.tool !== "string")
18413
18587
  return;
18414
18588
  const act = turnActivity.get(input2.sessionID);
18415
- if (!act)
18416
- return;
18417
- if (isWriteTool(input2.tool) || input2.tool === "plan_tick")
18418
- act.writes++;
18419
- if (input2.tool === "goal_check")
18420
- act.checks++;
18589
+ if (act) {
18590
+ if (isWriteTool(input2.tool) || input2.tool === "plan_tick")
18591
+ act.writes++;
18592
+ if (input2.tool === "goal_check")
18593
+ act.checks++;
18594
+ }
18595
+ const crewAct = crewTurnActivity.get(input2.sessionID);
18596
+ if (crewAct) {
18597
+ if (input2.tool === "task")
18598
+ crewAct.dispatches++;
18599
+ if (isWriteTool(input2.tool))
18600
+ crewAct.writes++;
18601
+ }
18421
18602
  },
18422
18603
  "experimental.chat.system.transform": async (input2, output) => {
18423
18604
  if (!input2.sessionID)
@@ -18506,7 +18687,7 @@ async function v2Setup(ctx) {
18506
18687
  if (draft.get(id) !== undefined)
18507
18688
  continue;
18508
18689
  const provenance = foldPath(agent.file) === foldPath(v2GlobalPath) ? { global: true } : { ns: agent.ns, file: agent.file };
18509
- const v1def = forgeAgentDef(matId, agent.def, provenance);
18690
+ const v1def = forgeAgentDef(matId, agent.def, provenance, agent.shared ?? undefined);
18510
18691
  draft.update(id, (agentDraft) => {
18511
18692
  agentDraft.description = v1def.description;
18512
18693
  agentDraft.system = v1def.prompt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.19.0",
3
+ "version": "0.20.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",