@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 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 → plan_approve (user dialog = approval gate)
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` **and manually copy `SKILL.md`** to
62
- `~/.config/opencode/skills/plan/SKILL.md` (the package has no installer
63
- script; that mode has no bundled skill otherwise).
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/skill
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`, `skills.paths` entry, `command.plan`, `command.goal`, goal permission keys | vanishes when the plugin is removed; nothing is written to disk |
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) + `src/plan-file.ts` / `src/goal-file.ts`
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) + `SKILL.md` (planning discipline, discovered via
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 { fileURLToPath } from "node:url";
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; phases change through commands (/plan, /goal) and tools.
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
- Plan discipline (the tooling enforces the hard parts; you supply the judgment):
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. Load the plan skill (skill tool), then follow its planning discipline for this 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: "Ask the user to approve the session's draft plan (draft -> approved). The permission layer pins this call to a confirmation dialog — the user's Allow IS the approval. After approval the draft-phase write ban is lifted. Optionally pass a one-line summary of what changed since the last revision.",
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 (continuationInFlight.has(sessionID))
13891
- return;
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 (goal.doc.session && goal.doc.session !== sessionID) {
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
- const hadActivity = !!act && (act.writes > 0 || act.checks > 0);
13925
+ turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
13923
13926
  turnActivity.set(sessionID, { writes: 0, checks: 0 });
13924
- try {
13925
- const fresh = parseGoalLoose(readFileSync2(goal.path, "utf8"));
13926
- if (fresh && fresh.turnsUsed > 0) {
13927
- atomicWrite(goal.path, appendLedger(readFileSync2(goal.path, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: hadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
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
- if (hadActivity) {
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 summary then call plan_approve for approval, or /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";
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 && goal.doc.status === "active")
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.0",
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
- "skill"
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.