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