@sorenllm/opencode-forge 0.2.0 → 0.2.2
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 +11 -9
- package/dist/index.js +94 -81
- package/package.json +2 -3
- package/SKILL.md +0 -91
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ OpenSpec spec workflows remain a third, separate lane.
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
/plan fix login timeout → read-only recon → plan_write (draft, writes denied)
|
|
11
|
-
→ present →
|
|
11
|
+
→ present, end turn → USER REVIEW (revise / discard / go-ahead)
|
|
12
|
+
→ plan_approve (user dialog = final gate) on explicit go-ahead
|
|
12
13
|
→ execute task by task, plan_tick on each (timestamped audit)
|
|
13
14
|
→ all ticked → per-criterion self-check → plan_close
|
|
14
15
|
(user dialog = completion gate) → done
|
|
@@ -58,9 +59,9 @@ opencode plugin github:ChengZiiii/opencode-forge --global
|
|
|
58
59
|
|
|
59
60
|
Local development: add `"file:///<repo abs path>"` to the `plugin` array in
|
|
60
61
|
your opencode config. Single-file install: copy `dist/index.js` to
|
|
61
|
-
`~/.config/opencode/plugin/forge.js`
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
`~/.config/opencode/plugin/forge.js` — it is fully self-contained (the plan
|
|
63
|
+
discipline rides inside the /plan command template; there is no separate
|
|
64
|
+
skill file).
|
|
64
65
|
|
|
65
66
|
Note: do not enable opencode's experimental plan mode
|
|
66
67
|
(`OPENCODE_EXPERIMENTAL_PLAN_MODE`) together with forge — two plan mechanisms
|
|
@@ -77,7 +78,7 @@ the plugin never writes them):
|
|
|
77
78
|
"forge": {
|
|
78
79
|
"model": "provider/model", // pick any model for forge
|
|
79
80
|
"disable": true // one-knob return to native: no forge,
|
|
80
|
-
// build/plan restored, no tools/commands
|
|
81
|
+
// build/plan restored, no tools/commands
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
}
|
|
@@ -108,7 +109,7 @@ What this plugin touches, exhaustively:
|
|
|
108
109
|
| --- | --- | --- |
|
|
109
110
|
| `<project>/.opencode/plan/*.md` | plan files | user data — kept forever, uninstall never deletes |
|
|
110
111
|
| `<project>/.opencode/goal/*.md` | goal files (contract, Check Log, Turn Ledger) | user data — kept forever, uninstall never deletes |
|
|
111
|
-
| merged config object (RAM only) | forge agent, native build/plan `disable`, `
|
|
112
|
+
| merged config object (RAM only) | forge agent, native build/plan `disable`, `command.plan`, `command.goal`, goal permission keys | vanishes when the plugin is removed; nothing is written to disk |
|
|
112
113
|
| `~/.cache/opencode/packages/...` | installed package copy | written by the `opencode plugin` installer, not the plugin |
|
|
113
114
|
| `~/.config/opencode/opencode.json` | `plugin` array entry | written by the installer |
|
|
114
115
|
|
|
@@ -208,11 +209,12 @@ bun run bundle # rebuild self-contained dist/index.js (committed)
|
|
|
208
209
|
```
|
|
209
210
|
|
|
210
211
|
Architecture: `plugin.ts` (dual entry — v1 `server` full-featured + v2
|
|
211
|
-
`setup` defensive forward-compat
|
|
212
|
+
`setup` defensive forward-compat; the /plan and /goal command templates each
|
|
213
|
+
carry their own full discipline, hermes-style: the entry turn is the
|
|
214
|
+
rulebook) + `src/plan-file.ts` / `src/goal-file.ts`
|
|
212
215
|
(pure document cores, unit-tested, no opencode imports) + `src/run-check.ts`
|
|
213
216
|
(shell/file-contract runner with tree-kill timeouts and a workspace path
|
|
214
|
-
guard)
|
|
215
|
-
`config.skills.paths`). Behavioral changes go through the
|
|
217
|
+
guard). Behavioral changes go through the
|
|
216
218
|
OpenSpec workflow in `openspec/` — see AGENTS.md. Common pitfalls live in
|
|
217
219
|
`../opencode-plugin-dev-pitfalls.md`.
|
|
218
220
|
|
package/dist/index.js
CHANGED
|
@@ -12335,8 +12335,7 @@ function tool(input) {
|
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
12337
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
12338
|
-
import {
|
|
12339
|
-
import { dirname, join as join2, relative } from "node:path";
|
|
12338
|
+
import { join as join2, relative } from "node:path";
|
|
12340
12339
|
import { tmpdir } from "node:os";
|
|
12341
12340
|
|
|
12342
12341
|
// src/plan-file.ts
|
|
@@ -12721,7 +12720,7 @@ function goalFileName(date5, slug, existingNames = []) {
|
|
|
12721
12720
|
return name;
|
|
12722
12721
|
}
|
|
12723
12722
|
function atomicWrite(file2, text) {
|
|
12724
|
-
const tmp = `${file2}.tmp`;
|
|
12723
|
+
const tmp = `${file2}.${process.pid}-${Math.random().toString(36).slice(2, 6)}.tmp`;
|
|
12725
12724
|
writeFileSync(tmp, text);
|
|
12726
12725
|
renameSync(tmp, file2);
|
|
12727
12726
|
}
|
|
@@ -12772,6 +12771,8 @@ function frontmatterBlock2(meta) {
|
|
|
12772
12771
|
`updated: ${meta.updated}`,
|
|
12773
12772
|
`revision: ${meta.revision}`
|
|
12774
12773
|
];
|
|
12774
|
+
if (meta.armedAt)
|
|
12775
|
+
lines.push(`armed_at: ${meta.armedAt}`);
|
|
12775
12776
|
if (meta.session)
|
|
12776
12777
|
lines.push(`session: ${meta.session}`);
|
|
12777
12778
|
lines.push(`max_turns: ${meta.maxTurns}`, `turns_used: ${meta.turnsUsed}`, `max_minutes: ${meta.maxMinutes}`);
|
|
@@ -12798,6 +12799,7 @@ function renderGoal(input, meta) {
|
|
|
12798
12799
|
maxTurns: input.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
12799
12800
|
turnsUsed: meta.turnsUsed ?? 0,
|
|
12800
12801
|
maxMinutes: input.maxMinutes ?? DEFAULT_MAX_MINUTES,
|
|
12802
|
+
...meta.armedAt ? { armedAt: meta.armedAt } : {},
|
|
12801
12803
|
...meta.status === "paused" && meta.stopReason ? { stopReason: meta.stopReason } : {}
|
|
12802
12804
|
}),
|
|
12803
12805
|
"## Goal",
|
|
@@ -12940,6 +12942,7 @@ function parseGoal(text) {
|
|
|
12940
12942
|
return {
|
|
12941
12943
|
status,
|
|
12942
12944
|
created: fm.created ?? "",
|
|
12945
|
+
armedAt: fm.armed_at ?? "",
|
|
12943
12946
|
updated: fm.updated ?? "",
|
|
12944
12947
|
revision: Math.max(1, num(fm.revision, 1)),
|
|
12945
12948
|
session: fm.session ?? "",
|
|
@@ -12992,6 +12995,7 @@ function transitionGoal(text, to, now, opts = {}) {
|
|
|
12992
12995
|
if (!opts.session)
|
|
12993
12996
|
throw new GoalError("Arming/resuming requires the owning session id");
|
|
12994
12997
|
next = setFm(next, "session", opts.session);
|
|
12998
|
+
next = setFm(next, "armed_at", now);
|
|
12995
12999
|
next = next.replace(/^stop_reason:.*$\r?\n?/m, "");
|
|
12996
13000
|
}
|
|
12997
13001
|
return setUpdated2(next, now);
|
|
@@ -13010,7 +13014,7 @@ function bumpBudget(text, addTurns, now) {
|
|
|
13010
13014
|
function budgetState(doc2) {
|
|
13011
13015
|
if (doc2.turnsUsed >= doc2.maxTurns)
|
|
13012
13016
|
return "budget-turns";
|
|
13013
|
-
const start = Date.parse(doc2.created);
|
|
13017
|
+
const start = Date.parse(doc2.armedAt || doc2.created);
|
|
13014
13018
|
if (Number.isFinite(start) && Date.now() - start > doc2.maxMinutes * 60000)
|
|
13015
13019
|
return "budget-time";
|
|
13016
13020
|
return "ok";
|
|
@@ -13076,12 +13080,12 @@ function appendLedger(text, entry, now) {
|
|
|
13076
13080
|
function carryHistory(newText, oldDoc) {
|
|
13077
13081
|
let out = newText;
|
|
13078
13082
|
if (oldDoc.log.length > 0) {
|
|
13079
|
-
out = out.replace("(no checks recorded yet)", oldDoc.log.join(`
|
|
13083
|
+
out = out.replace("(no checks recorded yet)", () => oldDoc.log.join(`
|
|
13080
13084
|
`));
|
|
13081
13085
|
}
|
|
13082
13086
|
if (oldDoc.ledger.length > 0) {
|
|
13083
13087
|
const lines = oldDoc.ledger.map((e) => `- turn ${e.turn} rev${e.revision} ${e.at} activity=${e.activity ? "yes" : "no"} (writes=${e.writes} checks=${e.checks})`);
|
|
13084
|
-
out = out.replace("(no continuation turns yet)", lines.join(`
|
|
13088
|
+
out = out.replace("(no continuation turns yet)", () => lines.join(`
|
|
13085
13089
|
`));
|
|
13086
13090
|
}
|
|
13087
13091
|
return out;
|
|
@@ -13154,11 +13158,14 @@ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
|
|
|
13154
13158
|
timedOut = true;
|
|
13155
13159
|
killTree();
|
|
13156
13160
|
}, opts.timeoutMs);
|
|
13161
|
+
const failsafe = setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
|
|
13162
|
+
failsafe.unref?.();
|
|
13157
13163
|
const settle = (r) => {
|
|
13158
13164
|
if (settled)
|
|
13159
13165
|
return;
|
|
13160
13166
|
settled = true;
|
|
13161
13167
|
clearTimeout(timer);
|
|
13168
|
+
clearTimeout(failsafe);
|
|
13162
13169
|
resolveRun(r);
|
|
13163
13170
|
};
|
|
13164
13171
|
child.stdout?.on("data", (d) => {
|
|
@@ -13171,7 +13178,6 @@ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
|
|
|
13171
13178
|
});
|
|
13172
13179
|
child.on("error", (err) => settle({ code: null, output, timedOut, spawnError: err.message }));
|
|
13173
13180
|
child.on("close", (code) => settle({ code, output, timedOut }));
|
|
13174
|
-
setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
|
|
13175
13181
|
});
|
|
13176
13182
|
var shellRunner = defaultShellRunner;
|
|
13177
13183
|
function truncateOutput(output) {
|
|
@@ -13244,29 +13250,10 @@ function formatOutcomes(outcomes) {
|
|
|
13244
13250
|
}
|
|
13245
13251
|
|
|
13246
13252
|
// plugin.ts
|
|
13247
|
-
var bundleDir = dirname(fileURLToPath(import.meta.url));
|
|
13248
|
-
var candidateDirs = [bundleDir, join2(bundleDir, "..")];
|
|
13249
|
-
var dataDir = candidateDirs.find((d) => existsSync(join2(d, "SKILL.md"))) ?? bundleDir;
|
|
13250
13253
|
var FORGE_AGENT = "forge";
|
|
13251
|
-
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
|
|
13254
|
+
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.
|
|
13252
13255
|
|
|
13253
|
-
|
|
13254
|
-
- When the user invokes /plan with a goal, or asks to plan first, load the plan skill and follow it: read-only reconnaissance, clarifying questions when the goal is ambiguous, then plan_write. It creates .opencode/plan/<date>-<slug>.md with status draft.
|
|
13255
|
-
- While the session's plan is in draft, every write tool is denied at the permission layer. Do not attempt write/edit/bash/task during planning; do not ask the user to bypass it. The only exits are plan_approve and /plan discard.
|
|
13256
|
-
- After plan_write, present the goal, chosen approach, and numbered task list briefly, then call plan_approve. The user approves it in a confirmation dialog — that dialog is the approval gate.
|
|
13257
|
-
- After approval, execute tasks one by one and call plan_tick with the task number immediately after each completion. Never batch ticks at the end; never tick before the work is actually done.
|
|
13258
|
-
- When all tasks are ticked, self-check every acceptance criterion with concrete evidence, then call plan_close with a per-criterion pass/evidence array. The user confirms closure in a dialog.
|
|
13259
|
-
- If the system prompt carries a [forge:plan-notice] line and the user has not mentioned the plan, relay its path and progress in one short line at the start of your reply.
|
|
13260
|
-
- Work that is expected to span sessions, touch many files over days, or need multi-round requirement review belongs to a spec workflow (e.g. OpenSpec), not a plan. Say so once and let the user choose; if they still want a plan, plan it.
|
|
13261
|
-
|
|
13262
|
-
Goal discipline (autonomous, host-verified execution; orthogonal to plans and spec workflows — a goal never reads plan state and plan state is never goal evidence):
|
|
13263
|
-
- /goal "<objective>" drafts a goal contract: goal, numbered success criteria, numbered verification items (shell commands the plugin runs itself, and file contracts file::text), constraints, non-goals, budgets. Show the verification items verbatim before arming. goal_write with arm=true presents a confirmation dialog — the user's Allow arms the autonomous loop. arm=false (/goal add) only queues an inert goal.
|
|
13264
|
-
- When a [forge:goal-continue] brief arrives, keep working the success criteria within the stated constraints. The plugin continues the session on idle until the goal completes, pauses, or hits its turn/minute budget.
|
|
13265
|
-
- goal_check runs the verification items on the host and appends results to the goal's Check Log — use it whenever you believe the criteria may hold. goal_complete re-runs EVERY item itself (fail-closed: any failing item refuses completion) and additionally requires a per-criterion attestation with concrete evidence, then a user confirmation dialog.
|
|
13266
|
-
- If you are genuinely blocked, call goal_pause with the blocker text — do not spin. When the goal is paused and the user clearly asks to continue (e.g. "continue", "resume"), call goal_resume; the dialog re-arms the loop. Ordinary chat never reactivates a goal.
|
|
13267
|
-
- Never claim the goal is complete without passing goal_complete. Never edit the goal file by hand; revise the contract through goal_write (which bumps the revision and invalidates earlier evidence).
|
|
13268
|
-
|
|
13269
|
-
Outside planning you are a normal full-capability coding agent.`;
|
|
13256
|
+
Workflow modes are strictly user-initiated. Never enter plan or goal mode — and never call a plan_* or goal_* tool — unless the user ran /plan or /goal, unmistakably asked for that mode (e.g. "plan first", "set a goal"), or the session already carries a [forge:plan-notice] / [forge:goal-notice] for a mode they started. Ordinary task requests are normal work. Mode-specific rules arrive with those commands and notices; when a notice is present, follow it.`;
|
|
13270
13257
|
var PLAN_COMMAND_TEMPLATE = [
|
|
13271
13258
|
'(forge plan harness routing. Argument: "$ARGUMENTS")',
|
|
13272
13259
|
"",
|
|
@@ -13277,7 +13264,19 @@ var PLAN_COMMAND_TEMPLATE = [
|
|
|
13277
13264
|
"- 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.",
|
|
13278
13265
|
'- 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.',
|
|
13279
13266
|
'- Argument "discard": call the plan_discard tool, then tell the user the plan was abandoned and writes are restored.',
|
|
13280
|
-
"- Any other argument: treat it as the task goal
|
|
13267
|
+
"- 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.",
|
|
13268
|
+
"",
|
|
13269
|
+
"## Plan discipline",
|
|
13270
|
+
"",
|
|
13271
|
+
"Plans are short-horizon, single-task-goal documents (.opencode/plan/<date>-<slug>.md). The harness enforces the hard parts (draft write-ban, approval/close dialogs); you supply the engineering judgment. Never edit or create plan files by hand — every change goes through the plan_* tools.",
|
|
13272
|
+
"",
|
|
13273
|
+
"1. Reconnaissance and alignment (read-only): explore with read/grep/glob only — all write tools, bash, and subagents are DENIED while a draft exists; do not attempt them, do not ask the user to bypass. Gather concrete evidence with file:line references (verified findings, not guesses). If the goal is ambiguous or leaves meaningful choices open (scope, approach, acceptance), settle them with the user FIRST — 1-3 focused questions — and only write the draft once the shape is agreed; never plan against assumptions the user could settle in one line.",
|
|
13274
|
+
"2. Draft (plan_write): call plan_write with structured fields; the tool renders and validates the fixed sections, so a malformed plan cannot exist. Quality bar: goal = one line, the outcome not the activity; context = verified findings with file:line evidence, including what you ruled out and why; approach = the chosen approach AND at least one rejected alternative with the reason (a plan with no considered alternative is a guess); tasks = 3-8 concrete, independently verifiable steps, each doable in one sitting ('improve the code' is invalid; 'extract the timeout constant into config.ts and default it to 3000' is valid); risks = what could break, blast radius, rollback path; acceptance = criteria verifiable by a command, a file, or an observable behavior (vague criteria will fail the close); nonGoals = explicit out-of-scope items. To revise after feedback, call plan_write again — while in draft it overwrites the same file.",
|
|
13275
|
+
`3. User review, then approval (plan_approve): after plan_write, present the plan in chat — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then STOP: end your turn and wait. The user owns the review, at their own pace: feedback → revise with plan_write, re-present, and wait again; rejection → /plan discard (or re-plan together); explicit go-ahead (e.g. "execute", "批准", "looks good") → call plan_approve — its confirmation dialog is the final hard gate, and the user's Allow starts execution. Never call plan_approve in the same turn that presents a draft, and never implement before approval succeeds.`,
|
|
13276
|
+
"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).",
|
|
13277
|
+
"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.",
|
|
13278
|
+
"",
|
|
13279
|
+
"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.",
|
|
13281
13280
|
""
|
|
13282
13281
|
].join(`
|
|
13283
13282
|
`);
|
|
@@ -13295,6 +13294,8 @@ var GOAL_COMMAND_TEMPLATE = [
|
|
|
13295
13294
|
'- Argument "discard" (also "stop"/"cancel"/"off"): call the goal_discard tool. The user confirms in a dialog.',
|
|
13296
13295
|
'- Argument starting with "add " (or the user clearly wants to queue without arming): create a NEW contract from the rest of the argument and call goal_write with arm=false — never revise an existing goal for an add, never omit arm. It becomes a queued, inert goal (no dialog).',
|
|
13297
13296
|
'- Any other argument: treat it as a goal statement. Extract contract markers into the structured goal_write fields: --check "cmd" becomes a shell verification item, --contains "file::text" a file-contract item, --success "..." an extra success criterion, --constraint "..." goes into constraints, --non-goal "..." into nonGoals, --max-turns N and --max-minutes N set budgets. Draft a complete contract (goal, criteria, checks, constraints, non-goals), SHOW the verification items verbatim to the user, then call goal_write with arm=true — a confirmation dialog arms the autonomous loop. If this session already has a live goal, say so and offer revise/queue/discard instead.',
|
|
13297
|
+
"",
|
|
13298
|
+
"Goal files are host-managed: never edit or create anything under .opencode/goal/ by hand — contract changes go through goal_write (each revision bump invalidates evidence collected under earlier revisions), state changes through the goal_* tools. Likewise never enter the goal loop or call any goal_* tool unless the user ran /goal or explicitly asked for a goal loop.",
|
|
13298
13299
|
""
|
|
13299
13300
|
].join(`
|
|
13300
13301
|
`);
|
|
@@ -13360,7 +13361,7 @@ function relFrom(worktree, path) {
|
|
|
13360
13361
|
return rel && !rel.startsWith("..") ? rel.replaceAll("\\", "/") : path;
|
|
13361
13362
|
}
|
|
13362
13363
|
var planWriteTool = tool({
|
|
13363
|
-
description: "Create or revise the session's plan (structured planning document, written to .opencode/plan/<date>-<slug>.md, status draft). The only sanctioned write while planning. Takes structured fields; the tool renders and validates the fixed sections — you cannot produce a malformed plan file.",
|
|
13364
|
+
description: "Only used inside the /plan flow (the user ran /plan, asked to plan first, or a [forge:plan-notice] is present) — never self-initiate planning. Create or revise the session's plan (structured planning document, written to .opencode/plan/<date>-<slug>.md, status draft). The only sanctioned write while planning. Takes structured fields; the tool renders and validates the fixed sections — you cannot produce a malformed plan file.",
|
|
13364
13365
|
args: {
|
|
13365
13366
|
goal: tool.schema.string().describe("One-line task goal (used for the filename slug and the Goal section)"),
|
|
13366
13367
|
context: tool.schema.string().describe("Context Findings: what the reconnaissance actually found, with file:line evidence references"),
|
|
@@ -13433,7 +13434,7 @@ async function gate(ask, permission, title) {
|
|
|
13433
13434
|
await ask({ permission, patterns: ["*"], always: [], metadata: { title } });
|
|
13434
13435
|
}
|
|
13435
13436
|
var planApproveTool = tool({
|
|
13436
|
-
description: "
|
|
13437
|
+
description: "Approve the session's draft plan (draft -> approved). Call it ONLY after presenting the plan and receiving the user's explicit go-ahead in chat — never in the same turn that presents the draft. The permission layer pins this call to a confirmation dialog — the user's Allow is the final hard gate and lifts the draft-phase write ban. Optionally pass a one-line summary of what changed since the last revision.",
|
|
13437
13438
|
args: {
|
|
13438
13439
|
summary: tool.schema.string().optional().describe("One-line summary presented alongside the approval request")
|
|
13439
13440
|
},
|
|
@@ -13534,8 +13535,11 @@ function resolveLiveGoal(state) {
|
|
|
13534
13535
|
function coerceChecks(rows) {
|
|
13535
13536
|
const items = [];
|
|
13536
13537
|
for (const c of rows) {
|
|
13538
|
+
if ([c.shell, c.containsFile, c.containsText].some((v) => typeof v === "string" && v.includes("`"))) {
|
|
13539
|
+
throw new GoalError("Verification items must not contain backticks (they break the goal file format)");
|
|
13540
|
+
}
|
|
13537
13541
|
if (typeof c.shell === "string" && c.shell.trim()) {
|
|
13538
|
-
items.push({ kind: "shell", cmd: c.shell.trim(), ...c.timeoutSec ? { timeoutSec: c.timeoutSec } : {} });
|
|
13542
|
+
items.push({ kind: "shell", cmd: c.shell.trim(), ...c.timeoutSec ? { timeoutSec: Math.min(Math.max(1, c.timeoutSec), 600) } : {} });
|
|
13539
13543
|
} else if (typeof c.containsFile === "string" && c.containsFile.trim() && typeof c.containsText === "string" && c.containsText.trim()) {
|
|
13540
13544
|
items.push({ kind: "contains", file: c.containsFile.trim(), text: c.containsText });
|
|
13541
13545
|
} else {
|
|
@@ -13545,7 +13549,7 @@ function coerceChecks(rows) {
|
|
|
13545
13549
|
return items;
|
|
13546
13550
|
}
|
|
13547
13551
|
var goalWriteTool = tool({
|
|
13548
|
-
description: "Create or revise the session's goal contract (written to .opencode/goal/<date>-<slug>.md). Creating with arm=true arms the autonomous continuation loop — a user confirmation dialog IS the arm action; arm=false only queues an inert goal (/goal add). While this session already has a live goal, creating another is refused — pass revise=true to edit the current contract instead (bumps the revision; evidence from earlier revisions no longer counts; budgets carry over unless explicitly changed). Orthogonal to plans: never reads plan state.",
|
|
13552
|
+
description: "Only used inside the /goal flow (the user ran /goal or explicitly asked for a goal loop) — never self-initiate. Create or revise the session's goal contract (written to .opencode/goal/<date>-<slug>.md). Creating with arm=true arms the autonomous continuation loop — a user confirmation dialog IS the arm action; arm=false only queues an inert goal (/goal add). While this session already has a live goal, creating another is refused — pass revise=true to edit the current contract instead (bumps the revision; evidence from earlier revisions no longer counts; budgets carry over unless explicitly changed). Orthogonal to plans: never reads plan state.",
|
|
13549
13553
|
args: {
|
|
13550
13554
|
goal: tool.schema.string().describe("One-line goal statement (the semantic completion requirement)"),
|
|
13551
13555
|
criteria: tool.schema.array(tool.schema.string()).describe("Success Criteria: numbered, verifiable outcomes"),
|
|
@@ -13591,6 +13595,7 @@ var goalWriteTool = tool({
|
|
|
13591
13595
|
revision: existing.doc.revision + 1,
|
|
13592
13596
|
...existing.doc.session ? { session: existing.doc.session } : {},
|
|
13593
13597
|
turnsUsed: existing.doc.turnsUsed,
|
|
13598
|
+
...existing.doc.armedAt ? { armedAt: existing.doc.armedAt } : {},
|
|
13594
13599
|
...existing.doc.status === "paused" && existing.doc.stopReason ? { stopReason: existing.doc.stopReason } : {}
|
|
13595
13600
|
}), existing.doc);
|
|
13596
13601
|
atomicWrite(existing.path, text2);
|
|
@@ -13620,7 +13625,7 @@ var goalWriteTool = tool({
|
|
|
13620
13625
|
const text = renderGoal(input, {
|
|
13621
13626
|
now,
|
|
13622
13627
|
status: arm ? "active" : "queued",
|
|
13623
|
-
...arm ? { session: context.sessionID } : {}
|
|
13628
|
+
...arm ? { session: context.sessionID, armedAt: now } : {}
|
|
13624
13629
|
});
|
|
13625
13630
|
atomicWrite(path, text);
|
|
13626
13631
|
state.goalPath = path;
|
|
@@ -13887,49 +13892,61 @@ async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
|
13887
13892
|
}
|
|
13888
13893
|
}
|
|
13889
13894
|
async function continueIfEligible(client, sessionID) {
|
|
13890
|
-
if (
|
|
13891
|
-
|
|
13892
|
-
let state = sessions.get(sessionID);
|
|
13893
|
-
if (!state) {
|
|
13894
|
-
try {
|
|
13895
|
-
const info = await client.session.get({ path: { id: sessionID } });
|
|
13896
|
-
const wt = effectiveWorktree(info?.worktree, info?.directory);
|
|
13897
|
-
if (!wt) {
|
|
13898
|
-
goalProbe(`skip: no worktree for lazy seed session=${sessionID}`);
|
|
13899
|
-
return;
|
|
13900
|
-
}
|
|
13901
|
-
state = ensureSession(sessionID, wt);
|
|
13902
|
-
goalProbe(`lazy-seeded session=${sessionID} worktree=${wt}`);
|
|
13903
|
-
} catch (err) {
|
|
13904
|
-
goalProbe(`lazy-seed failed session=${sessionID} err=${String(err)}`);
|
|
13905
|
-
return;
|
|
13906
|
-
}
|
|
13907
|
-
}
|
|
13908
|
-
const goal = resolveLiveGoal(state);
|
|
13909
|
-
if (!goal || goal.doc.status !== "active") {
|
|
13910
|
-
goalProbe(`skip: no live active goal session=${sessionID}`);
|
|
13895
|
+
if (forgeDisabled) {
|
|
13896
|
+
goalProbe(`skip: forge disabled session=${sessionID}`);
|
|
13911
13897
|
return;
|
|
13912
13898
|
}
|
|
13913
|
-
if (
|
|
13914
|
-
goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session}`);
|
|
13899
|
+
if (continuationInFlight.has(sessionID))
|
|
13915
13900
|
return;
|
|
13916
|
-
}
|
|
13917
13901
|
continuationInFlight.add(sessionID);
|
|
13918
13902
|
try {
|
|
13903
|
+
let state = sessions.get(sessionID);
|
|
13904
|
+
if (!state) {
|
|
13905
|
+
try {
|
|
13906
|
+
const info = await client.session.get({ path: { id: sessionID } });
|
|
13907
|
+
const wt = effectiveWorktree(info?.worktree, info?.directory);
|
|
13908
|
+
if (!wt) {
|
|
13909
|
+
goalProbe(`skip: no worktree for lazy seed session=${sessionID}`);
|
|
13910
|
+
return;
|
|
13911
|
+
}
|
|
13912
|
+
state = ensureSession(sessionID, wt);
|
|
13913
|
+
goalProbe(`lazy-seeded session=${sessionID} worktree=${wt}`);
|
|
13914
|
+
} catch (err) {
|
|
13915
|
+
goalProbe(`lazy-seed failed session=${sessionID} err=${String(err)}`);
|
|
13916
|
+
return;
|
|
13917
|
+
}
|
|
13918
|
+
}
|
|
13919
|
+
let accountedContinuationTurn = false;
|
|
13920
|
+
let turnHadActivity = false;
|
|
13919
13921
|
if (pendingContinuationTurn.has(sessionID)) {
|
|
13922
|
+
accountedContinuationTurn = true;
|
|
13920
13923
|
pendingContinuationTurn.delete(sessionID);
|
|
13921
13924
|
const act = turnActivity.get(sessionID);
|
|
13922
|
-
|
|
13925
|
+
turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
|
|
13923
13926
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
13927
|
-
|
|
13927
|
+
const ledgerPath = state.goalPath;
|
|
13928
|
+
if (ledgerPath && existsSync(ledgerPath)) {
|
|
13929
|
+
try {
|
|
13930
|
+
const fresh = parseGoalLoose(readFileSync2(ledgerPath, "utf8"));
|
|
13931
|
+
if (fresh && fresh.turnsUsed > 0) {
|
|
13932
|
+
atomicWrite(ledgerPath, appendLedger(readFileSync2(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
|
|
13933
|
+
}
|
|
13934
|
+
} catch (err) {
|
|
13935
|
+
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
13928
13936
|
}
|
|
13929
|
-
} catch (err) {
|
|
13930
|
-
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
13931
13937
|
}
|
|
13932
|
-
|
|
13938
|
+
}
|
|
13939
|
+
const goal = resolveLiveGoal(state);
|
|
13940
|
+
if (!goal || goal.doc.status !== "active") {
|
|
13941
|
+
goalProbe(`skip: no live active goal session=${sessionID}`);
|
|
13942
|
+
return;
|
|
13943
|
+
}
|
|
13944
|
+
if (!goal.doc.session || goal.doc.session !== sessionID) {
|
|
13945
|
+
goalProbe(`skip: not goal owner session=${sessionID} owner=${goal.doc.session || "(none)"}`);
|
|
13946
|
+
return;
|
|
13947
|
+
}
|
|
13948
|
+
if (accountedContinuationTurn) {
|
|
13949
|
+
if (turnHadActivity) {
|
|
13933
13950
|
noProgressStreak.delete(sessionID);
|
|
13934
13951
|
} else {
|
|
13935
13952
|
const n = (noProgressStreak.get(sessionID) ?? 0) + 1;
|
|
@@ -13972,6 +13989,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
13972
13989
|
transportFails.delete(sessionID);
|
|
13973
13990
|
pendingContinuationTurn.add(sessionID);
|
|
13974
13991
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
13992
|
+
state.goalPath = goal.path;
|
|
13975
13993
|
atomicWrite(goal.path, incTurns(readFileSync2(goal.path, "utf8"), nowIso()));
|
|
13976
13994
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
13977
13995
|
} catch (err) {
|
|
@@ -13989,6 +14007,8 @@ async function continueIfEligible(client, sessionID) {
|
|
|
13989
14007
|
}
|
|
13990
14008
|
}
|
|
13991
14009
|
function scheduleIdleContinuation(client, sessionID) {
|
|
14010
|
+
if (forgeDisabled)
|
|
14011
|
+
return;
|
|
13992
14012
|
const existing = idleTimers.get(sessionID);
|
|
13993
14013
|
if (existing)
|
|
13994
14014
|
clearTimeout(existing);
|
|
@@ -14021,12 +14041,6 @@ var server = async (input) => {
|
|
|
14021
14041
|
mode: existing?.mode ?? "primary",
|
|
14022
14042
|
prompt: existing?.prompt ?? FORGE_PROMPT
|
|
14023
14043
|
};
|
|
14024
|
-
const cfgAny = cfg;
|
|
14025
|
-
cfgAny.skills ??= {};
|
|
14026
|
-
cfgAny.skills.paths ??= [];
|
|
14027
|
-
if (!cfgAny.skills.paths.includes(dataDir)) {
|
|
14028
|
-
cfgAny.skills.paths.push(dataDir);
|
|
14029
|
-
}
|
|
14030
14044
|
cfg.command ??= {};
|
|
14031
14045
|
cfg.command["plan"] ??= {
|
|
14032
14046
|
template: PLAN_COMMAND_TEMPLATE,
|
|
@@ -14121,9 +14135,11 @@ var server = async (input) => {
|
|
|
14121
14135
|
if (active) {
|
|
14122
14136
|
const p = progressOf(active.doc);
|
|
14123
14137
|
const rel = relFrom(state.worktree, active.path);
|
|
14124
|
-
const rule = active.doc.status === "draft" ? "while in draft, write operations are denied at the tool layer; present the
|
|
14138
|
+
const rule = active.doc.status === "draft" ? "while in draft, write operations are denied at the tool layer; present the plan then end your turn to await the user's review — on feedback revise via plan_write and re-present; call plan_approve only after the user explicitly approves in chat (their confirmation dialog is the final gate); /plan discard to abandon" : "call plan_tick immediately after each completed task; when all are done, self-check every acceptance criterion and call plan_close";
|
|
14125
14139
|
output.system.push(`[forge:plan-notice] This session is bound to a plan: ${rel} (status: ${active.doc.status}, ${p.done}/${p.total} tasks done). Rule: ${rule}. If the user has not mentioned this plan yet, relay its path and progress to them in one short line at the start of your reply.`);
|
|
14126
14140
|
}
|
|
14141
|
+
if (forgeDisabled)
|
|
14142
|
+
return;
|
|
14127
14143
|
const goal = resolveLiveGoal(state);
|
|
14128
14144
|
if (goal) {
|
|
14129
14145
|
const rel = relFrom(state.worktree, goal.path);
|
|
@@ -14133,6 +14149,8 @@ var server = async (input) => {
|
|
|
14133
14149
|
}
|
|
14134
14150
|
},
|
|
14135
14151
|
"experimental.session.compacting": async (input2, output) => {
|
|
14152
|
+
if (forgeDisabled)
|
|
14153
|
+
return;
|
|
14136
14154
|
if (!input2.sessionID)
|
|
14137
14155
|
return;
|
|
14138
14156
|
const state = sessions.get(input2.sessionID);
|
|
@@ -14148,13 +14166,15 @@ ${d.criteria.map((c, i) => `${i + 1}. ${c}`).join(`
|
|
|
14148
14166
|
Post-compaction turns must keep following this goal and its constraints.`);
|
|
14149
14167
|
},
|
|
14150
14168
|
"experimental.compaction.autocontinue": async (input2, output) => {
|
|
14169
|
+
if (forgeDisabled)
|
|
14170
|
+
return;
|
|
14151
14171
|
if (!input2.sessionID)
|
|
14152
14172
|
return;
|
|
14153
14173
|
const state = sessions.get(input2.sessionID);
|
|
14154
14174
|
if (!state)
|
|
14155
14175
|
return;
|
|
14156
14176
|
const goal = resolveLiveGoal(state);
|
|
14157
|
-
if (goal
|
|
14177
|
+
if (goal)
|
|
14158
14178
|
output.enabled = false;
|
|
14159
14179
|
}
|
|
14160
14180
|
};
|
|
@@ -14173,13 +14193,6 @@ async function v2Setup(ctx) {
|
|
|
14173
14193
|
});
|
|
14174
14194
|
});
|
|
14175
14195
|
}
|
|
14176
|
-
if (typeof ctx.skill?.transform === "function") {
|
|
14177
|
-
await ctx.skill.transform(async (draft) => {
|
|
14178
|
-
if (typeof draft.source !== "function")
|
|
14179
|
-
return;
|
|
14180
|
-
draft.source({ type: "directory", path: dataDir });
|
|
14181
|
-
});
|
|
14182
|
-
}
|
|
14183
14196
|
}
|
|
14184
14197
|
var plugin_default = { id: "forge", server, setup: v2Setup };
|
|
14185
14198
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorenllm/opencode-forge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Single general-purpose forge agent for opencode with two orthogonal harnesses: a plan harness (file-backed plans in .opencode/plan/, approve/close confirmation gates, tick discipline, hard write-ban while planning) and a goal harness (autonomous, host-verified objectives in .opencode/goal/ — idle continuation under turn/minute budgets, shell + file-contract checks re-run by the plugin at the completion gate, no-progress/transport/budget auto-pause, queueing).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
-
"SKILL.md",
|
|
18
17
|
"README.md"
|
|
19
18
|
],
|
|
20
19
|
"scripts": {
|
|
@@ -29,7 +28,7 @@
|
|
|
29
28
|
"planning",
|
|
30
29
|
"workflow",
|
|
31
30
|
"single-agent",
|
|
32
|
-
"
|
|
31
|
+
"goal"
|
|
33
32
|
],
|
|
34
33
|
"license": "MIT",
|
|
35
34
|
"author": "chengsongren",
|
package/SKILL.md
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: plan
|
|
3
|
-
description: >-
|
|
4
|
-
The plan discipline for the forge agent. You **MUST** load this skill when
|
|
5
|
-
the /plan command routes a task goal to you, OR the user asks to "plan
|
|
6
|
-
first", "make a plan", "think before coding" before implementation. It
|
|
7
|
-
governs the whole plan lifecycle: read-only reconnaissance, clarifying
|
|
8
|
-
questions, plan_write (structured, tool-rendered), user-approval via
|
|
9
|
-
plan_approve, tick-as-you-go execution via plan_tick, per-criterion
|
|
10
|
-
self-check at plan_close, and the OpenSpec boundary for long-horizon work.
|
|
11
|
-
Do NOT load it for direct implementation requests with no planning intent.
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
# Plan Discipline (forge)
|
|
15
|
-
|
|
16
|
-
Plans are short-horizon, single-task-goal documents on disk
|
|
17
|
-
(`.opencode/plan/<date>-<slug>.md`). The harness (tools + permission layer)
|
|
18
|
-
enforces the hard parts; you supply the engineering judgment.
|
|
19
|
-
|
|
20
|
-
## Phase 1 — Reconnaissance (read-only)
|
|
21
|
-
|
|
22
|
-
- Explore with read/grep/glob only. **All write tools, bash, and task
|
|
23
|
-
(subagents) are denied while a draft exists** — do not attempt them, do not
|
|
24
|
-
ask the user to bypass.
|
|
25
|
-
- Gather concrete evidence with `file:line` references; the plan's Context
|
|
26
|
-
Findings section must contain findings you actually verified, not guesses.
|
|
27
|
-
- If the goal is ambiguous on scope, behavior, or acceptance — ask the user
|
|
28
|
-
1-3 focused questions FIRST. Do not plan against assumptions the user could
|
|
29
|
-
settle in one line.
|
|
30
|
-
|
|
31
|
-
## Phase 2 — Write the plan (plan_write)
|
|
32
|
-
|
|
33
|
-
Call `plan_write` with structured fields; the tool renders and validates the
|
|
34
|
-
fixed sections (Goal / Non-Goals / Context Findings / Approach and
|
|
35
|
-
Alternatives / Task List / Risks / Acceptance Criteria), so a malformed plan
|
|
36
|
-
cannot exist.
|
|
37
|
-
|
|
38
|
-
Quality bar for each field:
|
|
39
|
-
|
|
40
|
-
- **goal**: one line, the outcome — not the activity.
|
|
41
|
-
- **context**: verified findings with `file:line` evidence; include what you
|
|
42
|
-
ruled out and why.
|
|
43
|
-
- **approach**: the chosen approach AND at least one rejected alternative
|
|
44
|
-
with the reason. A plan with no considered alternative is a guess.
|
|
45
|
-
- **tasks**: 3-8 concrete, independently verifiable steps, each doable in one
|
|
46
|
-
sitting. A task like "improve the code" is invalid; "extract timeout
|
|
47
|
-
constant into config.ts and default it to 3000" is valid.
|
|
48
|
-
- **risks**: what could break, blast radius, rollback path.
|
|
49
|
-
- **acceptance**: criteria you can verify with a command, a file, or an
|
|
50
|
-
observable behavior. Vague criteria will fail the plan_close self-check.
|
|
51
|
-
- **nonGoals**: explicit out-of-scope items (what the user might expect but
|
|
52
|
-
will NOT get).
|
|
53
|
-
|
|
54
|
-
Revising: calling `plan_write` again while still in draft overwrites the same
|
|
55
|
-
file. Do this after user feedback instead of hand-editing.
|
|
56
|
-
|
|
57
|
-
## Phase 3 — Approval gate (plan_approve)
|
|
58
|
-
|
|
59
|
-
Present to the user, briefly: goal, chosen approach (one line why), the
|
|
60
|
-
numbered task list, and the acceptance criteria. Then call `plan_approve`.
|
|
61
|
-
The user confirms in a dialog — that confirmation IS the approval. If they
|
|
62
|
-
object, revise with `plan_write` and present again. Never proceed to
|
|
63
|
-
implementation before approval succeeds.
|
|
64
|
-
|
|
65
|
-
## Phase 4 — Execution (tick as you go)
|
|
66
|
-
|
|
67
|
-
- Execute tasks in order; after EACH task's work is actually done, call
|
|
68
|
-
`plan_tick` with its number immediately. Never batch ticks; never tick
|
|
69
|
-
ahead of reality — the tick timestamp is an audit trail.
|
|
70
|
-
- If mid-execution you discover the plan is wrong, do not silently improvise:
|
|
71
|
-
tell the user what changed and either finish the affected task anyway or
|
|
72
|
-
ask whether to revise (/plan with the same goal re-enters planning).
|
|
73
|
-
|
|
74
|
-
## Phase 5 — Completion gate (plan_close)
|
|
75
|
-
|
|
76
|
-
When all tasks are ticked: self-check EVERY acceptance criterion with
|
|
77
|
-
concrete evidence (`file:line`, command output, test result). Call
|
|
78
|
-
`plan_close` with one check per criterion, `pass` honest — a ✗ fails the
|
|
79
|
-
close and that is the design working, not an inconvenience. The user
|
|
80
|
-
confirms closure in a dialog.
|
|
81
|
-
|
|
82
|
-
## Boundaries
|
|
83
|
-
|
|
84
|
-
- **Abandon**: user cancels → `/plan discard` (or plan_discard). Terminal,
|
|
85
|
-
file kept as history, writes restored.
|
|
86
|
-
- **Resume**: new session with unfinished plan → the system notice carries
|
|
87
|
-
the path; `/plan resume` continues from the remaining tasks.
|
|
88
|
-
- **Spec-workflow boundary**: work expected to span multiple sessions, days
|
|
89
|
-
of multi-file change, or multi-round requirement review is spec work, not
|
|
90
|
-
plan work. Say so once (e.g. "this fits a spec workflow like OpenSpec
|
|
91
|
-
better"), let the user choose, and proceed with a plan only if they insist.
|