@sorenllm/opencode-forge 0.18.1 → 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 +123 -21
  2. package/dist/index.js +537 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -70,7 +70,7 @@ tool layer (verified against the host's `tools` filter):
70
70
  | | **forge family** — `forge` + your forge.json `forge-*` workers | **everyone else** — native `build` / `plan` / `general` / `explore` and your own agents |
71
71
  | --- | --- | --- |
72
72
  | exec surface | `forge_shell` / `forge_jobs` only; builtin `shell`/`bash` hidden (unconditional, probe-independent; covers user-defined `agent.forge` entries too) | builtin `shell`/`bash`, fully native |
73
- | forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` | none visible |
73
+ | forge tools (`plan_*`, `goal_*`, `crew_*`, `forge_shell`, `forge_jobs`, `forge_grep`, `forge_glob`) | `forge` sees all; `forge-*` workers only `forge_shell` + `forge_jobs` + the search pair (`forge_grep`/`forge_glob`) | none visible |
74
74
  | dispatching `forge-*` via `task` | allowed | refused at the tool layer |
75
75
  | writing `.opencode/plan/` / `.opencode/goal/` | allowed (state machine governs) | refused at the tool layer (reads stay open) |
76
76
  | forge system prompts (job guidance, plan/goal notices) | injected | never injected — zero forge text |
@@ -152,6 +152,10 @@ the plugin never writes them):
152
152
  "watchdog": {
153
153
  "mode": "kill", // "kill" (default) | "dry-run" | "off" — forge-family sessions only
154
154
  "stallMs": 600000 // stall threshold, min 60000
155
+ },
156
+ "search": {
157
+ "rgPath": "" // optional explicit ripgrep binary path for forge_grep/forge_glob;
158
+ // default resolution: this override (when it exists) → rg on PATH
155
159
  }
156
160
  }]
157
161
  ]
@@ -412,6 +416,45 @@ These are runtime debris, not data — with ONE caveat: **if you uninstall with
412
416
  /F /T`, or just reboot); deleting the directory afterwards is safe and
413
417
  complete.
414
418
 
419
+ ## Search tools (`forge_grep` / `forge_glob`)
420
+
421
+ rg-backed search for the forge family — same interface and output format as
422
+ the builtin `grep`/`glob` pair, but **`follow` and `hidden` default to TRUE**:
423
+ workspace content behind symlinks/junctions and inside dot-directories (which
424
+ the builtin pair silently misses) is in scope by default. `**/.git/**` is
425
+ always excluded. The builtin pair stays visible — the forge family is
426
+ instructed to prefer `forge_grep`/`forge_glob` first and use builtin
427
+ `grep`/`glob` as fallback only; the standing worker mandate reads:
428
+
429
+ > Run every search through the forge_grep/forge_glob tools first — the
430
+ > builtin grep/glob tools are fallback only. forge_grep/forge_glob see
431
+ > through symlinks/junctions and dot-directories because follow and hidden
432
+ > are on by default.
433
+
434
+ - **Params**: `pattern` (required); `path` (optional — defaults to the session
435
+ worktree; a file path targets that file); `include` (forge_grep file
436
+ filter); `follow` / `hidden` (optional booleans, both default `true`).
437
+ - **Output parity**: `Found N matches` with `path: Line n:` blocks
438
+ (forge_glob: a plain path list) — the same shapes as the builtin pair.
439
+ Results truncate at 100 with a truncation notice advising a narrower
440
+ path or pattern.
441
+ - **Follow dedupe**: a file reachable through its real path AND a link path
442
+ reports ONCE (lexicographically first path kept); when any dedupe occurred
443
+ the output ends with a `+N duplicate paths merged` line.
444
+ - **Outside-workspace links**: followed, with the resolved target disclosed
445
+ in the result.
446
+ - **Readonly by construction**: ripgrep is spawned directly with a fixed
447
+ argument vector — no shell, no redirection surface. The tools carry no
448
+ permission ask-gate and are not subject to the readonly exec seal, so
449
+ explicit-readonly workers search without friction.
450
+
451
+ **ripgrep dependency.** No binary is bundled. rg is resolved as:
452
+ `search.rgPath` config override (when it exists on disk) → `rg` on PATH →
453
+ fail-soft guidance error (never a crash):
454
+
455
+ > install ripgrep (or set search.rgPath in forge config to an existing rg
456
+ > binary), or fall back to the builtin grep/glob tools for this search
457
+
415
458
  ## Forge subagents (static worker agents in forge.json)
416
459
 
417
460
  Forge subagents are **static, user-authored worker agents**: each forge.json
@@ -523,6 +566,11 @@ out of a materialized id.
523
566
  // dispatches are blocked until the field is added). Invalid values
524
567
  // degrade to a warning + the directory-derived namespace.
