@cleocode/goal 0.9.0 → 0.9.3

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3 — 2026-10-28
4
+
5
+ - **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.
6
+ - **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`.
7
+ - **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.
8
+ - **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.
9
+
10
+ ## 0.9.2 — 2026-10-17
11
+
12
+ - **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.**
13
+ - **Install:** `opencode plugin add @cleocode/goal@0.9.2`. The plugin id remains `opencode-goal`; the git-tag install remains supported.
14
+
15
+ ## 0.9.1 — 2026-10-17
16
+
17
+ - **Superseded by 0.9.2 before publishing.** The version bump left the committed `dist/cli.js` stale, so the release workflow refused it. `0.9.1` was tagged but never published.
18
+
3
19
  ## 0.9.0 — 2026-10-17
4
20
 
5
21
  - **Published to npm as `@cleocode/goal`.** The package was `opencode-goal` (private, installed from git tags). It is now public with npm provenance. The plugin id stays `opencode-goal`, so existing configs, state and the TUI keep working. A tag-triggered `release.yml` publishes through npm Trusted Publishing (OIDC, no token).
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.3` 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.0",
6913
+ version: "0.9.3",
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,14 @@ 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
+ err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`);
7418
+ }
7412
7419
  const budget = {};
7413
7420
  if (budgetRaw.turns !== undefined) {
7414
7421
  const turns = Number(budgetRaw.turns);
@@ -7718,15 +7725,11 @@ function git(cwd, args) {
7718
7725
  return { ok: result.status === 0, out: result.stdout ?? "" };
7719
7726
  }
7720
7727
  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;
7728
+ var topLevel = (cwd) => {
7729
+ const r = git(cwd, ["rev-parse", "--show-toplevel"]);
7730
+ return r.ok ? r.out.trim() : undefined;
7731
+ };
7732
+ function treeState(cwd) {
7730
7733
  const diff = git(cwd, ["diff", "HEAD", "--no-color", "--", ".", GOAL_STATE]).out;
7731
7734
  const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ".", GOAL_STATE]).out;
7732
7735
  const contents = untracked.split(`
@@ -7738,8 +7741,31 @@ function fingerprint(cwd) {
7738
7741
  return file;
7739
7742
  }
7740
7743
  });
7741
- return createHash2("sha256").update(head).update(diff).update(contents.join(`
7742
- `)).update(worktreeSignature(cwd)).digest("hex").slice(0, 20);
7744
+ return createHash2("sha256").update(diff).update(contents.join(`
7745
+ `)).digest("hex").slice(0, 20);
7746
+ }
7747
+ function worktreeSignature(cwd) {
7748
+ const self = topLevel(cwd) ?? cwd;
7749
+ const blocks = git(cwd, ["worktree", "list", "--porcelain"]).out.split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean);
7750
+ return blocks.map((block) => {
7751
+ const lines = block.split(`
7752
+ `).map((line) => line.trim()).filter(Boolean);
7753
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
7754
+ if (!path)
7755
+ return block;
7756
+ if (path === self)
7757
+ return block;
7758
+ return `${block}
7759
+ ${treeState(path)}`;
7760
+ }).join(`
7761
+
7762
+ `);
7763
+ }
7764
+ function fingerprint(cwd) {
7765
+ if (!isRepo(cwd))
7766
+ return;
7767
+ const head = git(cwd, ["rev-parse", "HEAD"]).out;
7768
+ return createHash2("sha256").update(head).update(treeState(cwd)).update(worktreeSignature(cwd)).digest("hex").slice(0, 20);
7743
7769
  }
