@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.
- package/README.md +123 -21
- package/dist/index.js +537 -53
- 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
|
-
|
|
580
|
-
|
|
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
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
4. **
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
15052
|
-
|
|
15053
|
-
|
|
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 =
|
|
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
|
|
15221
|
-
const
|
|
15222
|
-
|
|
15223
|
-
|
|
15224
|
-
|
|
15225
|
-
|
|
15226
|
-
|
|
15227
|
-
|
|
15228
|
-
|
|
15229
|
-
|
|
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 (
|
|
15307
|
+
if (valueText.startsWith("-"))
|
|
15232
15308
|
continue;
|
|
15233
|
-
if (pathLike(
|
|
15234
|
-
return { detail: `${flag} ${
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 ?
|
|
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(`
|
|
17521
|
-
|
|
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 = (
|
|
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 (
|
|
18113
|
-
|
|
18114
|
-
|
|
18115
|
-
|
|
18116
|
-
|
|
18117
|
-
|
|
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.
|
|
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",
|