525
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.",
526
574
  "agents": {
527
575
  // PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
528
576
  "research": {
@@ -569,6 +617,26 @@ A rejected entry costs only itself — its siblings materialize normally.
569
617
  Findings surface in the plugin diagnostics log.
570
618
  - **`depths` is deprecated**: the array is ignored with a warning finding.
571
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.
572
640
  - Each agent materializes as `forge-<ns>-<id>` — a subagent-mode agent
573
641
  visible in the task tool's vocabulary (never hidden), whose description
574
642
  names its **pool provenance** (namespace + origin forge.json path +
@@ -576,8 +644,10 @@ A rejected entry costs only itself — its siblings materialize normally.
576
644
  layer), carrying the pinned `model`
577
645
  (pinned workers) or NO model key at all (Auto workers — the description
578
646
  says "inherits the parent session's model" instead of naming a brain),
579
- the worker discipline (workspace-relative paths only, verbatim reporting
580
- of tool refusals, evidence-bearing conclusions) around its role prompt,
647
+ the worker discipline (workspace-relative paths only, verbatim reporting
648
+ of tool refusals, evidence-bearing conclusions, search through
649
+ `forge_grep`/`forge_glob` first with builtin `grep`/`glob` as fallback
650
+ only) around its role prompt,
581
651
  and a deny-style permission: an ABSENT `shape` defaults to WRITE (open
582
652
  permissions — the compatibility-preserving default for existing pools);
583
653
  an explicit `shape: "readonly"` denies
@@ -675,7 +745,7 @@ Covered WRITE-CLASS indicators:
675
745
  | `mutating-cmdlet` | `Set-Content`, `Out-File`, `New-Item`, `Remove-Item`, `Move-Item`, `Copy-Item`, `Add-Content`, `Clear-Content` |
676
746
  | `git-mutation` | `git add/commit/push/reset/checkout/rebase/merge/clean/rm` |
677
747
  | `pkg-mutation` | `npm publish`, `npm install` (incl. `npm i`/`ci`/`add`), `bun add`/`install`, `pip`/`pip3 install` |
678
- | `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 |
679
749
 
680
750
  **Honest positioning — this is belt-in-depth, NOT a sandbox.** A command
681
751
  that fails to classify is treated as READ-CLASS (fail-open on the channel,
@@ -684,7 +754,9 @@ the same posture as every refusal belt). The known bypass surface, verbatim:
684
754
  no redirect is indirection the classifier does not model), **`Start-Process`
685
755
  writers** (spawning a writer process is explicitly out of scope), and
686
756
  **encoded payloads** (`powershell -EncodedPayload …`, base64 blobs) are NOT
687
- 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
688
760
  from "completely open" to "explicitly constructed indirection" — a true OS
689
761
  level seal (write-time redirection, shadow copies, containers, win32+POSIX
690
762
  dual implementations) was evaluated and rejected as a disproportionate
@@ -789,22 +861,52 @@ requires a plan. The flow:
789
861
  (arm=true) folding the declared subtasks into criteria/checks; the arm
790
862
  dialog is the user's gate;
791
863
  - **standby** — no call; the crew waits until armed or abandoned.
792
- 3. **Execute in waves** (armed crews only) of parallel native `task` calls
793
- (results return in-turn; the next wave launches only after the previous
794
- wave's results). GUI / computer-use subtasks are mutually exclusive within
795
- a wave (the desktop is a singleton; non-GUI subtasks still parallelize
796
- freely), and GUI walkthroughs use the `computer` tool rather than DIY
797
- shell screenshot pipelines.
798
- 4. **Missing role?** Run the subtask through a native task call anyway and
799
- tell the user — suggest configuring the missing role.
800
- 5. **Verify** each subtask against its acceptance-evidence statement;
801
- retry a failure at most once.
802
- 6. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
803
- report against the DECLARED plan: every declared subtask needs a
804
- PASS/FAIL verdict with evidence; an undeclared subtask in the report
805
- refuses the close (fold discoveries into an existing verdict, or discard
806
- and re-crew); a FAIL needs both failure reports.
807
- 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
808
910
  — ask-gated like the close, no verdict requirements, usable from PENDING
809
911
  (standby cancel) and EXECUTING (wrong decomposition) alike. The session is
810
912
  immediately free for a fresh `crew_begin` with the corrected plan.
package/dist/index.js CHANGED
@@ -12334,9 +12334,9 @@ function tool(input) {
12334
12334
  }
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
- import { execFileSync as execFileSync3 } from "node:child_process";
12337
+ import { execFileSync as execFileSync3, spawn as spawn6 } from "node:child_process";
12338
12338
  import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
12339
- import { basename as basename2, dirname as dirname4, isAbsolute, join as join6, relative } from "node:path";
12339
+ import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join6, relative as relative2 } from "node:path";
12340
12340
  import { tmpdir as tmpdir3, homedir } from "node:os";
12341
12341
 
12342
12342
  // src/plan-file.ts
@@ -15029,6 +15029,7 @@ var WORKER_MANDATES = [
15029
15029
  "Mandates (non-negotiable):",
15030
15030
  "- Use workspace-relative paths ONLY. Never write to or read from absolute paths outside this workspace; the workspace root is where you were started, not the filesystem root.",
15031
15031
  "- Run every shell command through the forge_shell tool (the builtin shell is not part of your toolset by design). Long-running or non-exiting commands: forge_shell with run_in_background, then collect results via forge_jobs poll before yielding.",
15032
+ "- Run every search through the forge_grep/forge_glob tools first — the builtin grep/glob tools are fallback only. forge_grep/forge_glob see through symlinks/junctions and dot-directories because follow and hidden are on by default.",
15032
15033
  "- If any tool call is refused or denied, report the refusal VERBATIM in your final answer and stop that line of work. Never improvise a workaround around a permission denial.",
15033
15034
  "- End with conclusions backed by evidence references (file:line, command output, or the exact file/content you produced)."
15034
15035
  ].join(`
@@ -15047,10 +15048,11 @@ var ROLE_PROMPTS = {
15047
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.",
15048
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."
15049
15050
  };
15050
- function rolePromptFor(agentId, def) {
15051
- if (def?.prompt && def.prompt.trim().length > 0)
15052
- return def.prompt;
15053
- 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}`;
15054
15056
  }
15055
15057
  function poolProvenanceLine(provenance) {
15056
15058
  if ("global" in provenance) {
@@ -15064,7 +15066,7 @@ function agentDescriptionFor(agentId, def, provenance) {
15064
15066
  const base = `forge subagent "${agentId}" (${shape}, ${brain}) — dispatch via the task tool with subagent_type "forge-${agentId}".`;
15065
15067
  return provenance === undefined ? base : `${base} ${poolProvenanceLine(provenance)}`;
15066
15068
  }
15067
- function forgeAgentDef(agentId, def, provenance) {
15069
+ function forgeAgentDef(agentId, def, provenance, sharedSeg) {
15068
15070
  const permission = { task: "deny" };
15069
15071
  const shape = def.shape ?? "write";
15070
15072
  if (shape === "readonly") {
@@ -15079,7 +15081,7 @@ function forgeAgentDef(agentId, def, provenance) {
15079
15081
  prompt: materializeAgentPrompt({
15080
15082
  agent: agentId,
15081
15083
  shape,
15082
- role: rolePromptFor(agentId, def)
15084
+ role: rolePromptFor(agentId, def, sharedSeg)
15083
15085
  }),
15084
15086
  permission,
15085
15087
  tools: { shell: false, bash: false }
@@ -15141,12 +15143,27 @@ var WRITE_TARGET_FLAGS = [
15141
15143
  "--destination",
15142
15144
  "-o"
15143
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
+ ];
15144
15161
  var EXEC_SEAL_INDICATORS = [
15145
15162
  { id: REDIRECT_INDICATOR, covers: "`>` `>>` and fd forms (`2>` `*>`) — bash and the PowerShell family share the operators" },
15146
15163
  { id: "mutating-cmdlet", covers: MUTATING_CMDLETS.join(", ") },
15147
15164
  { id: "git-mutation", covers: `git ${GIT_MUTATING_SUBCOMMANDS.join("/")}` },
15148
15165
  { id: "pkg-mutation", covers: PKG_MUTATING_COMMANDS.join(", ") + " (with common aliases: npm i/ci/add, bun install, pip3 install)" },
15149
- { 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(", ")})` }
15150
15167
  ];
