@cleocode/goal 0.9.2 → 0.9.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4 — 2026-10-28
4
+
5
+ - **Fixed — a contract locked before a validator tightening no longer bricks its run (D10).** A run whose locked generation carries a key the current validator rejects (the reported case: a free-text `budget.rationale` locked under 0.9.2) used to be unresumable: resume called it read-only, `goal_propose_amendment` claimed the disk differed, and `goal_recover_divergence` correctly said nothing differed. The locked bytes are now read leniently **only** to form the prior side of a repair amendment, the repair can be staged and owner-reviewed, and the resume refusal names the failing key and the exact `goal_propose_amendment` call instead of a generic read-only message. Strict validation of new and repaired contracts is unchanged.
6
+ - **Fixed — divergence detection reaches recovery even when the disk copy is invalid.** Differing bytes are a divergence; recovery now explains an invalid edit rather than reporting "nothing to recover" from a path that also refuses to stage.
7
+
8
+ ## 0.9.3 — 2026-10-28
9
+
10
+ - **Fixed — uncommitted work in a linked worktree now counts as progress (T116).** The fingerprint already covered a linked worktree's commits; it now also hashes each linked worktree's staged/unstaged diff and untracked sizes/mtimes. A run that works only in `.worktrees/<slug>` no longer records `changed:false` and auto-pauses as stalled. Measured cost on this repo with 6 worktrees: ~200 ms per fingerprint.
11
+ - **Renamed to open-goal.** The GitHub repo, package metadata, README/AGENTS instructions, plugin id and RPC id are now `open-goal` (was `opencode-goal`). The npm package name stays `@cleocode/goal`; the goal-format schema ids (`opencode-goal/provenance/v1`, `opencode-goal/handoff/v1`) are unchanged on purpose so existing evidence still verifies. Install identity matching still accepts the old git spec and plugin id, so upgrading replaces the old entry instead of running both. GitHub redirects the old repo URL; new links use `kryptobaseddev/open-goal`.
12
+ - **Fixed — an unknown `budget` key no longer fails open (D9).** The parser recognised only `turns`, `wall`, `tokens` and `cost_usd` and silently dropped anything else, so a contract written with `max_turns`/`max_tokens`/`max_cost_usd` ran with no budget at all and every "raise the budget" amendment was a no-op. Unknown keys are now a validation error naming the allowed keys, and a non-mapping `budget` is an error too.
13
+ - **Fixed — the turn backstop is no longer reported as an exhausted budget (D9).** A run with no `budget.turns` still stops at the 200-turn backstop, but it now stops with its own `backstop_limited` status, decision kind and ledger event, and every surface (banner, action-required line, quick actions, post-goal summary) names the real remedy: set `budget.turns`. Previously the status said `budget_limited` while the header said "no budget set", which cost one live goal five owner round-trips.
14
+
3
15
  ## 0.9.2 — 2026-10-17
4
16
 
5
17
  - **First installable release through Trusted Publishing (OIDC).** `release.yml` rebuilt the Node bundle first and then published `@cleocode/goal@0.9.2` with a signed provenance statement. `0.9.0` was a manual publish whose registry package metadata was not readable/installable; `0.9.1` was tagged but its committed bundle was stale and the workflow refused it. **No engine behaviour changed.**
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # opencode-goal
1
+ # open-goal (CLEO Goal plugin)
2
2
 
3
3
  Persistent, host-verified goal mode for [OpenCode 2](https://opencode.ai). Describe what you want, let the bundled **write-goal** interview turn it into a structured contract, and the plugin keeps the session working until the host — not the model — proves every criterion.
4
4
 
5
- > **Status: released (`v0.5.0`).** Engine, verification and TUI are built and tested end to end against OpenCode 2.0.22 with a scripted model, and each release is proven from its own installed git tag. CLEO integrates as an optional first-class citizen (`/goal cleo`). See [docs/HANDOFF.md](docs/HANDOFF.md).
5
+ > **Status: released (`0.9.4` on npm as `@cleocode/goal`).** Engine, verification and TUI are built and tested end to end against OpenCode 2.0.22 with a scripted model, and each release is proven from its own installed tag. CLEO integrates as an optional first-class citizen (`/goal cleo`). See [docs/HANDOFF.md](docs/HANDOFF.md).
6
+ >
7
+ > **Name:** this project is **open-goal** (repo `kryptobaseddev/open-goal`). It was called `opencode-goal` until 2026-10; the GitHub repo redirects the old URL, and the agent mailbox is **`open-goal-lead@macbook`**. Use `open-goal` in new code, docs, mail and instructions — not `opencode-goal`.
6
8
 
7
9
  ## Why another goal plugin
8
10
 
@@ -36,10 +38,10 @@ Or install it for every project through OpenCode itself (this writes the user-gl
36
38
  ```bash
37
39
  opencode plugin add "@cleocode/goal@<version>"
38
40
  opencode reload
39
- opencode plugin list # opencode-goal should be listed
41
+ opencode plugin list # open-goal should be listed
40
42
  ```
41
43
 
42
- Pin a version. A git tag still works as a spec: `github:kryptobaseddev/opencode-goal#v<version>`.
44
+ Pin a version. A git tag still works as a spec: `github:kryptobaseddev/open-goal#v<version>`.
43
45
 
44
46
  ### The `cleo-goal` bin
45
47
 
@@ -128,7 +130,7 @@ While a goal runs the model cannot edit the contract, files under `.opencode/goa
128
130
  Use the object form in your OpenCode config to change defaults:
129
131
 
130
132
  ```jsonc
131
- { "plugins": [{ "package": "github:kryptobaseddev/opencode-goal#v0.5.0", "options": { "stallTurns": 4, "cooldownMs": 3000 } }] }
133
+ { "plugins": [{ "package": "github:kryptobaseddev/open-goal#v0.5.0", "options": { "stallTurns": 4, "cooldownMs": 3000 } }] }
132
134
  ```
133
135
 
134
136
  `backstopTurns` (200, only when the contract has no turn budget) · `cooldownMs` (1500) · `stallTurns` (3) · `recoveryAt` (2) · `blockerRepeats` (3) · `maxPromptFailures` (3) · `verifierAgent` ("goal-verifier") · `verifierTimeoutMs` (240000) · `liveChecks` (true) · `launchApprovalMs` (600000).
package/dist/cli.js CHANGED
@@ -6910,7 +6910,7 @@ import { readFileSync as readFileSync4 } from "node:fs";
6910
6910
  // package.json
6911
6911
  var package_default = {
6912
6912
  name: "@cleocode/goal",
6913
- version: "0.9.2",
6913
+ version: "0.9.4",
6914
6914
  description: "CLEO Goal: persistent, host-verified goal mode for OpenCode 2 (and any harness through cleo-goal tick), with a structured goal contract and a live TUI.",
6915
6915
  type: "module",
6916
6916
  license: "MIT",
@@ -6966,9 +6966,9 @@ var package_default = {
6966
6966
  ],
6967
6967
  repository: {
6968
6968
  type: "git",
6969
- url: "git+https://github.com/kryptobaseddev/opencode-goal.git"
6969
+ url: "git+https://github.com/kryptobaseddev/open-goal.git"
6970
6970
  },
6971
- homepage: "https://github.com/kryptobaseddev/opencode-goal#readme",
6971
+ homepage: "https://github.com/kryptobaseddev/open-goal#readme",
6972
6972
  keywords: [
6973
6973
  "opencode",
6974
6974
  "opencode-plugin",
@@ -6986,7 +6986,7 @@ var package_default = {
6986
6986
  yaml: "2.8.1"
6987
6987
  },
6988
6988
  bugs: {
6989
- url: "https://github.com/kryptobaseddev/opencode-goal/issues"
6989
+ url: "https://github.com/kryptobaseddev/open-goal/issues"
6990
6990
  }
6991
6991
  };
6992
6992
 
@@ -6996,7 +6996,7 @@ import { homedir } from "node:os";
6996
6996
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6997
6997
  var INSTALL_SCHEMA_VERSION = 1;
6998
6998
  var PACKAGE_NAME = "@cleocode/goal";
6999
- var IDENTITIES = [PACKAGE_NAME, "github:kryptobaseddev/opencode-goal", "opencode-goal"];
6999
+ var IDENTITIES = [PACKAGE_NAME, "github:kryptobaseddev/open-goal", "github:kryptobaseddev/opencode-goal", "open-goal", "opencode-goal"];
7000
7000
  var PROJECT_CONFIG = join(".opencode", "opencode.json");
7001
7001
 
7002
7002
  class InstallError extends Error {
@@ -7408,7 +7408,17 @@ function parseContract(text, options = {}) {
7408
7408
  }
7409
7409
  if (hasCycle(plan))
7410
7410
  err("plan", "step dependencies form a cycle");
7411
+ const BUDGET_KEYS = ["turns", "wall", "tokens", "cost_usd"];
7412
+ if (raw.budget !== undefined && !isRecord(raw.budget))
7413
+ err("budget", `budget must be a mapping with ${BUDGET_KEYS.join(", ")}`);
7411
7414
  const budgetRaw = isRecord(raw.budget) ? raw.budget : {};
7415
+ for (const key of Object.keys(budgetRaw)) {
7416
+ if (BUDGET_KEYS.includes(key))
7417
+ continue;
7418
+ if (options.tolerateUnknownBudgetKeys)
7419
+ continue;
7420
+ err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`);
7421
+ }
7412
7422
  const budget = {};
