@sorenllm/opencode-forge 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +115 -21
  2. package/dist/index.js +1222 -54
  3. package/package.json +3 -4
  4. package/SKILL.md +0 -91
package/dist/index.js CHANGED
@@ -12334,9 +12334,8 @@ function tool(input) {
12334
12334
  }
12335
12335
  tool.schema = exports_external;
12336
12336
  // plugin.ts
12337
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12338
- import { fileURLToPath } from "node:url";
12339
- import { dirname, join, relative } from "node:path";
12337
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
12338
+ import { join as join2, relative } from "node:path";
12340
12339
  import { tmpdir } from "node:os";
12341
12340
 
12342
12341
  // src/plan-file.ts
@@ -12642,23 +12641,619 @@ function rankActivePlans(entries) {
12642
12641
  return entries.map((e) => ({ name: e.name, doc: parsePlanLoose(e.text) })).filter((e) => e.doc !== null && !isTerminal(e.doc.status)).sort((a, b) => a.doc.updated < b.doc.updated ? 1 : a.doc.updated > b.doc.updated ? -1 : a.name.localeCompare(b.name));
12643
12642
  }
12644
12643
 
12644
+ // src/goal-file.ts
12645
+ import { renameSync, writeFileSync } from "node:fs";
12646
+
12647
+ class GoalError extends Error {
12648
+ constructor(message) {
12649
+ super(message);
12650
+ this.name = "GoalError";
12651
+ }
12652
+ }
12653
+ var DEFAULT_MAX_TURNS = 25;
12654
+ var DEFAULT_MAX_MINUTES = 60;
12655
+ var HARD_MAX_TURNS = 200;
12656
+ var HARD_MAX_MINUTES = 480;
12657
+ var DEFAULT_TIMEOUT_SEC = 120;
12658
+ var MAX_TIMEOUT_SEC = 600;
12659
+ var TRANSPORT_FAILURE_LIMIT = 3;
12660
+ var NO_PROGRESS_LIMIT = 2;
12661
+ var TERMINAL2 = ["completed", "abandoned"];
12662
+ var LEGAL_TRANSITIONS2 = {
12663
+ queued: ["active", "abandoned"],
12664
+ active: ["paused", "completed", "abandoned"],
12665
+ paused: ["active", "abandoned"],
12666
+ completed: [],
12667
+ abandoned: []
12668
+ };
12669
+ var SECTION_ORDER2 = [
12670
+ "Goal",
12671
+ "Success Criteria",
12672
+ "Verification Checks",
12673
+ "Constraints",
12674
+ "Non-Goals",
12675
+ "Check Log",
12676
+ "Turn Ledger"
12677
+ ];
12678
+ var SECTION_HEADERS = {
12679
+ Goal: /^(#*)\s*goal\s*$/i,
12680
+ "Success Criteria": /^(#*)\s*success criteria\s*$/i,
12681
+ "Verification Checks": /^(#*)\s*verification checks\s*$/i,
12682
+ Constraints: /^(#*)\s*constraints\s*$/i,
12683
+ "Non-Goals": /^(#*)\s*non-goals?\s*$/i,
12684
+ "Check Log": /^(#*)\s*check log\s*$/i,
12685
+ "Turn Ledger": /^(#*)\s*turn ledger\s*$/i
12686
+ };
12687
+ var SHELL_LINE_RE = /^\s*(\d+)[.、)]\s*shell\s+`([^`]*)`(?:\s+\(timeout\s+(\d+)s?\))?$/i;
12688
+ var CONTAINS_LINE_RE = /^\s*(\d+)[.、)]\s*contains\s+`([^`]*)`\s*::\s*`([^`]*)`$/i;
12689
+ var NUMBERED_RE = /^\s*(\d+)[.、)]\s+(.+)$/;
12690
+ var LEDGER_RE = /^-\s*turn\s+(\d+)\s+rev(\d+)\s+(\S+)\s+activity=(yes|no)\s+\(writes=(\d+)\s+checks=(\d+)\)$/;
12691
+ function isGoalTerminal(status) {
12692
+ return TERMINAL2.includes(status);
12693
+ }
12694
+ function isGoalLive(status) {
12695
+ return status === "active" || status === "paused";
12696
+ }
12697
+ function canTransitionGoal(from, to) {
12698
+ const legal = LEGAL_TRANSITIONS2[from];
12699
+ return Array.isArray(legal) && legal.includes(to);
12700
+ }
12701
+ function slugifyGoal(goal) {
12702
+ const slug = goal.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/g, "");
12703
+ return slug || "goal";
12704
+ }
12705
+ function localDateNow(d = new Date) {
12706
+ const y = d.getFullYear();
12707
+ const m = String(d.getMonth() + 1).padStart(2, "0");
12708
+ const day = String(d.getDate()).padStart(2, "0");
12709
+ return `${y}-${m}-${day}`;
12710
+ }
12711
+ function goalFileName(date5, slug, existingNames = []) {
12712
+ const taken = new Set(existingNames);
12713
+ let base = `${date5}-${slug}`;
12714
+ let name = `${base}.md`;
12715
+ let i = 2;
12716
+ while (taken.has(name)) {
12717
+ name = `${base}-${i}.md`;
12718
+ i++;
12719
+ }
12720
+ return name;
12721
+ }
12722
+ function atomicWrite(file2, text) {
12723
+ const tmp = `${file2}.${process.pid}-${Math.random().toString(36).slice(2, 6)}.tmp`;
12724
+ writeFileSync(tmp, text);
12725
+ renameSync(tmp, file2);
12726
+ }
12727
+ function validateGoalInput(input) {
12728
+ const missing = [];
12729
+ const reqStr = (v) => typeof v === "string" && v.trim().length > 0;
12730
+ if (!reqStr(input.goal))
12731
+ missing.push("goal");
12732
+ if (!Array.isArray(input.criteria) || input.criteria.length === 0 || !input.criteria.every(reqStr)) {
12733
+ missing.push("criteria (at least one success criterion)");
12734
+ }
12735
+ if (!Array.isArray(input.checks) || input.checks.length === 0) {
12736
+ missing.push("checks (at least one verification item)");
12737
+ } else {
12738
+ for (const c of input.checks) {
12739
+ if (c.kind === "shell" && !reqStr(c.cmd)) {
12740
+ missing.push("checks (a shell item has an empty command)");
12741
+ break;
12742
+ }
12743
+ if (c.kind === "contains" && (!reqStr(c.file) || !reqStr(c.text))) {
12744
+ missing.push("checks (a contains item has an empty file or text)");
12745
+ break;
12746
+ }
12747
+ }
12748
+ }
12749
+ if (!reqStr(input.constraints))
12750
+ missing.push("constraints");
12751
+ if (input.maxTurns !== undefined && (!Number.isInteger(input.maxTurns) || input.maxTurns < 1 || input.maxTurns > HARD_MAX_TURNS)) {
12752
+ missing.push(`maxTurns (integer 1-${HARD_MAX_TURNS})`);
12753
+ }
12754
+ if (input.maxMinutes !== undefined && (!Number.isInteger(input.maxMinutes) || input.maxMinutes < 1 || input.maxMinutes > HARD_MAX_MINUTES)) {
12755
+ missing.push(`maxMinutes (integer 1-${HARD_MAX_MINUTES})`);
12756
+ }
12757
+ return missing;
12758
+ }
12759
+ function renderCheck(item) {
12760
+ if (item.kind === "shell") {
12761
+ const t = item.timeoutSec ?? DEFAULT_TIMEOUT_SEC;
12762
+ return `shell \`${item.cmd}\` (timeout ${t}s)`;
12763
+ }
12764
+ return `contains \`${item.file}\` :: \`${item.text}\``;
12765
+ }
12766
+ function frontmatterBlock2(meta) {
12767
+ const lines = [
12768
+ "---",
12769
+ `status: ${meta.status}`,
12770
+ `created: ${meta.created}`,
12771
+ `updated: ${meta.updated}`,
12772
+ `revision: ${meta.revision}`
12773
+ ];
12774
+ if (meta.armedAt)
12775
+ lines.push(`armed_at: ${meta.armedAt}`);
12776
+ if (meta.session)
12777
+ lines.push(`session: ${meta.session}`);
12778
+ lines.push(`max_turns: ${meta.maxTurns}`, `turns_used: ${meta.turnsUsed}`, `max_minutes: ${meta.maxMinutes}`);
12779
+ if (meta.status === "paused" && meta.stopReason)
12780
+ lines.push(`stop_reason: ${meta.stopReason}`);
12781
+ lines.push("---", "");
12782
+ return lines.join(`
12783
+ `);
12784
+ }
12785
+ function renderGoal(input, meta) {
12786
+ const missing = validateGoalInput(input);
12787
+ if (missing.length > 0) {
12788
+ throw new GoalError(`Goal contract incomplete; missing: ${missing.join(", ")}`);
12789
+ }
12790
+ const goal = input.goal.trim();
12791
+ const nonGoals = (input.nonGoals ?? []).filter((s) => s.trim().length > 0);
12792
+ const parts = [
12793
+ frontmatterBlock2({
12794
+ status: meta.status,
12795
+ created: meta.created ?? meta.now,
12796
+ updated: meta.now,
12797
+ revision: meta.revision ?? 1,
12798
+ session: meta.session,
12799
+ maxTurns: input.maxTurns ?? DEFAULT_MAX_TURNS,
12800
+ turnsUsed: meta.turnsUsed ?? 0,
12801
+ maxMinutes: input.maxMinutes ?? DEFAULT_MAX_MINUTES,
12802
+ ...meta.armedAt ? { armedAt: meta.armedAt } : {},
12803
+ ...meta.status === "paused" && meta.stopReason ? { stopReason: meta.stopReason } : {}
12804
+ }),
12805
+ "## Goal",
12806
+ "",
12807
+ goal,
12808
+ "",
12809
+ "## Success Criteria",
12810
+ "",
12811
+ input.criteria.map((c, i) => `${i + 1}. ${c.trim()}`).join(`
12812
+ `),
12813
+ "",
12814
+ "## Verification Checks",
12815
+ "",
12816
+ input.checks.map((c, i) => `${i + 1}. ${renderCheck(c)}`).join(`
12817
+ `),
12818
+ "",
12819
+ "## Constraints",
12820
+ "",
12821
+ input.constraints.trim(),
12822
+ "",
12823
+ "## Non-Goals",
12824
+ "",
12825
+ nonGoals.length > 0 ? nonGoals.map((s) => `- ${s.trim()}`).join(`
12826
+ `) : "(no non-goals declared)",
12827
+ "",
12828
+ "## Check Log",
12829
+ "",
12830
+ "(no checks recorded yet)",
12831
+ "",
12832
+ "## Turn Ledger",
12833
+ "",
12834
+ "(no continuation turns yet)",
12835
+ ""
12836
+ ];
12837
+ return parts.join(`
12838
+ `);
12839
+ }
12840
+ function parseFrontmatter2(text) {
12841
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
12842
+ if (!m)
12843
+ throw new GoalError("goal file is missing frontmatter");
12844
+ const fm = {};
12845
+ for (const line of m[1].split(/\r?\n/)) {
12846
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
12847
+ if (kv)
12848
+ fm[kv[1]] = kv[2].trim();
12849
+ }
12850
+ return { fm, body: text.slice(m[0].length) };
12851
+ }
12852
+ function matchSectionHeader2(line) {
12853
+ const trimmed = line.trim();
12854
+ if (!trimmed.startsWith("#"))
12855
+ return null;
12856
+ for (const key of Object.keys(SECTION_HEADERS)) {
12857
+ if (SECTION_HEADERS[key].test(trimmed))
12858
+ return key;
12859
+ }
12860
+ return null;
12861
+ }
12862
+ function splitSections2(body) {
12863
+ const sections = new Map;
12864
+ let current = null;
12865
+ const buf = [];
12866
+ const flush = () => {
12867
+ if (current !== null)
12868
+ sections.set(current, buf.join(`
12869
+ `).trim());
12870
+ buf.length = 0;
12871
+ };
12872
+ for (const line of body.split(/\r?\n/)) {
12873
+ const header = matchSectionHeader2(line);
12874
+ if (header !== null) {
12875
+ flush();
12876
+ current = header;
12877
+ } else if (current !== null) {
12878
+ buf.push(line);
12879
+ }
12880
+ }
12881
+ flush();
12882
+ return sections;
12883
+ }
12884
+ function parseGoal(text) {
12885
+ const { fm, body } = parseFrontmatter2(text);
12886
+ const status = fm.status ?? "queued";
12887
+ if (!["queued", "active", "paused", "completed", "abandoned"].includes(status)) {
12888
+ throw new GoalError(`Unknown goal status: ${fm.status}`);
12889
+ }
12890
+ const sections = splitSections2(body);
12891
+ for (const key of SECTION_ORDER2) {
12892
+ if (!sections.has(key)) {
12893
+ throw new GoalError(`Goal is missing section: ${key}`);
12894
+ }
12895
+ }
12896
+ const criteria = [];
12897
+ const seenCriterion = new Set;
12898
+ for (const line of (sections.get("Success Criteria") ?? "").split(/\r?\n/)) {
12899
+ const m = line.match(NUMBERED_RE);
12900
+ if (!m)
12901
+ continue;
12902
+ if (seenCriterion.has(Number(m[1])))
12903
+ throw new GoalError(`Duplicate success criterion number: ${m[1]}`);
12904
+ seenCriterion.add(Number(m[1]));
12905
+ criteria.push(m[2].trim());
12906
+ }
12907
+ if (criteria.length === 0)
12908
+ throw new GoalError("Success criteria are empty or malformed");
12909
+ const checks3 = [];
12910
+ const seenCheck = new Set;
12911
+ for (const line of (sections.get("Verification Checks") ?? "").split(/\r?\n/)) {
12912
+ const shell = line.match(SHELL_LINE_RE);
12913
+ if (shell) {
12914
+ if (seenCheck.has(Number(shell[1])))
12915
+ throw new GoalError(`Duplicate verification item number: ${shell[1]}`);
12916
+ seenCheck.add(Number(shell[1]));
12917
+ checks3.push({ kind: "shell", cmd: shell[2], ...shell[3] ? { timeoutSec: Number(shell[3]) } : {} });
12918
+ continue;
12919
+ }
12920
+ const contains = line.match(CONTAINS_LINE_RE);
12921
+ if (contains) {
12922
+ if (seenCheck.has(Number(contains[1])))
12923
+ throw new GoalError(`Duplicate verification item number: ${contains[1]}`);
12924
+ seenCheck.add(Number(contains[1]));
12925
+ checks3.push({ kind: "contains", file: contains[2], text: contains[3] });
12926
+ }
12927
+ }
12928
+ if (checks3.length === 0)
12929
+ throw new GoalError("Verification checks are empty or malformed");
12930
+ const log = (sections.get("Check Log") ?? "").split(/\r?\n/).filter((l) => l.startsWith("- "));
12931
+ const ledger = [];
12932
+ for (const line of (sections.get("Turn Ledger") ?? "").split(/\r?\n/)) {
12933
+ const m = line.match(LEDGER_RE);
12934
+ if (m) {
12935
+ ledger.push({ turn: Number(m[1]), revision: Number(m[2]), at: m[3], activity: m[4] === "yes", writes: Number(m[5]), checks: Number(m[6]) });
12936
+ }
12937
+ }
12938
+ const num = (v, dflt) => {
12939
+ const n = Number(v);
12940
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : dflt;
12941
+ };
12942
+ return {
12943
+ status,
12944
+ created: fm.created ?? "",
12945
+ armedAt: fm.armed_at ?? "",
12946
+ updated: fm.updated ?? "",
12947
+ revision: Math.max(1, num(fm.revision, 1)),
12948
+ session: fm.session ?? "",
12949
+ maxTurns: Math.max(1, num(fm.max_turns, DEFAULT_MAX_TURNS)),
12950
+ turnsUsed: num(fm.turns_used, 0),
12951
+ maxMinutes: Math.max(1, num(fm.max_minutes, DEFAULT_MAX_MINUTES)),
12952
+ ...status === "paused" && fm.stop_reason ? { stopReason: fm.stop_reason } : {},
12953
+ goal: (sections.get("Goal") ?? "").split(/\r?\n/)[0]?.trim() ?? "",
12954
+ criteria,
12955
+ checks: checks3,
12956
+ constraints: sections.get("Constraints") ?? "",
12957
+ nonGoals: (sections.get("Non-Goals") ?? "").split(/\r?\n/).filter((l) => l.startsWith("- ")).map((l) => l.slice(1).trim()),
12958
+ log,
12959
+ ledger
12960
+ };
12961
+ }
12962
+ function parseGoalLoose(text) {
12963
+ try {
12964
+ return parseGoal(text);
12965
+ } catch {
12966
+ return null;
12967
+ }
12968
+ }
12969
+ function setFm(text, key, value) {
12970
+ const re = new RegExp(`^${key}:.*$`, "m");
12971
+ if (re.test(text))
12972
+ return text.replace(re, `${key}: ${value}`);
12973
+ return text.replace(/^---\r?\n/, `---
12974
+ ${key}: ${value}
12975
+ `);
12976
+ }
12977
+ function setUpdated2(text, now) {
12978
+ return setFm(text, "updated", now);
12979
+ }
12980
+ function transitionGoal(text, to, now, opts = {}) {
12981
+ const { fm } = parseFrontmatter2(text);
12982
+ const from = fm.status ?? "queued";
12983
+ if (from === to)
12984
+ throw new GoalError(`Goal is already ${to}`);
12985
+ if (!canTransitionGoal(from, to)) {
12986
+ throw new GoalError(`Illegal goal status transition: ${from} -> ${to} (legal paths: queued -> active; active <-> paused; active -> completed; queued/active/paused -> abandoned)`);
12987
+ }
12988
+ let next = text.replace(/^status:.*$/m, `status: ${to}`);
12989
+ if (to === "paused") {
12990
+ if (!opts.stopReason)
12991
+ throw new GoalError("Pausing requires a stop reason (user/blocker/no-progress/budget-turns/budget-time/draft-conflict/transport-failures)");
12992
+ next = setFm(next, "stop_reason", opts.stopReason);
12993
+ }
12994
+ if (to === "active") {
12995
+ if (!opts.session)
12996
+ throw new GoalError("Arming/resuming requires the owning session id");
12997
+ next = setFm(next, "session", opts.session);
12998
+ next = setFm(next, "armed_at", now);
12999
+ next = next.replace(/^stop_reason:.*$\r?\n?/m, "");
13000
+ }
13001
+ return setUpdated2(next, now);
13002
+ }
13003
+ function incTurns(text, now) {
13004
+ const doc2 = parseGoal(text);
13005
+ const next = setFm(text, "turns_used", String(doc2.turnsUsed + 1));
13006
+ return setUpdated2(next, now);
13007
+ }
13008
+ function bumpBudget(text, addTurns, now) {
13009
+ const doc2 = parseGoal(text);
13010
+ const capped = Math.min(doc2.maxTurns + addTurns, HARD_MAX_TURNS);
13011
+ const next = setFm(text, "max_turns", String(capped));
13012
+ return setUpdated2(next, now);
13013
+ }
13014
+ function budgetState(doc2) {
13015
+ if (doc2.turnsUsed >= doc2.maxTurns)
13016
+ return "budget-turns";
13017
+ const start = Date.parse(doc2.armedAt || doc2.created);
13018
+ if (Number.isFinite(start) && Date.now() - start > doc2.maxMinutes * 60000)
13019
+ return "budget-time";
13020
+ return "ok";
13021
+ }
13022
+ var LOG_SIG_RE = /^-\s*\S+\s+run=(\S+)\s+(rev\d+)\s+(#\d+)\s+/;
13023
+ function appendCheckLog(text, runId, outcomes, now) {
13024
+ const doc2 = parseGoal(text);
13025
+ const existing = new Set(doc2.log.map((l) => l.match(LOG_SIG_RE)).filter((m) => m !== null).map((m) => `${m[1]} ${m[2]} ${m[3]}`));
13026
+ const add = outcomes.filter((o) => !existing.has(`${runId} rev${doc2.revision} #${o.index}`)).map((o) => {
13027
+ const label = o.label.replace(/\r?\n/g, " ");
13028
+ const flat = (o.detail || "(no output)").replace(/\r?\n/g, " ");
13029
+ const detail = flat.length > 400 ? `${flat.slice(0, 400)}…` : flat;
13030
+ return `- ${now} run=${runId} rev${doc2.revision} #${o.index} ${o.ok ? "OK" : "FAIL"} (${o.durationMs ?? 0}ms) \`${label}\` :: ${detail}`;
13031
+ });
13032
+ if (add.length === 0)
13033
+ return setUpdated2(text, now);
13034
+ const lines = text.split(/\r?\n/);
13035
+ const logHeaderIdx = lines.findIndex((l) => SECTION_HEADERS["Check Log"].test(l.trim()));
13036
+ if (logHeaderIdx === -1)
13037
+ throw new GoalError("Goal is missing section: Check Log");
13038
+ const placeholderIdx = lines.indexOf("(no checks recorded yet)", logHeaderIdx);
13039
+ if (placeholderIdx !== -1)
13040
+ lines.splice(placeholderIdx, 1);
13041
+ let lastLog = -1;
13042
+ for (let i = logHeaderIdx + 1;i < lines.length; i++) {
13043
+ if (lines[i].startsWith("- "))
13044
+ lastLog = i;
13045
+ else if (lines[i].trim() !== "")
13046
+ break;
13047
+ }
13048
+ const at = lastLog === -1 ? logHeaderIdx + 2 : lastLog + 1;
13049
+ lines.splice(at, 0, ...add);
13050
+ return setUpdated2(lines.join(`
13051
+ `), now);
13052
+ }
13053
+ function appendLedger(text, entry, now) {
13054
+ const lines = text.split(/\r?\n/);
13055
+ const line = `- turn ${entry.turn} rev${entry.revision} ${entry.at} activity=${entry.activity ? "yes" : "no"} (writes=${entry.writes} checks=${entry.checks})`;
13056
+ const headerIdx = lines.findIndex((l) => SECTION_HEADERS["Turn Ledger"].test(l.trim()));
13057
+ if (headerIdx === -1)
13058
+ throw new GoalError("Goal is missing section: Turn Ledger");
13059
+ const phIdx = lines.indexOf("(no continuation turns yet)", headerIdx);
13060
+ if (phIdx !== -1)
13061
+ lines.splice(phIdx, 1);
13062
+ const re = new RegExp(`^-\\s*turn\\s+${entry.turn}\\s+rev\\d+\\s+`);
13063
+ const existingIdx = lines.findIndex((l, i) => i > headerIdx && re.test(l));
13064
+ if (existingIdx !== -1) {
13065
+ lines[existingIdx] = line;
13066
+ } else {
13067
+ let lastLedger = -1;
13068
+ for (let i = headerIdx + 1;i < lines.length; i++) {
13069
+ if (lines[i].startsWith("- "))
13070
+ lastLedger = i;
13071
+ else if (lines[i].trim() !== "")
13072
+ break;
13073
+ }
13074
+ const at = lastLedger === -1 ? headerIdx + 2 : lastLedger + 1;
13075
+ lines.splice(at, 0, line);
13076
+ }
13077
+ return setUpdated2(lines.join(`
13078
+ `), now);
13079
+ }
13080
+ function carryHistory(newText, oldDoc) {
13081
+ let out = newText;
13082
+ if (oldDoc.log.length > 0) {
13083
+ out = out.replace("(no checks recorded yet)", () => oldDoc.log.join(`
13084
+ `));
13085
+ }
13086
+ if (oldDoc.ledger.length > 0) {
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})`);
13088
+ out = out.replace("(no continuation turns yet)", () => lines.join(`
13089
+ `));
13090
+ }
13091
+ return out;
13092
+ }
13093
+ function rankLiveGoals(entries) {
13094
+ return entries.map((e) => ({ name: e.name, doc: parseGoalLoose(e.text) })).filter((e) => e.doc !== null && isGoalLive(e.doc.status)).sort((a, b) => a.doc.updated < b.doc.updated ? 1 : a.doc.updated > b.doc.updated ? -1 : a.name.localeCompare(b.name));
13095
+ }
13096
+ function rankQueuedGoals(entries) {
13097
+ return entries.map((e) => ({ name: e.name, doc: parseGoalLoose(e.text) })).filter((e) => e.doc !== null && e.doc.status === "queued").sort((a, b) => a.doc.created > b.doc.created ? 1 : a.doc.created < b.doc.created ? -1 : a.name.localeCompare(b.name));
13098
+ }
13099
+ function completeCheckFailures(doc2, attestations) {
13100
+ const failures = [];
13101
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
13102
+ const byCriterion = new Map(attestations.map((a) => [norm(a.criterion), a]));
13103
+ doc2.criteria.forEach((criterion, i) => {
13104
+ const a = byCriterion.get(norm(criterion));
13105
+ if (!a) {
13106
+ failures.push(`Success criterion ${i + 1} has no self-attestation: ${criterion}`);
13107
+ } else if (!a.pass) {
13108
+ failures.push(`Success criterion ${i + 1} attested as unmet: ${criterion} (evidence: ${a.evidence || "none"})`);
13109
+ }
13110
+ });
13111
+ if (attestations.length > doc2.criteria.length) {
13112
+ failures.push(`${attestations.length - doc2.criteria.length} attestation(s) do not match any criterion of revision ${doc2.revision}`);
13113
+ }
13114
+ return failures;
13115
+ }
13116
+
13117
+ // src/run-check.ts
13118
+ import { spawn } from "node:child_process";
13119
+ import { readFileSync } from "node:fs";
13120
+ import { join, resolve, sep } from "node:path";
13121
+ var OUTPUT_LIMIT = 2048;
13122
+ var defaultShellRunner = (cmd, opts) => new Promise((resolveRun) => {
13123
+ let child;
13124
+ try {
13125
+ child = spawn(cmd, {
13126
+ shell: true,
13127
+ cwd: opts.cwd,
13128
+ windowsHide: true,
13129
+ ...process.platform !== "win32" ? { detached: true } : {}
13130
+ });
13131
+ } catch (err) {
13132
+ resolveRun({ code: null, output: "", timedOut: false, spawnError: String(err) });
13133
+ return;
13134
+ }
13135
+ let output = "";
13136
+ let timedOut = false;
13137
+ let settled = false;
13138
+ const killTree = () => {
13139
+ if (!child.pid) {
13140
+ child.kill();
13141
+ return;
13142
+ }
13143
+ if (process.platform === "win32") {
13144
+ try {
13145
+ spawn("taskkill", ["/pid", String(child.pid), "/F", "/T"], { windowsHide: true, stdio: "ignore" });
13146
+ } catch {
13147
+ child.kill();
13148
+ }
13149
+ } else {
13150
+ try {
13151
+ process.kill(-child.pid, "SIGKILL");
13152
+ } catch {
13153
+ child.kill("SIGKILL");
13154
+ }
13155
+ }
13156
+ };
13157
+ const timer = setTimeout(() => {
13158
+ timedOut = true;
13159
+ killTree();
13160
+ }, opts.timeoutMs);
13161
+ const failsafe = setTimeout(() => settle({ code: null, output, timedOut: true, spawnError: "timeout settle fallback" }), opts.timeoutMs + 30000);
13162
+ failsafe.unref?.();
13163
+ const settle = (r) => {
13164
+ if (settled)
13165
+ return;
13166
+ settled = true;
13167
+ clearTimeout(timer);
13168
+ clearTimeout(failsafe);
13169
+ resolveRun(r);
13170
+ };
13171
+ child.stdout?.on("data", (d) => {
13172
+ if (output.length < OUTPUT_LIMIT * 2)
13173
+ output += d.toString();
13174
+ });
13175
+ child.stderr?.on("data", (d) => {
13176
+ if (output.length < OUTPUT_LIMIT * 2)
13177
+ output += d.toString();
13178
+ });
13179
+ child.on("error", (err) => settle({ code: null, output, timedOut, spawnError: err.message }));
13180
+ child.on("close", (code) => settle({ code, output, timedOut }));
13181
+ });
13182
+ var shellRunner = defaultShellRunner;
13183
+ function truncateOutput(output) {
13184
+ const flat = output.replace(/\r?\n/g, " ⏎ ").trim();
13185
+ return flat.length > OUTPUT_LIMIT ? `${flat.slice(0, OUTPUT_LIMIT)}…` : flat;
13186
+ }
13187
+ function insideWorktree(worktree, file2) {
13188
+ const root = resolve(worktree);
13189
+ const abs = resolve(root, file2);
13190
+ return abs === root || abs.startsWith(root + sep);
13191
+ }
13192
+ async function runShellItem(item, index, worktree) {
13193
+ const timeoutSec = Math.min(item.timeoutSec ?? DEFAULT_TIMEOUT_SEC, MAX_TIMEOUT_SEC);
13194
+ const started = Date.now();
13195
+ const r = await shellRunner(item.cmd, { cwd: worktree, timeoutMs: timeoutSec * 1000 });
13196
+ const durationMs = Date.now() - started;
13197
+ if (r.spawnError) {
13198
+ return { index, kind: "shell", label: item.cmd, ok: false, detail: `spawn failed: ${r.spawnError}`, durationMs };
13199
+ }
13200
+ if (r.timedOut) {
13201
+ return { index, kind: "shell", label: item.cmd, ok: false, detail: `timed out after ${timeoutSec}s (partial output: ${truncateOutput(r.output) || "none"})`, durationMs };
13202
+ }
13203
+ return {
13204
+ index,
13205
+ kind: "shell",
13206
+ label: item.cmd,
13207
+ ok: r.code === 0,
13208
+ detail: `exit=${r.code} ${truncateOutput(r.output)}`.trim(),
13209
+ durationMs
13210
+ };
13211
+ }
13212
+ function runContainsItem(item, index, worktree) {
13213
+ const started = Date.now();
13214
+ const label = `${item.file} :: ${item.text}`;
13215
+ if (!insideWorktree(worktree, item.file)) {
13216
+ return { index, kind: "contains", label, ok: false, detail: "path escapes the workspace boundary", durationMs: Date.now() - started };
13217
+ }
13218
+ const abs = join(worktree, item.file);
13219
+ let content;
13220
+ try {
13221
+ content = readFileSync(abs, "utf8");
13222
+ } catch (err) {
13223
+ return { index, kind: "contains", label, ok: false, detail: `cannot read file: ${err.message}`, durationMs: Date.now() - started };
13224
+ }
13225
+ const ok = content.includes(item.text);
13226
+ return {
13227
+ index,
13228
+ kind: "contains",
13229
+ label,
13230
+ ok,
13231
+ detail: ok ? `found in ${item.file} (${content.length} chars)` : `required text not found in ${item.file} (${content.length} chars)`,
13232
+ durationMs: Date.now() - started
13233
+ };
13234
+ }
13235
+ async function runChecks(checks3, worktree) {
13236
+ const outcomes = [];
13237
+ for (let i = 0;i < checks3.length; i++) {
13238
+ const item = checks3[i];
13239
+ outcomes.push(item.kind === "shell" ? await runShellItem(item, i + 1, worktree) : runContainsItem(item, i + 1, worktree));
13240
+ }
13241
+ return outcomes;
13242
+ }
13243
+ function outcomesAllOk(outcomes) {
13244
+ return outcomes.every((o) => o.ok);
13245
+ }
13246
+ function formatOutcomes(outcomes) {
13247
+ return outcomes.map((o) => `#${o.index} [${o.ok ? "PASS" : "FAIL"}] (${o.kind}) ${o.label}
13248
+ ${o.detail}`).join(`
13249
+ `);
13250
+ }
13251
+
12645
13252
  // plugin.ts
12646
- var bundleDir = dirname(fileURLToPath(import.meta.url));
12647
- var candidateDirs = [bundleDir, join(bundleDir, "..")];
12648
- var dataDir = candidateDirs.find((d) => existsSync(join(d, "SKILL.md"))) ?? bundleDir;
12649
13253
  var FORGE_AGENT = "forge";
12650
- 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) 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.
12651
13255
 
12652
- Plan discipline (the tooling enforces the hard parts; you supply the judgment):
12653
- - 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.
12654
- - 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.
12655
- - 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.
12656
- - 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.
12657
- - 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.
12658
- - 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.
12659
- - 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.
12660
-
12661
- 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.`;
12662
13257
  var PLAN_COMMAND_TEMPLATE = [
12663
13258
  '(forge plan harness routing. Argument: "$ARGUMENTS")',
12664
13259
  "",
@@ -12669,7 +13264,38 @@ var PLAN_COMMAND_TEMPLATE = [
12669
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.",
12670
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.',
12671
13266
  '- Argument "discard": call the plan_discard tool, then tell the user the plan was abandoned and writes are restored.',
12672
- "- 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 (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 on scope, behavior, or acceptance, ask the user 1-3 focused questions FIRST — do not 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. Approval (plan_approve): present briefly — goal, chosen approach with one line why, the numbered task list, the acceptance criteria — then call plan_approve. The user's confirmation in the dialog IS the approval; if they object, revise with plan_write and present again. 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.",
13280
+ ""
13281
+ ].join(`
13282
+ `);
13283
+ var GOAL_COMMAND_TEMPLATE = [
13284
+ '(forge goal harness routing. Argument: "$ARGUMENTS")',
13285
+ "",
13286
+ "Current .opencode/goal/ directory:",
13287
+ "!`ls -1 .opencode/goal 2>/dev/null || echo '(empty)'`",
13288
+ "",
13289
+ "Route on the argument above (decide silently; do not recite this routing text to the user):",
13290
+ "- Argument empty: for every non-terminal goal in the directory above, read its frontmatter and report to the user: path, status (queued/active/paused + stop_reason), revision, turns_used/max_turns budget. Mention queued goals and the /goal next promotion option. Ask whether to resume/promote one or arm something new.",
13291
+ '- Argument "pause": call the goal_pause tool (with the blocker text as `blocker` when there is one), then tell the user the loop is stopped and how to resume.',
13292
+ `- Argument "resume": call the goal_resume tool for the session's paused goal (optionally with addTurns if the user asked for more budget). If no live goal exists but queued goals do, promote the oldest via goal_resume instead. The user confirms in a dialog.`,
13293
+ '- Argument "next": promote the oldest queued goal via the goal_resume tool (no live goal must remain). The user confirms in a dialog.',
13294
+ '- Argument "discard" (also "stop"/"cancel"/"off"): call the goal_discard tool. The user confirms in a dialog.',
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).',
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.",
12673
13299
  ""
12674
13300
  ].join(`
12675
13301
  `);
@@ -12695,13 +13321,13 @@ function nowIso() {
12695
13321
  return new Date().toISOString();
12696
13322
  }
12697
13323
  function planDirOf(worktree) {
12698
- return join(worktree, ".opencode", "plan");
13324
+ return join2(worktree, ".opencode", "plan");
12699
13325
  }
12700
13326
  function readPlanDir(worktree) {
12701
13327
  const dir = planDirOf(worktree);
12702
13328
  if (!existsSync(dir))
12703
13329
  return [];
12704
- return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }));
13330
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
12705
13331
  }