15151
15168
  function stripQuoted(cmd) {
15152
15169
  let out = "";
@@ -15165,6 +15182,57 @@ function stripQuoted(cmd) {
15165
15182
  }
15166
15183
  return out;
15167
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
+ }
15168
15236
  function redirectMatch(cmd) {
15169
15237
  const bare = stripQuoted(cmd).replace(/\r?\n/g, " ");
15170
15238
  const m = /(?:^|[^>&])>{1,2}(?!&)[^\s;&|]*/.exec(bare);
@@ -15213,25 +15281,33 @@ function insideWorkspace(value, workspaceRoot) {
15213
15281
  return norm(value).startsWith(`${norm(workspaceRoot)}/`) || norm(value) === norm(workspaceRoot);
15214
15282
  }
15215
15283
  function workspaceWriteMatch(cmd, workspaceRoot) {
15216
- 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);
15217
15287
  for (let i = 0;i < tokens.length; i++) {
15218
15288
  const tok = tokens[i];
15289
+ if (tok.quoted)
15290
+ continue;
15219
15291
  let flag = "";
15220
- let value;
15221
- const eq = tok.indexOf("=");
15222
- if (eq > 0 && tok.startsWith("--")) {
15223
- flag = tok.slice(0, eq);
15224
- value = tok.slice(eq + 1);
15225
- } else if (WRITE_TARGET_FLAGS.includes(tok)) {
15226
- flag = tok;
15227
- value = tokens[i + 1];
15228
- }
15229
- 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)
15230
15306
  continue;
15231
- if (value.startsWith("-"))
15307
+ if (valueText.startsWith("-"))
15232
15308
  continue;
15233
- if (pathLike(value) && insideWorkspace(value, workspaceRoot)) {
15234
- return { detail: `${flag} ${value}` };
15309
+ if (pathLike(valueText) && insideWorkspace(valueText, workspaceRoot)) {
15310
+ return { detail: `${flag} ${valueText}` };
15235
15311
  }
15236
15312
  }
15237
15313
  return null;
@@ -15856,6 +15932,7 @@ function createPoolResolver(deps) {
15856
15932
  parsed = {
15857
15933
  agents: {},
15858
15934
  declaredPool: null,
15935
+ shared: null,
15859
15936
  findings: [
15860
15937
  {
15861
15938
  level: "error",
@@ -15865,11 +15942,20 @@ function createPoolResolver(deps) {
15865
15942
  ]
15866
15943
  };
15867
15944
  } else if (doc2.config !== undefined && !isRecord(doc2.config)) {
15868
- 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` }] };
15869
15946
  } else {
15870
15947
  const top = doc2.config === undefined ? undefined : doc2.config;
15871
15948
  const validated = validateAgentSet(top === undefined ? undefined : top.agents);
15872
- 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 };
15873
15959
  }
15874
15960
  fileCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, parsed });
15875
15961
  return parsed;
@@ -15924,7 +16010,7 @@ function createPoolResolver(deps) {
15924
16010
  }
15925
16011
  }
15926
16012
  }
15927
- families.push({ file: file2, parsed, ns: "", declaredPool });
16013
+ families.push({ file: file2, parsed, ns: "", declaredPool, shared: parsed?.shared ?? null });
15928
16014
  }
15929
16015
  const allDeclared = new Set;
15930
16016
  for (const f of families)
@@ -15957,10 +16043,10 @@ function createPoolResolver(deps) {
15957
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` });
15958
16044
  continue;
15959
16045
  }
15960
- 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 };
15961
16047
  ids.push(matId);