7413
7423
  if (budgetRaw.turns !== undefined) {
7414
7424
  const turns = Number(budgetRaw.turns);
@@ -7718,15 +7728,11 @@ function git(cwd, args) {
7718
7728
  return { ok: result.status === 0, out: result.stdout ?? "" };
7719
7729
  }
7720
7730
  var isRepo = (cwd) => git(cwd, ["rev-parse", "--is-inside-work-tree"]).out.trim() === "true";
7721
- function worktreeSignature(cwd) {
7722
- return git(cwd, ["worktree", "list", "--porcelain"]).out.split(`
7723
- `).map((line) => line.trim()).filter(Boolean).join(`
7724
- `);
7725
- }
7726
- function fingerprint(cwd) {
7727
- if (!isRepo(cwd))
7728
- return;
7729
- const head = git(cwd, ["rev-parse", "HEAD"]).out;
7731
+ var topLevel = (cwd) => {
7732
+ const r = git(cwd, ["rev-parse", "--show-toplevel"]);
7733
+ return r.ok ? r.out.trim() : undefined;
7734
+ };
7735
+ function treeState(cwd) {
7730
7736
  const diff = git(cwd, ["diff", "HEAD", "--no-color", "--", ".", GOAL_STATE]).out;
7731
7737
  const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ".", GOAL_STATE]).out;
7732
7738
  const contents = untracked.split(`
@@ -7738,8 +7744,31 @@ function fingerprint(cwd) {
7738
7744
  return file;
7739
7745
  }
7740
7746
  });
7741
- return createHash2("sha256").update(head).update(diff).update(contents.join(`
7742
- `)).update(worktreeSignature(cwd)).digest("hex").slice(0, 20);
7747
+ return createHash2("sha256").update(diff).update(contents.join(`
7748
+ `)).digest("hex").slice(0, 20);
7749
+ }
7750
+ function worktreeSignature(cwd) {
7751
+ const self = topLevel(cwd) ?? cwd;
7752
+ const blocks = git(cwd, ["worktree", "list", "--porcelain"]).out.split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean);
7753
+ return blocks.map((block) => {
7754
+ const lines = block.split(`
7755
+ `).map((line) => line.trim()).filter(Boolean);
7756
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
7757
+ if (!path)
7758
+ return block;
7759
+ if (path === self)
7760
+ return block;
7761
+ return `${block}
7762
+ ${treeState(path)}`;
7763
+ }).join(`
7764
+
7765
+ `);
7766
+ }
7767
+ function fingerprint(cwd) {
7768
+ if (!isRepo(cwd))
7769
+ return;
7770
+ const head = git(cwd, ["rev-parse", "HEAD"]).out;
7771
+ return createHash2("sha256").update(head).update(treeState(cwd)).update(worktreeSignature(cwd)).digest("hex").slice(0, 20);
7743
7772
  }
7744
7773
  function changedSince(cwd, base) {
7745
7774
  if (!isRepo(cwd))
@@ -8118,7 +8147,9 @@ function decide(state, signals, limits) {
8118
8147
  if (signals.budgetRatio >= 1 || signals.backstop) {
8119
8148
  if (!state.wrapup)
8120
8149
  return { kind: "admit", tail: "wrapup" };
8121
- return { kind: "stop", status: "budget_limited", code: "budget", reason: signals.backstop ? `backstop of ${limits.backstopTurns} turns reached` : "budget used" };
8150
+ if (signals.backstop)
8151
+ return { kind: "stop", status: "backstop_limited", code: "backstop", reason: `backstop of ${limits.backstopTurns} turns reached; no contract budget set — set budget.turns to control the run` };
8152
+ return { kind: "stop", status: "budget_limited", code: "budget", reason: "budget used" };
8122
8153
  }
8123
8154
  if (state.counters.noProgress >= limits.stallTurns) {
8124
8155
  if (signals.activeStalled && signals.onStall === "continue")
package/dist/tui.js CHANGED
@@ -16,7 +16,7 @@ import { createStore, reconcile } from "solid-js/store";
16
16
  var anyObject = { type: "object", additionalProperties: true };
17
17
  var targetObject = { type: "object", properties: { key: { type: "string", minLength: 1, maxLength: 256 }, lock: { type: "string", minLength: 64, maxLength: 64 }, runId: { type: "string", minLength: 1, maxLength: 128 }, sessionID: { type: "string", minLength: 1, maxLength: 256 } }, required: ["key", "lock"], additionalProperties: false };
18
18
  var GoalRpc = {
19
- id: "opencode-goal",
19
+ id: "open-goal",
20
20
  methods: {
21
21
  provenance: {
22
22
  input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 } }, additionalProperties: false },
@@ -68,7 +68,7 @@ var GoalRpc = {
68
68
  properties: {
69
69
  sessionID: { type: "string" },
70
70
  slug: { type: "string" },
71
- kind: { type: "string", enum: ["needs_review", "paused-after-verdict", "blocked", "budget_limited", "amend-proposed", "supersede-ack", "complete", "start-picker", "owner-operation"] },
71
+ kind: { type: "string", enum: ["needs_review", "paused-after-verdict", "blocked", "budget_limited", "backstop_limited", "amend-proposed", "supersede-ack", "complete", "start-picker", "owner-operation"] },
72
72
  title: { type: "string" },
73
73
  message: { type: "string" },
74
74
  question: { type: "string" },
@@ -107,6 +107,7 @@ var STATUS = {
107
107
  blocked: { icon: "\u26D4", label: "blocked", tone: "error" },
108
108
  needs_review: { icon: "\u2691", label: "needs review", tone: "warning" },
109
109
  budget_limited: { icon: "\u2298", label: "budget used", tone: "warning" },
110
+ backstop_limited: { icon: "\u23F1", label: "turn backstop", tone: "warning" },
110
111
  complete: { icon: "\u2713", label: "complete", tone: "success" },
111
112
  failed: { icon: "\u2717", label: "failed", tone: "error" },
112
113
  aborted: { icon: "\u25A0", label: "aborted", tone: "muted" },
@@ -185,6 +186,8 @@ function bannerText(view) {
185
186
  return { text: `\u25CE ${st.icon} Goal needs review \u2014 ${view.reason ?? ""} \xB7 /goal approve <id> or ${RESUME}`, tone: "warning" };
186
187
  case "budget_limited":
187
188
  return { text: `\u25CE ${st.icon} Goal stopped at its budget \u2014 raise budget in goal.yaml and /goal start, or /goal abort`, tone: "warning" };
189
+ case "backstop_limited":
190
+ return { text: `\u25CE ${st.icon} Goal stopped at the turn backstop \u2014 no budget was set \xB7 set budget.turns in goal.yaml and amend, or /goal abort`, tone: "warning" };
188
191
  default:
189
192
  return;
190
193
  }
@@ -256,6 +259,7 @@ var STATUS2 = {
256
259
  blocked: { icon: "\u26D4", label: "blocked", tone: "error" },
257
260
  needs_review: { icon: "\u2691", label: "needs review", tone: "warning" },
258
261
  budget_limited: { icon: "\u2298", label: "budget used", tone: "warning" },
262
+ backstop_limited: { icon: "\u23F1", label: "turn backstop", tone: "warning" },
259
263
  complete: { icon: "\u2713", label: "complete", tone: "success" },
260
264
  failed: { icon: "\u2717", label: "failed", tone: "error" },
261
265
  aborted: { icon: "\u25A0", label: "aborted", tone: "muted" },
@@ -325,7 +329,7 @@ function cardAffordanceTab(view, now, width = 40) {
325
329
  const truncated = new Set(cardCompactLines(view, now, width).filter((l) => l.tab && l.text.includes("\u2026")).map((l) => l.tab));
326
330
  return order.find((t) => truncated.has(t));
327
331
  }
328
- var humanKinds = new Set(["verdict", "approve", "reject", "amend-proposed", "amended", "blocked", "complete", "summary", "flag", "attached", "paused", "resumed", "aborted", "budget_limited", "superseded-by", "approve ", "reject "]);
332
+ var humanKinds = new Set(["verdict", "approve", "reject", "amend-proposed", "amended", "blocked", "complete", "summary", "flag", "attached", "paused", "resumed", "aborted", "budget_limited", "backstop_limited", "superseded-by", "approve ", "reject "]);
329
333
  function quickActRows(view) {
330
334
  const rows = [];
331
335
  if (["running", "waiting", "verifying"].includes(view.status)) {
@@ -344,6 +348,8 @@ function quickActRows(view) {
344
348
  rows.push({ label: "Verify now", description: "run the host checks + verifier immediately", act: "verify" });
345
349
  } else if (view.status === "budget_limited") {
346
350
  rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" });
351
+ } else if (view.status === "backstop_limited") {
352
+ rows.push({ label: "Abort the goal", description: "stop at the turn backstop", act: "abort" });
347
353
  } else if (isTerminalStatus(view.status)) {
348
354
  rows.push({ label: "Archive the goal", description: "demote to goals-archive/ (history intact)", act: "archive" });
349
355
  rows.push({ label: "Start the next goal", description: "open the write-goal interview", act: "start-next" });
@@ -371,6 +377,9 @@ function decisionRows(view) {
371
377
  } else if (view.status === "budget_limited") {
372
378
  rows.push({ label: "Raise budget and amend", description: "Describe the budget change in the Composer; review the agent's staged diff" });
373
379
  rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" });
380
+ } else if (view.status === "backstop_limited") {
381
+ rows.push({ label: "Set budget.turns and amend", description: "The run had no budget; name a turn limit in the Composer and review the staged diff" });
382
+ rows.push({ label: "Abort the goal", description: "stop at the turn backstop", act: "abort" });
374
383
  }
375
384
  }
376
385
  if (view.status === "paused")
@@ -596,7 +605,7 @@ function pointerSelect(control, event, rowHeight) {
596
605
  var SIDEBAR_WIDTH = 40;
597
606
  var PANEL_NAME = "goal-dashboard";
598
607
  var tui_default = {
599
- id: "opencode-goal",
608
+ id: "open-goal",
600
609
  async setup(context) {
601
610
  const rpc = context.client.rpc(GoalRpc);
602
611
  const [views, setViews] = createStore({});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/goal",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "CLEO Goal: persistent, host-verified goal mode for OpenCode 2 (and any harness through cleo-goal tick), with a structured goal contract and a live TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,9 +56,9 @@
56
56
  ],
57
57
  "repository": {
58
58
  "type": "git",
59
- "url": "git+https://github.com/kryptobaseddev/opencode-goal.git"
59
+ "url": "git+https://github.com/kryptobaseddev/open-goal.git"
60
60
  },
61
- "homepage": "https://github.com/kryptobaseddev/opencode-goal#readme",
61
+ "homepage": "https://github.com/kryptobaseddev/open-goal#readme",
62
62
  "keywords": [
63
63
  "opencode",
64
64
  "opencode-plugin",
@@ -76,6 +76,6 @@
76
76
  "yaml": "2.8.1"
77
77
  },
78
78
  "bugs": {
79
- "url": "https://github.com/kryptobaseddev/opencode-goal/issues"
79
+ "url": "https://github.com/kryptobaseddev/open-goal/issues"
80
80
  }
81
81
  }
@@ -51,7 +51,7 @@ Map the decisions as a tree. Each round, ask the **frontier** — every decision
51
51
  Log every question and answer to `decisions.jsonl` (one JSON object per line: `{"t","q","options","a","by":"owner"|"assumed"}`). Anything you decide without asking becomes an `assumptions` entry with a rationale and `reversible` — never a silent default.
52
52
 
53
53
  ### 4 · Draft `goal.yaml`
54
- Start from `assets/goal.template.yaml` (or the shape below). Then validate: call `goal_validate({slug})` when the opencode-goal plugin is present and fix every error; otherwise self-check against the rules below. Repeat until clean.
54
+ Start from `assets/goal.template.yaml` (or the shape below). Then validate: call `goal_validate({slug})` when the open-goal plugin is present and fix every error; otherwise self-check against the rules below. Repeat until clean.
55
55
 
56
56
  ### 5 · Review
57
57
  Show the owner the contract compactly: outcome, a table of criteria (id · statement · check), invariants, protected paths, non-goals, budget. Ask: **Approve** (Recommended) / Revise criteria / Revise scope / Save as draft.
@@ -181,7 +181,7 @@ stop:
181
181
 
182
182
  Check kinds: `command` (`run`, `expect: {exit | stdout_contains | stdout_regex}`, `timeout` s, `runs`, `live`) · `file` (`path`, `exists`) · `contains` (`path`, `text`|`regex`) · `absent` (`pattern`, `paths`) · `diff` (`paths` unchanged since start) · `verifier` (`ask`: judged by a read-only verifier that must quote files) · `human` (`ask`: owner sign-off).
183
183
 
184
- Optional blocks: `why`, `scope {in, out}`, `constraints`, `budget {turns, wall: "3h", tokens: "3M", cost_usd}`, `autonomy {questions: defer|allow, on_user_message: steer|pause, on_interrupt: pause|resume-on-message}`, `verification {mode: host|host+verifier|strict, max_rejections}` (default `host+verifier`, 3).
184
+ Optional blocks: `why`, `scope {in, out}`, `constraints`, `budget {turns, wall: "3h", tokens: "3M", cost_usd}`, `autonomy {questions: defer|allow, on_user_message: steer|pause, on_interrupt: pause|resume-on-message}`, `verification {mode: host|host+verifier|strict, max_rejections}` (default `host+verifier`, 3). The budget keys are exactly `turns`, `wall`, `tokens` and `cost_usd`; any other key (`max_turns`, `max_tokens`, …) is a validation error, and a contract with no `turns` is still capped by a 200-turn backstop (reported as `backstop_limited`, not as an exhausted budget).
185
185
 
186
186
  Rules the validator enforces: `schema: goal/v1`; `id` equals the directory; `intent.verbatim`, `outcome`, ≥1 `non_goals` and ≥1 essential criterion are required; ids are `C1…`, `I1…`, `S1…`, `A1…` and unique; every criterion has a check of a known kind with its required fields; invariants are host-checkable; plan references exist and have no cycles; budgets parse. Warnings: an outcome that reads as an activity ("keep improving…"), vague words without a number ("fast", "robust", "clean"), no host-checkable essential criterion. **Quote any value containing `: `** with single quotes. Keep every `command` check on a **single line**: YAML folded scalars (`>-`) fold equally-indented lines but keep literal newlines before more-indented ones, so a loop body written on its own line becomes a separate shell command at verify time. Rehearse each command check exactly as stored (extract it from the parsed YAML, run it through the login shell) before claiming.
187
187
 
@@ -20,7 +20,7 @@ The plugin's parser (`src/contract/parse.ts` in opencode-goal) is the source of
20
20
  | `protect` | no | globs the worker may not edit; enforced before edits and re-checked at verification |
21
21
  | `assumptions` | no | ids `A1…`; `status` assumed (default) / confirmed / vetoed |
22
22
  | `plan` | no | ids `S1…`; `proves` must reference criteria; `depends_on` must reference steps; no cycles |
23
- | `budget` | no | `turns` (int), `wall` ("90m", "3h", "1h30m"), `tokens` (400k, "3M"; spend across the run), `cost_usd` |
23
+ | `budget` | no | `turns` (int), `wall` ("90m", "3h", "1h30m"), `tokens` (400k, "3M"; spend across the run), `cost_usd`. Exactly these keys: any other key is an error, never a silent drop. With no `turns`, the run is capped by a 200-turn backstop that reports `backstop_limited`. |
24
24
  | `stop.when`, `stop.escalate_when` | no | shown to the worker; escalation means `goal_block` |
25
25
  | `autonomy` | no | `questions` defer (default) / allow · `on_user_message` steer (default) / pause · `on_interrupt` pause (default) / resume-on-message · `amend`: owner-set policy, a list of change classes the agent may apply to its own contract without asking — `timeout` (a check's timeout only), `budget`, `plan` (plan steps and assumptions), `recover` (adopt an in-policy out-of-band disk edit). Default `[]` = owner reviews every change. Criteria statements/checks, protect, scope, verification, stop rules and the policy itself are never agent-applicable. |
26
26
  | `verification` | no | `mode` host / host+verifier (default) / strict · `max_rejections` 1-10 (default 3) · `read`: extra folders or files outside the project the verifier may READ (plain paths, relative to the project root; never writable; everything else outside the project stays denied) |
@@ -48,6 +48,6 @@ The plugin's parser (`src/contract/parse.ts` in opencode-goal) is the source of
48
48
 
49
49
  ## Validator messages
50
50
 
51
- Errors: wrong schema · bad or mismatched id · missing title / intent.verbatim / outcome · no non-goals · no criteria · no essential criterion · criterion id not `C<n>` · missing statement · missing or unknown check kind · missing kind fields (run, path, pattern, paths, ask, text|regex) · invalid regex · non-positive timeout · invariant with a non-host kind · duplicate ids · plan id not `S<n>` · unknown `proves`/`depends_on` · dependency cycle · bad budget values · bad autonomy / verification values · invalid YAML (with a hint for unquoted `: `).
51
+ Errors: wrong schema · bad or mismatched id · missing title / intent.verbatim / outcome · no non-goals · no criteria · no essential criterion · criterion id not `C<n>` · missing statement · missing or unknown check kind · missing kind fields (run, path, pattern, paths, ask, text|regex) · invalid regex · non-positive timeout · invariant with a non-host kind · duplicate ids · plan id not `S<n>` · unknown `proves`/`depends_on` · dependency cycle · bad budget values or unknown budget keys · bad autonomy / verification values · invalid YAML (with a hint for unquoted `: `).
52
52
 
53
53
  Warnings: title over 80 chars · outcome reads as an activity · vague words without a number (outcome and statements) · no host-checkable essential criterion.
@@ -9,7 +9,7 @@ import { isAbsolute, join, relative, resolve, sep } from "node:path"
9
9
  export const INSTALL_SCHEMA_VERSION = 1
10
10
  export const PACKAGE_NAME = "@cleocode/goal"
11
11
  /** Specs that name this plugin, whatever the version: the npm package and the git repo it was installed from before npm. */
12
- const IDENTITIES = [PACKAGE_NAME, "github:kryptobaseddev/opencode-goal", "opencode-goal"]
12
+ const IDENTITIES = [PACKAGE_NAME, "github:kryptobaseddev/open-goal", "github:kryptobaseddev/opencode-goal", "open-goal", "opencode-goal"]
13
13
  export const PROJECT_CONFIG = join(".opencode", "opencode.json")
14
14
 
15
15
  type Options = Record<string, unknown>
@@ -204,7 +204,7 @@ type YamlParse = (text: string) => unknown
204
204
  const bunYaml = (globalThis as { Bun?: { YAML?: { parse: YamlParse } } }).Bun?.YAML
205
205
  const parseYaml: YamlParse = (text) => (bunYaml ? bunYaml.parse(text) : parseYamlPortable(text))
206
206
 
207
- export function parseContract(text: string, options: { slug?: string } = {}): ParseResult {
207
+ export function parseContract(text: string, options: { slug?: string; tolerateUnknownBudgetKeys?: boolean } = {}): ParseResult {
208
208
  const issues: Issue[] = []
209
209
  const lock = lockOf(text)
210
210
  let raw: unknown
@@ -280,7 +280,22 @@ export function parseContract(text: string, options: { slug?: string } = {}): Pa
280
280
  }
281
281
  if (hasCycle(plan)) err("plan", "step dependencies form a cycle")
282
282
 
283
+ // D9: the budget block had the same fail-open shape as D6 — an unknown key
284
+ // such as `max_turns` was silently dropped, leaving no budget at all and a
285
+ // mysterious 200-turn backstop. Every key is now either recognised or named
286
+ // in an error.
287
+ const BUDGET_KEYS = ["turns", "wall", "tokens", "cost_usd"] as const
288
+ if (raw.budget !== undefined && !isRecord(raw.budget)) err("budget", `budget must be a mapping with ${BUDGET_KEYS.join(", ")}`)
283
289
  const budgetRaw = isRecord(raw.budget) ? raw.budget : {}
290
+ for (const key of Object.keys(budgetRaw)) {
291
+ if ((BUDGET_KEYS as readonly string[]).includes(key)) continue
292
+ // The default is a hard error (D9: a typo must not silently remove the
293
+ // budget). The repair path opts in to tolerating metadata keys so a
294
+ // contract locked under an older, laxer validator can still be read and
295
+ // amended forward instead of bricking its run.
296
+ if (options.tolerateUnknownBudgetKeys) continue
297
+ err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`)
298
+ }
284
299
  const budget: Budget = {}
