@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.
- package/README.md +55 -18
- package/dist/index.js +76 -27
- 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
|
|
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).
|
|
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
|
|
423
|
-
|
|
424
|
-
|
|
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** —
|
|
535
|
-
|
|
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 —
|
|
541
|
-
| a NEW directory joining the host (another project
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
649
|
-
on a shared host,
|
|
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
|
|
12513
|
-
|
|
12514
|
-
|
|
12515
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
16307
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|