@sorenllm/opencode-forge 0.13.0 → 0.14.1

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 +55 -18
  2. package/dist/index.js +76 -27
  3. package/package.json +64 -64
package/README.md CHANGED
@@ -31,7 +31,18 @@ OpenSpec spec workflows remain a third, separate lane.
31
31
  ```
32
32
 
33
33
  - Plan files: `.opencode/plan/<date>-<slug>.md` in your project, frontmatter
34
- state machine `draft → approved → done` (exit: `abandoned`).
34
+ state machine `draft → approved → done`, exits `abandoned` (plain discard) and
35
+ `superseded` (`plan_discard {reason, supersede: "<successor path>"}` —
36
+ the exit for work whose execution moves to another harness, e.g.
37
+ `.opencode/crew/2026-10-01-....md`, rather than ends). Every terminal
38
+ transition appends a dated narrative section to the file body in the same
39
+ write as the status flip: close = each acceptance criterion's
40
+ pass/evidence; discard/supersede = the reason verbatim (`(none given)`
41
+ when absent; a supersession also records the successor path). Appended
42
+ sections are parser-inert — status reads frontmatter only, tick
43
+ counts read the Task List only — and a superseded plan's Task List
44
+ stays the frozen decision snapshot: closure IS the supersession, and
45
+ crew/goal results never tick it.
35
46
  - While a plan is in draft, `write` / `edit` / `bash` / `task` are **denied
36
47
  at the permission layer** — including your own `allow` config, and for
37
48
  every agent in that session (the draft protects shared session state; see
@@ -69,7 +80,17 @@ entirely (the hide is withdrawn in the same stroke). The forge family is
69
80
  prompted to route every shell command through `forge_shell` — on hosts that
70
81
  ignore injected tools maps the builtin tool stays visible but is refused at
71
82
  call time, and the channel mandate is what keeps the model from trying it
72
- first (the belt refusal remains the backstop). Mixed sessions (Tab
83
+ first (the belt refusal remains the backstop). On such unfiltered hosts the
84
+ fallback belt family is a three-part enumeration of hard refusals at the
85
+ tool layer: a forge-family builtin `shell`/`bash` call is refused with a
86
+ pointer to `forge_shell`; a non-forge agent's call of any forge tool is
87
+ refused with the partition guidance; and a `forge-*` worker (any family
88
+ member whose agent name is not exactly `forge`) calling a harness state tool
89
+ (`plan_*`, `goal_*`, `crew_begin`, `crew_close`) is refused as primary-only.
90
+ That refusal fires before any execution or user confirmation dialog, so a
91
+ worker's state-tool call never reaches a gate, and the error names the way
92
+ out: report findings back to the orchestrating session — the primary forge
93
+ agent alone calls the state tools. Mixed sessions (Tab
73
94
  between agents in one session): harness state (plans/goals) stays
74
95
  session-bound, tool surfaces follow the current speaker, and the goal loop
75
96
  parks instead of driving a non-forge turn. One deliberate exception: a
@@ -419,9 +440,10 @@ discovered by walking up:
419
440
  The two layers **merge**: agents combine, and on an id collision the project
420
441
  definition wins wholesale (one definition per id — no cross-layer field
421
442
  blending). A broken file empties only its own layer/pool (error finding with
422
- the parse location); everything else still applies. Both layers hot-apply —
423
- edits, and files appearing or disappearing, take effect at the next config
424
- hook without a host restart (see the apply-timing table below).
443
+ the parse location); everything else still applies. Both layers re-read at
444
+ every real materialization — host boot, a first config build, or an
445
+ opencode config reload — without a restart; a new session alone does
446
+ not re-read them (see the apply-timing table below).
425
447
 
426
448
  **Hierarchical pools on a shared host.** Agent frontends (paseo and friends)
427
449
  routinely share ONE opencode host process across projects, re-initializing
@@ -531,14 +553,18 @@ A rejected entry costs only itself — its siblings materialize normally.
531
553
  > error finding naming the missing half. Fix per entry: add the missing
532
554
  > `thoughtLevel`, or delete `model` to make it an Auto worker.
533
555
 
534
- **What applies when** — file truth is hot; only the anchor set is frozen at
535
- host start:
556
+ **What applies when** — only a real materialization re-reads the pool
557
+ files into the `forge-*` vocabulary; the anchor set itself only ever grows.
558
+ Materialization triggers (2026-10-01 live finding): host boot (a freshly
559
+ started CLI process reads the files as they are), the FIRST config build
560
+ for a directory on this host, and opencode config file reloads. A NEW
561
+ session on a long-lived shared host (serve-type) does NOT re-materialize:
536
562
 
537
563
  | change | needs a host restart? |
538
564
  | --- | --- |
539
- | `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) |
540
- | agent added / removed / edited, any pool file created or deleted | no restart — applies at the next config hook (materialization re-runs there; the crew gate and depth lookups always resolve fresh) |
541
- | a NEW directory joining the host (another project's session on the shared host) | no restart — the re-initialization itself adds the anchor and its pools |
565
+ | `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) — depth lookups resolve the pool files fresh at every dispatch, independent of materialization |
566
+ | agent added / removed / edited, any pool file created or deleted | no restart — but not at the next session either: the materialized `forge-*` set rebuilds only at the next REAL materialization (host boot, opencode config reload, or a first config build); on a long-lived shared host pool changes wait for that rebuild or a restart. The crew gate and depth lookups resolve the pool files fresh regardless |
567
+ | a NEW directory joining the host (the first session of another project on the shared host) | no restart — that first config build itself re-initializes the plugin and appends the anchor and its pools (append-only; existing pools never flip) |
542
568
 
543
569
  ### Dispatching
544
570
 
@@ -580,12 +606,21 @@ requires a plan. The flow:
580
606
 
581
607
  1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
582
608
  titled subtask each, optionally naming the intended `forge-*` agent.
609
+ When the macro contract descends from a plan artifact, pass its path as
610
+ the optional `lineage` argument — recorded as a first-class `lineage:`
611
+ line in the crew record header and disclosed in the registration output
612
+ (explicit argument only; never inferred from a recently discarded plan).
583
613
  Registration enters a **PENDING** crew: execution has not started. The
584
- registration output confirms the roster **grouped by pool with its
585
- origins** (which forge.json families the dispatchable set materialized
586
- from, the primary marked), points at the crew record file, presents the
587
- three execution-mode choices, and stops the turn. Reconnaissance task
588
- calls are free BEFORE registration.
614
+ registration output LEADS with the crew record path line (same
615
+ first-block prominence as the plan tools' `Plan created:`) and the
616
+ PENDING pause text instructs the model to relay that path to the user
617
+ alongside the choice — so you can verify the record really landed on
618
+ disk (an earlier output order buried the path line, and the model
619
+ reliably drops what it buries — leading with it is the fix). The
620
+ output then confirms the roster **grouped by pool with its origins**
621
+ (which forge.json families the dispatchable set materialized from, the
622
+ primary marked), presents the three execution-mode choices, and stops
623
+ the turn. Reconnaissance task calls are free BEFORE registration.
589
624
  2. **The pause is mechanical**: while the crew pends, every `task` dispatch is
590
625
  refused by an interception belt. The user chooses:
591
626
  - **supervised waves now** — `crew_begin {execution: "waves"}` lifts the
@@ -645,9 +680,11 @@ session AI do it — the latter only on your explicit go-ahead in that
645
680
  conversation, through the normal visible write path (when the AI writes a
646
681
  sub-pool file it also proposes a short stable `pool` namespace so ids
647
682
  survive directory renames). This is the plugin's only configuration
648
- invitation. Saved files apply at the next config hook — no restart needed;
649
- on a shared host, opening a session in a new workspace adds that anchor and
650
- its pools automatically.
683
+ invitation. Saved files apply at the next real materialization — no
684
+ restart needed; on a shared host, the first session in a NEW workspace adds
685
+ that anchor and its pools automatically (a new session in an existing
686
+ workspace does not re-read the files — see the apply-timing table
687
+ above).
651
688
 
652
689
  ### Debris note
653
690
 
package/dist/index.js CHANGED
@@ -12346,12 +12346,13 @@ class PlanError extends Error {
12346
12346
  this.name = "PlanError";
12347
12347
  }
12348
12348
  }
12349
- var TERMINAL = ["done", "abandoned"];
12349
+ var TERMINAL = ["done", "abandoned", "superseded"];
12350
12350
  var LEGAL_TRANSITIONS = {
12351
- draft: ["approved", "abandoned"],
12352
- approved: ["done", "abandoned"],
12351
+ draft: ["approved", "abandoned", "superseded"],
12352
+ approved: ["done", "abandoned", "superseded"],
12353
12353
  done: [],
12354
- abandoned: []
12354
+ abandoned: [],
12355
+ superseded: []
12355
12356
  };
12356
12357
  var SECTION_ORDER = [
12357
12358
  "Goal",
@@ -12499,6 +12500,7 @@ function matchSectionHeader(line) {
12499
12500
  }
12500
12501
  function splitSections(body) {
12501
12502
  const sections = new Map;
12503
+ const claimed = new Set;
12502
12504
  let current = null;
12503
12505
  const buf = [];
12504
12506
  const flush = () => {
@@ -12509,12 +12511,14 @@ function splitSections(body) {
12509
12511
  };
12510
12512
  for (const line of body.split(/\r?\n/)) {
12511
12513
  const header = matchSectionHeader(line);
12512
- if (header !== null) {
12513
- flush();
12514
- current = header;
12515
- } else if (current !== null) {
12516
- buf.push(line);
12514
+ if (header === null || claimed.has(header)) {
12515
+ if (current !== null)
12516
+ buf.push(line);
12517
+ continue;
12517
12518
  }
12519
+ flush();
12520
+ claimed.add(header);
12521
+ current = header;
12518
12522
  }
12519
12523
  flush();
12520
12524
  return sections;
@@ -12525,7 +12529,7 @@ var ACCEPT_RE = /^\s*(\d+)[.、)]\s*(.+)$/;
12525
12529
  function parsePlan(text) {
12526
12530
  const { fm, body } = parseFrontmatter(text);
12527
12531
  const status = fm.status ?? "draft";
12528
- if (!["draft", "approved", "done", "abandoned"].includes(status)) {
12532
+ if (!["draft", "approved", "done", "abandoned", "superseded"].includes(status)) {
12529
12533
  throw new PlanError(`Unknown plan status: ${fm.status}`);
12530
12534
  }
12531
12535
  const sections = splitSections(body);
@@ -12612,11 +12616,35 @@ function transitionStatus(text, to, now) {
12612
12616
  if (from === to)
12613
12617
  throw new PlanError(`Plan is already ${to}`);
12614
12618
  if (!canTransition(from, to)) {
12615
- throw new PlanError(`Illegal status transition: ${from} -> ${to} (legal path: draft -> approved -> done; draft/approved -> abandoned)`);
12619
+ throw new PlanError(`Illegal status transition: ${from} -> ${to} (legal path: draft -> approved -> done; draft/approved -> abandoned/superseded)`);
12616
12620
  }
12617
12621
  const next = text.replace(/^status:.*$/m, `status: ${to}`);
12618
12622
  return setUpdated(next, now);
12619
12623
  }
12624
+ function appendTerminalSection(text, kind, opts = {}, now = new Date().toISOString()) {
12625
+ if (!["closed", "abandoned", "superseded"].includes(kind)) {
12626
+ throw new PlanError(`Unknown terminal section kind: ${kind}`);
12627
+ }
12628
+ const lines = [`## ${now} — ${kind}`, ""];
12629
+ const reason = (opts.reason ?? "").trim();
12630
+ lines.push(`reason: ${reason.length > 0 ? reason : "(none given)"}`);
12631
+ const successor = (opts.successor ?? "").trim();
12632
+ if (successor.length > 0)
12633
+ lines.push(`successor: ${successor}`);
12634
+ if (opts.checks && opts.checks.length > 0) {
12635
+ lines.push("checks:");
12636
+ for (const c of opts.checks) {
12637
+ lines.push(`- ${c.pass ? "PASS" : "FAIL"} — ${c.criterion.trim()}`);
12638
+ lines.push(` evidence: ${c.evidence.trim() || "(none)"}`);
12639
+ }
12640
+ }
12641
+ const base = text.replace(/\r?\n$/, "");
12642
+ return `${base}
12643
+
12644
+ ${lines.join(`
12645
+ `)}
12646
+ `;
12647
+ }
12620
12648
  function progressOf(doc2) {
12621
12649
  return { total: doc2.tasks.length, done: doc2.tasks.filter((t) => t.done).length };
12622
12650
  }