285
300
  if (budgetRaw.turns !== undefined) {
286
301
  const turns = Number(budgetRaw.turns)
@@ -1,7 +1,7 @@
1
1
  import type { EngineMessage } from "../server/messages"
2
2
  import { OwnerReviewStore, type ReviewChoice, type ReviewRequest } from "./owner-reviews"
3
3
 
4
- export type DecisionKind = "needs_review" | "paused-after-verdict" | "blocked" | "budget_limited" | "amend-proposed" | "supersede-ack" | "complete" | "start-picker" | "owner-operation"
4
+ export type DecisionKind = "needs_review" | "paused-after-verdict" | "blocked" | "budget_limited" | "backstop_limited" | "amend-proposed" | "supersede-ack" | "complete" | "start-picker" | "owner-operation"
5
5
  export type DecisionTarget = { slug: string; runId?: string; sessionID?: string; lock: string; status: string }
6
6
  export type DecisionRequest = ReviewRequest & {
7
7
  kind: DecisionKind
@@ -57,7 +57,7 @@ export type LoopAction =
57
57
  | { kind: "verify" }
58
58
  | { kind: "admit"; tail: TailKind }
59
59
  | { kind: "schedule"; tail: TailKind; delayMs: number; waiting?: { reason: string } }
60
- | { kind: "stop"; status: Status; code: "blocked" | "stalled" | "budget" | "wrapup"; reason: string }
60
+ | { kind: "stop"; status: Status; code: "blocked" | "stalled" | "budget" | "backstop" | "wrapup"; reason: string }
61
61
 
62
62
  /**
63
63
  * The one decision. Ordering mirrors the adapter exactly:
@@ -78,7 +78,12 @@ export function decide(state: RunState, signals: LoopSignals, limits: LoopLimits
78
78
  if (signals.budgetRatio >= 1 || signals.backstop) {
79
79
  // One wrap-up turn is admitted before the run stops for the owner.
80
80
  if (!state.wrapup) return { kind: "admit", tail: "wrapup" }
81
- return { kind: "stop", status: "budget_limited", code: "budget", reason: signals.backstop ? `backstop of ${limits.backstopTurns} turns reached` : "budget used" }
81
+ // D9: a backstop stop is NOT an exhausted budget. It means no contract
82
+ // budget was set at all, so it gets its own status and a reason that names
83
+ // the remedy instead of pretending the owner's budget ran out.
84
+ if (signals.backstop)
85
+ return { kind: "stop", status: "backstop_limited", code: "backstop", reason: `backstop of ${limits.backstopTurns} turns reached; no contract budget set — set budget.turns to control the run` }
86
+ return { kind: "stop", status: "budget_limited", code: "budget", reason: "budget used" }
82
87
  }
83
88
  if (state.counters.noProgress >= limits.stallTurns) {
84
89
  // Owner-set policy: a genuinely stalled-but-active run may continue with a
@@ -11,6 +11,7 @@ export type Status =
11
11
  | "blocked"
12
12
  | "needs_review"
13
13
  | "budget_limited"
14
+ | "backstop_limited"
14
15
  | "complete"
15
16
  | "failed"
16
17
  | "aborted"
@@ -140,7 +141,7 @@ export function ownerCan(state: RunState, action: OwnerAction): string | undefin
140
141
  case "pause":
141
142
  return isActive(state.status) ? undefined : `goal is ${state.status}`
142
143
  case "resume":
143
- return ["paused", "blocked", "needs_review", "budget_limited"].includes(state.status) ? undefined : `goal is ${state.status}; only a stopped goal can resume`
144
+ return ["paused", "blocked", "needs_review", "budget_limited", "backstop_limited"].includes(state.status) ? undefined : `goal is ${state.status}; only a stopped goal can resume`
144
145
  case "abort":
145
146
  return isTerminal(state.status) ? `goal is already ${state.status}` : undefined
146
147
  case "verify":
package/src/rpc.ts CHANGED
@@ -9,7 +9,7 @@ export type { LaunchView } from "./engine/launches"
9
9
  const targetObject = { type: "object", properties: { key: { type: "string", minLength: 1, maxLength: 256 }, lock: { type: "string", minLength: 64, maxLength: 64 }, runId: { type: "string", minLength: 1, maxLength: 128 }, sessionID: { type: "string", minLength: 1, maxLength: 256 } }, required: ["key", "lock"], additionalProperties: false } as const
10
10
 
11
11
  export const GoalRpc = {
12
- id: "opencode-goal",
12
+ id: "open-goal",
13
13
  methods: {
14
14
  provenance: {
15
15
  input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 } }, additionalProperties: false },
@@ -61,7 +61,7 @@ export const GoalRpc = {
61
61
  properties: {
62
62
  sessionID: { type: "string" },
63
63
  slug: { type: "string" },
64
- kind: { type: "string", enum: ["needs_review", "paused-after-verdict", "blocked", "budget_limited", "amend-proposed", "supersede-ack", "complete", "start-picker", "owner-operation"] },
64
+ kind: { type: "string", enum: ["needs_review", "paused-after-verdict", "blocked", "budget_limited", "backstop_limited", "amend-proposed", "supersede-ack", "complete", "start-picker", "owner-operation"] },
65
65
  title: { type: "string" },
66
66
  message: { type: "string" },
67
67
  question: { type: "string" },
package/src/server/app.ts CHANGED
@@ -365,6 +365,18 @@ export class GoalApp {
365
365
  return value
366
366
  }
367
367
 
368
+ /** T163/D9 repair: a generation locked under an older, laxer validator can
369
+ * become unreadable when the validator tightens (for example a free-text
370
+ * `budget.rationale` key). The strict parser stays the default; this helper
371
+ * is used only to form the "prior" side of a repair amendment so the owner
372
+ * can stage a valid generation instead of hand-editing goal.yaml. It
373
+ * tolerates unknown `budget.*` keys and nothing else. */
374
+ private priorContract(text: string, slug: string): Contract | undefined {
375
+ const strict = parseContract(text, { slug })
376
+ if (strict.contract) return strict.contract
377
+ return parseContract(text, { slug, tolerateUnknownBudgetKeys: true }).contract
378
+ }
379
+
368
380
  private contractOf(state: RunState): Contract | undefined {
369
381
  const cached = this.loaded.get(state.slug)
370
382
  if (cached && cached.lock === state.lock) return cached.contract
@@ -399,7 +411,10 @@ export class GoalApp {
399
411
  * — never infer it from the presence of a pending proposal. */
400
412
  private divergenceOf(state: RunState): { diskLock: string; diskText: string } | undefined {
401
413
  const disk = this.store.readContract(state.slug)
402
- if (!disk?.contract || disk.lock === state.lock) return undefined
414
+ // Differing bytes are a divergence even when the disk copy does not parse:
415
+ // recovery must at least be reachable so it can name an invalid edit
416
+ // instead of reporting "nothing to recover" while staging refuses.
417
+ if (!disk || disk.lock === state.lock) return undefined
403
418
  return { diskLock: disk.lock, diskText: disk.text }
404
419
  }
405
420
 
@@ -572,7 +587,9 @@ export class GoalApp {
572
587
  ? { actionRequired: `blocked on ${state.blocker?.key ?? "owner decision"}: ${state.blocker?.reason ?? state.reason ?? ""}${state.blocker?.needs ? ` — needs ${state.blocker.needs}` : ""}` }
573
588
  : state.status === "budget_limited"
574
589
  ? { actionRequired: `budget used: raise the budget and amend, or abort (${state.reason ?? ""})` }
575
- : {}),
590
+ : state.status === "backstop_limited"
591
+ ? { actionRequired: `turn backstop: no contract budget set — set budget.turns and amend, or abort (${state.reason ?? ""})` }
592
+ : {}),
576
593
  amendments: state.amendments.filter((a) => a.status === "proposed").length,
577
594
  flags: state.flags.length,
578
595
  updatedAt: state.updatedAt,
@@ -586,7 +603,7 @@ export class GoalApp {
586
603
 
587
604
  /**
588
605
  * T045: the post-goal summary, emitted on every complete / needs_review /
589
- * budget_limited transition. Built from data the run already has, shown to
606
+ * budget_limited / backstop_limited transition. Built from data the run already has, shown to
590
607
  * the owner (rpc event → TUI dialog) and the agent (one-line transcript
591
608
  * notice), and persisted under evidence/ with a ledger event.
592
609
  */
@@ -1188,18 +1205,25 @@ export class GoalApp {
1188
1205
  // fresh diff there is not execution or transfer of the old consent.
1189
1206
  if (state.sessionID !== sessionID && this.factsOf(state.sessionID).busy && state.status !== "paused") throw new Error("The selected goal is executing elsewhere; pause it before proposing an amendment")
1190
1207
  const disk = this.store.readContract(state.slug)
1191
- if (!disk?.contract || disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1208
+ if (!disk) throw new Error("No goal.yaml on disk for the selected goal")
1209
+ if (disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1210
+ // D9 repair: when the lock still matches but the current validator now
1211
+ // rejects a metadata key, the locked generation is unreadable. Read it
1212
+ // leniently so the owner can stage a valid repair generation instead of
1213
+ // hitting a wall where propose refuses and recover says nothing differs.
1214
+ const prior = disk.contract ?? this.priorContract(disk.text, state.slug)
1215
+ if (!prior) throw new Error(`The locked contract is invalid and cannot be read for repair:\n${formatIssues(disk.issues)}`)
1192
1216
  const next = parseContract(input.yaml, { slug: state.slug })
1193
1217
  if (!next.contract) throw new Error(`Invalid amendment; current contract unchanged:\n${formatIssues(next.issues)}`)
1194
1218
  if (next.lock === state.lock) throw new Error("Nothing changed; no amendment staged")
1195
- const diff = amendmentDiff(disk.contract, next.contract)
1219
+ const diff = amendmentDiff(prior, next.contract)
1196
1220
  // Owner-set autonomy: inside the launched policy the agent applies the
1197
1221
  // change itself (journalled, owner-revertible). Anything outside it,
1198
1222
  // including the policy itself, is routed to the owner below.
1199
- const policy = disk.contract.autonomy.amend
1200
- const change = classifyChange(disk.contract, next.contract)
1223
+ const policy = prior.autonomy.amend
1224
+ const change = classifyChange(prior, next.contract)
1201
1225
  const allowed = policyAllows(policy, change)
1202
- if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, disk.contract, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
1226
+ if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, prior, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
1203
1227
  const outsidePolicy = policy.length ? allowed.reasons : []
1204
1228
  // A proposal is not permission to mutate the oracle. Stop the target
1205
1229
  // under its OLD contract while the exact diff is being reviewed.
@@ -1572,18 +1596,28 @@ export class GoalApp {
1572
1596
  const state = this.runs.get(request.targetSessionID)
1573
1597
  const disk = this.store.readContract(request.slug)
1574
1598
  const next = parseContract(request.nextText, { slug: request.slug })
1575
- if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk?.contract || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
1599
+ // A lock match is what binds the review to this generation; the disk
1600
+ // contract may still be unreadable under a tightened validator when the
1601
+ // reviewed change repairs it (D9 repair path).
1602
+ if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
1576
1603
  this.amendmentReviews.update(request.id, { status: "failed", result: "Target/run/contract changed since review; no amendment applied." })
1577
1604
  this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "review binding changed" })
1578
1605
  this.noticeM(request.sessionID, M.amendmentChanged())
1579
1606
  return
1580
1607
  }
1608
+ const prior = disk.contract ?? this.priorContract(disk.text, request.slug)
1609
+ if (!prior) {
1610
+ this.amendmentReviews.update(request.id, { status: "failed", result: "The locked contract is invalid and cannot be read; no amendment applied." })
1611
+ this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "locked contract unreadable" })
1612
+ this.noticeM(request.sessionID, M.amendmentChanged())
1613
+ return
1614
+ }
1581
1615
  const reserved = this.amendmentReviews.reserve(request.id)
1582
1616
  if (!reserved) return
1583
1617
  const acceptedAt = Date.now()
1584
1618
  const stopped = structuredClone(state)
1585
1619
  setStatus(stopped, "paused", "owner accepted amendment; resume when ready")
1586
- const result = amendedState(stopped, disk.contract, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
1620
+ const result = amendedState(stopped, prior, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
1587
1621
  const approved = this.amendmentReviews.writeAhead(request.id, { state: result, acceptedAt })
1588
1622
  try {
1589
1623
  const applied = this.store.commitAmendment(approved)
@@ -2557,6 +2591,13 @@ export class GoalApp {
2557
2591
  await this.transcript(state.sessionID, `◎ budget used (${contract.title}). The owner decides: raise the budget in goal.yaml and amend, or abort. Do not start new work.`)
2558
2592
  await this.summarize(state, contract)
2559
2593
  return this.persist(state)
2594
+ case "backstop":
2595
+ setStatus(state, "backstop_limited", action.reason)
2596
+ this.store.ledger(state.slug, { type: "backstop_limited", turns: state.turn, backstopTurns: this.options.backstopTurns })
2597
+ this.decide(state.sessionID, state.slug, "backstop_limited", M.backstopStopped(contract.title, this.options.backstopTurns))
2598
+ await this.transcript(state.sessionID, `◎ turn backstop reached (${contract.title}): no contract budget was set. The owner decides: set budget.turns in goal.yaml and amend, or abort. Do not start new work.`)
2599
+ await this.summarize(state, contract)
2600
+ return this.persist(state)
2560
2601
  case "stalled":
2561
2602
  this.store.ledger(state.slug, { type: "stall", turns: stallTurns, activity: active, reason: stallReason, policy: contract.autonomy.on_stall })
2562
2603
  setStatus(state, "paused", action.reason)
@@ -3078,6 +3119,7 @@ export class GoalApp {
3078
3119
  return { ok: true, message: `Goal paused: ${state.title} — /goal resume · /goal abort` }
3079
3120
  case "resume": {
3080
3121
  if (state.status === "budget_limited") return { ok: false, message: "The goal used its budget. Raise budget in goal.yaml and start a new run, or abort it." }
3122
+ if (state.status === "backstop_limited") return { ok: false, message: "The goal stopped at the turn backstop because no budget was set. Set budget.turns in goal.yaml and amend it, or abort it." }
3081
3123
  // T091: a resume is idempotent — an already-running run journals no
3082
3124
  // second receipt and is not admitted twice.
3083
3125
  if (state.status === "running" && !state.wait) return { ok: true, message: `Goal already running: ${state.title} — /goal status · /goal pause` }
@@ -3169,7 +3211,7 @@ export class GoalApp {
3169
3211
  private async provenance(sessionID?: string): Promise<RuntimeProvenance> {
3170
3212
  const listed: any = await this.ctx.plugin.list({ location: { directory: this.root } } as any)
3171
3213
  const entries = Array.isArray(listed) ? listed : listed?.data ?? []
3172
- const entry = entries.find((p: any) => p.id === "opencode-goal")
3214
+ const entry = entries.find((p: any) => p.id === "open-goal" || p.id === "opencode-goal")
3173
3215
  const source = entry?.source ?? { type: "unknown" }
3174
3216
  const state = sessionID ? this.runs.get(sessionID) : undefined
3175
3217
  const result: RuntimeProvenance = {
@@ -3570,6 +3612,16 @@ export class GoalApp {
3570
3612
  const divergent = liveGoal && this.divergenceOf(liveGoal)
3571
3613
  if (liveGoal && divergent)
3572
3614
  return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal), message: `${this.divergenceRefusal(liveGoal, divergent)} No ${action} was executed.` }
3615
+ // T163/D9 repair: the locked bytes themselves may fail the CURRENT
3616
+ // validator even though the disk was never edited (a generation locked
3617
+ // by an older, laxer parser). That is not a read-only draft and not a
3618
+ // divergence; name the invalid key and the one supported fix.
3619
+ if (liveGoal) {
3620
+ const disk = this.store.readContract(liveGoal.slug)
3621
+ if (disk && disk.lock === liveGoal.lock && !disk.contract)
3622
+ return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal),
3623
+ message: `The locked contract of "${liveGoal.slug}" is invalid under this plugin version, so no ${action} can run:\n${formatIssues(disk.issues)}\nThe disk bytes ARE the locked generation (SHA256 ${liveGoal.lock.slice(0, 12)}…), so there is nothing to recover. Fix only the named key(s) and stage a validated replacement with goal_propose_amendment {"slug":"${liveGoal.slug}","expectedLock":"${liveGoal.lock}","yaml":"<full corrected goal.yaml>"}; the owner reviews and accepts it, which re-locks the run. No hand-edit and no ${action} was executed.` }
3624
+ }
3573
3625
  return { ok: false, message: "This selected goal is read-only (draft, archived or invalid); review/launch a valid draft instead." }
3574
3626
  }
3575
3627
  const state = [...this.runs.values()].find((s) => s.slug === selected.slug && s.runId === selected.runId) ?? this.store.readRun(selected.slug)
@@ -7,7 +7,7 @@ export type HelpFacts = { version: string }
7
7
 
8
8
  export function goalHelp({ version }: HelpFacts): string {
9
9
  return [
10
- `# opencode-goal ${version} — the goal loop, for agents and owners`,
10
+ `# open-goal ${version} — the goal loop, for agents and owners`,
11
11
  "",
12
12
  "## Commands (owner)",
13
13
  "- `/goal new <what you want done>` — the write-goal interview (skill attached; similar-goal probe runs first)",
@@ -7,7 +7,7 @@ import { GoalApp } from "./app"
7
7
  const instances = new Map<string, GoalApp>()
8
8
 
9
9
  export default {
10
- id: "opencode-goal",
10
+ id: "open-goal",
11
11
  async setup(ctx: Plugin.Context) {
12
12
  const key = ctx.location.directory
13
13
  const previous = instances.get(key)
@@ -112,6 +112,14 @@ export const M = {
112
112
  abort,
113
113
  archive,
114
114
  ], "question"),
115
+ // D9: a turn backstop is not an exhausted budget. Say what actually happened
116
+ // and name the one key that turns it into an owner-set limit.
117
+ backstopStopped: (title: string, turns: number) =>
118
+ msg("goal.backstop.stopped", "warning", `Goal stopped at the ${turns}-turn backstop: ${title}. No contract budget was set, so the run had no limit to raise. Set budget.turns in goal.yaml and amend, or abort.`, [
119
+ { label: "Set budget.turns (amend)", run: "reply in the Composer with `budget: {turns: <n>}`; the agent stages a reviewed amendment" },
120
+ abort,
121
+ archive,
122
+ ], "question"),
115
123
  blocked: (key: string, reason: string, needs?: string) =>
116
124
  msg("goal.blocked", "warning", `Goal blocked on ${key}: ${reason}${needs ? ` — it needs: ${needs}` : " — only the owner can resolve this"}.`, [
117
125
  { label: "Resolve, then resume", run: "/goal resume", act: "resume" },
@@ -78,9 +78,9 @@ export function provenanceSplit(criteria: SummaryCriterion[]): string | undefine
78
78
  * dialog — the full report (criteria, non-goals, findings) stays in `text`. */
79
79
  export function buildDigest(s: Omit<PostGoalSummary, "digest" | "text">): string {
80
80
  const lines: string[] = []
81
- const icon = s.status === "complete" ? "✓" : s.status === "failed" ? "✗" : s.status === "budget_limited" ? "⊘" : "⚑"
81
+ const icon = s.status === "complete" ? "✓" : s.status === "failed" ? "✗" : s.status === "budget_limited" ? "⊘" : s.status === "backstop_limited" ? "⏱" : "⚑"
82
82
  const proven = s.criteria.filter((c) => c.status === "pass").length
83
- const terminal = new Set(["complete", "failed", "aborted", "needs_review", "budget_limited"])
83
+ const terminal = new Set(["complete", "failed", "aborted", "needs_review", "budget_limited", "backstop_limited"])
84
84
  const stopped = terminal.has(s.status) ? "loop stopped" : "loop running"
85
85
  lines.push(`${icon} ${s.status.replace(/_/g, " ")} · ${proven}/${s.criteria.length} proven · ${s.turns} turn${s.turns === 1 ? "" : "s"} · ${stopped}`)
86
86
  lines.push(s.completed)
@@ -124,6 +124,7 @@ export function findingsFromLedger(events: Array<Record<string, unknown>>): Summ
124
124
  else if (e.type === "amended") out.push({ kind: "amended", text: `contract amended (generation ${e.generation ?? "?"}): ${e.summary ?? ""}`, at })
125
125
  else if (e.type === "amend-proposed") out.push({ kind: "amend-proposed", text: `amendment proposed: ${e.summary ?? ""}`, at })
126
126
  else if (e.type === "budget_limited") out.push({ kind: "budget_limited", text: "the run hit its budget", at })
127
+ else if (e.type === "backstop_limited") out.push({ kind: "backstop_limited", text: `the run hit the ${e.backstopTurns ?? "turn"} backstop because no budget was set`, at })
127
128
  }
128
129
  return out
129
130
  }
@@ -162,6 +163,8 @@ export function buildPostGoalSummary(input: SummaryInput): PostGoalSummary {
162
163
  followUps.push("Resolve the review: /goal approve <C#> (final) or /goal reject <C#> <why>, then /goal verify.")
163
164
  } else if (state.status === "budget_limited") {
164
165
  followUps.push("Raise the budget in goal.yaml and start a new run, or wrap up: /goal archive after aborting.")
166
+ } else if (state.status === "backstop_limited") {
167
+ followUps.push("No budget was set, so the run stopped at the turn backstop. Set budget.turns in goal.yaml and amend it, or wrap up: /goal archive after aborting.")
165
168
  }
166
169
  if (scopeAudit.length) followUps.push("Break the discussed-but-unproven items out into a follow-up goal so they stop riding on this one.")
167
170
  followUps.push("Decompose long-running work with CLEO" + (input.cleo ? " (this project is already linked; keep tasks completing with evidence)." : ": this project has no .cleo workspace — installing it gives goals a task board that outlives any single run (the link stays optional and CLI-only)."))
@@ -223,6 +226,9 @@ export function buildPostGoalSummary(input: SummaryInput): PostGoalSummary {
223
226
  } else if (state.status === "budget_limited") {
224
227
  nextSteps.push("Raise the budget in goal.yaml, then start a new run")
225
228
  nextSteps.push("Or wrap up — /goal archive")
229
+ } else if (state.status === "backstop_limited") {
230
+ nextSteps.push("Set budget.turns in goal.yaml, then amend the run")
231
+ nextSteps.push("Or wrap up — /goal archive")
226
232
  } else {
227
233
  nextSteps.push("Inspect the goal — /goal status")
228
234
  nextSteps.push("Start a goal yourself — /goal new <what you want done>")
@@ -22,6 +22,7 @@ const STATUS: Record<string, { icon: string; label: string; tone: Tone }> = {
22
22
  blocked: { icon: "⛔", label: "blocked", tone: "error" },
23
23
  needs_review: { icon: "⚑", label: "needs review", tone: "warning" },
24
24
  budget_limited: { icon: "⊘", label: "budget used", tone: "warning" },
25
+ backstop_limited: { icon: "⏱", label: "turn backstop", tone: "warning" },
25
26
  complete: { icon: "✓", label: "complete", tone: "success" },
26
27
  failed: { icon: "✗", label: "failed", tone: "error" },
27
28
  aborted: { icon: "■", label: "aborted", tone: "muted" },
@@ -155,7 +156,7 @@ export function cardAffordanceTab(view: GoalView, now: number, width = 40): Dash
155
156
  return order.find((t) => truncated.has(t))
156
157
  }
157
158
 
158
- const humanKinds = new Set(["verdict", "approve", "reject", "amend-proposed", "amended", "blocked", "complete", "summary", "flag", "attached", "paused", "resumed", "aborted", "budget_limited", "superseded-by", "approve ", "reject "])
159
+ const humanKinds = new Set(["verdict", "approve", "reject", "amend-proposed", "amended", "blocked", "complete", "summary", "flag", "attached", "paused", "resumed", "aborted", "budget_limited", "backstop_limited", "superseded-by", "approve ", "reject "])
159
160
 
160
161
  /** (T074 sign-off fix) Quick acts: owner actions that ALWAYS make sense for
161
162
  * the goal's current state — the Decisions tab is interactive even when no
@@ -178,6 +179,8 @@ export function quickActRows(view: GoalView): ActionRow[] {
178
179
  rows.push({ label: "Verify now", description: "run the host checks + verifier immediately", act: "verify" })
179
180
  } else if (view.status === "budget_limited") {
180
181
  rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" })
182
+ } else if (view.status === "backstop_limited") {
183
+ rows.push({ label: "Abort the goal", description: "stop at the turn backstop", act: "abort" })
181
184
  } else if (isTerminalStatus(view.status)) {
182
185
  rows.push({ label: "Archive the goal", description: "demote to goals-archive/ (history intact)", act: "archive" })
183
186
  rows.push({ label: "Start the next goal", description: "open the write-goal interview", act: "start-next" })
@@ -211,6 +214,9 @@ export function decisionRows(view: GoalView): ActionRow[] {
211
214
  } else if (view.status === "budget_limited") {
212
215
  rows.push({ label: "Raise budget and amend", description: "Describe the budget change in the Composer; review the agent's staged diff" })
213
216
  rows.push({ label: "Abort the goal", description: "stop at the budget", act: "abort" })
217
+ } else if (view.status === "backstop_limited") {
218
+ rows.push({ label: "Set budget.turns and amend", description: "The run had no budget; name a turn limit in the Composer and review the staged diff" })
219
+ rows.push({ label: "Abort the goal", description: "stop at the turn backstop", act: "abort" })
214
220
  }
215
221
  }
216
222
  if (view.status === "paused") rows.push({ label: "Resume the goal", description: view.reason ?? "", act: "resume" })
package/src/tui/format.ts CHANGED
@@ -17,6 +17,7 @@ export const STATUS: Record<string, { icon: string; label: string; tone: Tone }>
17
17
  blocked: { icon: "⛔", label: "blocked", tone: "error" },
18
18
  needs_review: { icon: "⚑", label: "needs review", tone: "warning" },
19
19
  budget_limited: { icon: "⊘", label: "budget used", tone: "warning" },
20
+ backstop_limited: { icon: "⏱", label: "turn backstop", tone: "warning" },
20
21
  complete: { icon: "✓", label: "complete", tone: "success" },
21
22
  failed: { icon: "✗", label: "failed", tone: "error" },
22
23
  aborted: { icon: "■", label: "aborted", tone: "muted" },
@@ -108,6 +109,8 @@ export function bannerText(view: GoalView): { text: string; tone: Tone } | undef
108
109
  return { text: `◎ ${st.icon} Goal needs review — ${view.reason ?? ""} · /goal approve <id> or ${RESUME}`, tone: "warning" }
109
110
  case "budget_limited":
110
111
  return { text: `◎ ${st.icon} Goal stopped at its budget — raise budget in goal.yaml and /goal start, or /goal abort`, tone: "warning" }
112
+ case "backstop_limited":
113
+ return { text: `◎ ${st.icon} Goal stopped at the turn backstop — no budget was set · set budget.turns in goal.yaml and amend, or /goal abort`, tone: "warning" }
111
114
  default:
112
115
  return undefined
113
116
  }
package/src/tui/index.tsx CHANGED
@@ -20,7 +20,7 @@ const SIDEBAR_WIDTH = 40
20
20
  const PANEL_NAME = "goal-dashboard"
21
21
 
22
22
  export default {
23
- id: "opencode-goal",
23
+ id: "open-goal",
24
24
  async setup(context: Context) {
25
25
  const rpc = context.client.rpc(GoalRpc as any) as any
26
26
  const [views, setViews] = createStore<Record<string, GoalView | null>>({})
package/src/util/git.ts CHANGED
@@ -27,22 +27,9 @@ export const headCommit = (cwd: string): string | undefined => {
27
27
  return r.ok ? r.out.trim() : undefined
28
28
  }
29
29
 
30
- /** T116: linked worktrees live under the project but their commits are invisible
31
- * to the root worktree's diff, so the last goal run recorded changed:false and
32
- * tripped the stall auto-pause three times. `git worktree list --porcelain`
33
- * names every worktree's HEAD and branch; a commit in any of them changes it. */
34
- export function worktreeSignature(cwd: string): string {
35
- return git(cwd, ["worktree", "list", "--porcelain"])
36
- .out.split("\n")
37
- .map((line) => line.trim())
38
- .filter(Boolean)
39
- .join("\n")
40
- }
41
-
42
- /** Hash of HEAD + staged/unstaged diff + untracked file list + linked-worktree HEADs, excluding goal state. */
43
- export function fingerprint(cwd: string): string | undefined {
44
- if (!isRepo(cwd)) return undefined
45
- const head = git(cwd, ["rev-parse", "HEAD"]).out
30
+ /** Hash of one worktree's staged/unstaged diff plus untracked file sizes and
31
+ * mtimes, excluding goal state. Same rule the root worktree has always used. */
32
+ function treeState(cwd: string): string {
46
33
  const diff = git(cwd, ["diff", "HEAD", "--no-color", "--", ".", GOAL_STATE]).out
47
34
  const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ".", GOAL_STATE]).out
48
35
  const contents = untracked
@@ -57,7 +44,35 @@ export function fingerprint(cwd: string): string | undefined {
57
44
  return file
58
45
  }
59
46
  })
60
- return createHash("sha256").update(head).update(diff).update(contents.join("\n")).update(worktreeSignature(cwd)).digest("hex").slice(0, 20)
47
+ return createHash("sha256").update(diff).update(contents.join("\n")).digest("hex").slice(0, 20)
48
+ }
49
+
50
+ /** T116: linked worktrees live under the project but their work is invisible to
51
+ * the root worktree's diff, so a run recorded changed:false and tripped the
52
+ * stall auto-pause. `git worktree list --porcelain` names every worktree; the
53
+ * signature now carries HEAD/branch AND the dirty state of each linked
54
+ * worktree, so uncommitted work counts as progress too, not only commits.
55
+ * The cwd worktree is skipped because `fingerprint` hashes it separately. */
56
+ export function worktreeSignature(cwd: string): string {
57
+ const self = topLevel(cwd) ?? cwd
58
+ const blocks = git(cwd, ["worktree", "list", "--porcelain"]).out.split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean)
59
+ return blocks
60
+ .map((block) => {
61
+ const lines = block.split("\n").map((line) => line.trim()).filter(Boolean)
62
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length)
63
+ if (!path) return block
64
+ if (path === self) return block
65
+ return `${block}\n${treeState(path)}`
66
+ })
67
+ .join("\n\n")
68
+ }
69
+
70
+ /** Hash of HEAD + staged/unstaged diff + untracked file list + every linked
71
+ * worktree's HEAD and dirty state, excluding goal state. */
72
+ export function fingerprint(cwd: string): string | undefined {
73
+ if (!isRepo(cwd)) return undefined
74
+ const head = git(cwd, ["rev-parse", "HEAD"]).out
75
+ return createHash("sha256").update(head).update(treeState(cwd)).update(worktreeSignature(cwd)).digest("hex").slice(0, 20)
61
76
  }
62
77
 
63
78
  /** T109: a bounded, host-computed git history digest. The verifier child has only