@sorenllm/opencode-forge 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -30
- package/dist/index.js +427 -110
- 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
|
|
@@ -172,7 +183,7 @@ What this plugin touches, exhaustively:
|
|
|
172
183
|
| `<tmp>/opencode-forge/jobs/registry.json` | persistent survivor registry (bounded: 100 entries) | runtime debris — after uninstall, kill any still-running `survive` jobs yourself first |
|
|
173
184
|
| `<tmp>/opencode-forge/watchdog/log.jsonl` | watchdog interventions ledger (bounded: 200 entries, oldest rotated) | runtime debris — delete freely, also after uninstall |
|
|
174
185
|
| `<tmp>/opencode-forge/dispatch/ledger.jsonl` | dispatch ledger — **no longer written** (the dispatch engine was removed); only present as inert debris from older versions | runtime debris — delete freely |
|
|
175
|
-
|
|
|
186
|
+
| `.opencode/forge.json` files (the nearest on each host anchor's ancestor chain, every one in an anchor's subtree, and `~/.config/opencode/forge.json`) | your static subagent definitions (JSONC) — pool families MERGED over the global layer per agent id (primary anchor's root pool plain ids, others namespaced). **User data — the plugin only reads them, never writes or migrates them** | yours — version the project ones, keep the global one out of sync tools if it holds machine-specific models |
|
|
176
187
|
| `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
|
|
177
188
|
| `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
|
|
178
189
|
|
|
@@ -418,26 +429,61 @@ discovered by walking up:
|
|
|
418
429
|
|
|
419
430
|
The two layers **merge**: agents combine, and on an id collision the project
|
|
420
431
|
definition wins wholesale (one definition per id — no cross-layer field
|
|
421
|
-
blending). A broken file empties only its own layer (error finding with
|
|
422
|
-
parse location);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
432
|
+
blending). A broken file empties only its own layer/pool (error finding with
|
|
433
|
+
the parse location); everything else still applies. Both layers re-read at
|
|
434
|
+
every real materialization — host boot, a first config build, or an
|
|
435
|
+
opencode config reload — without a restart; a new session alone does
|
|
436
|
+
not re-read them (see the apply-timing table below).
|
|
437
|
+
|
|
438
|
+
**Hierarchical pools on a shared host.** Agent frontends (paseo and friends)
|
|
439
|
+
routinely share ONE opencode host process across projects, re-initializing
|
|
440
|
+
plugins with different directories. The plugin therefore keeps an
|
|
441
|
+
**append-only anchor set**: every host initialization appends its directory
|
|
442
|
+
(first one = the *primary anchor*), and pools resolve from the union —
|
|
443
|
+
anchor flips can only ever ADD pools, never remove them. Per anchor the
|
|
444
|
+
family is:
|
|
445
|
+
|
|
446
|
+
- the **root pool** — the nearest forge.json walking up from that anchor
|
|
447
|
+
(merged over the global layer per agent id);
|
|
448
|
+
- every **sub-pool** — a `.opencode/forge.json` anywhere in the anchor's
|
|
449
|
+
subdirectories, discovered by a bounded breadth-first scan (lexicographic
|
|
450
|
+
sibling order; dependency dirs like `node_modules`/`.git`/`dist` are
|
|
451
|
+
skipped; 2,000-directory budget per anchor).
|
|
452
|
+
|
|
453
|
+
The **primary anchor's root pool keeps plain `forge-<id>` ids** (a
|
|
454
|
+
single-project host is unchanged); every other family materializes
|
|
455
|
+
**namespaced** as `forge-<ns>-<id>`. `ns` is the file's optional `pool`
|
|
456
|
+
field, else the sanitized directory name. Pool identity is the absolute file
|
|
457
|
+
path — the same file reached through two anchors materializes exactly once;
|
|
458
|
+
namespace and materialized-id collisions resolve deterministically with an
|
|
459
|
+
error finding, never by silent shadowing. The `/crew` roster and the
|
|
460
|
+
`crew_begin` registration output list the dispatchable agents **grouped by
|
|
461
|
+
pool** with each pool's origin path, the primary marked.
|
|
462
|
+
|
|
463
|
+
| axis | follows | survives restart? |
|
|
464
|
+
| --- | --- | --- |
|
|
465
|
+
| pools (the `forge-*` vocabulary) | host directories — the anchor set | the set re-seeds from the spawn anchor only |
|
|
466
|
+
| session artifacts (plan/goal/crew records) | the session's own workspace | files on disk are the source of truth |
|
|
426
467
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
468
|
+
A forge.json in a directory NO host anchor covers is NOT silently ignored:
|
|
469
|
+
`/crew` names it as the residual mismatch and tells you a host
|
|
470
|
+
initialization in that workspace adds its pools (or fold the agents into the
|
|
471
|
+
global layer).
|
|
431
472
|
|
|
432
473
|
**Id naming**: use plain role words (`research`, `coder`, …) — the plugin
|
|
433
|
-
materializes every id as `forge-<id>` itself
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
474
|
+
materializes every id as `forge-<id>` itself (namespaced pools:
|
|
475
|
+
`forge-<ns>-<id>`). A `forge-`-prefixed id in the file would double up
|
|
476
|
+
(`forge-coder` → agent `forge-forge-coder`); the loader auto-strips the
|
|
477
|
+
prefix (all repetitions) with a warning so existing files keep working, but
|
|
478
|
+
rename the entries to silence it. The namespace infix is never parsed back
|
|
479
|
+
out of a materialized id.
|
|
437
480
|
|
|
438
481
|
```jsonc
|
|
439
482
|
{
|
|
440
|
-
//
|
|
483
|
+
// sub-pool files only: namespace for this family's ids (forge-<ns>-<id>).
|
|
484
|
+
// Short, stable, [a-z0-9-] up to 24 chars — ids survive directory renames.
|
|
485
|
+
// Invalid values degrade to a warning + the directory-derived namespace.
|
|
486
|
+
// "pool": "aa",
|
|
441
487
|
"agents": {
|
|
442
488
|
// PINNED worker: model + thoughtLevel form an ATOMIC PAIR — set BOTH
|
|
443
489
|
"research": {
|
|
@@ -497,12 +543,18 @@ A rejected entry costs only itself — its siblings materialize normally.
|
|
|
497
543
|
> error finding naming the missing half. Fix per entry: add the missing
|
|
498
544
|
> `thoughtLevel`, or delete `model` to make it an Auto worker.
|
|
499
545
|
|
|
500
|
-
**What applies when** —
|
|
546
|
+
**What applies when** — only a real materialization re-reads the pool
|
|
547
|
+
files into the `forge-*` vocabulary; the anchor set itself only ever grows.
|
|
548
|
+
Materialization triggers (2026-10-01 live finding): host boot (a freshly
|
|
549
|
+
started CLI process reads the files as they are), the FIRST config build
|
|
550
|
+
for a directory on this host, and opencode config file reloads. A NEW
|
|
551
|
+
session on a long-lived shared host (serve-type) does NOT re-materialize:
|
|
501
552
|
|
|
502
553
|
| change | needs a host restart? |
|
|
503
554
|
| --- | --- |
|
|
504
|
-
| `thoughtLevel` on an existing agent | no (applies to NEW sessions of that agent; running sessions keep their frozen depth) |
|
|
505
|
-
| agent added / removed, or
|
|
555
|
+
| `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 |
|
|
556
|
+
| 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 |
|
|
557
|
+
| 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) |
|
|
506
558
|
|
|
507
559
|
### Dispatching
|
|
508
560
|
|
|
@@ -544,11 +596,21 @@ requires a plan. The flow:
|
|
|
544
596
|
|
|
545
597
|
1. **Register** the declared plan: `crew_begin {objective, subtasks}` — one
|
|
546
598
|
titled subtask each, optionally naming the intended `forge-*` agent.
|
|
599
|
+
When the macro contract descends from a plan artifact, pass its path as
|
|
600
|
+
the optional `lineage` argument — recorded as a first-class `lineage:`
|
|
601
|
+
line in the crew record header and disclosed in the registration output
|
|
602
|
+
(explicit argument only; never inferred from a recently discarded plan).
|
|
547
603
|
Registration enters a **PENDING** crew: execution has not started. The
|
|
548
|
-
registration output
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
604
|
+
registration output LEADS with the crew record path line (same
|
|
605
|
+
first-block prominence as the plan tools' `Plan created:`) and the
|
|
606
|
+
PENDING pause text instructs the model to relay that path to the user
|
|
607
|
+
alongside the choice — so you can verify the record really landed on
|
|
608
|
+
disk (an earlier output order buried the path line, and the model
|
|
609
|
+
reliably drops what it buries — leading with it is the fix). The
|
|
610
|
+
output then confirms the roster **grouped by pool with its origins**
|
|
611
|
+
(which forge.json families the dispatchable set materialized from, the
|
|
612
|
+
primary marked), presents the three execution-mode choices, and stops
|
|
613
|
+
the turn. Reconnaissance task calls are free BEFORE registration.
|
|
552
614
|
2. **The pause is mechanical**: while the crew pends, every `task` dispatch is
|
|
553
615
|
refused by an interception belt. The user chooses:
|
|
554
616
|
- **supervised waves now** — `crew_begin {execution: "waves"}` lifts the
|
|
@@ -601,13 +663,18 @@ itself (a rootish guard keeps degenerate global-project worktrees from
|
|
|
601
663
|
landing files on the drive root or the launch anchor). The anchor follows the
|
|
602
664
|
session's workspace, never the objective's scope.
|
|
603
665
|
|
|
604
|
-
**Not initialized?** `/crew` on a host with
|
|
605
|
-
with one-time setup guidance: the two file paths, a
|
|
606
|
-
the choice of configuring it yourself OR having the
|
|
607
|
-
latter only on your explicit go-ahead in that
|
|
608
|
-
normal visible write path
|
|
609
|
-
|
|
610
|
-
|
|
666
|
+
**Not initialized?** `/crew` on a host with ZERO pools across its whole
|
|
667
|
+
anchor set refuses with one-time setup guidance: the two file paths, a
|
|
668
|
+
copy-paste template, and the choice of configuring it yourself OR having the
|
|
669
|
+
session AI do it — the latter only on your explicit go-ahead in that
|
|
670
|
+
conversation, through the normal visible write path (when the AI writes a
|
|
671
|
+
sub-pool file it also proposes a short stable `pool` namespace so ids
|
|
672
|
+
survive directory renames). This is the plugin's only configuration
|
|
673
|
+
invitation. Saved files apply at the next real materialization — no
|
|
674
|
+
restart needed; on a shared host, the first session in a NEW workspace adds
|
|
675
|
+
that anchor and its pools automatically (a new session in an existing
|
|
676
|
+
workspace does not re-read the files — see the apply-timing table
|
|
677
|
+
above).
|
|
611
678
|
|
|
612
679
|
### Debris note
|
|
613
680
|
|
package/dist/index.js
CHANGED
|
@@ -12335,7 +12335,7 @@ function tool(input) {
|
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
12337
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
-
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as
|
|
12338
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync8, realpathSync, statSync as statSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
12339
12339
|
import { isAbsolute, join as join6, relative } from "node:path";
|
|
12340
12340
|
import { tmpdir as tmpdir3, homedir } from "node:os";
|
|
12341
12341
|
|
|
@@ -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
|
}
|
|
@@ -15104,7 +15132,7 @@ function resolvePinnedDepth(thoughtLevel, family, ladder) {
|
|
|
15104
15132
|
}
|
|
15105
15133
|
|
|
15106
15134
|
// src/forge-config.ts
|
|
15107
|
-
import { readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
15135
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
|
|
15108
15136
|
function sanitizeJsonc(text) {
|
|
15109
15137
|
const out = [];
|
|
15110
15138
|
let i = 0;
|
|
@@ -15456,6 +15484,25 @@ function parentDirOf(p) {
|
|
|
15456
15484
|
const parent = s.slice(0, i + 1);
|
|
15457
15485
|
return parent === "" ? null : parent;
|
|
15458
15486
|
}
|
|
15487
|
+
function forgeCandidateAt(dir) {
|
|
15488
|
+
const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
|
15489
|
+
const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
|
|
15490
|
+
return `${base}.opencode${sep2}forge.json`;
|
|
15491
|
+
}
|
|
15492
|
+
function discoverProjectFileWith(stat, anchor) {
|
|
15493
|
+
let dir = (anchor ?? "").trim();
|
|
15494
|
+
if (!dir)
|
|
15495
|
+
return null;
|
|
15496
|
+
for (;; ) {
|
|
15497
|
+
const candidate = forgeCandidateAt(dir);
|
|
15498
|
+
if (stat(candidate) !== null)
|
|
15499
|
+
return candidate;
|
|
15500
|
+
const parent = parentDirOf(dir);
|
|
15501
|
+
if (parent === null || parent === dir)
|
|
15502
|
+
return null;
|
|
15503
|
+
dir = parent;
|
|
15504
|
+
}
|
|
15505
|
+
}
|
|
15459
15506
|
function createForgeConfigLoader(deps) {
|
|
15460
15507
|
const stat = deps.stat ?? ((path) => {
|
|
15461
15508
|
try {
|
|
@@ -15512,28 +15559,9 @@ function createForgeConfigLoader(deps) {
|
|
|
15512
15559
|
cache.set(path, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
15513
15560
|
return result;
|
|
15514
15561
|
};
|
|
15515
|
-
const candidateAt = (dir) => {
|
|
15516
|
-
const sep2 = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
|
|
15517
|
-
const base = dir.endsWith("/") || dir.endsWith("\\") ? dir : `${dir}${sep2}`;
|
|
15518
|
-
return `${base}.opencode${sep2}forge.json`;
|
|
15519
|
-
};
|
|
15520
|
-
const discoverProjectFile = (anchor) => {
|
|
15521
|
-
let dir = (anchor ?? "").trim();
|
|
15522
|
-
if (!dir)
|
|
15523
|
-
return null;
|
|
15524
|
-
for (;; ) {
|
|
15525
|
-
const candidate = candidateAt(dir);
|
|
15526
|
-
if (stat(candidate) !== null)
|
|
15527
|
-
return candidate;
|
|
15528
|
-
const parent = parentDirOf(dir);
|
|
15529
|
-
if (parent === null || parent === dir)
|
|
15530
|
-
return null;
|
|
15531
|
-
dir = parent;
|
|
15532
|
-
}
|
|
15533
|
-
};
|
|
15534
15562
|
return {
|
|
15535
15563
|
load() {
|
|
15536
|
-
const projectPath =
|
|
15564
|
+
const projectPath = discoverProjectFileWith(stat, deps.projectDir ?? "");
|
|
15537
15565
|
const project = projectPath ? readCandidate(projectPath, "project") : null;
|
|
15538
15566
|
const global = globalPath ? readCandidate(globalPath, "global") : null;
|
|
15539
15567
|
if (project && global) {
|
|
@@ -15553,6 +15581,228 @@ function createForgeConfigLoader(deps) {
|
|
|
15553
15581
|
}
|
|
15554
15582
|
};
|
|
15555
15583
|
}
|
|
15584
|
+
var POOL_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "target", ".cache", "venv", ".venv", "__pycache__", "coverage"]);
|
|
15585
|
+
var POOL_SCAN_BUDGET = 2000;
|
|
15586
|
+
function sanitizeNamespace(name) {
|
|
15587
|
+
const collapsed = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
15588
|
+
return collapsed || "pool";
|
|
15589
|
+
}
|
|
15590
|
+
function validatePoolField(v) {
|
|
15591
|
+
if (typeof v !== "string")
|
|
15592
|
+
return { ok: false };
|
|
15593
|
+
const trimmed = v.trim();
|
|
15594
|
+
if (trimmed.length < 1 || trimmed.length > 24 || !/^[a-z0-9-]+$/.test(trimmed))
|
|
15595
|
+
return { ok: false };
|
|
15596
|
+
return { ok: true, ns: trimmed };
|
|
15597
|
+
}
|
|
15598
|
+
function dirBasename(dir) {
|
|
15599
|
+
let s = dir;
|
|
15600
|
+
while (s.length > 1 && (s.endsWith("/") || s.endsWith("\\")))
|
|
15601
|
+
s = s.slice(0, -1);
|
|
15602
|
+
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
|
15603
|
+
return i < 0 ? s : s.slice(i + 1);
|
|
15604
|
+
}
|
|
15605
|
+
function codepointCompare(a, b) {
|
|
15606
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
15607
|
+
}
|
|
15608
|
+
function createPoolResolver(deps) {
|
|
15609
|
+
const stat = deps.stat ?? ((path) => {
|
|
15610
|
+
try {
|
|
15611
|
+
const s = statSync4(path);
|
|
15612
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
15613
|
+
} catch {
|
|
15614
|
+
return null;
|
|
15615
|
+
}
|
|
15616
|
+
});
|
|
15617
|
+
const readFile = deps.readFile ?? ((path) => readFileSync7(path, "utf8"));
|
|
15618
|
+
const readdir = deps.readdir ?? ((dir) => {
|
|
15619
|
+
try {
|
|
15620
|
+
return readdirSync4(dir, { withFileTypes: true }).map((d) => ({ name: d.name, dir: d.isDirectory() }));
|
|
15621
|
+
} catch {
|
|
15622
|
+
return null;
|
|
15623
|
+
}
|
|
15624
|
+
});
|
|
15625
|
+
const budget = typeof deps.scanBudget === "number" && deps.scanBudget > 0 ? deps.scanBudget : POOL_SCAN_BUDGET;
|
|
15626
|
+
const globalPath = forgeConfigPaths({ homeDir: deps.homeDir }).global;
|
|
15627
|
+
const fileCache = new Map;
|
|
15628
|
+
const readPool = (path) => {
|
|
15629
|
+
const st = stat(path);
|
|
15630
|
+
if (st === null) {
|
|
15631
|
+
fileCache.delete(path);
|
|
15632
|
+
return null;
|
|
15633
|
+
}
|
|
15634
|
+
const hit = fileCache.get(path);
|
|
15635
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
15636
|
+
return hit.parsed;
|
|
15637
|
+
let parsed = null;
|
|
15638
|
+
const text = readFile(path);
|
|
15639
|
+
const doc2 = parseForgeJsonc(text);
|
|
15640
|
+
if (!doc2.ok) {
|
|
15641
|
+
parsed = {
|
|
15642
|
+
agents: {},
|
|
15643
|
+
declaredPool: null,
|
|
15644
|
+
findings: [
|
|
15645
|
+
{
|
|
15646
|
+
level: "error",
|
|
15647
|
+
code: "config-parse-error",
|
|
15648
|
+
message: `${path}: ${doc2.error.message}${doc2.error.line !== undefined ? ` (line ${doc2.error.line}, column ${doc2.error.column})` : ""} — this pool contributes no agents until the file parses`
|
|
15649
|
+
}
|
|
15650
|
+
]
|
|
15651
|
+
};
|
|
15652
|
+
} else if (doc2.config !== undefined && !isRecord(doc2.config)) {
|
|
15653
|
+
parsed = { agents: {}, declaredPool: null, findings: [{ level: "error", code: "config-not-object", message: `${path}: top level must be an object — this pool contributes no agents` }] };
|
|
15654
|
+
} else {
|
|
15655
|
+
const top = doc2.config === undefined ? undefined : doc2.config;
|
|
15656
|
+
const validated = validateAgentSet(top === undefined ? undefined : top.agents);
|
|
15657
|
+
parsed = { agents: validated.agents, declaredPool: top === undefined ? null : top.pool, findings: validated.findings };
|
|
15658
|
+
}
|
|
15659
|
+
fileCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, parsed });
|
|
15660
|
+
return parsed;
|
|
15661
|
+
};
|
|
15662
|
+
const dirCache = new Map;
|
|
15663
|
+
const listDir = (dir) => {
|
|
15664
|
+
const st = stat(dir);
|
|
15665
|
+
if (st === null) {
|
|
15666
|
+
dirCache.delete(dir);
|
|
15667
|
+
return null;
|
|
15668
|
+
}
|
|
15669
|
+
const hit = dirCache.get(dir);
|
|
15670
|
+
if (hit && hit.mtimeMs === st.mtimeMs)
|
|
15671
|
+
return hit;
|
|
15672
|
+
const listing = readdir(dir);
|
|
15673
|
+
const children = listing === null ? null : [...listing].sort((a, b) => codepointCompare(a.name, b.name));
|
|
15674
|
+
let poolFile = null;
|
|
15675
|
+
const candidate = forgeCandidateAt(dir);
|
|
15676
|
+
if (stat(candidate) !== null)
|
|
15677
|
+
poolFile = candidate;
|
|
15678
|
+
const entry = { mtimeMs: st.mtimeMs, children, poolFile };
|
|
15679
|
+
dirCache.set(dir, entry);
|
|
15680
|
+
return entry;
|
|
15681
|
+
};
|
|
15682
|
+
const scanSubtree = (anchor) => {
|
|
15683
|
+
const files = [];
|
|
15684
|
+
const seen = new Set;
|
|
15685
|
+
let opened = 0;
|
|
15686
|
+
const sep2 = anchor.includes("\\") && !anchor.includes("/") ? "\\" : "/";
|
|
15687
|
+
const joinDir = (parent, name) => parent.endsWith("/") || parent.endsWith("\\") ? `${parent}${name}` : `${parent}${sep2}${name}`;
|
|
15688
|
+
const queue = [anchor];
|
|
15689
|
+
while (queue.length > 0 && opened < budget) {
|
|
15690
|
+
const dir = queue.shift();
|
|
15691
|
+
if (seen.has(dir))
|
|
15692
|
+
continue;
|
|
15693
|
+
seen.add(dir);
|
|
15694
|
+
const entry = listDir(dir);
|
|
15695
|
+
opened++;
|
|
15696
|
+
if (entry === null || entry.children === null)
|
|
15697
|
+
continue;
|
|
15698
|
+
const isAnchor = dir === anchor;
|
|
15699
|
+
if (!isAnchor && entry.poolFile !== null)
|
|
15700
|
+
files.push(entry.poolFile);
|
|
15701
|
+
for (const child of entry.children) {
|
|
15702
|
+
if (!child.dir)
|
|
15703
|
+
continue;
|
|
15704
|
+
if (POOL_SKIP_DIRS.has(child.name) || child.name === ".opencode")
|
|
15705
|
+
continue;
|
|
15706
|
+
queue.push(joinDir(dir, child.name));
|
|
15707
|
+
}
|
|
15708
|
+
}
|
|
15709
|
+
return files;
|
|
15710
|
+
};
|
|
15711
|
+
return {
|
|
15712
|
+
resolve(anchorsInput) {
|
|
15713
|
+
const anchors = [];
|
|
15714
|
+
for (const a of anchorsInput) {
|
|
15715
|
+
const t = (a ?? "").trim();
|
|
15716
|
+
if (t && !anchors.includes(t))
|
|
15717
|
+
anchors.push(t);
|
|
15718
|
+
}
|
|
15719
|
+
const findings = [];
|
|
15720
|
+
const globalParsed = globalPath ? readPool(globalPath) : null;
|
|
15721
|
+
if (globalParsed)
|
|
15722
|
+
findings.push(...globalParsed.findings);
|
|
15723
|
+
const primaryRootFile = anchors.length > 0 ? discoverProjectFileWith(stat, anchors[0]) : null;
|
|
15724
|
+
const uniqueFiles = new Set;
|
|
15725
|
+
if (primaryRootFile)
|
|
15726
|
+
uniqueFiles.add(primaryRootFile);
|
|
15727
|
+
for (const anchor of anchors) {
|
|
15728
|
+
for (const f of scanSubtree(anchor))
|
|
15729
|
+
uniqueFiles.add(f);
|
|
15730
|
+
if (anchor !== anchors[0]) {
|
|
15731
|
+
const rootFile = discoverProjectFileWith(stat, anchor);
|
|
15732
|
+
if (rootFile)
|
|
15733
|
+
uniqueFiles.add(rootFile);
|
|
15734
|
+
}
|
|
15735
|
+
}
|
|
15736
|
+
const plainPoolFile = primaryRootFile ?? (globalParsed !== null && globalPath ? globalPath : null);
|
|
15737
|
+
const orderedFiles = [plainPoolFile, ...[...uniqueFiles].filter((f) => f !== plainPoolFile).sort(codepointCompare)].filter((f) => f !== null);
|
|
15738
|
+
const families = [];
|
|
15739
|
+
for (const file2 of orderedFiles) {
|
|
15740
|
+
const parsed = readPool(file2);
|
|
15741
|
+
if (parsed && file2 !== globalPath)
|
|
15742
|
+
findings.push(...parsed.findings);
|
|
15743
|
+
const isPrimary = plainPoolFile !== null && file2 === plainPoolFile;
|
|
15744
|
+
let declaredPool = null;
|
|
15745
|
+
if (!isPrimary && parsed) {
|
|
15746
|
+
const v = validatePoolField(parsed.declaredPool);
|
|
15747
|
+
if (parsed.declaredPool !== undefined && parsed.declaredPool !== null) {
|
|
15748
|
+
if (v.ok) {
|
|
15749
|
+
declaredPool = v.ns;
|
|
15750
|
+
} else {
|
|
15751
|
+
findings.push({ level: "warn", code: "pool-field-invalid", message: `${file2}: invalid "pool" field ${JSON.stringify(String(parsed.declaredPool))} (expected [a-z0-9-], 1-24 chars) — falling back to the directory-derived namespace` });
|
|
15752
|
+
}
|
|
15753
|
+
}
|
|
15754
|
+
}
|
|
15755
|
+
families.push({ file: file2, parsed, ns: null, declaredPool, primary: isPrimary });
|
|
15756
|
+
}
|
|
15757
|
+
const allDeclared = new Set;
|
|
15758
|
+
for (const f of families)
|
|
15759
|
+
if (f.declaredPool)
|
|
15760
|
+
allDeclared.add(f.declaredPool);
|
|
15761
|
+
const usedNs = new Map;
|
|
15762
|
+
for (const f of families) {
|
|
15763
|
+
if (f.primary)
|
|
15764
|
+
continue;
|
|
15765
|
+
const base = f.declaredPool ?? sanitizeNamespace(dirBasename(parentDirLabel(parentDirLabel(f.file))));
|
|
15766
|
+
let ns = base;
|
|
15767
|
+
let suffix = 2;
|
|
15768
|
+
while ((usedNs.has(ns) || ns !== base && allDeclared.has(ns)) && suffix < 100) {
|
|
15769
|
+
ns = `${base}-${suffix++}`;
|
|
15770
|
+
}
|
|
15771
|
+
const owner = usedNs.get(base);
|
|
15772
|
+
if (owner !== undefined && owner !== f.file) {
|
|
15773
|
+
findings.push({ level: "error", code: "pool-namespace-collision", message: `namespace "${base}" is already used by ${owner} — ${f.file} materializes under "${ns}" instead` });
|
|
15774
|
+
}
|
|
15775
|
+
f.ns = ns;
|
|
15776
|
+
usedNs.set(ns, f.file);
|
|
15777
|
+
}
|
|
15778
|
+
const agents = {};
|
|
15779
|
+
const origins = [];
|
|
15780
|
+
for (const f of families) {
|
|
15781
|
+
const merged = { ...globalParsed?.agents ?? {}, ...f.parsed?.agents ?? {} };
|
|
15782
|
+
const ids = [];
|
|
15783
|
+
for (const rawId of Object.keys(merged)) {
|
|
15784
|
+
const matId = f.ns === null ? rawId : `${f.ns}-${rawId}`;
|
|
15785
|
+
const existing = agents[matId];
|
|
15786
|
+
if (existing !== undefined) {
|
|
15787
|
+
findings.push({ level: "error", code: "materialized-id-collision", message: `materialized agent forge-${matId} from ${f.file} collides with the one from ${existing.file} — the earlier pool in resolution order wins, this agent is skipped` });
|
|
15788
|
+
continue;
|
|
15789
|
+
}
|
|
15790
|
+
agents[matId] = { materializedId: matId, def: merged[rawId], ns: f.ns, file: f.file };
|
|
15791
|
+
ids.push(matId);
|
|
15792
|
+
}
|
|
15793
|
+
origins.push({ file: f.file, ns: f.ns, declaredPool: f.declaredPool, primary: f.primary, materializedIds: ids });
|
|
15794
|
+
}
|
|
15795
|
+
return { families: origins, agents, findings };
|
|
15796
|
+
}
|
|
15797
|
+
};
|
|
15798
|
+
}
|
|
15799
|
+
function parentDirLabel(filePath) {
|
|
15800
|
+
let s = filePath;
|
|
15801
|
+
while (s.length > 1 && (s.endsWith("/") || s.endsWith("\\")))
|
|
15802
|
+
s = s.slice(0, -1);
|
|
15803
|
+
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
|
15804
|
+
return i < 0 ? s : s.slice(0, i);
|
|
15805
|
+
}
|
|
15556
15806
|
|
|
15557
15807
|
// plugin.ts
|
|
15558
15808
|
var FORGE_AGENT = "forge";
|
|
@@ -15597,7 +15847,7 @@ var PLAN_COMMAND_TEMPLATE = [
|
|
|
15597
15847
|
"Route on the argument above (decide silently; do not recite this routing text to the user):",
|
|
15598
15848
|
"- 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.",
|
|
15599
15849
|
'- 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.',
|
|
15600
|
-
'- Argument "discard": call the plan_discard tool, then tell the user the plan was abandoned and writes are restored.',
|
|
15850
|
+
'- 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.',
|
|
15601
15851
|
"- 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.",
|
|
15602
15852
|
"",
|
|
15603
15853
|
"## Plan discipline",
|
|
@@ -15610,7 +15860,7 @@ var PLAN_COMMAND_TEMPLATE = [
|
|
|
15610
15860
|
"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).",
|
|
15611
15861
|
"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.",
|
|
15612
15862
|
"",
|
|
15613
|
-
"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.",
|
|
15863
|
+
"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.",
|
|
15614
15864
|
""
|
|
15615
15865
|
].join(`
|
|
15616
15866
|
`);
|
|
@@ -15665,8 +15915,35 @@ function noteToolAgent(context) {
|
|
|
15665
15915
|
}
|
|
15666
15916
|
}
|
|
15667
15917
|
var forgeDisabled = false;
|
|
15668
|
-
var
|
|
15669
|
-
var
|
|
15918
|
+
var hostAnchors = [];
|
|
15919
|
+
var poolResolver = null;
|
|
15920
|
+
var poolResolution = null;
|
|
15921
|
+
function normalizeAnchorDir(dir) {
|
|
15922
|
+
let p = (dir ?? "").trim();
|
|
15923
|
+
if (!p)
|
|
15924
|
+
return "";
|
|
15925
|
+
try {
|
|
15926
|
+
p = realpathSync(p);
|
|
15927
|
+
} catch {}
|
|
15928
|
+
if (process.platform === "win32")
|
|
15929
|
+
return p.replace(/\//g, "\\").toLowerCase();
|
|
15930
|
+
return p;
|
|
15931
|
+
}
|
|
15932
|
+
function rememberHostAnchor(dir) {
|
|
15933
|
+
const normalized = normalizeAnchorDir(dir);
|
|
15934
|
+
if (!normalized)
|
|
15935
|
+
return;
|
|
15936
|
+
if (!hostAnchors.includes(normalized))
|
|
15937
|
+
hostAnchors.push(normalized);
|
|
15938
|
+
if (!poolResolver) {
|
|
15939
|
+
poolResolver = createPoolResolver({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
15940
|
+
}
|
|
15941
|
+
}
|
|
15942
|
+
function resolvePools() {
|
|
15943
|
+
if (!poolResolver || hostAnchors.length === 0)
|
|
15944
|
+
return null;
|
|
15945
|
+
return poolResolver.resolve(hostAnchors);
|
|
15946
|
+
}
|
|
15670
15947
|
var sessionAnchorLoaders = new Map;
|
|
15671
15948
|
function loaderForAnchor(anchor) {
|
|
15672
15949
|
const key = anchor || "-";
|
|
@@ -15694,13 +15971,14 @@ function crewRecordPathFor(worktree, objective) {
|
|
|
15694
15971
|
name = `${date5}-${slug}-${n++}.md`;
|
|
15695
15972
|
return join6(dir, name);
|
|
15696
15973
|
}
|
|
15697
|
-
function writeCrewRecord(path, objective, anchor, subtasks) {
|
|
15974
|
+
function writeCrewRecord(path, objective, anchor, subtasks, lineage) {
|
|
15698
15975
|
mkdirSync5(join6(path, ".."), { recursive: true });
|
|
15699
15976
|
writeFileSync5(path, [
|
|
15700
15977
|
`# Crew Record: ${objective}`,
|
|
15701
15978
|
"",
|
|
15702
15979
|
`- registered: ${nowIso()}`,
|
|
15703
15980
|
`- anchor: ${anchor}`,
|
|
15981
|
+
...lineage ? [`- lineage: ${lineage}`] : [],
|
|
15704
15982
|
"",
|
|
15705
15983
|
`## Declared plan (${subtasks.length})`,
|
|
15706
15984
|
...subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
@@ -15730,6 +16008,7 @@ function governingGoalFor(sessionID) {
|
|
|
15730
16008
|
function crewPendingChoiceLines() {
|
|
15731
16009
|
return [
|
|
15732
16010
|
"[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):",
|
|
16011
|
+
`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.`,
|
|
15733
16012
|
` 1. supervised waves NOW — when the user says go, call crew_begin {execution: "waves"} and run the wave discipline;`,
|
|
15734
16013
|
` 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
16014
|
` 3. standby — no call; the crew waits until armed or abandoned.`
|
|
@@ -15874,7 +16153,7 @@ function readPlanDir(worktree) {
|
|
|
15874
16153
|
const dir = planDirOf(worktree);
|
|
15875
16154
|
if (!existsSync3(dir))
|
|
15876
16155
|
return [];
|
|
15877
|
-
return
|
|
16156
|
+
return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
|
|
15878
16157
|
}
|
|
15879
16158
|
function ensureSession(sessionID, worktree) {
|
|
15880
16159
|
const existing = sessions.get(sessionID);
|
|
@@ -15941,7 +16220,7 @@ var planWriteTool = tool({
|
|
|
15941
16220
|
} else {
|
|
15942
16221
|
const dir = planDirOf(state.worktree);
|
|
15943
16222
|
mkdirSync5(dir, { recursive: true });
|
|
15944
|
-
path = join6(dir, planFileName(localDate(), slugify(args.goal),
|
|
16223
|
+
path = join6(dir, planFileName(localDate(), slugify(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md"))));
|
|
15945
16224
|
mode = "created";
|
|
15946
16225
|
}
|
|
15947
16226
|
const text = renderPlan(args, now, created);
|
|
@@ -16034,28 +16313,40 @@ var planCloseTool = tool({
|
|
|
16034
16313
|
Fix the implementation and retry, or revise the plan first.`);
|
|
16035
16314
|
}
|
|
16036
16315
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
16037
|
-
|
|
16316
|
+
const closedAt = nowIso();
|
|
16317
|
+
const closed = appendTerminalSection(transitionStatus(readFileSync8(active.path, "utf8"), "done", closedAt), "closed", { reason: "completion gate passed (user confirmed)", checks: args.checks }, closedAt);
|
|
16318
|
+
writeFileSync5(active.path, closed);
|
|
16038
16319
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
16039
16320
|
return {
|
|
16040
16321
|
title: "plan done",
|
|
16041
|
-
output: `Plan completed and closed (done): ${relFrom(state.worktree, active.path)}. All ${args.checks.length} self-checks passed.`
|
|
16322
|
+
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.`
|
|
16042
16323
|
};
|
|
16043
16324
|
}
|
|
16044
16325
|
});
|
|
16045
16326
|
var planDiscardTool = tool({
|
|
16046
|
-
description: "Abandon the session's active plan (draft/approved -> abandoned) and lift the draft write ban.
|
|
16327
|
+
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.",
|
|
16047
16328
|
args: {
|
|
16048
|
-
reason: tool.schema.string().optional().describe("Short reason recorded in the reply")
|
|
16329
|
+
reason: tool.schema.string().optional().describe("Short reason recorded in the reply and in the appended terminal section, verbatim"),
|
|
16330
|
+
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")
|
|
16049
16331
|
},
|
|
16050
|
-
execute: async (
|
|
16332
|
+
execute: async (args, context) => {
|
|
16051
16333
|
const state = ensureSession(context.sessionID, worktreeFor(context));
|
|
16052
16334
|
const active = resolveActivePlan(state);
|
|
16053
16335
|
if (!active)
|
|
16054
16336
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
16055
|
-
|
|
16337
|
+
const successor = (args.supersede ?? "").trim();
|
|
16338
|
+
const superseding = successor.length > 0;
|
|
16339
|
+
const target = superseding ? "superseded" : "abandoned";
|
|
16340
|
+
const exitedAt = nowIso();
|
|
16341
|
+
const next = appendTerminalSection(transitionStatus(readFileSync8(active.path, "utf8"), target, exitedAt), superseding ? "superseded" : "abandoned", { reason: args.reason, ...superseding ? { successor } : {} }, exitedAt);
|
|
16342
|
+
writeFileSync5(active.path, next);
|
|
16056
16343
|
state.planPath = undefined;
|
|
16057
|
-
|
|
16058
|
-
|
|
16344
|
+
const section = `## ${exitedAt} — ${target}`;
|
|
16345
|
+
context.metadata({ title: superseding ? `Plan superseded: ${active.doc.goal}` : `Plan abandoned: ${active.doc.goal}` });
|
|
16346
|
+
return {
|
|
16347
|
+
title: superseding ? "plan superseded" : "plan abandoned",
|
|
16348
|
+
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.`
|
|
16349
|
+
};
|
|
16059
16350
|
}
|
|
16060
16351
|
});
|
|
16061
16352
|
function goalDirOf(worktree) {
|
|
@@ -16065,7 +16356,7 @@ function readGoalDir(worktree) {
|
|
|
16065
16356
|
const dir = goalDirOf(worktree);
|
|
16066
16357
|
if (!existsSync3(dir))
|
|
16067
16358
|
return [];
|
|
16068
|
-
return
|
|
16359
|
+
return readdirSync5(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync8(join6(dir, name), "utf8") }));
|
|
16069
16360
|
}
|
|
16070
16361
|
function resolveSessionGoal(state) {
|
|
16071
16362
|
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
@@ -16174,7 +16465,7 @@ var goalWriteTool = tool({
|
|
|
16174
16465
|
}
|
|
16175
16466
|
const dir = goalDirOf(state.worktree);
|
|
16176
16467
|
mkdirSync5(dir, { recursive: true });
|
|
16177
|
-
const name = goalFileName(localDateNow(), slugifyGoal(args.goal),
|
|
16468
|
+
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync5(dir).filter((f) => f.endsWith(".md")));
|
|
16178
16469
|
const path = join6(dir, name);
|
|
16179
16470
|
const text = renderGoal(input, {
|
|
16180
16471
|
now,
|
|
@@ -16546,30 +16837,38 @@ var CREW_INIT_TEMPLATE = [
|
|
|
16546
16837
|
"",
|
|
16547
16838
|
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16548
16839
|
"",
|
|
16549
|
-
"CREW IS NOT INITIALIZED: no forge subagents are
|
|
16840
|
+
"CREW IS NOT INITIALIZED: no forge subagents are materialized across the whole host anchor set (zero pools). Refuse to enter orchestration and tell the user this feature needs a one-time setup, offering BOTH options:",
|
|
16550
16841
|
"",
|
|
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):",
|
|
16842
|
+
"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; a .opencode/forge.json in any anchor subdirectory additionally forms a namespaced sub-pool family, its ids materializing as forge-<ns>-<id>):",
|
|
16552
16843
|
"{paths}",
|
|
16553
16844
|
"",
|
|
16554
|
-
"
|
|
16845
|
+
"Two axes, distinct: POOLS follow host directories (the host's anchor set); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in a directory no host anchor covers is not dispatchable on this host.",
|
|
16846
|
+
"",
|
|
16847
|
+
'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. For sub-pool files, ALSO include a proposed short stable `pool` namespace (a [a-z0-9-] word up to 24 chars — e.g. "aa" for a devAa specialist pool) so the materialized ids survive directory renames.',
|
|
16555
16848
|
"",
|
|
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).",
|
|
16849
|
+
"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). Optional top level: `pool` — the file's namespace.",
|
|
16557
16850
|
"{template}",
|
|
16558
16851
|
"",
|
|
16559
|
-
"After the file is saved:
|
|
16852
|
+
"After the file is saved: file edits, creations, and deletions apply at the next config hook — NO host restart is needed. Only the anchor set itself is fixed at host start: host re-initializations add anchors and pools, they never remove them."
|
|
16560
16853
|
].join(`
|
|
16561
16854
|
`);
|
|
16562
|
-
function crewOrchestrationTemplate(
|
|
16855
|
+
function crewOrchestrationTemplate(resolution) {
|
|
16856
|
+
const families = resolution?.families ?? [];
|
|
16857
|
+
const familyLines = families.map((f) => {
|
|
16858
|
+
const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
|
|
16859
|
+
return ` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`;
|
|
16860
|
+
});
|
|
16563
16861
|
return [
|
|
16564
16862
|
'(forge crew orchestration. Argument: "$ARGUMENTS")',
|
|
16565
16863
|
"",
|
|
16566
16864
|
"FAMILY GUARD (step 0, before anything else): if you do not have the crew_begin tool in your available tools, you are not in a forge session — STOP now and tell the user that /crew belongs to the forge agent, who should switch to forge (Tab) and rerun it there.",
|
|
16567
16865
|
"",
|
|
16568
|
-
|
|
16866
|
+
"Configured forge subagents (task-tool vocabulary), grouped by pool:",
|
|
16867
|
+
...familyLines.length > 0 ? familyLines : [" (none)"],
|
|
16569
16868
|
"",
|
|
16570
16869
|
"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:",
|
|
16571
16870
|
"",
|
|
16572
|
-
|
|
16871
|
+
'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.',
|
|
16573
16872
|
`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
16873
|
"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
16874
|
"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.",
|
|
@@ -16587,32 +16886,37 @@ var crewBeginTool = tool({
|
|
|
16587
16886
|
subtasks: tool.schema.array(tool.schema.object({
|
|
16588
16887
|
title: tool.schema.string().describe("Short unique subtask title — the crew_close report must use these exact titles"),
|
|
16589
16888
|
agent: tool.schema.string().optional().describe("Intended forge-* agent id for this subtask, when one matches")
|
|
16590
|
-
})).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list")
|
|
16889
|
+
})).describe("The declared subtask plan — decompose the objective into these before any execution; crew_close cross-checks the report against exactly this list"),
|
|
16890
|
+
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.")
|
|
16591
16891
|
},
|
|
16592
16892
|
execute: async (args, context) => {
|
|
16593
16893
|
noteToolAgent(context);
|
|
16594
16894
|
const state = stateForBan(context.sessionID);
|
|
16595
16895
|
const active = state ? resolveActivePlan(state) : null;
|
|
16596
16896
|
if (active && active.doc.status === "draft") {
|
|
16597
|
-
throw new Error("[forge:crew] A plan is in draft; crew orchestration is denied during planning.
|
|
16897
|
+
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).');
|
|
16598
16898
|
}
|
|
16599
|
-
const
|
|
16600
|
-
const hostIds =
|
|
16899
|
+
const resolution = resolvePools();
|
|
16900
|
+
const hostIds = resolution ? Object.keys(resolution.agents) : [];
|
|
16601
16901
|
const anchor = sessionAnchor(context);
|
|
16602
|
-
const
|
|
16603
|
-
const sessionLoaded = anchor && anchor !== hostAnchorDir ? loaderForAnchor(anchor).load() : hostLoaded;
|
|
16902
|
+
const sessionLoaded = anchor ? loaderForAnchor(anchor).load() : null;
|
|
16604
16903
|
const sessionProject = sessionLoaded?.projectPath ?? null;
|
|
16904
|
+
const foldPath = (p) => process.platform === "win32" ? p.replace(/\//g, "\\").toLowerCase() : p;
|
|
16905
|
+
const sessionCovered = sessionProject === null || (resolution?.families.some((f) => foldPath(f.file) === foldPath(sessionProject)) ?? false);
|
|
16605
16906
|
if (hostIds.length === 0) {
|
|
16606
|
-
const
|
|
16907
|
+
const primaryAnchorDir = hostAnchors[0] ?? hostWorktree;
|
|
16908
|
+
const anchorNote = hostAnchors.length > 1 ? `host anchors: ${hostAnchors.join(", ")}` : `launched from ${primaryAnchorDir || "an unresolvable directory"}`;
|
|
16909
|
+
const paths = forgeConfigPaths({ projectDir: primaryAnchorDir || undefined, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
16607
16910
|
const lines2 = [
|
|
16608
|
-
|
|
16609
|
-
`Two layers, MERGED (the project layer overrides the global one per agent id): ${
|
|
16610
|
-
|
|
16611
|
-
"
|
|
16612
|
-
"
|
|
16911
|
+
`[forge:crew] CREW IS NOT INITIALIZED on this host: no forge subagents are materialized across the whole anchor set (${anchorNote}). Crew orchestration is unavailable until the user sets it up.`,
|
|
16912
|
+
`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}. Sub-pool files (.opencode/forge.json in an anchor's subdirectories) contribute additional namespaced families.`,
|
|
16913
|
+
"Two axes, distinct: POOLS follow host directories (the anchor set above); SESSION ARTIFACTS (plan/goal/crew records) follow the session. A forge.json in an unanchored directory is not dispatchable on this host.",
|
|
16914
|
+
`Template (JSONC): top-level optional "pool" names this file's namespace (short stable [a-z0-9-] up to 24 chars — sub-pool ids materialize as forge-<ns>-<id>); {"agents": {"research": {"model": "provider/model", "thoughtLevel": "low", "prompt": "short role description"}}}`,
|
|
16915
|
+
"Agent ids are plain role words WITHOUT the `forge-` prefix — the plugin materializes each id as `forge-<id>` (namespaced pools: `forge-<ns>-<id>`); a prefixed id is auto-stripped with a warning.",
|
|
16916
|
+
"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), and INCLUDE a proposed short stable `pool` namespace for sub-pool files so ids survive directory renames. File edits, creations, and deletions apply at the next config hook — no host restart; only the anchor set itself is fixed at host start (re-initializations add pools, never remove)."
|
|
16613
16917
|
];
|
|
16614
|
-
if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject) {
|
|
16615
|
-
lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but
|
|
16918
|
+
if (sessionLoaded && Object.keys(sessionLoaded.agents).length > 0 && sessionProject && !sessionCovered) {
|
|
16919
|
+
lines2.push(`NOTE: this session's workspace DOES have a forge.json (${sessionProject}) with usable agents — but NO host anchor covers it (${anchorNote}). A host initialization in that workspace adds its pools; or fold the agents into the global layer.`);
|
|
16616
16920
|
}
|
|
16617
16921
|
throw new Error(lines2.join(`
|
|
16618
16922
|
`));
|
|
@@ -16692,13 +16996,14 @@ var crewBeginTool = tool({
|
|
|
16692
16996
|
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
16997
|
}
|
|
16694
16998
|
const governed = execution === "waves";
|
|
16999
|
+
const lineage = String(args.lineage ?? "").trim();
|
|
16695
17000
|
const anchorDir = anchor || hostWorktree;
|
|
16696
17001
|
let recordPath = null;
|
|
16697
17002
|
let recordNote = "";
|
|
16698
17003
|
if (anchorDir) {
|
|
16699
17004
|
try {
|
|
16700
17005
|
recordPath = crewRecordPathFor(anchorDir, objectiveText);
|
|
16701
|
-
writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks);
|
|
17006
|
+
writeCrewRecord(recordPath, objectiveText, anchorDir, subtasks, lineage || undefined);
|
|
16702
17007
|
recordNote = `Crew record (inspectable history; NOT a resume mechanism): ${relFrom(anchorDir, recordPath)}`;
|
|
16703
17008
|
} catch {
|
|
16704
17009
|
recordPath = null;
|
|
@@ -16708,21 +17013,29 @@ var crewBeginTool = tool({
|
|
|
16708
17013
|
crews.set(context.sessionID, { objective: objectiveText, startedAt: nowIso(), subtasks, mode: governed ? "executing" : "pending", recordPath });
|
|
16709
17014
|
appendCrewRecord(recordPath, governed ? "registered (born armed under a governing goal)" : "registered (pending)", subtasks.map((s, i) => `${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`));
|
|
16710
17015
|
let disclosure = "";
|
|
16711
|
-
if (sessionProject &&
|
|
16712
|
-
const localOnly = Object.keys(sessionLoaded?.agents ?? {})
|
|
17016
|
+
if (sessionProject && !sessionCovered) {
|
|
17017
|
+
const localOnly = Object.keys(sessionLoaded?.agents ?? {});
|
|
16713
17018
|
if (localOnly.length > 0) {
|
|
16714
17019
|
disclosure = `
|
|
16715
|
-
[forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge
|
|
17020
|
+
[forge:crew] NOTE: this session's workspace has its own forge.json (${sessionProject}) defining ${localOnly.map((id) => `forge-<ns>-${id} (namespaced by its pool)`).join(", ")} — NOT dispatchable on this host (no anchor covers it; anchors: ${hostAnchors.join(", ") || "none"}). A host initialization in that workspace adds its pools, or fold the agents into the global layer.`;
|
|
16716
17021
|
}
|
|
16717
17022
|
}
|
|
16718
|
-
const
|
|
16719
|
-
const
|
|
17023
|
+
const familyLines = [];
|
|
17024
|
+
const families = resolution?.families ?? [];
|
|
17025
|
+
for (const f of families.slice(0, 8)) {
|
|
17026
|
+
const label = f.primary ? "root pool (PRIMARY, plain ids)" : `pool ${f.ns} (forge-${f.ns}-*)`;
|
|
17027
|
+
familyLines.push(` - ${label}: ${f.file} — ${f.materializedIds.map((id) => `forge-${id}`).join(", ") || "no agents"}`);
|
|
17028
|
+
}
|
|
17029
|
+
if (families.length > 8)
|
|
17030
|
+
familyLines.push(` - …and ${families.length - 8} more pools`);
|
|
16720
17031
|
const lines = [
|
|
17032
|
+
...recordNote ? [recordNote] : [],
|
|
17033
|
+
...lineage ? [`Lineage: ${lineage}`] : [],
|
|
16721
17034
|
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
17035
|
`Declared plan (${subtasks.length}):`,
|
|
16723
17036
|
...subtasks.map((s, i) => ` ${i + 1}. ${s.title}${s.agent ? ` → ${s.agent}` : ""}`),
|
|
16724
|
-
`Dispatchable roster origin
|
|
16725
|
-
...
|
|
17037
|
+
`Dispatchable roster origin — ${families.length} pool${families.length === 1 ? "" : "s"} across the host anchor set${hostAnchors.length > 0 ? ` (anchors: ${hostAnchors.join(", ")})` : ""}:`,
|
|
17038
|
+
...familyLines,
|
|
16726
17039
|
...disclosure ? [disclosure] : []
|
|
16727
17040
|
];
|
|
16728
17041
|
if (governed) {
|
|
@@ -17040,7 +17353,9 @@ function scheduleIdleContinuation(client, sessionID) {
|
|
|
17040
17353
|
}, IDLE_DEBOUNCE_MS));
|
|
17041
17354
|
}
|
|
17042
17355
|
var server = async (input, options) => {
|
|
17043
|
-
|
|
17356
|
+
const effectiveInitDir = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
|
|
17357
|
+
hostWorktree = effectiveInitDir;
|
|
17358
|
+
rememberHostAnchor(effectiveInitDir);
|
|
17044
17359
|
const client = input.client;
|
|
17045
17360
|
jobClient = input.client;
|
|
17046
17361
|
const jobsOpts = options?.jobs;
|
|
@@ -17151,23 +17466,17 @@ var server = async (input, options) => {
|
|
|
17151
17466
|
if (permSection[gateKey] !== "deny")
|
|
17152
17467
|
permSection[gateKey] = "ask";
|
|
17153
17468
|
}
|
|
17154
|
-
|
|
17155
|
-
|
|
17156
|
-
|
|
17157
|
-
homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir()
|
|
17158
|
-
});
|
|
17159
|
-
forgeLoaderDir = hostWorktree;
|
|
17160
|
-
}
|
|
17161
|
-
const loaded = forgeLoader.load();
|
|
17162
|
-
for (const finding of loaded.findings) {
|
|
17469
|
+
const resolution = resolvePools();
|
|
17470
|
+
poolResolution = resolution;
|
|
17471
|
+
for (const finding of resolution?.findings ?? []) {
|
|
17163
17472
|
console.warn(`[forge:config] ${finding.level}: ${finding.message}`);
|
|
17164
17473
|
}
|
|
17165
17474
|
materializedSnapshot = {};
|
|
17166
|
-
for (const [
|
|
17167
|
-
const id = `forge-${
|
|
17475
|
+
for (const [matId, agent] of Object.entries(resolution?.agents ?? {})) {
|
|
17476
|
+
const id = `forge-${matId}`;
|
|
17168
17477
|
if (agentSection[id] !== undefined)
|
|
17169
17478
|
continue;
|
|
17170
|
-
const entry = forgeAgentDef(
|
|
17479
|
+
const entry = forgeAgentDef(matId, agent.def);
|
|
17171
17480
|
agentSection[id] = entry;
|
|
17172
17481
|
materializedSnapshot[id] = entry;
|
|
17173
17482
|
}
|
|
@@ -17212,12 +17521,13 @@ var server = async (input, options) => {
|
|
|
17212
17521
|
cfg.command ??= {};
|
|
17213
17522
|
const existingCrew = cfg.command["crew"];
|
|
17214
17523
|
if (!existingCrew || String(existingCrew.template ?? "").startsWith("(forge crew")) {
|
|
17215
|
-
const
|
|
17216
|
-
const
|
|
17217
|
-
|
|
17524
|
+
const agentCount = Object.keys(resolution?.agents ?? {}).length;
|
|
17525
|
+
const primaryAnchorDir = hostAnchors[0] ?? undefined;
|
|
17526
|
+
const template = agentCount > 0 ? crewOrchestrationTemplate(resolution ?? null) : CREW_INIT_TEMPLATE.replaceAll("{paths}", ` - ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).project}
|
|
17527
|
+
- ${forgeConfigPaths({ projectDir: primaryAnchorDir, homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() }).global}`).replaceAll("{template}", JSON.stringify({ pool: "short-stable-namespace (optional, [a-z0-9-] up to 24 chars — only meaningful for sub-pool files)", agents: { research: { model: "provider/model", thoughtLevel: "low", prompt: "short role description — what this agent does" } } }, null, 2).split(`
|
|
17218
17528
|
`).map((l) => " " + l).join(`
|
|
17219
17529
|
`));
|
|
17220
|
-
const description =
|
|
17530
|
+
const description = agentCount > 0 ? "forge crew orchestration: register a declared subtask plan, execute it in waves of parallel native task calls, close with an evidence-checked report" : "forge crew orchestration — NOT INITIALIZED: no forge subagents configured; invoking it shows the one-time setup guidance";
|
|
17221
17531
|
cfg.command["crew"] = { template, description };
|
|
17222
17532
|
crewCommandSnapshot = { template, description };
|
|
17223
17533
|
}
|
|
@@ -17320,7 +17630,7 @@ var server = async (input, options) => {
|
|
|
17320
17630
|
return;
|
|
17321
17631
|
}
|
|
17322
17632
|
const agentId = agentName.slice("forge-".length);
|
|
17323
|
-
const def =
|
|
17633
|
+
const def = resolvePools()?.agents[agentId]?.def;
|
|
17324
17634
|
if (!def?.thoughtLevel)
|
|
17325
17635
|
return;
|
|
17326
17636
|
const providerID = typeof raw.model?.providerID === "string" ? raw.model.providerID : "";
|
|
@@ -17348,7 +17658,14 @@ var server = async (input, options) => {
|
|
|
17348
17658
|
modelsDevCatalog = c;
|
|
17349
17659
|
},
|
|
17350
17660
|
crewCommand: () => crewCommandSnapshot,
|
|
17351
|
-
materialized: () => materializedSnapshot
|
|
17661
|
+
materialized: () => materializedSnapshot,
|
|
17662
|
+
resetAnchors: () => {
|
|
17663
|
+
hostAnchors.length = 0;
|
|
17664
|
+
poolResolver = null;
|
|
17665
|
+
poolResolution = null;
|
|
17666
|
+
},
|
|
17667
|
+
anchors: () => [...hostAnchors],
|
|
17668
|
+
resolveNow: () => resolvePools()
|
|
17352
17669
|
},
|
|
17353
17670
|
"permission.ask": async (input2, output) => {
|
|
17354
17671
|
const meta = input2.metadata ?? {};
|
|
@@ -17514,20 +17831,20 @@ async function v2Setup(ctx) {
|
|
|
17514
17831
|
});
|
|
17515
17832
|
}
|
|
17516
17833
|
try {
|
|
17517
|
-
const
|
|
17518
|
-
const
|
|
17519
|
-
for (const [
|
|
17520
|
-
const id = `forge-${
|
|
17834
|
+
const resolver = createPoolResolver({ homeDir: process.env.FORGE_TEST_FORGE_HOME ?? homedir() });
|
|
17835
|
+
const resolution = resolver.resolve([process.cwd()]);
|
|
17836
|
+
for (const [matId, agent] of Object.entries(resolution.agents)) {
|
|
17837
|
+
const id = `forge-${matId}`;
|
|
17521
17838
|
if (draft.get(id) !== undefined)
|
|
17522
17839
|
continue;
|
|
17523
|
-
const v1def = forgeAgentDef(
|
|
17524
|
-
draft.update(id, (
|
|
17525
|
-
|
|
17526
|
-
|
|
17527
|
-
|
|
17840
|
+
const v1def = forgeAgentDef(matId, agent.def);
|
|
17841
|
+
draft.update(id, (agentDraft) => {
|
|
17842
|
+
agentDraft.description = v1def.description;
|
|
17843
|
+
agentDraft.system = v1def.prompt;
|
|
17844
|
+
agentDraft.mode = v1def.mode;
|
|
17528
17845
|
if (v1def.model !== undefined)
|
|
17529
|
-
|
|
17530
|
-
|
|
17846
|
+
agentDraft.model = v1def.model;
|
|
17847
|
+
agentDraft.permission = v1def.permission;
|
|
17531
17848
|
});
|
|
17532
17849
|
}
|
|
17533
17850
|
} catch {}
|
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.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
|
+
}
|