7744
7770
  function changedSince(cwd, base) {
7745
7771
  if (!isRepo(cwd))
@@ -8118,7 +8144,9 @@ function decide(state, signals, limits) {
8118
8144
  if (signals.budgetRatio >= 1 || signals.backstop) {
8119
8145
  if (!state.wrapup)
8120
8146
  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" };
8147
+ if (signals.backstop)
8148
+ 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` };
8149
+ return { kind: "stop", status: "budget_limited", code: "budget", reason: "budget used" };
8122
8150
  }
8123
8151
  if (state.counters.noProgress >= limits.stallTurns) {
8124
8152
  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.0",
3
+ "version": "0.9.3",
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>
@@ -280,7 +280,16 @@ 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)) err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`)
292
+ }
284
293
  const budget: Budget = {}
285
294
  if (budgetRaw.turns !== undefined) {
286
295
  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
@@ -572,7 +572,9 @@ export class GoalApp {
572
572
  ? { actionRequired: `blocked on ${state.blocker?.key ?? "owner decision"}: ${state.blocker?.reason ?? state.reason ?? ""}${state.blocker?.needs ? ` — needs ${state.blocker.needs}` : ""}` }
573
573
  : state.status === "budget_limited"
574
574
  ? { actionRequired: `budget used: raise the budget and amend, or abort (${state.reason ?? ""})` }
575
- : {}),
575
+ : state.status === "backstop_limited"
576
+ ? { actionRequired: `turn backstop: no contract budget set — set budget.turns and amend, or abort (${state.reason ?? ""})` }
577
+ : {}),
576
578
  amendments: state.amendments.filter((a) => a.status === "proposed").length,
577
579
  flags: state.flags.length,
578
580
  updatedAt: state.updatedAt,
@@ -586,7 +588,7 @@ export class GoalApp {
586
588
 
587
589
  /**
588
590
  * T045: the post-goal summary, emitted on every complete / needs_review /
589
- * budget_limited transition. Built from data the run already has, shown to
591
+ * budget_limited / backstop_limited transition. Built from data the run already has, shown to
590
592
  * the owner (rpc event → TUI dialog) and the agent (one-line transcript
591
593
  * notice), and persisted under evidence/ with a ledger event.
592
594
  */
@@ -2557,6 +2559,13 @@ export class GoalApp {
2557
2559
  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
2560
  await this.summarize(state, contract)
2559
2561
  return this.persist(state)
2562
+ case "backstop":
2563
+ setStatus(state, "backstop_limited", action.reason)
2564
+ this.store.ledger(state.slug, { type: "backstop_limited", turns: state.turn, backstopTurns: this.options.backstopTurns })
2565
+ this.decide(state.sessionID, state.slug, "backstop_limited", M.backstopStopped(contract.title, this.options.backstopTurns))
2566
+ 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.`)
2567
+ await this.summarize(state, contract)
2568
+ return this.persist(state)
2560
2569
  case "stalled":
2561
2570
  this.store.ledger(state.slug, { type: "stall", turns: stallTurns, activity: active, reason: stallReason, policy: contract.autonomy.on_stall })
2562
2571
  setStatus(state, "paused", action.reason)
@@ -3078,6 +3087,7 @@ export class GoalApp {
3078
3087
  return { ok: true, message: `Goal paused: ${state.title} — /goal resume · /goal abort` }
3079
3088
  case "resume": {
3080
3089
  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." }
3090
+ 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
3091
  // T091: a resume is idempotent — an already-running run journals no
3082
3092
  // second receipt and is not admitted twice.
3083
3093
  if (state.status === "running" && !state.wait) return { ok: true, message: `Goal already running: ${state.title} — /goal status · /goal pause` }
@@ -3169,7 +3179,7 @@ export class GoalApp {
3169
3179
  private async provenance(sessionID?: string): Promise<RuntimeProvenance> {
3170
3180
  const listed: any = await this.ctx.plugin.list({ location: { directory: this.root } } as any)
3171
3181
  const entries = Array.isArray(listed) ? listed : listed?.data ?? []
3172
- const entry = entries.find((p: any) => p.id === "opencode-goal")
3182
+ const entry = entries.find((p: any) => p.id === "open-goal" || p.id === "opencode-goal")
3173
3183
  const source = entry?.source ?? { type: "unknown" }
3174
3184
  const state = sessionID ? this.runs.get(sessionID) : undefined
3175
3185
  const result: RuntimeProvenance = {
@@ -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