15962
16048
  }
15963
- 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 });
15964
16050
  }
15965
16051
  return { families: origins, agents, findings };
15966
16052
  }
@@ -15974,6 +16060,202 @@ function parentDirLabel(filePath) {
15974
16060
  return i < 0 ? s : s.slice(0, i);
15975
16061
  }
15976
16062
 
16063
+ // src/search-core.ts
16064
+ import { join as winJoin } from "node:path";
16065
+ import { join as posixJoin } from "node:path/posix";
16066
+ import { relative, isAbsolute } from "node:path";
16067
+ import { existsSync as fsExistsSync } from "node:fs";
16068
+ var GIT_EXCLUSION_GLOB = "--glob=!**/.git/**";
16069
+ function buildRgArgv(params) {
16070
+ const { kind, pattern, path, include } = params;
16071
+ const follow = params.follow !== false;
16072
+ const hidden = params.hidden !== false;
16073
+ const argv = ["--no-config", "--json"];
16074
+ if (hidden)
16075
+ argv.push("--hidden");
16076
+ if (follow)
16077
+ argv.push("--follow");
16078
+ argv.push(GIT_EXCLUSION_GLOB);
16079
+ if (typeof include === "string" && include.length > 0) {
16080
+ argv.push(`--glob=${include}`);
16081
+ }
16082
+ argv.push("--");
16083
+ argv.push(pattern);
16084
+ if (typeof path === "string" && path.length > 0)
16085
+ argv.push(path);
16086
+ return argv;
16087
+ }
16088
+ function buildRgFilesArgv(params) {
16089
+ const follow = params.follow !== false;
16090
+ const hidden = params.hidden !== false;
16091
+ const argv = ["--no-config", "--files"];
16092
+ if (hidden)
16093
+ argv.push("--hidden");
16094
+ if (follow)
16095
+ argv.push("--follow");
16096
+ argv.push(`--glob=${params.pattern}`);
16097
+ argv.push(GIT_EXCLUSION_GLOB);
16098
+ if (typeof params.path === "string" && params.path.length > 0)
16099
+ argv.push(params.path);
16100
+ return argv;
16101
+ }
16102
+ function parseRgFilesList(stdout) {
16103
+ if (typeof stdout !== "string" || stdout.length === 0)
16104
+ return [];
16105
+ return stdout.split(/\r?\n/).filter((l) => l.length > 0).map((p) => ({ path: p, line: 0, text: "" }));
16106
+ }
16107
+ var MAX_STREAM_LINE_LENGTH = 1048576;
16108
+ function parseRgJsonStream(stdout) {
16109
+ const out = { records: [], garbageLines: 0, oversizedLines: 0 };
16110
+ if (typeof stdout !== "string" || stdout.length === 0)
16111
+ return out;
16112
+ const lines = stdout.split(/\r?\n/);
16113
+ for (const raw of lines) {
16114
+ if (raw.length === 0)
16115
+ continue;
16116
+ if (raw.length > MAX_STREAM_LINE_LENGTH) {
16117
+ out.oversizedLines++;
16118
+ continue;
16119
+ }
16120
+ let ev;
16121
+ try {
16122
+ ev = JSON.parse(raw);
16123
+ } catch {
16124
+ out.garbageLines++;
16125
+ continue;
16126
+ }
16127
+ if (ev === null || typeof ev !== "object" || ev.type !== "match")
16128
+ continue;
16129
+ const data = ev.data;
16130
+ if (data === null || typeof data !== "object")
16131
+ continue;
16132
+ const pathText = data?.path?.text;
16133
+ const lineNumber = data?.line_number;
16134
+ const lineText = data?.lines?.text;
16135
+ if (typeof pathText !== "string" || typeof lineNumber !== "number" || typeof lineText !== "string")
16136
+ continue;
16137
+ out.records.push({ path: pathText, line: lineNumber, text: lineText });
16138
+ }
16139
+ return out;
16140
+ }
16141
+ function createMemoizedResolver(resolve2) {
16142
+ const cache = new Map;
16143
+ return (p) => {
16144
+ const hit = cache.get(p);
16145
+ if (hit !== undefined)
16146
+ return hit;
16147
+ const v = resolve2(p);
16148
+ cache.set(p, v);
16149
+ return v;
16150
+ };
16151
+ }
16152
+ function isOutsideWorktree(resolved, root) {
16153
+ const norm = (s) => s.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
16154
+ const r = norm(root);
16155
+ const v = norm(resolved);
16156
+ if (v === r)
16157
+ return false;
16158
+ const rel = relative(r, v);
16159
+ return rel.startsWith("..") || isAbsolute(rel);
16160
+ }
16161
+ function dedupeFollowMatches(records, opts) {
16162
+ if (!opts.follow)
16163
+ return { records: [...records], mergedDuplicates: 0, outsideTargets: [] };
16164
+ const resolve2 = createMemoizedResolver(opts.resolvePath);
16165
+ const seen = new Map;
16166
+ const outside = new Set;
16167
+ let merged = 0;
16168
+ for (const rec of records) {
16169
+ const real = resolve2(rec.path);
16170
+ if (isOutsideWorktree(real, opts.worktreeRoot))
16171
+ outside.add(real);
16172
+ const bucket = seen.get(real);
16173
+ if (bucket === undefined) {
16174
+ seen.set(real, [rec]);
16175
+ continue;
16176
+ }
16177
+ const sameLine = bucket.find((k) => k.line === rec.line);
16178
+ if (sameLine !== undefined) {
16179
+ merged++;
16180
+ if (rec.path < sameLine.path)
16181
+ sameLine.path = rec.path;
16182
+ continue;
16183
+ }
16184
+ if (rec.path < bucket[0].path)
16185
+ bucket[0].path = rec.path;
16186
+ bucket.push(rec);
16187
+ }
16188
+ const kept = [];
16189
+ for (const bucket of seen.values()) {
16190
+ const firstPath = bucket.reduce((m, r) => r.path < m ? r.path : m, bucket[0].path);
16191
+ for (const r of bucket)
16192
+ kept.push({ ...r, path: firstPath });
16193
+ }
16194
+ kept.sort((a, b) => a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1);
16195
+ return { records: kept, mergedDuplicates: merged, outsideTargets: [...outside].sort() };
16196
+ }
16197
+ var RG_MISSING_REMEDIES = "install ripgrep (or set search.rgPath in forge config to an existing rg binary), or fall back to the builtin grep/glob tools for this search";
16198
+ function resolveRgBinary(config2, seams) {
16199
+ const platform = seams?.platform ?? process.platform;
16200
+ const sep2 = seams?.pathSep ?? (platform === "win32" ? ";" : ":");
16201
+ const exists = seams?.existsSync ?? fsExistsSync;
16202
+ const pathEnv = seams?.pathEnv ?? (process.env.PATH ?? "");
16203
+ if (typeof config2?.rgPath === "string" && config2.rgPath.length > 0 && exists(config2.rgPath)) {
16204
+ return { ok: true, binary: config2.rgPath, source: "config" };
16205
+ }
16206
+ const exe = platform === "win32" ? "rg.exe" : "rg";
16207
+ const join6 = platform === "win32" ? winJoin : posixJoin;
16208
+ for (const dir of pathEnv.split(sep2)) {
16209
+ if (dir.length === 0)
16210
+ continue;
16211
+ const candidate = join6(dir, exe);
16212
+ if (exists(candidate))
16213
+ return { ok: true, binary: candidate, source: "path" };
16214
+ }
16215
+ return {
16216
+ ok: false,
16217
+ error: `ripgrep (rg) not found — ${RG_MISSING_REMEDIES}`
16218
+ };
16219
+ }
16220
+ var MAX_RESULTS = 100;
16221
+ function formatSearchOutput(records, opts) {
16222
+ const lines = [];
16223
+ const total = records.length;
16224
+ if (total === 0) {
16225
+ lines.push("No files found");
16226
+ } else if (opts.kind === "glob") {
16227
+ const unique = [];
16228
+ const seen = new Set;
16229
+ for (const r of records) {
16230
+ if (!seen.has(r.path)) {
16231
+ seen.add(r.path);
16232
+ unique.push(r.path);
16233
+ }
16234
+ }
16235
+ for (const p of unique.slice(0, MAX_RESULTS))
16236
+ lines.push(p);
16237
+ if (unique.length > MAX_RESULTS) {
16238
+ lines.push(`(truncated at ${MAX_RESULTS} results — narrow the path or pattern)`);
16239
+ }
16240
+ } else {
16241
+ lines.push(`Found ${total} matches`);
16242
+ const shown = records.slice(0, MAX_RESULTS);
16243
+ for (const r of shown)
16244
+ lines.push(`${r.path}: Line ${r.line}: ${r.text.replace(/\r?\n$/, "")}`);
16245
+ if (total > MAX_RESULTS) {
16246
+ lines.push(`(truncated at ${MAX_RESULTS} results of ${total} — narrow the path or pattern)`);
16247
+ }
16248
+ }
16249
+ for (const t of opts.outsideTargets ?? []) {
16250
+ lines.push(`outside-workspace target searched via link: ${t}`);
16251
+ }
16252
+ const merged = opts.mergedDuplicates ?? 0;
16253
+ if (merged > 0)
16254
+ lines.push(`+${merged} duplicate paths merged`);
16255
+ return lines.join(`
16256
+ `);
16257
+ }
16258
+
15977
16259
  // plugin.ts
