@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 +16 -0
- package/README.md +7 -5
- package/dist/cli.js +45 -17
- package/dist/tui.js +13 -4
- package/package.json +4 -4
- package/skills/write-goal/SKILL.md +2 -2
- package/skills/write-goal/references/goal-schema.md +2 -2
- package/src/cli/install.ts +1 -1
- package/src/contract/parse.ts +9 -0
- package/src/engine/decisions.ts +1 -1
- package/src/engine/loop.ts +7 -2
- package/src/engine/state.ts +2 -1
- package/src/rpc.ts +2 -2
- package/src/server/app.ts +13 -3
- package/src/server/help.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/messages.ts +8 -0
- package/src/summary/build.ts +8 -2
- package/src/tui/dashboard.ts +7 -1
- package/src/tui/format.ts +3 -0
- package/src/tui/index.tsx +1 -1
- package/src/util/git.ts +32 -17
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
|
-
#
|
|
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 (`
|
|
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 #
|
|
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/
|
|
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/
|
|
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.
|
|
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/
|
|
6969
|
+
url: "git+https://github.com/kryptobaseddev/open-goal.git"
|
|
6970
6970
|
},
|
|
6971
|
-
homepage: "https://github.com/kryptobaseddev/
|
|
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/
|
|
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
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
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(
|
|
7742
|
-
`)).
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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.
|
|
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/
|
|
59
|
+
"url": "git+https://github.com/kryptobaseddev/open-goal.git"
|
|
60
60
|
},
|
|
61
|
-
"homepage": "https://github.com/kryptobaseddev/
|
|
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/
|
|
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
|
|
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.
|
package/src/cli/install.ts
CHANGED
|
@@ -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>
|
package/src/contract/parse.ts
CHANGED
|
@@ -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)
|
package/src/engine/decisions.ts
CHANGED
|
@@ -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
|
package/src/engine/loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/engine/state.ts
CHANGED
|
@@ -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: "
|
|
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 = {
|
package/src/server/help.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type HelpFacts = { version: string }
|
|
|
7
7
|
|
|
8
8
|
export function goalHelp({ version }: HelpFacts): string {
|
|
9
9
|
return [
|
|
10
|
-
`#
|
|
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)",
|
package/src/server/index.ts
CHANGED
package/src/server/messages.ts
CHANGED
|
@@ -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" },
|
package/src/summary/build.ts
CHANGED
|
@@ -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>")
|
package/src/tui/dashboard.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
|
|
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(
|
|
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
|