@@ -15796,6 +15824,7 @@ var FORGE_STATE_TOOLS = [
15796
15824
  var FORGE_EXEC_TOOLS = ["forge_shell", "forge_jobs"];
15797
15825
  var FORGE_ALL_TOOLS = [...FORGE_STATE_TOOLS, ...FORGE_EXEC_TOOLS];
15798
15826
  var FORGE_TOOL_SET = new Set(FORGE_ALL_TOOLS);
15827
+ var FORGE_STATE_TOOL_SET = new Set(FORGE_STATE_TOOLS);
15799
15828
  var NATIVE_PARTITION_FALLBACKS = ["build", "plan", "general", "explore"];
15800
15829
  var FORGE_PROMPT = `You are forge — the single general-purpose coding agent. You handle every task directly: exploration, planning, implementation, and verification. There is no agent switching.
15801
15830
 
@@ -15819,7 +15848,7 @@ var PLAN_COMMAND_TEMPLATE = [
15819
15848
  "Route on the argument above (decide silently; do not recite this routing text to the user):",
15820
15849
  "- Argument empty: for every non-terminal plan (status draft or approved) in the directory above, read its frontmatter and task checkboxes, then report to the user: path, status, progress (x/y ticked). Ask whether to resume one or start something new.",
15821
15850
  '- Argument "resume": pick the most recently updated non-terminal plan, summarize its remaining unticked tasks to the user in one short list, then continue executing it — tick each task the moment it is done (plan_tick). If none exists, say so.',
15822
- '- Argument "discard": call the plan_discard tool, then tell the user the plan was abandoned and writes are restored.',
15851
+ '- Argument "discard": call the plan_discard tool with a short reason, then tell the user the plan was abandoned and writes are restored. If the work is NOT ending but moving to another harness — crew orchestration (/crew) or a goal contract (/goal) — pass supersede with the successor artifact path (e.g. ".opencode/crew/2026-10-01-....md") so the plan is marked superseded instead, with the successor recorded in the plan file.',
15823
15852
  "- Any other argument: treat it as the task goal — you are now in planning mode for this goal. Follow the plan discipline below end to end.",
15824
15853
  "",
15825
15854
  "## Plan discipline",
@@ -15832,7 +15861,7 @@ var PLAN_COMMAND_TEMPLATE = [
15832
15861
  "4. Execution (tick as you go): work tasks in order; call plan_tick with the task number immediately after EACH task's work is actually done — never batch ticks, never tick ahead of reality (tick timestamps are an audit trail). If the plan turns out wrong mid-execution, do not silently improvise: tell the user what changed and either finish the affected task or ask about revising (/plan with the same goal re-enters planning).",
15833
15862
  "5. Completion (plan_close): when all tasks are ticked, self-check EVERY acceptance criterion with concrete evidence (file:line, command output, test result), then call plan_close with one check per criterion, pass/fail honest — a failing check refuses the close, and that is the design working, not an inconvenience. The user confirms closure in a dialog.",
15834
15863
  "",
15835
- "Boundaries: /plan discard abandons the plan (terminal, file kept as history, writes restored); /plan resume continues an unfinished plan from its remaining tasks. Work expected to span multiple sessions or days of multi-file change is spec work (e.g. OpenSpec), not plan work — say so once and let the user choose.",
15864
+ "Boundaries: /plan discard abandons the plan (terminal, file kept as history, writes restored) — pass supersede (the successor artifact path) when the work moves to crew/goal rather than ends, marking it superseded; /plan resume continues an unfinished plan from its remaining tasks. Work expected to span multiple sessions or days of multi-file change is spec work (e.g. OpenSpec), not plan work — say so once and let the user choose.",
15836
15865
  ""
15837
15866
  ].join(`
15838
15867
  `);
@@ -15943,13 +15972,14 @@ function crewRecordPathFor(worktree, objective) {
15943
15972
  name = `${date5}-${slug}-${n++}.md`;
15944
15973
  return join6(dir, name);
15945
15974
  }
15946
- function writeCrewRecord(path, objective, anchor, subtasks) {
15975
+ function writeCrewRecord(path, objective, anchor, subtasks, lineage) {
15947
15976
  mkdirSync5(join6(path, ".."), { recursive: true });
15948
15977
  writeFileSync5(path, [
15949
15978
  `# Crew Record: ${objective}`,
15950
15979
  "",
15951
15980
  `- registered: ${nowIso()}`,
15952
15981
  `- anchor: ${anchor}`,
15982
+ ...lineage ? [`- lineage: ${lineage}`] : [],
15953
15983
  "",
15954
15984
  `## Declared plan (${subtasks.length})`,
15955
15985
  ...subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
@@ -15979,6 +16009,7 @@ function governingGoalFor(sessionID) {
15979
16009
  function crewPendingChoiceLines() {
15980
16010
  return [
15981
16011
  "[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):",
16012
+ `RELAY FIRST: tell the user the crew record path (it leads this output) alongside this choice — the record is on disk from the registration moment, and the user must hear it.`,
15982
16013
  ` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline;`,
15983
16014
  ` 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;`,
15984
16015
  ` 3. standby — no call; the crew waits until armed or abandoned.`
@@ -16283,28 +16314,40 @@ var planCloseTool = tool({
16283
16314
  Fix the implementation and retry, or revise the plan first.`);
16284
16315
  }
16285
16316
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
16286
- writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "done", nowIso()));
16317
+ const closedAt = nowIso();
16318
+ const closed = appendTerminalSection(transitionStatus(readFileSync8(active.path, "utf8"), "done", closedAt), "closed", { reason: "completion gate passed (user confirmed)", checks: args.checks }, closedAt);
16319
+ writeFileSync5(active.path, closed);
16287
16320
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
16288
16321
  return {
16289
16322
  title: "plan done",
16290
- output: `Plan completed and closed (done): ${relFrom(state.worktree, active.path)}. All ${args.checks.length} self-checks passed.`
16323
+ output: `Plan completed and closed (done): ${relFrom(state.worktree, active.path)}. All ${args.checks.length} self-checks passed. Terminal section "## ${closedAt} — closed" appended with the per-criterion verdicts.`
16291
16324
  };
16292
16325
  }
16293
16326
  });
16294
16327
  var planDiscardTool = tool({
16295
- description: "Abandon the session's active plan (draft/approved -> abandoned) and lift the draft write ban. Terminal: the file stays in .opencode/plan/ as history. Invoke via /plan discard or directly when the user cancels the task.",
16328
+ description: "Abandon the session's active plan (draft/approved -> abandoned) and lift the draft write ban. Pass `supersede` (the successor artifact's path or identifier, e.g. a .opencode/crew/ or .opencode/goal/ file) when the work is not ending but moving to another harness (crew orchestration, a goal contract): the plan then becomes superseded instead of abandoned, with the successor recorded. Either way a dated terminal section carrying the reason verbatim (a missing reason lands as `reason: (none given)`) is appended to the file in the SAME write as the status flip, and the file stays in .opencode/plan/ as history. Invoke via /plan discard or directly when the user cancels the task.",
16296
16329
  args: {
16297
- reason: tool.schema.string().optional().describe("Short reason recorded in the reply")
16330
+ reason: tool.schema.string().optional().describe("Short reason recorded in the reply and in the appended terminal section, verbatim"),
16331
+ supersede: tool.schema.string().optional().describe("Successor artifact's path or identifier (crew record, goal contract, ...); when present the plan becomes superseded instead of abandoned and this value is persisted as the successor reference")
16298
16332
  },
16299
- execute: async (_args, context) => {
16333
+ execute: async (args, context) => {
16300
16334
  const state = ensureSession(context.sessionID, worktreeFor(context));
16301
16335
  const active = resolveActivePlan(state);
16302
16336
  if (!active)
16303
16337
  throw new PlanError("No plan to abandon in this workspace.");
16304
- writeFileSync5(active.path, transitionStatus(readFileSync8(active.path, "utf8"), "abandoned", nowIso()));
16338
+ const successor = (args.supersede ?? "").trim();
16339
+ const superseding = successor.length > 0;
16340
+ const target = superseding ? "superseded" : "abandoned";
16341
+ const exitedAt = nowIso();
16342
+ const next = appendTerminalSection(transitionStatus(readFileSync8(active.path, "utf8"), target, exitedAt), superseding ? "superseded" : "abandoned", { reason: args.reason, ...superseding ? { successor } : {} }, exitedAt);
16343
+ writeFileSync5(active.path, next);
16305
16344
  state.planPath = undefined;
16306
- context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
16307
- return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
16345
+ const section = `## ${exitedAt} — ${target}`;
16346
+ context.metadata({ title: superseding ? `Plan superseded: ${active.doc.goal}` : `Plan abandoned: ${active.doc.goal}` });
16347
+ return {
16348
+ title: superseding ? "plan superseded" : "plan abandoned",
16349
+ output: superseding ? `Plan superseded (not abandoned): ${relFrom(state.worktree, active.path)}. Terminal section "${section}" appended (successor: ${successor}). Write operations are restored.` : `Plan abandoned: ${relFrom(state.worktree, active.path)}. Terminal section "${section}" appended. Write operations are restored.`
16350
+ };
16308
16351
  }
16309
16352
  });
16310
16353
  function goalDirOf(worktree) {
@@ -16826,7 +16869,7 @@ function crewOrchestrationTemplate(resolution) {
16826
16869
  "",
16827
16870
  "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:",
16828
16871
  "",
16829
- "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.",
16872
+ '1. REGISTER: call `crew_begin` with the objective AND the declared subtask plan (subtasks[]: one {title} per subtask, optionally naming the intended forge-* agent). When the macro contract descends from a plan artifact, pass its path as the optional `lineage` argument (e.g. lineage: ".opencode/plan/2026-10-01-my-plan.md") — the descent is recorded as a first-class line in the crew record header; lineage is never inferred. Reconnaissance BEFORE registration is free (task calls included). If crew_begin refuses (a crew is already active here, or a plan draft is active), STOP and tell the user why — never start a second crew. A wrong plan is fixed by abandoning and re-crewing (crew_close {abandon: true, reason}), never by improvising outside the declared plan.',
16830
16873
  `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.`,
16831
16874
  "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.",
16832
16875
  "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.",
@@ -16844,14 +16887,15 @@ var crewBeginTool = tool({
16844
16887
  subtasks: tool.schema.array(tool.schema.object({
16845
16888
  title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
16846
16889
  agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
16847
- })).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
16890
+ })).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list"),
16891
+ lineage: tool.schema.string().optional().describe("Free-text origin of the macro contract — e.g. the plan artifact path this crew descends from (.opencode/plan/<date>-<slug>.md). Recorded as a first-class lineage: line in the crew record header and disclosed in the registration output. Explicit argument only — never inferred from recently-terminal plans.")
16848
16892
  },
16849
16893
  execute: async (args, context) => {
16850
16894
  noteToolAgent(context);
16851
16895
  const state = stateForBan(context.sessionID);
16852
16896
  const active = state ? resolveActivePlan(state) : null;
16853
16897
  if (active && active.doc.status === "draft") {
16854
- throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Call plan_approve for user approval, or /plan discard to abandon the plan.");
16898
+ throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning. Three exits: " + "plan_approve puts the plan to user approval (an APPROVED plan does not block /crew — only a draft does); " + "/plan discard abandons the plan outright; " + '/plan discard {supersede: "<successor path or id>"} moves the work into this crew (the plan becomes superseded, the successor recorded).');
16855
16899
  }
16856
16900
  const resolution = resolvePools();
16857
16901
  const hostIds = resolution ? Object.keys(resolution.agents) : [];
@@ -16953,13 +16997,14 @@ var crewBeginTool = tool({
16953
16997
  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).');
16954
16998
  }
16955
16999
  const governed = execution === "waves";
17000
+ const lineage = String(args.lineage ?? "").trim();
16956
17001
  const anchorDir = anchor || hostWorktree;
16957
17002
  let recordPath = null;
16958
17003
  let recordNote = "";
16959
17004
  if (anchorDir) {
16960
17005
  try {
16961
17006
  recordPath = crewRecordPathFor(anchorDir, objectiveText);
16962
- writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks);
17007
+ writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks, lineage || undefined);
16963
17008
  recordNote = `Crew record (inspectable history; NOT a resume mechanism): ${relFrom(anchorDir, recordPath)}`;
16964
17009
  } catch {
16965
17010
  recordPath = null;
@@ -16985,12 +17030,13 @@ var crewBeginTool = tool({
16985
17030
  if (families.length > 8)
16986
17031
  familyLines.push(` - …and ${families.length - 8} more pools`);
16987
17032
  const lines = [
17033
+ ...recordNote ? [recordNote] : [],
17034
+ ...lineage ? [`Lineage: ${lineage}`] : [],
16988
17035
  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}`,
16989
17036
  `Declared plan (${subtasks.length}):`,
16990
17037
  ...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
16991
17038
  `Dispatchable roster origin — ${families.length} pool${families.length === 1 ? "" : "s"} across the host anchor set${hostAnchors.length > 0 ? ` (anchors: ${hostAnchors.join(", ")})` : ""}:`,
16992
17039
  ...familyLines,
16993
- ...recordNote ? [recordNote] : [],
16994
17040
  ...disclosure ? [disclosure] : []
16995
17041
  ];
16996
17042
  if (governed) {
@@ -17553,6 +17599,9 @@ var server = async (input, options) => {
17553
17599
  if (isForgeFamilyAgent(speaker) && (input2.tool === "shell" || input2.tool === "bash") && !jobsKeepBuiltinShell && jobStage() < 2) {
17554
17600
  throw new Error(`[forge:partition] Builtin shell refused: the forge family executes through forge_shell (jobs.keepBuiltinShell or jobs.mode: "native" restores the builtin shell). Re-issue the command through forge_shell.`);
17555
17601
  }
17602
+ if (isForgeFamilyAgent(speaker) && speaker.toLowerCase() !== FORGE_AGENT && FORGE_STATE_TOOL_SET.has(input2.tool)) {
17603
+ throw new Error(`[forge:partition] Tool refused: "${input2.tool}" is a harness state tool reserved for the primary forge agent; "${speaker}" is a forge worker. Report your findings back to the orchestrating session — the primary agent calls ${input2.tool} at the gate.`);
17604
+ }
17556
17605
  }
17557
17606
  }
17558
17607
  },
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@sorenllm/opencode-forge",
3
- "version": "0.13.0",
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
- "type": "module",
6
- "main": "./dist/index.js",
7
- "exports": {
8
- ".": {
9
- "import": "./dist/index.js"
10
- },
11
- "./server": {
12
- "import": "./dist/index.js"
13
- }
14
- },
15
- "files": [
16
- "dist",
17
- "README.md"
18
- ],
19
- "scripts": {
20
- "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
- "test": "node --test tests/*.test.mjs",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "keywords": [
25
- "opencode",
26
- "opencode-plugin",
27
- "subagents",
28
- "plan",
29
- "planning",
30
- "workflow",
31
- "single-agent",
32
- "goal",
33
- "crew",
34
- "orchestration",
35
- "background-jobs",
36
- "shell",
37
- "watchdog"
38
- ],
39
- "license": "MIT",
40
- "author": "chengsongren",
41
- "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
45
- },
46
- "bugs": {
47
- "url": "https://github.com/ChengZiiii/opencode-forge/issues"
48
- },
49
- "peerDependencies": {
50
- "@opencode-ai/plugin": "^1.18.0"
51
- },
52
- "devDependencies": {
53
- "@opencode-ai/plugin": "^1.18.0",
54
- "@types/node": "^22.13.9",
55
- "typescript": "^5.8.2"
56
- },
57
- "engines": {
58
- "opencode": "^1.18.0"
59
- },
60
- "publishConfig": {
61
- "access": "public",
62
- "registry": "https://registry.npmjs.org/"
63
- }
64
- }
1
+ {
2
+ "name": "@sorenllm/opencode-forge",
3
+ "version": "0.14.1",
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
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./server": {
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "bundle": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && bun build ./plugin.ts --outfile ./dist/index.js --target node --format esm",
21
+ "test": "node --test tests/*.test.mjs",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "opencode-plugin",
27
+ "subagents",
28
+ "plan",
29
+ "planning",
30
+ "workflow",
31
+ "single-agent",
32
+ "goal",
33
+ "crew",
34
+ "orchestration",
35
+ "background-jobs",
36
+ "shell",
37
+ "watchdog"
38
+ ],
39
+ "license": "MIT",
40
+ "author": "chengsongren",
41
+ "homepage": "https://github.com/ChengZiiii/opencode-forge#readme",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/ChengZiiii/opencode-forge.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/ChengZiiii/opencode-forge/issues"
48
+ },
49
+ "peerDependencies": {
50
+ "@opencode-ai/plugin": "^1.18.0"
51
+ },
52
+ "devDependencies": {
53
+ "@opencode-ai/plugin": "^1.18.0",
54
+ "@types/node": "^22.13.9",
55
+ "typescript": "^5.8.2"
56
+ },
57
+ "engines": {
58
+ "opencode": "^1.18.0"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "registry": "https://registry.npmjs.org/"
63
+ }
64
+ }