15978
16260
  var FORGE_AGENT = "forge";
15979
16261
  var FORGE_STATE_TOOLS = [
@@ -15992,7 +16274,8 @@ var FORGE_STATE_TOOLS = [
15992
16274
  "crew_close"
15993
16275
  ];
15994
16276
  var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
15995
- var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
16277
+ var FORGE_SEARCH_TOOLS = ["forge_grep", "forge_glob"];
16278
+ var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS, ...FORGE_SEARCH_TOOLS];
15996
16279
  var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
15997
16280
  var FORGE_STATE_TOOL_SET = new Set(FORGE_STATE_TOOLS);
15998
16281
  var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
@@ -16165,6 +16448,8 @@ function crewPoolComplianceRefusal(anchor) {
16165
16448
  const suggested = fam?.ns ?? sanitizeNamespace(basename2(dirname4(dirname4(poolFile))));
16166
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.`;
16167
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.";
16168
16453
  var crews = new Map;
16169
16454
  function crewRecordPathFor(worktree, objective) {
16170
16455
  const dir = join6(worktree, ".opencode", "crew");
@@ -16214,7 +16499,7 @@ function crewPendingChoiceLines() {
16214
16499
  return [
16215
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):",
16216
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.`,
16217
- ` 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);`,
16218
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;`,
16219
16504
  ` 3. standby — no call; the crew waits until armed or abandoned.`
16220
16505
  ];
@@ -16424,7 +16709,7 @@ function resolveActivePlan(state) {
16424
16709
  return { path, doc: ranked[0].doc };
16425
16710
  }
16426
16711
  function relFrom(worktree, path) {
16427
- const rel = relative(worktree, path);
16712
+ const rel = relative2(worktree, path);
16428
16713
  return rel && !rel.startsWith("..") ? rel.replaceAll("\\", "/") : path;
16429
16714
  }
16430
16715
  var planWriteTool = tool({
@@ -16912,7 +17197,7 @@ var forgeShellTool = tool({
16912
17197
  }
16913
17198
  }
16914
17199
  const { registry: registry2, fence } = ensureJobLifecycle();
16915
- const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
17200
+ const cwd = args.workdir ? isAbsolute2(args.workdir) ? args.workdir : join6(state.worktree, args.workdir) : state.worktree;
16916
17201
  const started = startJob(jobManager, {
16917
17202
  cmd: args.command,
16918
17203
  cwd,
@@ -17066,6 +17351,107 @@ ${p.newOutput || "(none in this window)"}`,
17066
17351
  };
