@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.
Files changed (3) hide show
  1. package/README.md +64 -11
  2. package/dist/index.js +203 -34
  3. 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/` and `.opencode/goal/`
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 flow:
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
- 2. **Execute in waves** of parallel native `task` calls (results return
538
- in-turn; the next wave launches only after the previous wave's results).
539
- 3. **Missing role?** Run the subtask through a native task call anyway and
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
- 4. **Verify** each subtask against its acceptance-evidence statement;
569
+ 5. **Verify** each subtask against its acceptance-evidence statement;
542
570
  retry a failure at most once.
543
- 5. **Close** with `crew_close` — a hard, ask-gated gate cross-checking the
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. The close output is the
548
- crew's record — crew state is in-memory and a host restart ends it
549
- honestly.
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 [id, entry] of Object.entries(raw)) {
15362
- const label = `agents.${id}`;
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: 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.",
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. Reconnaissance first if you need it to decompose well; a wrong plan is fixed by discarding and re-crewing, never by improvising outside the declared plan.",
16513
- "2. WAVES, NEVER FLOODS: 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.",
16514
- "3. 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.",
16515
- "4. 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.",
16516
- "5. 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.",
16517
- "6. 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 your 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."
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: "Register this session's crew (from /crew) with its DECLARED subtask plan. Refuses when a crew is already active in the session, while a plan draft is active, or when subtasks are missing/duplicated. Crew state is in-memory: a host restart ends it honestly.",
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 paths = forgeConfigPaths({ projectDir: hostAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
16545
- const lines = [
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): ${paths.project} (nearest .opencode/forge.json walking up from the workspace) OR ${paths.global}.`,
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
- lines.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.`);
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(lines.join(`
16617
+ throw new Error(lines2.join(`
16555
16618
  `));
16556
16619
  }
16557
- if (!args.objective?.trim())
16558
- throw new Error("crew_begin requires a non-empty objective (from /crew <objective>).");
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
- crews.set(context.sessionID, { objective: args.objective.trim(), startedAt: nowIso(), subtasks });
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: ${args.objective.trim().slice(0, 60)}`,
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 with the final report (ask-level user confirmation). The gate cross-checks the report against the declared subtask plan: every declared subtask needs a verdict (PASS/FAIL) and an evidence statement; an undeclared subtask in the report refuses the close; FAIL requires both failure reports (attempt + the one retry). On success the crew ends and the summary is the tool output (there is no persistent crew record).",
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
- await gate(context.ask, "crew_close", `Close crew: ${crew.objective}`);
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
- output.system.push(`[forge:crew-active] A crew is ACTIVE in this session: "${crewActive.objective}" (started ${crewActive.startedAt}). A second /crew must be refused; finish with crew_close when every subtask has a verdict.`);
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.11.1",
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",