12706
13332
  function ensureSession(sessionID, worktree) {
12707
13333
  const existing = sessions.get(sessionID);
@@ -12709,7 +13335,7 @@ function ensureSession(sessionID, worktree) {
12709
13335
  existing.worktree = worktree;
12710
13336
  return existing;
12711
13337
  }
12712
- const state = { worktree };
13338
+ const state = { sessionID, worktree };
12713
13339
  sessions.set(sessionID, state);
12714
13340
  return state;
12715
13341
  }
@@ -12718,7 +13344,7 @@ function worktreeFor(context) {
12718
13344
  }
12719
13345
  function resolveActivePlan(state) {
12720
13346
  if (state.planPath && existsSync(state.planPath)) {
12721
- const doc2 = parsePlanLoose(readFileSync(state.planPath, "utf8"));
13347
+ const doc2 = parsePlanLoose(readFileSync2(state.planPath, "utf8"));
12722
13348
  if (doc2 && !isTerminal(doc2.status))
12723
13349
  return { path: state.planPath, doc: doc2 };
12724
13350
  state.planPath = undefined;
@@ -12726,7 +13352,7 @@ function resolveActivePlan(state) {
12726
13352
  const ranked = rankActivePlans(readPlanDir(state.worktree));
12727
13353
  if (ranked.length === 0)
12728
13354
  return null;
12729
- const path = join(planDirOf(state.worktree), ranked[0].name);
13355
+ const path = join2(planDirOf(state.worktree), ranked[0].name);
12730
13356
  state.planPath = path;
12731
13357
  return { path, doc: ranked[0].doc };
12732
13358
  }
@@ -12735,7 +13361,7 @@ function relFrom(worktree, path) {
12735
13361
  return rel && !rel.startsWith("..") ? rel.replaceAll("\\", "/") : path;
12736
13362
  }
12737
13363
  var planWriteTool = tool({
12738
- 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.",
12739
13365
  args: {
12740
13366
  goal: tool.schema.string().describe("One-line task goal (used for the filename slug and the Goal section)"),
12741
13367
  context: tool.schema.string().describe("Context Findings: what the reconnaissance actually found, with file:line evidence references"),
@@ -12761,11 +13387,11 @@ var planWriteTool = tool({
12761
13387
  } else {
12762
13388
  const dir = planDirOf(state.worktree);
12763
13389
  mkdirSync(dir, { recursive: true });
12764
- path = join(dir, planFileName(localDate(), slugify(args.goal), readdirSync(dir).filter((f) => f.endsWith(".md"))));
13390
+ path = join2(dir, planFileName(localDate(), slugify(args.goal), readdirSync(dir).filter((f) => f.endsWith(".md"))));
12765
13391
  mode = "created";
12766
13392
  }
12767
13393
  const text = renderPlan(args, now, created);
12768
- writeFileSync(path, text);
13394
+ writeFileSync2(path, text);
12769
13395
  state.planPath = path;
12770
13396
  const doc2 = parsePlan(text);
12771
13397
  context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
@@ -12793,8 +13419,8 @@ var planTickTool = tool({
12793
13419
  if (active.doc.status !== "approved") {
12794
13420
  throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
12795
13421
  }
12796
- const next = tickTask(readFileSync(active.path, "utf8"), args.n, nowIso());
12797
- writeFileSync(active.path, next);
13422
+ const next = tickTask(readFileSync2(active.path, "utf8"), args.n, nowIso());
13423
+ writeFileSync2(active.path, next);
12798
13424
  const doc2 = parsePlan(next);
12799
13425
  const p = progressOf(doc2);
12800
13426
  context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
@@ -12821,7 +13447,7 @@ var planApproveTool = tool({
12821
13447
  throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
12822
13448
  }
12823
13449
  await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
12824
- writeFileSync(active.path, transitionStatus(readFileSync(active.path, "utf8"), "approved", nowIso()));
13450
+ writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "approved", nowIso()));
12825
13451
  context.metadata({ title: `Plan approved: ${active.doc.goal}` });
12826
13452
  return {
12827
13453
  title: "plan approved",
@@ -12854,7 +13480,7 @@ var planCloseTool = tool({
12854
13480
  Fix the implementation and retry, or revise the plan first.`);
12855
13481
  }
12856
13482
  await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
12857
- writeFileSync(active.path, transitionStatus(readFileSync(active.path, "utf8"), "done", nowIso()));
13483
+ writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "done", nowIso()));
12858
13484
  context.metadata({ title: `Plan done: ${active.doc.goal}` });
12859
13485
  return {
12860
13486
  title: "plan done",
@@ -12872,19 +13498,317 @@ var planDiscardTool = tool({
12872
13498
  const active = resolveActivePlan(state);
12873
13499
  if (!active)
12874
13500
  throw new PlanError("No plan to abandon in this workspace.");
12875
- writeFileSync(active.path, transitionStatus(readFileSync(active.path, "utf8"), "abandoned", nowIso()));
13501
+ writeFileSync2(active.path, transitionStatus(readFileSync2(active.path, "utf8"), "abandoned", nowIso()));
12876
13502
  state.planPath = undefined;
12877
13503
  context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
12878
13504
  return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
12879
13505
  }
12880
13506
  });
13507
+ function goalDirOf(worktree) {
13508
+ return join2(worktree, ".opencode", "goal");
13509
+ }
13510
+ function readGoalDir(worktree) {
13511
+ const dir = goalDirOf(worktree);
13512
+ if (!existsSync(dir))
13513
+ return [];
13514
+ return readdirSync(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync2(join2(dir, name), "utf8") }));
13515
+ }
13516
+ function resolveSessionGoal(state) {
13517
+ if (state.goalPath && existsSync(state.goalPath)) {
13518
+ const doc2 = parseGoalLoose(readFileSync2(state.goalPath, "utf8"));
13519
+ if (doc2 && !isGoalTerminal(doc2.status))
13520
+ return { path: state.goalPath, doc: doc2 };
13521
+ state.goalPath = undefined;
13522
+ }
13523
+ const live = rankLiveGoals(readGoalDir(state.worktree))[0];
13524
+ if (live) {
13525
+ const path = join2(goalDirOf(state.worktree), live.name);
13526
+ state.goalPath = path;
13527
+ return { path, doc: live.doc };
13528
+ }
13529
+ return null;
13530
+ }
13531
+ function resolveLiveGoal(state) {
13532
+ const g = resolveSessionGoal(state);
13533
+ return g && (g.doc.status === "active" || g.doc.status === "paused") ? g : null;
13534
+ }
13535
+ function coerceChecks(rows) {
13536
+ const items = [];
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
+ }
13541
+ if (typeof c.shell === "string" && c.shell.trim()) {
13542
+ items.push({ kind: "shell", cmd: c.shell.trim(), ...c.timeoutSec ? { timeoutSec: Math.min(Math.max(1, c.timeoutSec), 600) } : {} });
13543
+ } else if (typeof c.containsFile === "string" && c.containsFile.trim() && typeof c.containsText === "string" && c.containsText.trim()) {
13544
+ items.push({ kind: "contains", file: c.containsFile.trim(), text: c.containsText });
13545
+ } else {
13546
+ throw new GoalError("Each verification item needs either `shell` or both `containsFile` and `containsText`");
13547
+ }
13548
+ }
13549
+ return items;
13550
+ }
13551
+ var goalWriteTool = tool({
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.",
13553
+ args: {
13554
+ goal: tool.schema.string().describe("One-line goal statement (the semantic completion requirement)"),
13555
+ criteria: tool.schema.array(tool.schema.string()).describe("Success Criteria: numbered, verifiable outcomes"),
13556
+ checks: tool.schema.array(tool.schema.object({
13557
+ shell: tool.schema.string().optional().describe("Shell command the plugin itself executes on the host"),
13558
+ containsFile: tool.schema.string().optional().describe("File contract: workspace-relative file path"),
13559
+ containsText: tool.schema.string().optional().describe("File contract: required literal text in that file"),
13560
+ timeoutSec: tool.schema.number().int().positive().optional().describe("Shell timeout in seconds (default 120, max 600)")
13561
+ })).describe("Verification Checks: at least one; each item is a shell command or a file::text contract"),
13562
+ constraints: tool.schema.string().describe("Constraints: boundaries the work must respect"),
13563
+ nonGoals: tool.schema.array(tool.schema.string()).optional().describe("Non-Goals: explicit out-of-scope items"),
13564
+ maxTurns: tool.schema.number().int().positive().optional().describe("Turn budget (default 25, hard max 200)"),
13565
+ maxMinutes: tool.schema.number().int().positive().optional().describe("Wall-clock budget in minutes (default 60, hard max 480)"),
13566
+ arm: tool.schema.boolean().optional().describe("true = arm the loop now (user dialog); false = queue inert (/goal add)"),
13567
+ revise: tool.schema.boolean().optional().describe("true = edit the existing goal's contract instead of refusing (revision bump)")
13568
+ },
13569
+ execute: async (args, context) => {
13570
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13571
+ const now = nowIso();
13572
+ const checks3 = coerceChecks(args.checks);
13573
+ const input = {
13574
+ goal: args.goal,
13575
+ criteria: args.criteria,
13576
+ checks: checks3,
13577
+ constraints: args.constraints,
13578
+ ...args.nonGoals ? { nonGoals: args.nonGoals } : {},
13579
+ ...args.maxTurns ? { maxTurns: args.maxTurns } : {},
13580
+ ...args.maxMinutes ? { maxMinutes: args.maxMinutes } : {}
13581
+ };
13582
+ const existing = resolveSessionGoal(state);
13583
+ if (existing && args.revise !== true) {
13584
+ throw new GoalError(`This session already has a goal: ${relFrom(state.worktree, existing.path)} (status: ${existing.doc.status}). Re-run goal_write with revise=true to edit its contract, /goal add to queue a new one, or goal_discard to abandon it first.`);
13585
+ }
13586
+ if (existing) {
13587
+ const text2 = carryHistory(renderGoal({
13588
+ ...input,
13589
+ ...input.maxTurns === undefined ? { maxTurns: existing.doc.maxTurns } : {},
13590
+ ...input.maxMinutes === undefined ? { maxMinutes: existing.doc.maxMinutes } : {}
13591
+ }, {
13592
+ now,
13593
+ status: existing.doc.status,
13594
+ created: existing.doc.created,
13595
+ revision: existing.doc.revision + 1,
13596
+ ...existing.doc.session ? { session: existing.doc.session } : {},
13597
+ turnsUsed: existing.doc.turnsUsed,
13598
+ ...existing.doc.armedAt ? { armedAt: existing.doc.armedAt } : {},
13599
+ ...existing.doc.status === "paused" && existing.doc.stopReason ? { stopReason: existing.doc.stopReason } : {}
13600
+ }), existing.doc);
13601
+ atomicWrite(existing.path, text2);
13602
+ const doc3 = parseGoal(text2);
13603
+ context.metadata({ title: `Revise goal (rev ${doc3.revision}): ${doc3.goal}` });
13604
+ return {
13605
+ title: `goal revised (rev ${doc3.revision})`,
13606
+ output: [
13607
+ `Goal contract revised: ${relFrom(state.worktree, existing.path)} (revision ${doc3.revision}).`,
13608
+ `Evidence recorded under earlier revisions no longer counts. Status stays ${doc3.status}; budget stays ${doc3.turnsUsed}/${doc3.maxTurns} turns.`
13609
+ ].join(`
13610
+ `)
13611
+ };
13612
+ }
13613
+ const arm = args.arm !== false;
13614
+ if (arm) {
13615
+ const plan = resolveActivePlan(state);
13616
+ if (plan && plan.doc.status === "draft") {
13617
+ throw new GoalError(`This session has a plan in draft (${relFrom(state.worktree, plan.path)}); autonomous execution cannot arm against the planning-phase write ban. Approve the plan (plan_approve) or discard it (/plan discard) first.`);
13618
+ }
13619
+ await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
13620
+ }
13621
+ const dir = goalDirOf(state.worktree);
13622
+ mkdirSync(dir, { recursive: true });
13623
+ const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync(dir).filter((f) => f.endsWith(".md")));
13624
+ const path = join2(dir, name);
13625
+ const text = renderGoal(input, {
13626
+ now,
13627
+ status: arm ? "active" : "queued",
13628
+ ...arm ? { session: context.sessionID, armedAt: now } : {}
13629
+ });
13630
+ atomicWrite(path, text);
13631
+ state.goalPath = path;
13632
+ const doc2 = parseGoal(text);
13633
+ context.metadata({ title: `${arm ? "Arm" : "Queue"} goal: ${doc2.goal}` });
13634
+ return {
13635
+ title: `goal ${arm ? "armed" : "queued"}: ${doc2.goal}`,
13636
+ output: arm ? [
13637
+ `Goal armed: ${relFrom(state.worktree, path)} (revision 1, ${doc2.criteria.length} criteria, ${doc2.checks.length} verification items, budget ${doc2.maxTurns} turns / ${doc2.maxMinutes} min).`,
13638
+ "The loop continues this session on idle until the goal completes, pauses, or the budget runs out. Work the criteria; call goal_check whenever you believe they hold; goal_complete re-runs every check itself and asks the user. If blocked, goal_pause with the blocker."
13639
+ ].join(`
13640
+ `) : `Goal queued (inert): ${relFrom(state.worktree, path)}. It never runs until promoted via /goal next or goal_resume (user dialog).`
13641
+ };
13642
+ }
13643
+ });
13644
+ var goalCheckTool = tool({
13645
+ description: "Run the goal's verification items on the host (shell commands executed by the plugin in the workspace, file contracts re-read from disk) and append the dated, revision-stamped results to the goal's Check Log. Advisory feedback — only goal_complete has gating authority.",
13646
+ args: {
13647
+ items: tool.schema.array(tool.schema.number().int().positive()).optional().describe("Optional subset of verification item numbers; omit to run all")
13648
+ },
13649
+ execute: async (args, context) => {
13650
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13651
+ const goal = resolveSessionGoal(state);
13652
+ if (!goal || goal.doc.status !== "active" && goal.doc.status !== "paused") {
13653
+ throw new GoalError("No live goal in this workspace (active or paused). Arm one with /goal <objective> first.");
13654
+ }
13655
+ const wanted = args.items ? new Set(args.items) : null;
13656
+ const selected = goal.doc.checks.map((c, i) => ({ c, n: i + 1 })).filter((x) => !wanted || wanted.has(x.n));
13657
+ if (selected.length === 0)
13658
+ throw new GoalError("None of the given item numbers exist in this goal's Verification Checks.");
13659
+ const outcomes = await runChecks(selected.map((x) => x.c), state.worktree);
13660
+ outcomes.forEach((o, i) => {
13661
+ o.index = selected[i].n;
13662
+ });
13663
+ const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13664
+ const next = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
13665
+ atomicWrite(goal.path, next);
13666
+ const ok = outcomesAllOk(outcomes);
13667
+ context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
13668
+ return {
13669
+ title: `goal_check ${ok ? "all pass" : "failing"}`,
13670
+ output: [
13671
+ `Host verification run (revision ${goal.doc.revision}), recorded in the Check Log:`,
13672
+ formatOutcomes(outcomes),
13673
+ ok ? "All selected items pass. Proceed to goal_complete (it re-runs everything itself at the gate)." : "Failing items remain — fix the work, then re-run goal_check."
13674
+ ].join(`
13675
+ `)
13676
+ };
13677
+ }
13678
+ });
13679
+ var goalCompleteTool = tool({
13680
+ description: "Close the goal (active -> completed). Re-executes EVERY verification item itself on the host right now (fail-closed: any failing shell command, timeout, missing file, or absent contract text refuses completion) and requires a per-criterion attestation array (pass + concrete evidence). Only then does the user confirmation dialog appear — the user's Allow closes the goal.",
13681
+ args: {
13682
+ attestations: tool.schema.array(tool.schema.object({
13683
+ criterion: tool.schema.string().describe("The success criterion text, copied verbatim from the goal"),
13684
+ pass: tool.schema.boolean().describe("Whether the criterion is met"),
13685
+ evidence: tool.schema.string().describe("Concrete evidence: file:line, command output, test result")
13686
+ })).describe("One entry per success criterion of the current revision, in goal order")
13687
+ },
13688
+ execute: async (args, context) => {
13689
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13690
+ const goal = resolveSessionGoal(state);
13691
+ if (!goal || goal.doc.status !== "active") {
13692
+ throw new GoalError(`No active goal to complete (status: ${goal?.doc.status ?? "none"}). goal_resume an active loop first.`);
13693
+ }
13694
+ const outcomes = await runChecks(goal.doc.checks, state.worktree);
13695
+ const failures = outcomes.filter((o) => !o.ok);
13696
+ if (failures.length > 0) {
13697
+ const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13698
+ atomicWrite(goal.path, appendCheckLog(readFileSync2(goal.path, "utf8"), runId2, outcomes, nowIso()));
13699
+ throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
13700
+ ${formatOutcomes(failures)}
13701
+ Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
13702
+ }
13703
+ const attestationFailures = completeCheckFailures(goal.doc, args.attestations);
13704
+ if (attestationFailures.length > 0) {
13705
+ throw new GoalError(`Completion gate: self-attestation failed (revision ${goal.doc.revision}).
13706
+ - ${attestationFailures.join(`
13707
+ - `)}`);
13708
+ }
13709
+ await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
13710
+ const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
13711
+ let text = appendCheckLog(readFileSync2(goal.path, "utf8"), runId, outcomes, nowIso());
13712
+ text = transitionGoal(text, "completed", nowIso());
13713
+ atomicWrite(goal.path, text);
13714
+ context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
13715
+ return {
13716
+ title: "goal completed",
13717
+ output: `Goal completed and closed: ${relFrom(state.worktree, goal.path)}. All ${outcomes.length} verification items re-run passing at the gate; ${args.attestations.length} attestations recorded.`
13718
+ };
13719
+ }
13720
+ });
13721
+ var goalPauseTool = tool({
13722
+ description: "Pause the session's live goal (active -> paused); the continuation loop stops immediately. Always safe, no confirmation needed. Pass the blocker text when pausing because you are stuck (stop_reason blocker); omit it for a user-requested pause.",
13723
+ args: {
13724
+ blocker: tool.schema.string().optional().describe("Specific blocker that prevents progress (recorded as stop_reason blocker)")
13725
+ },
13726
+ execute: async (args, context) => {
13727
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13728
+ const goal = resolveSessionGoal(state);
13729
+ if (!goal || goal.doc.status !== "active") {
13730
+ throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
13731
+ }
13732
+ const stopReason = args.blocker ? "blocker" : "user";
13733
+ atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
13734
+ engineForgetSession(context.sessionID);
13735
+ context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
13736
+ return {
13737
+ title: `goal paused (${stopReason})`,
13738
+ output: `Goal paused: ${relFrom(state.worktree, goal.path)} (stop_reason: ${stopReason}${args.blocker ? ` — ${args.blocker}` : ""}). Continuation is stopped; /goal resume or an explicit "continue" from the user re-arms it through a confirmation dialog.`
13739
+ };
13740
+ }
13741
+ });
13742
+ var goalResumeTool = tool({
13743
+ description: "Re-arm a paused goal, or promote the oldest queued goal (/goal next). A user confirmation dialog IS the re-arm action. Optionally tops up the turn budget (hard-capped). Ownership rebinds to this session. When the goal is paused and the user explicitly says continue/resume, this is the tool to call; ordinary chat must never reactivate a goal.",
13744
+ args: {
13745
+ addTurns: tool.schema.number().int().positive().optional().describe("Extra turns to add to the budget (capped by the hard ceiling)")
13746
+ },
13747
+ execute: async (args, context) => {
13748
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13749
+ let goal = resolveSessionGoal(state);
13750
+ if (!goal || goal.doc.status === "queued") {
13751
+ const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
13752
+ if (oldest) {
13753
+ const path = join2(goalDirOf(state.worktree), oldest.name);
13754
+ state.goalPath = path;
13755
+ goal = { path, doc: oldest.doc };
13756
+ }
13757
+ }
13758
+ if (!goal || goal.doc.status !== "paused" && goal.doc.status !== "queued") {
13759
+ throw new GoalError(`No paused or queued goal to resume (status: ${goal?.doc.status ?? "none"}).`);
13760
+ }
13761
+ const promoting = goal.doc.status === "queued";
13762
+ await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
13763
+ let text = readFileSync2(goal.path, "utf8");
13764
+ if (args.addTurns)
13765
+ text = bumpBudget(text, args.addTurns, nowIso());
13766
+ text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
13767
+ atomicWrite(goal.path, text);
13768
+ state.goalPath = goal.path;
13769
+ engineForgetSession(context.sessionID);
13770
+ const doc2 = parseGoal(text);
13771
+ context.metadata({ title: `${promoting ? "Goal promoted" : "Goal resumed"}: ${doc2.goal}` });
13772
+ return {
13773
+ title: promoting ? "goal promoted" : "goal resumed",
13774
+ output: `Goal ${promoting ? "promoted from the queue and armed" : "resumed"}: ${relFrom(state.worktree, goal.path)} (budget ${doc2.turnsUsed}/${doc2.maxTurns} turns, owned by this session). The loop continues on the next idle.`
13775
+ };
13776
+ }
13777
+ });
13778
+ var goalDiscardTool = tool({
13779
+ description: "Abandon the session's goal (live or queued -> abandoned) and stop the loop. A user confirmation dialog IS the discard action — abandoning the user's objective is the user's decision. The file stays in .opencode/goal/ as history.",
13780
+ args: {
13781
+ reason: tool.schema.string().optional().describe("Short reason recorded in the reply")
13782
+ },
13783
+ execute: async (_args, context) => {
13784
+ const state = ensureSession(context.sessionID, worktreeFor(context));
13785
+ const goal = resolveSessionGoal(state);
13786
+ if (!goal)
13787
+ throw new GoalError("No goal to discard in this workspace.");
13788
+ await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
13789
+ atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "abandoned", nowIso()));
13790
+ state.goalPath = undefined;
13791
+ engineForgetSession(context.sessionID);
13792
+ context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
13793
+ return {
13794
+ title: "goal abandoned",
13795
+ output: `Goal abandoned: ${relFrom(state.worktree, goal.path)}${_args.reason ? ` (${_args.reason})` : ""}. The file remains as history; the loop is stopped.`
13796
+ };
13797
+ }
13798
+ });
12881
13799
  function forgeTools() {
12882
13800
  return {
12883
13801
  plan_write: planWriteTool,
12884
13802
  plan_tick: planTickTool,
12885
13803
  plan_approve: planApproveTool,
12886
13804
  plan_close: planCloseTool,
12887
- plan_discard: planDiscardTool
13805
+ plan_discard: planDiscardTool,
13806
+ goal_write: goalWriteTool,
13807
+ goal_check: goalCheckTool,
13808
+ goal_complete: goalCompleteTool,
13809
+ goal_pause: goalPauseTool,
13810
+ goal_resume: goalResumeTool,
13811
+ goal_discard: goalDiscardTool
12888
13812
  };
12889
13813
  }
12890
13814
  var hostWorktree = "";
@@ -12898,10 +13822,207 @@ function stateForBan(sessionID) {
12898
13822
  return null;
12899
13823
  return ensureSession(sessionID, hostWorktree);
12900
13824
  }
13825
+ var IDLE_DEBOUNCE_MS = Number(process.env.FORGE_GOAL_DEBOUNCE_MS ?? 2000);
13826
+ var idleTimers = new Map;
13827
+ var continuationInFlight = new Set;
13828
+ var transportFails = new Map;
13829
+ var noProgressStreak = new Map;
13830
+ var turnActivity = new Map;
13831
+ var pendingContinuationTurn = new Set;
13832
+ function goalProbe(line) {
13833
+ if (process.env.FORGE_GOAL_PROBE) {
13834
+ try {
13835
+ appendFileSync(join2(tmpdir(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
13836
+ `);
13837
+ } catch {}
13838
+ }
13839
+ }
13840
+ function engineForgetSession(sessionID) {
13841
+ const t = idleTimers.get(sessionID);
13842
+ if (t)
13843
+ clearTimeout(t);
13844
+ idleTimers.delete(sessionID);
13845
+ continuationInFlight.delete(sessionID);
13846
+ transportFails.delete(sessionID);
13847
+ noProgressStreak.delete(sessionID);
13848
+ turnActivity.delete(sessionID);
13849
+ pendingContinuationTurn.delete(sessionID);
13850
+ }
13851
+ function engineForgetAll() {
13852
+ for (const id of [...idleTimers.keys()])
13853
+ engineForgetSession(id);
13854
+ }
13855
+ function goalBriefText(state, goal) {
13856
+ const d = goal.doc;
13857
+ return [
13858
+ `[forge:goal-continue] Continue the active goal (revision ${d.revision}): ${d.goal}`,
13859
+ `Success criteria:
13860
+ ${d.criteria.map((c, i) => `${i + 1}. ${c}`).join(`
13861
+ `)}`,
13862
+ `Verification items (the plugin runs these itself; never fake their output):
13863
+ ${d.checks.map((c, i) => `${i + 1}. ${c.kind === "shell" ? `shell \`${c.cmd}\`` : `contains \`${c.file}\` :: \`${c.text}\``}`).join(`
13864
+ `)}`,
13865
+ d.constraints.trim() ? `Constraints: ${d.constraints.trim()}` : "",
13866
+ `Budget: ${d.turnsUsed}/${d.maxTurns} turns used. Work the criteria now; call goal_check when you believe they hold, then goal_complete (it re-runs every check itself and asks the user). If genuinely blocked, call goal_pause with the blocker — do not spin.`,
13867
+ `Goal file: ${relFrom(state.worktree, goal.path)}`
13868
+ ].filter((s) => s.length > 0).join(`
13869
+ `);
13870
+ }
13871
+ function wrapupBriefText(goal, reason) {
13872
+ return [
13873
+ `[forge:goal-wrapup] The goal loop is stopping (stop_reason: ${reason}); the goal is now PAUSED.`,
13874
+ `Goal (revision ${goal.doc.revision}): ${goal.doc.goal}`,
13875
+ "Produce a concise handoff summary and nothing else: what is done, what remains, the single next concrete step. Do not continue working the goal in this turn."
13876
+ ].join(`
13877
+ `);
13878
+ }
13879
+ async function autoPauseGoal(client, state, goal, reason, wrapup) {
13880
+ goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
13881
+ atomicWrite(goal.path, transitionGoal(readFileSync2(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
13882
+ engineForgetSession(state.sessionID);
13883
+ if (wrapup && goal.doc.session) {
13884
+ try {
13885
+ await client.session.prompt({
13886
+ path: { id: goal.doc.session },
13887
+ body: { parts: [{ type: "text", text: wrapupBriefText(goal, reason) }] }
13888
+ });
13889
+ } catch (err) {
13890
+ goalProbe(`wrapup delivery failed session=${state.sessionID} err=${String(err)}`);
13891
+ }
13892
+ }
13893
+ }
13894
+ async function continueIfEligible(client, sessionID) {
13895
+ if (forgeDisabled) {
13896
+ goalProbe(`skip: forge disabled session=${sessionID}`);
13897
+ return;
13898
+ }
13899
+ if (continuationInFlight.has(sessionID))
13900
+ return;
13901
+ continuationInFlight.add(sessionID);
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;
13921
+ if (pendingContinuationTurn.has(sessionID)) {
13922
+ accountedContinuationTurn = true;
13923
+ pendingContinuationTurn.delete(sessionID);
13924
+ const act = turnActivity.get(sessionID);
13925
+ turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
13926
+ turnActivity.set(sessionID, { writes: 0, checks: 0 });
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)}`);
13936
+ }
13937
+ }
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) {
13950
+ noProgressStreak.delete(sessionID);
13951
+ } else {
13952
+ const n = (noProgressStreak.get(sessionID) ?? 0) + 1;
13953
+ noProgressStreak.set(sessionID, n);
13954
+ goalProbe(`no-progress session=${sessionID} streak=${n}`);
13955
+ if (n >= NO_PROGRESS_LIMIT) {
13956
+ await autoPauseGoal(client, state, goal, "no-progress", true);
13957
+ return;
13958
+ }
13959
+ }
13960
+ }
13961
+ const plan = resolveActivePlan(state);
13962
+ if (plan && plan.doc.status === "draft") {
13963
+ await autoPauseGoal(client, state, goal, "draft-conflict", false);
13964
+ return;
13965
+ }
13966
+ const bs = budgetState(goal.doc);
13967
+ if (bs !== "ok") {
13968
+ await autoPauseGoal(client, state, goal, bs, true);
13969
+ return;
13970
+ }
13971
+ if (typeof client.session.status === "function") {
13972
+ try {
13973
+ const st = await client.session.status({ path: { id: sessionID } });
13974
+ const t = st?.[sessionID]?.type;
13975
+ if (t && t !== "idle") {
13976
+ goalProbe(`skip: session busy again session=${sessionID} status=${t}`);
13977
+ return;
13978
+ }
13979
+ } catch (err) {
13980
+ goalProbe(`skip: status check failed session=${sessionID} err=${String(err)}`);
13981
+ return;
13982
+ }
13983
+ }
13984
+ try {
13985
+ await client.session.prompt({
13986
+ path: { id: sessionID },
13987
+ body: { parts: [{ type: "text", text: goalBriefText(state, goal) }] }
13988
+ });
13989
+ transportFails.delete(sessionID);
13990
+ pendingContinuationTurn.add(sessionID);
13991
+ turnActivity.set(sessionID, { writes: 0, checks: 0 });
13992
+ state.goalPath = goal.path;
13993
+ atomicWrite(goal.path, incTurns(readFileSync2(goal.path, "utf8"), nowIso()));
13994
+ goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
13995
+ } catch (err) {
13996
+ const n = (transportFails.get(sessionID) ?? 0) + 1;
13997
+ transportFails.set(sessionID, n);
13998
+ goalProbe(`transport failure session=${sessionID} count=${n} err=${String(err)}`);
13999
+ if (n >= TRANSPORT_FAILURE_LIMIT) {
14000
+ await autoPauseGoal(client, state, goal, "transport-failures", false);
14001
+ }
14002
+ }
14003
+ } catch (err) {
14004
+ goalProbe(`continuation aborted session=${sessionID} err=${String(err)}`);
14005
+ } finally {
14006
+ continuationInFlight.delete(sessionID);
14007
+ }
14008
+ }
14009
+ function scheduleIdleContinuation(client, sessionID) {
14010
+ if (forgeDisabled)
14011
+ return;
14012
+ const existing = idleTimers.get(sessionID);
14013
+ if (existing)
14014
+ clearTimeout(existing);
14015
+ idleTimers.set(sessionID, setTimeout(() => {
14016
+ idleTimers.delete(sessionID);
14017
+ continueIfEligible(client, sessionID);
14018
+ }, IDLE_DEBOUNCE_MS));
14019
+ }
12901
14020
  var server = async (input) => {
12902
14021
  hostWorktree = effectiveWorktree(input.worktree, input.directory) || input.directory || "";
14022
+ const client = input.client;
12903
14023
  return {
12904
14024
  dispose: async () => {
14025
+ engineForgetAll();
12905
14026
  sessions.clear();
12906
14027
  },
12907
14028
  config: async (cfg) => {
@@ -12920,20 +14041,18 @@ var server = async (input) => {
12920
14041
  mode: existing?.mode ?? "primary",
12921
14042
  prompt: existing?.prompt ?? FORGE_PROMPT
12922
14043
  };
12923
- const cfgAny = cfg;
12924
- cfgAny.skills ??= {};
12925
- cfgAny.skills.paths ??= [];
12926
- if (!cfgAny.skills.paths.includes(dataDir)) {
12927
- cfgAny.skills.paths.push(dataDir);
12928
- }
12929
14044
  cfg.command ??= {};
12930
14045
  cfg.command["plan"] ??= {
12931
14046
  template: PLAN_COMMAND_TEMPLATE,
12932
14047
  description: "forge plan harness: no argument lists in-progress plans; resume continues the latest; discard abandons it; a goal enters planning discipline"
12933
14048
  };
14049
+ cfg.command["goal"] ??= {
14050
+ template: GOAL_COMMAND_TEMPLATE,
14051
+ description: "forge goal harness (autonomous, host-verified): no argument lists goals; pause/resume/discard drive the loop; add queues; next promotes; an objective drafts a contract and arms it (--check/--contains markers become verification items)"
14052
+ };
12934
14053
  const perm = cfg.permission;
12935
14054
  const permSection = perm ?? (cfg.permission = {});
12936
- for (const gateKey of ["plan_approve", "plan_close"]) {
14055
+ for (const gateKey of ["plan_approve", "plan_close", "goal_write", "goal_complete", "goal_resume", "goal_discard"]) {
12937
14056
  if (permSection[gateKey] !== "deny")
12938
14057
  permSection[gateKey] = "ask";
12939
14058
  }
@@ -12944,7 +14063,7 @@ var server = async (input) => {
12944
14063
  "tool.execute.before": async (input2) => {
12945
14064
  if (process.env.FORGE_PERM_PROBE) {
12946
14065
  try {
12947
- appendFileSync(join(tmpdir(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
14066
+ appendFileSync(join2(tmpdir(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
12948
14067
  `);
12949
14068
  } catch {}
12950
14069
  }
@@ -12962,11 +14081,11 @@ var server = async (input) => {
12962
14081
  const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
12963
14082
  if (process.env.FORGE_PERM_PROBE) {
12964
14083
  try {
12965
- appendFileSync(join(tmpdir(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
14084
+ appendFileSync(join2(tmpdir(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
12966
14085
  `);
12967
14086
  } catch {}
12968
14087
  }
12969
- if (name === "plan_approve" || name === "plan_close") {
14088
+ if (name === "plan_approve" || name === "plan_close" || name === "goal_write" || name === "goal_complete" || name === "goal_resume" || name === "goal_discard") {
12970
14089
  if (output.status !== "deny")
12971
14090
  output.status = "ask";
12972
14091
  return;
@@ -12987,6 +14106,24 @@ var server = async (input) => {
12987
14106
  ensureSession(info.id, worktree);
12988
14107
  }
12989
14108
  }
14109
+ if (event.type === "session.idle") {
14110
+ const sessionID = event.properties.sessionID;
14111
+ if (typeof sessionID === "string" && sessionID) {
14112
+ goalProbe(`idle event session=${sessionID}`);
14113
+ scheduleIdleContinuation(client, sessionID);
14114
+ }
14115
+ }
14116
+ },
14117
+ "tool.execute.after": async (input2) => {
14118
+ if (typeof input2.tool !== "string")
14119
+ return;
14120
+ const act = turnActivity.get(input2.sessionID);
14121
+ if (!act)
14122
+ return;
14123
+ if (isWriteTool(input2.tool) || input2.tool === "plan_tick")
14124
+ act.writes++;
14125
+ if (input2.tool === "goal_check")
14126
+ act.checks++;
12990
14127
  },
12991
14128
  "experimental.chat.system.transform": async (input2, output) => {
12992
14129
  if (!input2.sessionID)
@@ -12995,12 +14132,50 @@ var server = async (input) => {
12995
14132
  if (!state)
12996
14133
  return;
12997
14134
  const active = resolveActivePlan(state);
12998
- if (!active)
14135
+ if (active) {
14136
+ const p = progressOf(active.doc);
14137
+ const rel = relFrom(state.worktree, active.path);
14138
+ 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";
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.`);
14140
+ }
14141
+ if (forgeDisabled)
14142
+ return;
14143
+ const goal = resolveLiveGoal(state);
14144
+ if (goal) {
14145
+ const rel = relFrom(state.worktree, goal.path);
14146
+ const d = goal.doc;
14147
+ const rule = d.status === "active" ? "continue working the success criteria; call goal_check for host-run feedback and goal_complete at the gate (it re-runs every check itself); if genuinely blocked call goal_pause with the blocker" : "the goal is paused; if the user clearly asks to continue (e.g. 'continue'/'resume'), call goal_resume — the user confirms in a dialog; ordinary chat never reactivates it";
14148
+ output.system.push(`[forge:goal-notice] This session is bound to a goal: ${rel} (status: ${d.status}${d.stopReason ? `, stopped: ${d.stopReason}` : ""}, revision ${d.revision}, ${d.turnsUsed}/${d.maxTurns} turns used). Rule: ${rule}. If the user has not mentioned the goal yet, relay its path and status to them in one short line at the start of your reply.`);
14149
+ }
14150
+ },
14151
+ "experimental.session.compacting": async (input2, output) => {
14152
+ if (forgeDisabled)
14153
+ return;
14154
+ if (!input2.sessionID)
14155
+ return;
14156
+ const state = sessions.get(input2.sessionID);
14157
+ if (!state)
14158
+ return;
14159
+ const goal = resolveLiveGoal(state);
14160
+ if (!goal)
12999
14161
  return;
13000
- const p = progressOf(active.doc);
13001
- const rel = relFrom(state.worktree, active.path);
13002
- 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";
13003
- 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.`);
14162
+ const d = goal.doc;
14163
+ output.context.push(`[forge:goal-brief] A live goal governs this session: ${relFrom(state.worktree, goal.path)} (status: ${d.status}, revision ${d.revision}, ${d.turnsUsed}/${d.maxTurns} turns used). Goal: ${d.goal}. Success criteria:
14164
+ ${d.criteria.map((c, i) => `${i + 1}. ${c}`).join(`
14165
+ `)}
14166
+ Post-compaction turns must keep following this goal and its constraints.`);
14167
+ },
14168
+ "experimental.compaction.autocontinue": async (input2, output) => {
14169
+ if (forgeDisabled)
14170
+ return;
14171
+ if (!input2.sessionID)
14172
+ return;
14173
+ const state = sessions.get(input2.sessionID);
14174
+ if (!state)
14175
+ return;
14176
+ const goal = resolveLiveGoal(state);
14177
+ if (goal)
14178
+ output.enabled = false;
13004
14179
  }
13005
14180
  };
13006
14181
  };
@@ -13018,13 +14193,6 @@ async function v2Setup(ctx) {
13018
14193
  });
13019
14194
  });
13020
14195
  }
13021
- if (typeof ctx.skill?.transform === "function") {
13022
- await ctx.skill.transform(async (draft) => {
13023
- if (typeof draft.source !== "function")
13024
- return;
13025
- draft.source({ type: "directory", path: dataDir });
13026
- });
13027
- }
13028
14196
  }
13029
14197
  var plugin_default = { id: "forge", server, setup: v2Setup };
13030
14198
  export {