17067
17352
  }
17068
17353
  });
17354
+ var searchRgPathOverride;
17355
+ var FORGE_SEARCH_PREFERENCE_LINE = "Prefer forge_grep/forge_glob over the builtin grep/glob — follow and hidden are on by default (symlinks/junctions and dot-directories are in scope; the builtin pair is blind to both).";
17356
+ var FORGE_GREP_DESCRIPTION = [
17357
+ "Fast content search (rg --json under the hood) that sees through symlinks/junctions and dot-directories where the builtin pair is blind. Readonly by construction: the pattern and path are passed to ripgrep as single literal arguments — no shell exists, so shell metacharacters can neither redirect output nor alter execution.",
17358
+ "Output matches the builtin grep's format: `Found N matches` header + `path: Line n: content` blocks, truncated at 100 results with a notice. With follow on (default), a file reachable through multiple paths reports once and the output ends with a `+N duplicate paths merged` line; links whose targets lie outside the workspace are followed and the resolved outside target is disclosed.",
17359
+ FORGE_SEARCH_PREFERENCE_LINE
17360
+ ].join(`
17361
+ `);
17362
+ var FORGE_GLOB_DESCRIPTION = [
17363
+ "Fast file-pattern search (rg --files under the hood, the file pattern rides rg's --glob filter) that lists matching paths — including EMPTY files — and paths behind symlinks/junctions and inside dot-directories where the builtin glob is blind. Readonly by construction — no shell ever exists.",
17364
+ "Output matches the builtin glob's format: a plain unique path list (`No files found` when empty), truncated at 100 results with a notice. With follow on (default), duplicate link-path hits merge and the output ends with a `+N duplicate paths merged` line.",
17365
+ FORGE_SEARCH_PREFERENCE_LINE
17366
+ ].join(`
17367
+ `);
17368
+ function reportedPathResolver(worktree) {
17369
+ return (p) => {
17370
+ try {
17371
+ return realpathSync.native(isAbsolute2(p) ? p : join6(worktree, p)).toString();
17372
+ } catch {
17373
+ return p;
17374
+ }
17375
+ };
17376
+ }
17377
+ async function runRgSearch(kind, args, context) {
17378
+ const resolution = resolveRgBinary(searchRgPathOverride !== undefined ? { rgPath: searchRgPathOverride } : undefined);
17379
+ if (!resolution.ok)
17380
+ return { title: "ripgrep not found", output: resolution.error };
17381
+ const state = ensureSession(context.sessionID, worktreeFor(context));
17382
+ const follow = args.follow !== false;
17383
+ const hidden = args.hidden !== false;
17384
+ const targetPath = typeof args.path === "string" && args.path.length > 0 ? isAbsolute2(args.path) ? args.path : join6(state.worktree, args.path) : undefined;
17385
+ const searchPath = targetPath ?? ".";
17386
+ const argv = kind === "glob" ? buildRgFilesArgv({ pattern: args.pattern, path: searchPath, follow, hidden }) : buildRgArgv({ kind, pattern: args.pattern, include: args.include, path: searchPath, follow, hidden });
17387
+ context.metadata?.({ title: `forge_${kind}: ${args.pattern.slice(0, 60)}` });
17388
+ const child = spawn6(resolution.binary, argv, { cwd: state.worktree, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
17389
+ let stdout = "";
17390
+ let stderr = "";
17391
+ let spawnError;
17392
+ child.on("error", (err) => {
17393
+ spawnError = err;
17394
+ });
17395
+ child.stdout?.on("data", (d) => {
17396
+ stdout += d.toString("utf8");
17397
+ });
17398
+ child.stderr?.on("data", (d) => {
17399
+ stderr += d.toString("utf8");
17400
+ });
17401
+ const code = await new Promise((resolve2) => {
17402
+ child.on("close", (c) => resolve2(c));
17403
+ });
17404
+ if (spawnError !== undefined) {
17405
+ return { title: "rg spawn failed", output: `[forge:search] Failed to run ripgrep (${resolution.binary}): ${spawnError.message}` };
17406
+ }
17407
+ if (code !== null && code > 1) {
17408
+ return { title: `rg exit ${code}`, output: [`[forge:search] ripgrep exited ${code}:`, stderr.trim() || "(no stderr)", "Narrow the path or fix the pattern (rg treats it as a regular expression)."].join(`
17409
+ `) };
17410
+ }
17411
+ const stripDotPrefix = (p) => p.replace(/^\.(?:\\|\/)/, "");
17412
+ const rawRecords = kind === "glob" ? parseRgFilesList(stdout) : parseRgJsonStream(stdout).records;
17413
+ const records = rawRecords.map((r) => ({ ...r, path: stripDotPrefix(r.path) }));
17414
+ const deduped = dedupeFollowMatches(records, {
17415
+ worktreeRoot: state.worktree,
17416
+ resolvePath: reportedPathResolver(state.worktree),
17417
+ follow
17418
+ });
17419
+ return {
17420
+ title: `${deduped.records.length} result${deduped.records.length === 1 ? "" : "s"}`,
17421
+ output: formatSearchOutput(deduped.records, {
17422
+ kind,
17423
+ mergedDuplicates: deduped.mergedDuplicates,
17424
+ outsideTargets: deduped.outsideTargets
17425
+ })
17426
+ };
17427
+ }
17428
+ var forgeGrepTool = tool({
17429
+ description: FORGE_GREP_DESCRIPTION,
17430
+ args: {
17431
+ pattern: tool.schema.string().describe("The regular expression to search for (passed to ripgrep verbatim as a single literal argument)"),
17432
+ path: tool.schema.string().optional().describe("Optional target file or directory (workspace-relative, or absolute). Defaults to the session worktree"),
17433
+ include: tool.schema.string().optional().describe('File filter glob (e.g. "*.ts") — only matching files are searched'),
17434
+ follow: tool.schema.boolean().optional().describe("Follow symlinks/junctions during traversal (default TRUE — diverges from the builtin pair by design)"),
17435
+ hidden: tool.schema.boolean().optional().describe("Search dot-directories (default TRUE — diverges from the builtin pair by design)")
17436
+ },
17437
+ execute: async (args, context) => {
17438
+ noteToolAgent(context);
17439
+ return runRgSearch("grep", { pattern: args.pattern, path: args.path, include: args.include, follow: args.follow, hidden: args.hidden }, context);
17440
+ }
17441
+ });
17442
+ var forgeGlobTool = tool({
17443
+ description: FORGE_GLOB_DESCRIPTION,
17444
+ args: {
17445
+ pattern: tool.schema.string().describe(`The file-pattern glob to list (e.g. "**/*.ts") — rides ripgrep's --glob filter verbatim`),
17446
+ path: tool.schema.string().optional().describe("Optional target directory (workspace-relative, or absolute). Defaults to the session worktree"),
17447
+ follow: tool.schema.boolean().optional().describe("Follow symlinks/junctions during traversal (default TRUE — diverges from the builtin pair by design)"),
17448
+ hidden: tool.schema.boolean().optional().describe("List dot-directories' files (default TRUE — diverges from the builtin pair by design)")
17449
+ },
17450
+ execute: async (args, context) => {
17451
+ noteToolAgent(context);
17452
+ return runRgSearch("glob", { pattern: args.pattern, path: args.path, follow: args.follow, hidden: args.hidden }, context);
17453
+ }
17454
+ });
17069
17455
  var CREW_INIT_TEMPLATE = [
17070
17456
  '(forge crew orchestration. Argument: "$ARGUMENTS")',
17071
17457
  "",
@@ -17106,8 +17492,8 @@ function crewOrchestrationTemplate(resolution) {
17106
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:",
17107
17493
  "",
17108
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.',
17109
- `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.`,
17110
- "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).",
17111
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.",
17112
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.",
17113
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.",
@@ -17201,9 +17587,11 @@ var crewBeginTool = tool({
17201
17587
  title: `crew armed: ${crew.objective.slice(0, 60)}`,
17202
17588
  output: [
17203
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).`,
17204
17591
  `Declared plan (${crew.subtasks.length}):`,
17205
17592
  ...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
17206
- "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
17207
17595
  ].join(`
17208
17596
  `)
17209
17597
  };
@@ -17255,6 +17643,7 @@ var crewBeginTool = tool({
17255
17643
  recordNote = "Crew record could not be written (fail-soft; the crew still runs).";
17256
17644
  }
17257
17645
  }
17646
+ crewEngineForgetSession(context.sessionID);
17258
17647
  crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
17259
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}` : ""}`));
17260
17649
  let disclosure = "";
@@ -17292,7 +17681,7 @@ var crewBeginTool = tool({
17292
17681
  ...disclosure ? [disclosure] : []
17293
17682
  ];
17294
17683
  if (governed) {
17295
- 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);
17296
17685
  } else {
17297
17686
  lines.push(...crewPendingChoiceLines());
17298
17687
  }
@@ -17383,6 +17772,8 @@ function forgeTools() {
17383
17772
  tools.forge_shell = forgeShellTool;
17384
17773
  tools.forge_jobs = forgeJobsTool;
17385
17774
  }
17775
+ tools.forge_grep = forgeGrepTool;
17776
+ tools.forge_glob = forgeGlobTool;
17386
17777
  if (!forgeDisabled) {
17387
17778
  tools.crew_begin = crewBeginTool;
17388
17779
  tools.crew_close = crewCloseTool;
@@ -17430,6 +17821,91 @@ function engineForgetAll() {
17430
17821
  for (const id of [...idleTimers.keys()])
17431
17822
  engineForgetSession(id);
17432
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
+ }
17433
17909
  function goalBriefText(state, goal) {
17434
17910
  const d = goal.doc;
17435
17911
  return [
@@ -17442,7 +17918,7 @@ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\``
17442
17918
  `)}`,
17443
17919
  d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
17444
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.`,
17445
- `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).`,
17446
17922
  `Goal file: ${relFrom(state.worktree, goal.path)}`
17447
17923
  ].filter((s) => s.length > 0).join(`
17448
17924
  `);
@@ -17516,12 +17992,9 @@ async function continueIfEligible(client, sessionID) {
17516
17992
  }
17517
17993
  }
17518
17994
  const goal = resolveLiveGoal(state);
17519
- if (!goal || goal.doc.status !== "active") {
17520
- goalProbe(`skip: no live active goal session=${sessionID}`);
17521
- return;
17522
- }
17523
- if (!goal.doc.session || goal.doc.session !== sessionID) {
17524
- 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);
17525
17998
  return;
17526
17999
  }
17527
18000
  if (!sessionIsForgeFamily(sessionID)) {
@@ -17628,6 +18101,8 @@ var server = async (input, options) => {
17628
18101
  jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
17629
18102
  if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
17630
18103
  jobsSurviveMode = jobsOpts.survive;
18104
+ const searchOpts = options?.search;
18105
+ searchRgPathOverride = typeof searchOpts?.rgPath === "string" && searchOpts.rgPath.trim().length > 0 ? searchOpts.rgPath : undefined;
17631
18106
  const wdOpts = options?.watchdog;
17632
18107
  const wdFallbacks = [];
17633
18108
  const wdMode = parseMode(wdOpts?.mode);
@@ -17742,7 +18217,7 @@ var server = async (input, options) => {
17742
18217
  if (agentSection[id] !== undefined)
17743
18218
  continue;
17744
18219
  const provenance = foldPath(agent.file) === foldPath(globalMaterializationPath) ? { global: true } : { ns: agent.ns, file: agent.file };
17745
- const entry = forgeAgentDef(matId, agent.def, provenance);
18220
+ const entry = forgeAgentDef(matId, agent.def, provenance, agent.shared ?? undefined);
17746
18221
  agentSection[id] = entry;
17747
18222
  materializedSnapshot[id] = entry;
17748
18223
  }
@@ -17859,7 +18334,7 @@ var server = async (input, options) => {
17859
18334
  if (anchor) {
17860
18335
  const complianceRefusal = crewPoolComplianceRefusal(anchor);
17861
18336
  if (complianceRefusal)
17862
- throw new Error(complianceRefusal);
18337
+ throw new Error(complianceRefusal + CREW_COMPLIANCE_NATIVE_EXIT);
17863
18338
  }
17864
18339
  }
17865
18340
  }
@@ -17872,7 +18347,7 @@ var server = async (input, options) => {
17872
18347
  if (raw) {
17873
18348
  const state = stateForBan(input2.sessionID);
17874
18349
  const base = state?.worktree ?? hostWorktree ?? "";
17875
- const abs = (isAbsolute(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
18350
+ const abs = (isAbsolute2(raw) ? raw : join6(base, raw)).replaceAll("\\", "/").toLowerCase();
17876
18351
  if (abs.includes("/.opencode/plan/") || abs.includes("/.opencode/goal/")) {
17877
18352
  throw new Error(`[forge:partition] Write refused: "${raw}" is inside the forge state directories (.opencode/plan, .opencode/goal), which belong to the forge agent in this workspace.`);
17878
18353
  }
@@ -18077,6 +18552,8 @@ var server = async (input, options) => {
18077
18552
  sealFindings.delete(info.id);
18078
18553
  sealUnknownNotes.delete(info.id);
18079
18554
  poolGateUnknownNotes.delete(info.id);
18555
+ crews.delete(info.id);
18556
+ crewEngineForgetSession(info.id);
18080
18557
  }
18081
18558
  }
18082
18559
  },
@@ -18109,12 +18586,19 @@ var server = async (input, options) => {
18109
18586
  if (typeof input2.tool !== "string")
18110
18587
  return;
18111
18588
  const act = turnActivity.get(input2.sessionID);
18112
- if (!act)
18113
- return;
18114
- if (isWriteTool(input2.tool) || input2.tool === "plan_tick")
18115
- act.writes++;
18116
- if (input2.tool === "goal_check")
18117
- 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
+ }
18118
18602
  },
18119
18603
  "experimental.chat.system.transform": async (input2, output) => {
18120
18604
  if (!input2.sessionID)
@@ -18203,7 +18687,7 @@ async function v2Setup(ctx) {
18203
18687
  if (draft.get(id) !== undefined)
18204
18688
  continue;
18205
18689
  const provenance = foldPath(agent.file) === foldPath(v2GlobalPath) ? { global: true } : { ns: agent.ns, file: agent.file };
18206
- const v1def = forgeAgentDef(matId, agent.def, provenance);
18690
+ const v1def = forgeAgentDef(matId, agent.def, provenance, agent.shared ?? undefined);
18207
18691
  draft.update(id, (agentDraft) => {
18208
18692
  agentDraft.description = v1def.description;
18209
18693
  agentDraft.system = v1def.prompt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sorenllm/opencode-forge",
3
- "version": "0.18.1",
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",