@sorenllm/opencode-forge 0.11.1 → 0.12.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 +64 -11
- package/dist/index.js +203 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,8 +150,8 @@ and the plugin's command of that name is not registered.
|
|
|
150
150
|
(otherwise the name lingers in the agent list).
|
|
151
151
|
4. Done — all runtime injections (agents, tool hiding, commands) are gone
|
|
152
152
|
with the plugin; nothing was ever written to your config
|
|
153
|
-
(the hide was runtime-only). Your `.opencode/plan
|
|
154
|
-
files are yours; delete them yourself if you want.
|
|
153
|
+
(the hide was runtime-only). Your `.opencode/plan/`, `.opencode/goal/`,
|
|
154
|
+
and `.opencode/crew/` files are yours; delete them yourself if you want.
|
|
155
155
|
5. Optional runtime debris: delete `<tmp>/opencode-forge/` (job logs, the
|
|
156
156
|
job registry ledger, the watchdog ledger, and any leftover dispatch
|
|
157
157
|
ledger from older versions — the file ledger table above lists
|
|
@@ -165,6 +165,7 @@ What this plugin touches, exhaustively:
|
|
|
165
165
|
| --- | --- | --- |
|
|
166
166
|
| `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
|
|
167
167
|
| `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
|
|
168
|
+
| `<project>/.opencode/crew/*.md` | crew records (declared plan + arm/convert/abandon/close history; written at `crew_begin` registration, appended at each transition; record-only — never a resume mechanism) | user data — kept forever, uninstall never deletes |
|
|
168
169
|
| merged config object (RAM only) | forge agent, static `forge-<id>` subagents, tool-partition injections (forge family shell hide, non-forge forge-tool hide, native build/plan/general/explore minimal entries), `command.plan`, `command.goal`, `command.crew`, goal permission keys, `permission.forge_shell`, `permission.crew_close` | vanishes when the plugin is removed; nothing is written to disk |
|
|
169
170
|
| `<tmp>/opencode-forge/jobs/<jobId>.log` | job output tee (full output; oldest rotated out above 50 files) | runtime debris — delete freely, also after uninstall |
|
|
170
171
|
| `<tmp>/opencode-forge/jobs/ledger.jsonl` | job registry ledger (bounded: 1 MB reset, 200 entries) | runtime debris — delete freely, also after uninstall |
|
|
@@ -428,6 +429,12 @@ workspace-local file that the host was not launched on is NOT silently
|
|
|
428
429
|
ignored: `/crew` names it and tells you to relaunch there (or fold the
|
|
429
430
|
agents into the global layer).
|
|
430
431
|
|
|
432
|
+
**Id naming**: use plain role words (`research`, `coder`, …) — the plugin
|
|
433
|
+
materializes every id as `forge-<id>` itself. A `forge-`-prefixed id in the
|
|
434
|
+
file would double up (`forge-coder` → agent `forge-forge-coder`); the loader
|
|
435
|
+
auto-strips the prefix (all repetitions) with a warning so existing files
|
|
436
|
+
keep working, but rename the entries to silence it.
|
|
437
|
+
|
|
431
438
|
```jsonc
|
|
432
439
|
{
|
|
433
440
|
// forge subagents — definitions; see the restart table below for apply timing
|
|
@@ -530,23 +537,69 @@ mid-session never rewrites a running session).
|
|
|
530
537
|
## Crew workflow (`/crew`)
|
|
531
538
|
|
|
532
539
|
`/crew <objective>` is the third state-activation command next to `/plan` and
|
|
533
|
-
`/goal`: it enters crew orchestration discipline for the session. The
|
|
540
|
+
`/goal`: it enters crew orchestration discipline for the session. The macro
|
|
541
|
+
contract above the crew may come from an approved plan, an OpenSpec change, or
|
|
542
|
+
inline text — the discipline is identical in all three cases and never
|
|
543
|
+
requires a plan. The flow:
|
|
534
544
|
|
|
535
545
|
1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
|
|
536
546
|
titled subtask each, optionally naming the intended `forge-*` agent.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
547
|
+
Registration enters a **PENDING** crew: execution has not started. The
|
|
548
|
+
registration output confirms the roster **with its origin** (which
|
|
549
|
+
forge.json layer the dispatchable set materialized from), points at the
|
|
550
|
+
crew record file, presents the three execution-mode choices, and stops the
|
|
551
|
+
turn. Reconnaissance task calls are free BEFORE registration.
|
|
552
|
+
2. **The pause is mechanical**: while the crew pends, every `task` dispatch is
|
|
553
|
+
refused by an interception belt. The user chooses:
|
|
554
|
+
- **supervised waves now** — `crew_begin {execution: "waves"}` lifts the
|
|
555
|
+
belt and the wave discipline runs;
|
|
556
|
+
- **convert to a goal contract** — `crew_begin {execution: "goal"}` ends
|
|
557
|
+
the crew as a conversion record and the session drafts `goal_write`
|
|
558
|
+
(arm=true) folding the declared subtasks into criteria/checks; the arm
|
|
559
|
+
dialog is the user's gate;
|
|
560
|
+
- **standby** — no call; the crew waits until armed or abandoned.
|
|
561
|
+
3. **Execute in waves** (armed crews only) of parallel native `task` calls
|
|
562
|
+
(results return in-turn; the next wave launches only after the previous
|
|
563
|
+
wave's results). GUI / computer-use subtasks are mutually exclusive within
|
|
564
|
+
a wave (the desktop is a singleton; non-GUI subtasks still parallelize
|
|
565
|
+
freely), and GUI walkthroughs use the `computer` tool rather than DIY
|
|
566
|
+
shell screenshot pipelines.
|
|
567
|
+
4. **Missing role?** Run the subtask through a native task call anyway and
|
|
540
568
|
tell the user — suggest configuring the missing role.
|
|
541
|
-
|
|
569
|
+
5. **Verify** each subtask against its acceptance-evidence statement;
|
|
542
570
|
retry a failure at most once.
|
|
543
|
-
|
|
571
|
+
6. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
|
|
544
572
|
report against the DECLARED plan: every declared subtask needs a
|
|
545
573
|
PASS/FAIL verdict with evidence; an undeclared subtask in the report
|
|
546
574
|
refuses the close (fold discoveries into an existing verdict, or discard
|
|
547
|
-
and re-crew); a FAIL needs both failure reports.
|
|
548
|
-
|
|
549
|
-
|
|
575
|
+
and re-crew); a FAIL needs both failure reports.
|
|
576
|
+
7. **Re-shard exit**: `crew_close {abandon: true, reason}` abandons the crew
|
|
577
|
+
— ask-gated like the close, no verdict requirements, usable from PENDING
|
|
578
|
+
(standby cancel) and EXECUTING (wrong decomposition) alike. The session is
|
|
579
|
+
immediately free for a fresh `crew_begin` with the corrected plan.
|
|
580
|
+
|
|
581
|
+
**Crew record**: registration writes `.opencode/crew/<date>-<slug>.md` under
|
|
582
|
+
the session anchor with the declared plan; arm / convert / abandon / close
|
|
583
|
+
append to it. It is inspectable history and a compaction-recovery anchor —
|
|
584
|
+
NOT a resume mechanism: crew state is in-memory and a host restart ends it
|
|
585
|
+
honestly (a stale record is inert).
|
|
586
|
+
|
|
587
|
+
**Goal-delegated orchestration**: while a live ACTIVE goal governs the
|
|
588
|
+
session, the pause folds into the goal's own authorization —
|
|
589
|
+
`crew_begin {objective, subtasks, execution: "waves"}` in ONE call is legal
|
|
590
|
+
(born armed, never pends), `crew_close` (report and abandon) are ask-free
|
|
591
|
+
(the goal's arm/complete/budget gates are the user boundary), and
|
|
592
|
+
`execution: "goal"` is refused (already governed). The goal continuation
|
|
593
|
+
brief carries the own-the-crew-layer directive, so an armed loop can crew,
|
|
594
|
+
re-shard (`abandon` + fresh one-call registration), and close without a human
|
|
595
|
+
turn. Neither harness requires its slash command — the ask dialogs are the
|
|
596
|
+
authorization surfaces; the templates are discipline carriers.
|
|
597
|
+
|
|
598
|
+
**Artifact anchoring** (plan / goal / crew alike): a git-repo session anchors
|
|
599
|
+
artifacts at the repo root; a plain-folder session anchors at the folder
|
|
600
|
+
itself (a rootish guard keeps degenerate global-project worktrees from
|
|
601
|
+
landing files on the drive root or the launch anchor). The anchor follows the
|
|
602
|
+
session's workspace, never the objective's scope.
|
|
550
603
|
|
|
551
604
|
**Not initialized?** `/crew` on a host with zero usable forge agents refuses
|
|
552
605
|
with one-time setup guidance: the two file paths, a copy-paste template, and
|
package/dist/index.js
CHANGED
|
@@ -15358,12 +15358,21 @@ function validateAgentSet(raw) {
|
|
|
15358
15358
|
findings.push({ level: "error", code: "agents-not-object", message: 'forge.json "agents" must be an object mapping agent ids to definitions — the agent set is empty' });
|
|
15359
15359
|
return { agents, findings };
|
|
15360
15360
|
}
|
|
15361
|
-
for (const [
|
|
15362
|
-
|
|
15361
|
+
for (const [rawId, entry] of Object.entries(raw)) {
|
|
15362
|
+
let label = `agents.${rawId}`;
|
|
15363
|
+
let id = rawId;
|
|
15364
|
+
if (rawId.startsWith("forge-")) {
|
|
15365
|
+
id = rawId.replace(/^(forge-)+/, "");
|
|
15366
|
+
findings.push({ level: "warn", code: "agent-id-forge-prefix", message: `${label}: the forge- prefix is added automatically at materialization — stripped to "${id}"; rename the entry to silence this warning` });
|
|
15367
|
+
}
|
|
15363
15368
|
if (!AGENT_ID_RE.test(id)) {
|
|
15364
15369
|
findings.push({ level: "error", code: "agent-id-invalid", message: `${label}: agent id must match [a-z0-9-] — entry skipped` });
|
|
15365
15370
|
continue;
|
|
15366
15371
|
}
|
|
15372
|
+
if (agents[id] !== undefined) {
|
|
15373
|
+
findings.push({ level: "error", code: "agent-id-collision", message: `${label}: normalizes to "${id}" which is already defined — entry skipped (first definition wins)` });
|
|
15374
|
+
continue;
|
|
15375
|
+
}
|
|
15367
15376
|
if (!isRecord(entry)) {
|
|
15368
15377
|
findings.push({ level: "error", code: "agent-not-object", message: `${label}: definition must be an object — entry skipped` });
|
|
15369
15378
|
continue;
|
|
@@ -15675,6 +15684,57 @@ function loaderForAnchor(anchor) {
|
|
|
15675
15684
|
return l;
|
|
15676
15685
|
}
|
|
15677
15686
|
var crews = new Map;
|
|
15687
|
+
function crewRecordPathFor(worktree, objective) {
|
|
15688
|
+
const dir = join6(worktree, ".opencode", "crew");
|
|
15689
|
+
const date5 = localDateNow();
|
|
15690
|
+
const slug = slugifyGoal(objective) || "crew";
|
|
15691
|
+
let name = `${date5}-${slug}.md`;
|
|
15692
|
+
let n = 2;
|
|
15693
|
+
while (existsSync3(join6(dir, name)))
|
|
15694
|
+
name = `${date5}-${slug}-${n++}.md`;
|
|
15695
|
+
return join6(dir, name);
|
|
15696
|
+
}
|
|
15697
|
+
function writeCrewRecord(path, objective, anchor, subtasks) {
|
|
15698
|
+
mkdirSync5(join6(path, ".."), { recursive: true });
|
|
15699
|
+
writeFileSync5(path, [
|
|
15700
|
+
`# Crew Record: ${objective}`,
|
|
15701
|
+
"",
|
|
15702
|
+
`- registered: ${nowIso()}`,
|
|
15703
|
+
`- anchor: ${anchor}`,
|
|
15704
|
+
"",
|
|
15705
|
+
`## Declared plan (${subtasks.length})`,
|
|
15706
|
+
...subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
15707
|
+
""
|
|
15708
|
+
].join(`
|
|
15709
|
+
`) + `
|
|
15710
|
+
`, "utf8");
|
|
15711
|
+
}
|
|
15712
|
+
function appendCrewRecord(path, heading, lines) {
|
|
15713
|
+
if (!path)
|
|
15714
|
+
return;
|
|
15715
|
+
try {
|
|
15716
|
+
appendFileSync(path, [`## ${nowIso()} — ${heading}`, ...lines, ""].join(`
|
|
15717
|
+
`) + `
|
|
15718
|
+
`, "utf8");
|
|
15719
|
+
} catch {}
|
|
15720
|
+
}
|
|
15721
|
+
function governingGoalFor(sessionID) {
|
|
15722
|
+
if (!sessionID)
|
|
15723
|
+
return false;
|
|
15724
|
+
const state = stateForBan(sessionID);
|
|
15725
|
+
if (!state)
|
|
15726
|
+
return false;
|
|
15727
|
+
const goal = resolveLiveGoal(state);
|
|
15728
|
+
return Boolean(goal && goal.doc.status === "active" && goal.doc.session === sessionID);
|
|
15729
|
+
}
|
|
15730
|
+
function crewPendingChoiceLines() {
|
|
15731
|
+
return [
|
|
15732
|
+
"[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):",
|
|
15733
|
+
` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline;`,
|
|
15734
|
+
` 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;`,
|
|
15735
|
+
` 3. standby — no call; the crew waits until armed or abandoned.`
|
|
15736
|
+
];
|
|
15737
|
+
}
|
|
15678
15738
|
var modelsDevCatalog = null;
|
|
15679
15739
|
var depthFindingNotes = new Map;
|
|
15680
15740
|
var crewCommandSnapshot = null;
|
|
@@ -16491,9 +16551,9 @@ var CREW_INIT_TEMPLATE = [
|
|
|
16491
16551
|
"1. The user configures it themselves: create either layer — MERGED, the project layer overrides the global one per agent id (the project file is the nearest .opencode/forge.json walking up from the workspace root):",
|
|
16492
16552
|
"{paths}",
|
|
16493
16553
|
"",
|
|
16494
|
-
"2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead. When you configure it:
|
|
16554
|
+
"2. Or, ONLY if the user explicitly asks you to configure it in this conversation, you may write the file yourself through the normal write path. NEVER write forge.json without that explicit go-ahead. When you configure it: name ids as PLAIN role words WITHOUT the `forge-` prefix — the plugin materializes every id as `forge-<id>` automatically (a `forge-`-prefixed id doubles up as `forge-forge-coder` and is auto-stripped with a warning). Give EVERY agent a SHORT prompt derived from the role the user asked for — one or two sentences describing its job (the user trims or extends from there); write a long prompt ONLY when the user explicitly asks for that agent.",
|
|
16495
16555
|
"",
|
|
16496
|
-
"Template (JSONC — comments allowed): `model` + `thoughtLevel` are an ATOMIC PAIR — set BOTH to pin the brain and depth (model = exact provider/model identity; thoughtLevel = none/low/medium/high/max or a native level name), or NEITHER for an Auto worker that inherits the parent session's model; exactly one of the two is rejected. Optional per agent: `prompt` — a short role description; an explicit prompt FULLY overrides the built-in role (only the ids `research` and `review` carry built-ins; any other id without a prompt gets a generic one-liner and will not know its job).",
|
|
16556
|
+
"Template (JSONC — comments allowed): ids are plain role words (research, coder, ...) — the plugin adds the `forge-` prefix at materialization; `model` + `thoughtLevel` are an ATOMIC PAIR — set BOTH to pin the brain and depth (model = exact provider/model identity; thoughtLevel = none/low/medium/high/max or a native level name), or NEITHER for an Auto worker that inherits the parent session's model; exactly one of the two is rejected. Optional per agent: `prompt` — a short role description; an explicit prompt FULLY overrides the built-in role (only the ids `research` and `review` carry built-ins; any other id without a prompt gets a generic one-liner and will not know its job).",
|
|
16497
16557
|
"{template}",
|
|
16498
16558
|
"",
|
|
16499
16559
|
"After the file is saved: NEW agents require a host restart to appear in the task tool; thoughtLevel edits on existing agents apply without restart. Until then, crew stays unavailable."
|
|
@@ -16507,21 +16567,23 @@ function crewOrchestrationTemplate(agentIds) {
|
|
|
16507
16567
|
"",
|
|
16508
16568
|
`Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
|
|
16509
16569
|
"",
|
|
16510
|
-
"You are entering CREW ORCHESTRATION discipline for this session (single subject: do not switch agents). Follow it exactly:",
|
|
16570
|
+
"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:",
|
|
16511
16571
|
"",
|
|
16512
|
-
"1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). 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.
|
|
16513
|
-
|
|
16514
|
-
"3.
|
|
16515
|
-
"4.
|
|
16516
|
-
"5.
|
|
16517
|
-
"6.
|
|
16572
|
+
"1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). 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.",
|
|
16573
|
+
`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.`,
|
|
16574
|
+
"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.",
|
|
16575
|
+
"4. 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.",
|
|
16576
|
+
"5. 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.",
|
|
16577
|
+
"6. RETRY AT MOST ONCE: a failed subtask may be retried once with an adjusted prompt or agent. If the retry also fails, record the subtask as FAIL with BOTH failure reports visible (attempts[]). Do not retry twice, do not paper over a failure.",
|
|
16578
|
+
"7. CLOSE: when every declared subtask has a verdict, call `crew_close` with the full report (title/verdict/evidence per subtask; attempts[] for FAILs). The gate cross-checks the report against the declared plan — a declared subtask missing from the report refuses the close, and so does an undeclared one. The user confirms the closing dialog (ask-free while a goal governs the session)."
|
|
16518
16579
|
].join(`
|
|
16519
16580
|
`);
|
|
16520
16581
|
}
|
|
16521
16582
|
var crewBeginTool = tool({
|
|
16522
|
-
description:
|
|
16583
|
+
description: `Register this session's crew (from /crew) with its DECLARED subtask plan — the crew starts PENDING: the execution-mode choice (waves / convert to goal / standby) is the user's. Dual-purpose: called with {execution} ONLY (no objective/subtasks) it ARMS the pending crew — "waves" lifts the dispatch belt, "goal" ends the crew as a conversion record (the following goal_write arm dialog is the user's gate). One-call register+arm ({objective, subtasks, execution:"waves"}) is legal ONLY while a live active goal governs the session (born armed, never pends). Refuses: active crew, plan draft, missing/duplicated subtasks. Crew state is in-memory: a host restart ends it honestly.`,
|
|
16523
16584
|
args: {
|
|
16524
|
-
objective: tool.schema.string().describe("One-line crew objective (from /crew)"),
|
|
16585
|
+
objective: tool.schema.string().optional().describe("One-line crew objective (from /crew) — required on registration, ABSENT on the arm call"),
|
|
16586
|
+
execution: tool.schema.string().optional().describe('Arm action: "waves" (supervised waves — lifts the belt) or "goal" (convert to a goal contract). Alone = arm the pending crew; with objective+subtasks = one-call born-armed registration, legal only under a governing active goal.'),
|
|
16525
16587
|
subtasks: tool.schema.array(tool.schema.object({
|
|
16526
16588
|
title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
|
|
16527
16589
|
agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
|
|
@@ -16541,22 +16603,69 @@ var crewBeginTool = tool({
|
|
|
16541
16603
|
const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
|
|
16542
16604
|
const sessionProject = sessionLoaded?.projectPath ?? null;
|
|
16543
16605
|
if (hostIds.length === 0) {
|
|
16544
|
-
const
|
|
16545
|
-
const
|
|
16606
|
+
const paths2 = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16607
|
+
const lines2 = [
|
|
16546
16608
|
"[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized. Crew orchestration is unavailable until the user sets it up.",
|
|
16547
|
-
`Two layers, MERGED (the project layer overrides the global one per agent id): ${
|
|
16609
|
+
`Two layers, MERGED (the project layer overrides the global one per agent id): ${paths2.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths2.global}.`,
|
|
16548
16610
|
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}',
|
|
16611
|
+
"Agent ids are plain role words WITHOUT the `forge-` prefix — the plugin materializes each id as `forge-<id>` (a prefixed id is auto-stripped with a warning).",
|
|
16549
16612
|
"The user may configure it themselves, or ask you to write the file — only on their explicit go-ahead. When you write it, give every agent a SHORT prompt from the role the user asked for (one or two sentences; long only on explicit request) — without a prompt, custom ids get a generic one-liner and will not know their job. Newly added agents need a host restart; thoughtLevel edits apply without one."
|
|
16550
16613
|
];
|
|
16551
16614
|
if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
|
|
16552
|
-
|
|
16615
|
+
lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but this host instance was launched from ${hostAnchorDir || "a different directory"}, so they are not in the task vocabulary. Relaunch opencode in that workspace, or add the agents to the global layer.`);
|
|
16553
16616
|
}
|
|
16554
|
-
throw new Error(
|
|
16617
|
+
throw new Error(lines2.join(`
|
|
16555
16618
|
`));
|
|
16556
16619
|
}
|
|
16557
|
-
|
|
16558
|
-
|
|
16620
|
+
const execution = typeof args.execution === "string" ? args.execution.trim().toLowerCase() : "";
|
|
16621
|
+
if (execution && execution !== "waves" && execution !== "goal") {
|
|
16622
|
+
throw new Error('[forge:crew] execution must be "waves" or "goal".');
|
|
16623
|
+
}
|
|
16624
|
+
const objectiveText = String(args.objective ?? "").trim();
|
|
16559
16625
|
const raw = Array.isArray(args.subtasks) ? args.subtasks : [];
|
|
16626
|
+
if (execution && !objectiveText && raw.length === 0) {
|
|
16627
|
+
const crew = crews.get(context.sessionID);
|
|
16628
|
+
if (!crew) {
|
|
16629
|
+
throw new Error("[forge:crew] No active crew to arm (crew state is in-memory and dies on host restart). Register one first: crew_begin {objective, subtasks}.");
|
|
16630
|
+
}
|
|
16631
|
+
if (crew.mode !== "pending") {
|
|
16632
|
+
throw new Error(`[forge:crew] This crew is already armed (${crew.mode}) — execute "${crew.objective}" instead of re-arming.`);
|
|
16633
|
+
}
|
|
16634
|
+
if (execution === "goal" && governingGoalFor(context.sessionID)) {
|
|
16635
|
+
throw new Error('[forge:crew] execution:"goal" is refused under a governing goal — this session already runs inside an active goal contract. Run supervised waves (arm or one-call register), or pause/discard the goal first if conversion is truly intended.');
|
|
16636
|
+
}
|
|
16637
|
+
if (execution === "goal") {
|
|
16638
|
+
crews.delete(context.sessionID);
|
|
16639
|
+
appendCrewRecord(crew.recordPath, "converted to goal", [
|
|
16640
|
+
"The crew ends here as a conversion record; the objective moves to a goal contract.",
|
|
16641
|
+
...crew.subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`)
|
|
16642
|
+
]);
|
|
16643
|
+
return {
|
|
16644
|
+
title: `crew converted: ${crew.objective.slice(0, 60)}`,
|
|
16645
|
+
output: [
|
|
16646
|
+
`[forge:crew] Crew ended as a CONVERSION record: ${crew.objective}`,
|
|
16647
|
+
"Now draft the goal contract: goal_write with arm=true, folding the declared subtasks below into its criteria and verification checks. The arm confirmation dialog is the user's gate for the whole autonomous path — do not ask again here.",
|
|
16648
|
+
`Declared subtasks to fold in (${crew.subtasks.length}):`,
|
|
16649
|
+
...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`)
|
|
16650
|
+
].join(`
|
|
16651
|
+
`)
|
|
16652
|
+
};
|
|
16653
|
+
}
|
|
16654
|
+
crew.mode = "executing";
|
|
16655
|
+
appendCrewRecord(crew.recordPath, "armed (waves)", ["The user chose supervised waves; the dispatch belt is lifted."]);
|
|
16656
|
+
return {
|
|
16657
|
+
title: `crew armed: ${crew.objective.slice(0, 60)}`,
|
|
16658
|
+
output: [
|
|
16659
|
+
`[forge:crew] Crew ARMED for supervised waves: ${crew.objective}`,
|
|
16660
|
+
`Declared plan (${crew.subtasks.length}):`,
|
|
16661
|
+
...crew.subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16662
|
+
"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."
|
|
16663
|
+
].join(`
|
|
16664
|
+
`)
|
|
16665
|
+
};
|
|
16666
|
+
}
|
|
16667
|
+
if (!objectiveText)
|
|
16668
|
+
throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
|
|
16560
16669
|
if (raw.length === 0)
|
|
16561
16670
|
throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
|
|
16562
16671
|
const seen = new Set;
|
|
@@ -16576,7 +16685,28 @@ var crewBeginTool = tool({
|
|
|
16576
16685
|
if (existing) {
|
|
16577
16686
|
throw new Error(`[forge:crew] This session already has an active crew: "${existing.objective}" (started ${existing.startedAt}). Finish and close it with crew_close before starting another.`);
|
|
16578
16687
|
}
|
|
16579
|
-
|
|
16688
|
+
if (execution === "waves" && !governingGoalFor(context.sessionID)) {
|
|
16689
|
+
throw new Error("[forge:crew] crew_begin cannot self-arm outside a governing goal: register with {objective, subtasks} only — the pause is the user's decision point, armed by a follow-up crew_begin {execution} call per their choice.");
|
|
16690
|
+
}
|
|
16691
|
+
if (execution === "goal") {
|
|
16692
|
+
throw new Error('[forge:crew] execution:"goal" converts an already-registered PENDING crew — register the plan first (no execution), then convert. Under a governing goal, only "waves" one-call arming is legal (the loop already owns the goal).');
|
|
16693
|
+
}
|
|
16694
|
+
const governed = execution === "waves";
|
|
16695
|
+
const anchorDir = anchor || hostWorktree;
|
|
16696
|
+
let recordPath = null;
|
|
16697
|
+
let recordNote = "";
|
|
16698
|
+
if (anchorDir) {
|
|
16699
|
+
try {
|
|
16700
|
+
recordPath = crewRecordPathFor(anchorDir, objectiveText);
|
|
16701
|
+
writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks);
|
|
16702
|
+
recordNote = `Crew record (inspectable history; NOT a resume mechanism): ${relFrom(anchorDir, recordPath)}`;
|
|
16703
|
+
} catch {
|
|
16704
|
+
recordPath = null;
|
|
16705
|
+
recordNote = "Crew record could not be written (fail-soft; the crew still runs).";
|
|
16706
|
+
}
|
|
16707
|
+
}
|
|
16708
|
+
crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
|
|
16709
|
+
appendCrewRecord(recordPath, governed ? "registered (born armed under a governing goal)" : "registered (pending)", subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`));
|
|
16580
16710
|
let disclosure = "";
|
|
16581
16711
|
if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
|
|
16582
16712
|
const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
|
|
@@ -16585,34 +16715,62 @@ var crewBeginTool = tool({
|
|
|
16585
16715
|
[forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-${id}`).join(", ")} — NOT dispatchable on this host (launched from ${hostAnchorDir || "a different directory"}). Relaunch opencode in that workspace to use them, or fold them into the global layer.`;
|
|
16586
16716
|
}
|
|
16587
16717
|
}
|
|
16718
|
+
const paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16719
|
+
const originDesc = hostLoaded?.projectPath ? `the project layer ${hostLoaded.projectPath}${hostLoaded.source === "project+global" ? ` merged with the global layer (${paths.global})` : ""}` : `the global layer (${paths.global})`;
|
|
16720
|
+
const lines = [
|
|
16721
|
+
governed ? `[forge:crew] Crew registered UNDER THE GOVERNING GOAL — born armed, never pends. Objective: ${objectiveText}` : `[forge:crew] Crew registered PENDING for this session (execution has NOT started). Objective: ${objectiveText}`,
|
|
16722
|
+
`Declared plan (${subtasks.length}):`,
|
|
16723
|
+
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16724
|
+
`Dispatchable roster origin: ${originDesc} — materialized by this host instance${hostAnchorDir ? ` (launched from ${hostAnchorDir})` : ""}.`,
|
|
16725
|
+
...recordNote ? [recordNote] : [],
|
|
16726
|
+
...disclosure ? [disclosure] : []
|
|
16727
|
+
];
|
|
16728
|
+
if (governed) {
|
|
16729
|
+
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.");
|
|
16730
|
+
} else {
|
|
16731
|
+
lines.push(...crewPendingChoiceLines());
|
|
16732
|
+
}
|
|
16588
16733
|
return {
|
|
16589
|
-
title: `crew registered: ${
|
|
16590
|
-
output:
|
|
16591
|
-
`[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
|
|
16592
|
-
`Declared plan (${subtasks.length}):`,
|
|
16593
|
-
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16594
|
-
"Proceed with the 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.",
|
|
16595
|
-
...disclosure ? [disclosure] : []
|
|
16596
|
-
].join(`
|
|
16734
|
+
title: `crew registered: ${objectiveText.slice(0, 60)}`,
|
|
16735
|
+
output: lines.join(`
|
|
16597
16736
|
`)
|
|
16598
16737
|
};
|
|
16599
16738
|
}
|
|
16600
16739
|
});
|
|
16601
16740
|
var crewCloseTool = tool({
|
|
16602
|
-
description: "Close this session's crew
|
|
16741
|
+
description: "Close this session's crew. Two paths: (a) final report — the gate cross-checks the report against the declared subtask plan (every declared subtask needs a verdict and an evidence statement; an undeclared subtask refuses the close; FAIL requires both failure reports); (b) abandon {abandon: true, reason} — the re-shard exit: no verdict requirements, ends the crew with an abandonment record, frees the session for a fresh crew_begin. Both paths are ask-gated UNLESS a live active goal governs the session (then ask-free — the goal's own gates are the user boundary).",
|
|
16603
16742
|
args: {
|
|
16604
16743
|
report: tool.schema.array(tool.schema.object({
|
|
16605
16744
|
title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
|
|
16606
16745
|
verdict: tool.schema.string().describe("PASS or FAIL"),
|
|
16607
16746
|
evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
|
|
16608
16747
|
attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
|
|
16609
|
-
})).describe("The full crew report — one entry per declared subtask")
|
|
16748
|
+
})).optional().describe("The full crew report — one entry per declared subtask (not needed when abandoning)"),
|
|
16749
|
+
abandon: tool.schema.boolean().optional().describe("true = abandon the crew (re-shard / standby-cancel): ends it with an abandonment record, no verdicts required"),
|
|
16750
|
+
reason: tool.schema.string().optional().describe("Why the crew is abandoned (recorded in the crew record)")
|
|
16610
16751
|
},
|
|
16611
16752
|
execute: async (args, context) => {
|
|
16612
16753
|
noteToolAgent(context);
|
|
16613
16754
|
const crew = crews.get(context.sessionID);
|
|
16614
16755
|
if (!crew)
|
|
16615
16756
|
throw new Error("[forge:crew] No active crew in this session (crew state is in-memory and dies on host restart). Start one with /crew <objective>.");
|
|
16757
|
+
if (args.abandon === true) {
|
|
16758
|
+
const reason = String(args.reason ?? "").trim() || "(no reason given)";
|
|
16759
|
+
if (!governingGoalFor(context.sessionID))
|
|
16760
|
+
await gate(context.ask, "crew_close", `Abandon crew: ${crew.objective}`);
|
|
16761
|
+
crews.delete(context.sessionID);
|
|
16762
|
+
appendCrewRecord(crew.recordPath, "abandoned", [`mode at abandon: ${crew.mode}`, `reason: ${reason}`, "The session is free for a fresh crew_begin (re-shard or new objective)."]);
|
|
16763
|
+
return {
|
|
16764
|
+
title: `crew abandoned: ${crew.objective.slice(0, 60)}`,
|
|
16765
|
+
output: [
|
|
16766
|
+
`[forge:crew] Crew ABANDONED: ${crew.objective}`,
|
|
16767
|
+
`mode at abandon: ${crew.mode}`,
|
|
16768
|
+
`reason: ${reason}`,
|
|
16769
|
+
"The session is free for a fresh crew_begin — register the corrected plan next (it starts PENDING again unless a governing goal arms it in the same call)."
|
|
16770
|
+
].join(`
|
|
16771
|
+
`)
|
|
16772
|
+
};
|
|
16773
|
+
}
|
|
16616
16774
|
const report = args.report ?? [];
|
|
16617
16775
|
const check2 = validateCrewReport(report, crew.subtasks);
|
|
16618
16776
|
if (!check2.ok) {
|
|
@@ -16621,16 +16779,21 @@ ${check2.gaps.map((g) => `- ${g}`).join(`
|
|
|
16621
16779
|
`)}
|
|
16622
16780
|
The crew stays active; complete or correct the report and call crew_close again.`);
|
|
16623
16781
|
}
|
|
16624
|
-
|
|
16782
|
+
if (!governingGoalFor(context.sessionID))
|
|
16783
|
+
await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
|
|
16625
16784
|
crews.delete(context.sessionID);
|
|
16626
16785
|
const fails = report.filter((r) => String(r.verdict).toUpperCase() === "FAIL").length;
|
|
16786
|
+
appendCrewRecord(crew.recordPath, "closed", [
|
|
16787
|
+
`subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
|
|
16788
|
+
...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`)
|
|
16789
|
+
]);
|
|
16627
16790
|
return {
|
|
16628
16791
|
title: `crew closed: ${crew.objective.slice(0, 60)}`,
|
|
16629
16792
|
output: [
|
|
16630
16793
|
`[forge:crew] Crew closed: ${crew.objective}`,
|
|
16631
16794
|
`subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
|
|
16632
16795
|
...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
|
|
16633
|
-
"This output is the crew's record (crew state is in-memory; nothing persists)."
|
|
16796
|
+
crew.recordPath ? `Crew record (history): ${crew.recordPath}` : "This output is the crew's record (crew state is in-memory; nothing persists)."
|
|
16634
16797
|
].join(`
|
|
16635
16798
|
`)
|
|
16636
16799
|
};
|
|
@@ -16713,6 +16876,7 @@ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\``
|
|
|
16713
16876
|
`)}`,
|
|
16714
16877
|
d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
|
|
16715
16878
|
`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.`,
|
|
16879
|
+
`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.`,
|
|
16716
16880
|
`Goal file: ${relFrom(state.worktree, goal.path)}`
|
|
16717
16881
|
].filter((s) => s.length > 0).join(`
|
|
16718
16882
|
`);
|
|
@@ -17088,6 +17252,10 @@ var server = async (input, options) => {
|
|
|
17088
17252
|
throw new Error(`[forge:partition] Dispatch refused: "${target}" is a forge-family worker and the current agent ("${speaker}") is outside the forge family. Use the native general/explore subagents, or switch to the forge agent to dispatch forge-* workers.`);
|
|
17089
17253
|
}
|
|
17090
17254
|
}
|
|
17255
|
+
const pendingCrew = input2.sessionID ? crews.get(input2.sessionID) : undefined;
|
|
17256
|
+
if (pendingCrew && pendingCrew.mode === "pending") {
|
|
17257
|
+
throw new Error(`[forge:crew] Dispatch refused: the crew "${pendingCrew.objective}" is PENDING (registered, not armed). Present the execution-mode choice to the user — supervised waves now / convert to a goal contract / standby — then arm per their choice with crew_begin {execution: "waves" | "goal"}. Reconnaissance task calls are free only BEFORE registration.`);
|
|
17258
|
+
}
|
|
17091
17259
|
}
|
|
17092
17260
|
if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
|
|
17093
17261
|
const speaker = sessionAgents.get(input2.sessionID);
|
|
@@ -17274,7 +17442,8 @@ var server = async (input, options) => {
|
|
|
17274
17442
|
return;
|
|
17275
17443
|
const crewActive = crews.get(input2.sessionID);
|
|
17276
17444
|
if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
|
|
17277
|
-
|
|
17445
|
+
const modeText = crewActive.mode === "pending" ? "PENDING (present the execution-mode choice; every task dispatch is refused until armed)" : "armed (executing)";
|
|
17446
|
+
output.system.push(`[forge:crew-active] A crew is ${modeText} in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict, or crew_close {abandon: true, reason} to re-shard.`);
|
|
17278
17447
|
}
|
|
17279
17448
|
if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
|
|
17280
17449
|
output.system.push(jobsKeepBuiltinShell ? "[forge:job-guidance] Long-running or possibly non-exiting shell commands (dev servers, watchers, installers, anything spawning detached children) go through forge_shell, never the builtin shell: it returns on idle/success/exit with a jobId instead of blocking indefinitely; manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion." : "[forge:job-guidance] Every shell command — quick ones included — goes through forge_shell: the builtin shell/bash tools are refused on this agent, so never try them first. forge_shell returns on exit/success/idle with a jobId instead of blocking indefinitely; long-running or non-exiting commands use run_in_background. Manage jobs with forge_jobs. Delegated agents: collect your job results with forge_jobs poll before yielding your conclusion.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|