@sorenllm/opencode-forge 0.11.2 → 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 +58 -11
- package/dist/index.js +189 -30
- 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 |
|
|
@@ -536,23 +537,69 @@ mid-session never rewrites a running session).
|
|
|
536
537
|
## Crew workflow (`/crew`)
|
|
537
538
|
|
|
538
539
|
`/crew <objective>` is the third state-activation command next to `/plan` and
|
|
539
|
-
`/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:
|
|
540
544
|
|
|
541
545
|
1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
|
|
542
546
|
titled subtask each, optionally naming the intended `forge-*` agent.
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
546
568
|
tell the user — suggest configuring the missing role.
|
|
547
|
-
|
|
569
|
+
5. **Verify** each subtask against its acceptance-evidence statement;
|
|
548
570
|
retry a failure at most once.
|
|
549
|
-
|
|
571
|
+
6. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
|
|
550
572
|
report against the DECLARED plan: every declared subtask needs a
|
|
551
573
|
PASS/FAIL verdict with evidence; an undeclared subtask in the report
|
|
552
574
|
refuses the close (fold discoveries into an existing verdict, or discard
|
|
553
|
-
and re-crew); a FAIL needs both failure reports.
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
556
603
|
|
|
557
604
|
**Not initialized?** `/crew` on a host with zero usable forge agents refuses
|
|
558
605
|
with one-time setup guidance: the two file paths, a copy-paste template, and
|
package/dist/index.js
CHANGED
|
@@ -15684,6 +15684,57 @@ function loaderForAnchor(anchor) {
|
|
|
15684
15684
|
return l;
|
|
15685
15685
|
}
|
|
15686
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
|
+
}
|
|
15687
15738
|
var modelsDevCatalog = null;
|
|
15688
15739
|
var depthFindingNotes = new Map;
|
|
15689
15740
|
var crewCommandSnapshot = null;
|
|
@@ -16516,21 +16567,23 @@ function crewOrchestrationTemplate(agentIds) {
|
|
|
16516
16567
|
"",
|
|
16517
16568
|
`Configured forge subagents (task-tool vocabulary): ${agentIds.map((id) => `forge-${id}`).join(", ")}`,
|
|
16518
16569
|
"",
|
|
16519
|
-
"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:",
|
|
16520
16571
|
"",
|
|
16521
|
-
"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.
|
|
16522
|
-
|
|
16523
|
-
"3.
|
|
16524
|
-
"4.
|
|
16525
|
-
"5.
|
|
16526
|
-
"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)."
|
|
16527
16579
|
].join(`
|
|
16528
16580
|
`);
|
|
16529
16581
|
}
|
|
16530
16582
|
var crewBeginTool = tool({
|
|
16531
|
-
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.`,
|
|
16532
16584
|
args: {
|
|
16533
|
-
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.'),
|
|
16534
16587
|
subtasks: tool.schema.array(tool.schema.object({
|
|
16535
16588
|
title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
|
|
16536
16589
|
agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
|
|
@@ -16550,23 +16603,69 @@ var crewBeginTool = tool({
|
|
|
16550
16603
|
const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
|
|
16551
16604
|
const sessionProject = sessionLoaded?.projectPath ?? null;
|
|
16552
16605
|
if (hostIds.length === 0) {
|
|
16553
|
-
const
|
|
16554
|
-
const
|
|
16606
|
+
const paths2 = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16607
|
+
const lines2 = [
|
|
16555
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.",
|
|
16556
|
-
`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}.`,
|
|
16557
16610
|
'Template (JSONC): {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}',
|
|
16558
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).",
|
|
16559
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."
|
|
16560
16613
|
];
|
|
16561
16614
|
if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
|
|
16562
|
-
|
|
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.`);
|
|
16563
16616
|
}
|
|
16564
|
-
throw new Error(
|
|
16617
|
+
throw new Error(lines2.join(`
|
|
16565
16618
|
`));
|
|
16566
16619
|
}
|
|
16567
|
-
|
|
16568
|
-
|
|
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();
|
|
16569
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>).");
|
|
16570
16669
|
if (raw.length === 0)
|
|
16571
16670
|
throw new Error("[forge:crew] crew_begin requires subtasks[] — declare the plan before executing (decompose the objective into titled subtasks).");
|
|
16572
16671
|
const seen = new Set;
|
|
@@ -16586,7 +16685,28 @@ var crewBeginTool = tool({
|
|
|
16586
16685
|
if (existing) {
|
|
16587
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.`);
|
|
16588
16687
|
}
|
|
16589
|
-
|
|
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}` : ""}`));
|
|
16590
16710
|
let disclosure = "";
|
|
16591
16711
|
if (sessionProject && sessionProject !== (hostLoaded?.projectPath ?? null)) {
|
|
16592
16712
|
const localOnly = Object.keys(sessionLoaded?.agents ?? {}).filter((id) => !hostIds.includes(id));
|
|
@@ -16595,34 +16715,62 @@ var crewBeginTool = tool({
|
|
|
16595
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.`;
|
|
16596
16716
|
}
|
|
16597
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
|
+
}
|
|
16598
16733
|
return {
|
|
16599
|
-
title: `crew registered: ${
|
|
16600
|
-
output:
|
|
16601
|
-
`[forge:crew] Crew registered for this session. Objective: ${args.objective.trim()}`,
|
|
16602
|
-
`Declared plan (${subtasks.length}):`,
|
|
16603
|
-
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16604
|
-
"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.",
|
|
16605
|
-
...disclosure ? [disclosure] : []
|
|
16606
|
-
].join(`
|
|
16734
|
+
title: `crew registered: ${objectiveText.slice(0, 60)}`,
|
|
16735
|
+
output: lines.join(`
|
|
16607
16736
|
`)
|
|
16608
16737
|
};
|
|
16609
16738
|
}
|
|
16610
16739
|
});
|
|
16611
16740
|
var crewCloseTool = tool({
|
|
16612
|
-
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).",
|
|
16613
16742
|
args: {
|
|
16614
16743
|
report: tool.schema.array(tool.schema.object({
|
|
16615
16744
|
title: tool.schema.string().describe("Subtask title — must match a title declared at crew_begin"),
|
|
16616
16745
|
verdict: tool.schema.string().describe("PASS or FAIL"),
|
|
16617
16746
|
evidence: tool.schema.string().describe("Acceptance evidence: what the result contained that satisfies the acceptance statement"),
|
|
16618
16747
|
attempts: tool.schema.array(tool.schema.string()).optional().describe("For FAIL: both failure reports (attempt and retry)")
|
|
16619
|
-
})).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)")
|
|
16620
16751
|
},
|
|
16621
16752
|
execute: async (args, context) => {
|
|
16622
16753
|
noteToolAgent(context);
|
|
16623
16754
|
const crew = crews.get(context.sessionID);
|
|
16624
16755
|
if (!crew)
|
|
16625
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
|
+
}
|
|
16626
16774
|
const report = args.report ?? [];
|
|
16627
16775
|
const check2 = validateCrewReport(report, crew.subtasks);
|
|
16628
16776
|
if (!check2.ok) {
|
|
@@ -16631,16 +16779,21 @@ ${check2.gaps.map((g) => `- ${g}`).join(`
|
|
|
16631
16779
|
`)}
|
|
16632
16780
|
The crew stays active; complete or correct the report and call crew_close again.`);
|
|
16633
16781
|
}
|
|
16634
|
-
|
|
16782
|
+
if (!governingGoalFor(context.sessionID))
|
|
16783
|
+
await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
|
|
16635
16784
|
crews.delete(context.sessionID);
|
|
16636
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
|
+
]);
|
|
16637
16790
|
return {
|
|
16638
16791
|
title: `crew closed: ${crew.objective.slice(0, 60)}`,
|
|
16639
16792
|
output: [
|
|
16640
16793
|
`[forge:crew] Crew closed: ${crew.objective}`,
|
|
16641
16794
|
`subtasks: ${report.length} (PASS ${report.length - fails}, FAIL ${fails})`,
|
|
16642
16795
|
...report.map((r) => `- ${r.title}: ${String(r.verdict).toUpperCase()} — ${String(r.evidence).slice(0, 200)}`),
|
|
16643
|
-
"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)."
|
|
16644
16797
|
].join(`
|
|
16645
16798
|
`)
|
|
16646
16799
|
};
|
|
@@ -16723,6 +16876,7 @@ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\``
|
|
|
16723
16876
|
`)}`,
|
|
16724
16877
|
d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
|
|
16725
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.`,
|
|
16726
16880
|
`Goal file: ${relFrom(state.worktree, goal.path)}`
|
|
16727
16881
|
].filter((s) => s.length > 0).join(`
|
|
16728
16882
|
`);
|
|
@@ -17098,6 +17252,10 @@ var server = async (input, options) => {
|
|
|
17098
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.`);
|
|
17099
17253
|
}
|
|
17100
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
|
+
}
|
|
17101
17259
|
}
|
|
17102
17260
|
if ((input2.tool === "write" || input2.tool === "edit") && input2.sessionID) {
|
|
17103
17261
|
const speaker = sessionAgents.get(input2.sessionID);
|
|
@@ -17284,7 +17442,8 @@ var server = async (input, options) => {
|
|
|
17284
17442
|
return;
|
|
17285
17443
|
const crewActive = crews.get(input2.sessionID);
|
|
17286
17444
|
if (crewActive && !output.system.some((s) => s.startsWith("[forge:crew-active]"))) {
|
|
17287
|
-
|
|
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.`);
|
|
17288
17447
|
}
|
|
17289
17448
|
if (!forgeDisabled && jobStage() < 2 && !output.system.some((s) => s.startsWith("[forge:job-guidance]"))) {
|
|
17290
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",
|