@cleocode/goal 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/dist/cli.js +6 -3
- package/package.json +1 -1
- package/src/contract/parse.ts +8 -2
- package/src/server/app.ts +50 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
- **Fixed — a contract locked before a validator tightening no longer bricks its run (D10).** A run whose locked generation carries a key the current validator rejects (the reported case: a free-text `budget.rationale` locked under 0.9.2) used to be unresumable: resume called it read-only, `goal_propose_amendment` claimed the disk differed, and `goal_recover_divergence` correctly said nothing differed. The locked bytes are now read leniently **only** to form the prior side of a repair amendment, the repair can be staged and owner-reviewed, and the resume refusal names the failing key and the exact `goal_propose_amendment` call instead of a generic read-only message. Strict validation of new and repaired contracts is unchanged.
|
|
6
|
+
- **Fixed — divergence detection reaches recovery even when the disk copy is invalid.** Differing bytes are a divergence; recovery now explains an invalid edit rather than reporting "nothing to recover" from a path that also refuses to stage.
|
|
7
|
+
|
|
3
8
|
## 0.9.3 — 2026-10-28
|
|
4
9
|
|
|
5
10
|
- **Fixed — uncommitted work in a linked worktree now counts as progress (T116).** The fingerprint already covered a linked worktree's commits; it now also hashes each linked worktree's staged/unstaged diff and untracked sizes/mtimes. A run that works only in `.worktrees/<slug>` no longer records `changed:false` and auto-pauses as stalled. Measured cost on this repo with 6 worktrees: ~200 ms per fingerprint.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 (`0.9.
|
|
5
|
+
> **Status: released (`0.9.4` on npm as `@cleocode/goal`).** Engine, verification and TUI are built and tested end to end against OpenCode 2.0.22 with a scripted model, and each release is proven from its own installed tag. CLEO integrates as an optional first-class citizen (`/goal cleo`). See [docs/HANDOFF.md](docs/HANDOFF.md).
|
|
6
6
|
>
|
|
7
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`.
|
|
8
8
|
|
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.4",
|
|
6914
6914
|
description: "CLEO Goal: persistent, host-verified goal mode for OpenCode 2 (and any harness through cleo-goal tick), with a structured goal contract and a live TUI.",
|
|
6915
6915
|
type: "module",
|
|
6916
6916
|
license: "MIT",
|
|
@@ -7413,8 +7413,11 @@ function parseContract(text, options = {}) {
|
|
|
7413
7413
|
err("budget", `budget must be a mapping with ${BUDGET_KEYS.join(", ")}`);
|
|
7414
7414
|
const budgetRaw = isRecord(raw.budget) ? raw.budget : {};
|
|
7415
7415
|
for (const key of Object.keys(budgetRaw)) {
|
|
7416
|
-
if (
|
|
7417
|
-
|
|
7416
|
+
if (BUDGET_KEYS.includes(key))
|
|
7417
|
+
continue;
|
|
7418
|
+
if (options.tolerateUnknownBudgetKeys)
|
|
7419
|
+
continue;
|
|
7420
|
+
err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`);
|
|
7418
7421
|
}
|
|
7419
7422
|
const budget = {};
|
|
7420
7423
|
if (budgetRaw.turns !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cleocode/goal",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "CLEO Goal: persistent, host-verified goal mode for OpenCode 2 (and any harness through cleo-goal tick), with a structured goal contract and a live TUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/contract/parse.ts
CHANGED
|
@@ -204,7 +204,7 @@ type YamlParse = (text: string) => unknown
|
|
|
204
204
|
const bunYaml = (globalThis as { Bun?: { YAML?: { parse: YamlParse } } }).Bun?.YAML
|
|
205
205
|
const parseYaml: YamlParse = (text) => (bunYaml ? bunYaml.parse(text) : parseYamlPortable(text))
|
|
206
206
|
|
|
207
|
-
export function parseContract(text: string, options: { slug?: string } = {}): ParseResult {
|
|
207
|
+
export function parseContract(text: string, options: { slug?: string; tolerateUnknownBudgetKeys?: boolean } = {}): ParseResult {
|
|
208
208
|
const issues: Issue[] = []
|
|
209
209
|
const lock = lockOf(text)
|
|
210
210
|
let raw: unknown
|
|
@@ -288,7 +288,13 @@ export function parseContract(text: string, options: { slug?: string } = {}): Pa
|
|
|
288
288
|
if (raw.budget !== undefined && !isRecord(raw.budget)) err("budget", `budget must be a mapping with ${BUDGET_KEYS.join(", ")}`)
|
|
289
289
|
const budgetRaw = isRecord(raw.budget) ? raw.budget : {}
|
|
290
290
|
for (const key of Object.keys(budgetRaw)) {
|
|
291
|
-
if (
|
|
291
|
+
if ((BUDGET_KEYS as readonly string[]).includes(key)) continue
|
|
292
|
+
// The default is a hard error (D9: a typo must not silently remove the
|
|
293
|
+
// budget). The repair path opts in to tolerating metadata keys so a
|
|
294
|
+
// contract locked under an older, laxer validator can still be read and
|
|
295
|
+
// amended forward instead of bricking its run.
|
|
296
|
+
if (options.tolerateUnknownBudgetKeys) continue
|
|
297
|
+
err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`)
|
|
292
298
|
}
|
|
293
299
|
const budget: Budget = {}
|
|
294
300
|
if (budgetRaw.turns !== undefined) {
|
package/src/server/app.ts
CHANGED
|
@@ -365,6 +365,18 @@ export class GoalApp {
|
|
|
365
365
|
return value
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
+
/** T163/D9 repair: a generation locked under an older, laxer validator can
|
|
369
|
+
* become unreadable when the validator tightens (for example a free-text
|
|
370
|
+
* `budget.rationale` key). The strict parser stays the default; this helper
|
|
371
|
+
* is used only to form the "prior" side of a repair amendment so the owner
|
|
372
|
+
* can stage a valid generation instead of hand-editing goal.yaml. It
|
|
373
|
+
* tolerates unknown `budget.*` keys and nothing else. */
|
|
374
|
+
private priorContract(text: string, slug: string): Contract | undefined {
|
|
375
|
+
const strict = parseContract(text, { slug })
|
|
376
|
+
if (strict.contract) return strict.contract
|
|
377
|
+
return parseContract(text, { slug, tolerateUnknownBudgetKeys: true }).contract
|
|
378
|
+
}
|
|
379
|
+
|
|
368
380
|
private contractOf(state: RunState): Contract | undefined {
|
|
369
381
|
const cached = this.loaded.get(state.slug)
|
|
370
382
|
if (cached && cached.lock === state.lock) return cached.contract
|
|
@@ -399,7 +411,10 @@ export class GoalApp {
|
|
|
399
411
|
* — never infer it from the presence of a pending proposal. */
|
|
400
412
|
private divergenceOf(state: RunState): { diskLock: string; diskText: string } | undefined {
|
|
401
413
|
const disk = this.store.readContract(state.slug)
|
|
402
|
-
|
|
414
|
+
// Differing bytes are a divergence even when the disk copy does not parse:
|
|
415
|
+
// recovery must at least be reachable so it can name an invalid edit
|
|
416
|
+
// instead of reporting "nothing to recover" while staging refuses.
|
|
417
|
+
if (!disk || disk.lock === state.lock) return undefined
|
|
403
418
|
return { diskLock: disk.lock, diskText: disk.text }
|
|
404
419
|
}
|
|
405
420
|
|
|
@@ -1190,18 +1205,25 @@ export class GoalApp {
|
|
|
1190
1205
|
// fresh diff there is not execution or transfer of the old consent.
|
|
1191
1206
|
if (state.sessionID !== sessionID && this.factsOf(state.sessionID).busy && state.status !== "paused") throw new Error("The selected goal is executing elsewhere; pause it before proposing an amendment")
|
|
1192
1207
|
const disk = this.store.readContract(state.slug)
|
|
1193
|
-
if (!disk
|
|
1208
|
+
if (!disk) throw new Error("No goal.yaml on disk for the selected goal")
|
|
1209
|
+
if (disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
|
|
1210
|
+
// D9 repair: when the lock still matches but the current validator now
|
|
1211
|
+
// rejects a metadata key, the locked generation is unreadable. Read it
|
|
1212
|
+
// leniently so the owner can stage a valid repair generation instead of
|
|
1213
|
+
// hitting a wall where propose refuses and recover says nothing differs.
|
|
1214
|
+
const prior = disk.contract ?? this.priorContract(disk.text, state.slug)
|
|
1215
|
+
if (!prior) throw new Error(`The locked contract is invalid and cannot be read for repair:\n${formatIssues(disk.issues)}`)
|
|
1194
1216
|
const next = parseContract(input.yaml, { slug: state.slug })
|
|
1195
1217
|
if (!next.contract) throw new Error(`Invalid amendment; current contract unchanged:\n${formatIssues(next.issues)}`)
|
|
1196
1218
|
if (next.lock === state.lock) throw new Error("Nothing changed; no amendment staged")
|
|
1197
|
-
const diff = amendmentDiff(
|
|
1219
|
+
const diff = amendmentDiff(prior, next.contract)
|
|
1198
1220
|
// Owner-set autonomy: inside the launched policy the agent applies the
|
|
1199
1221
|
// change itself (journalled, owner-revertible). Anything outside it,
|
|
1200
1222
|
// including the policy itself, is routed to the owner below.
|
|
1201
|
-
const policy =
|
|
1202
|
-
const change = classifyChange(
|
|
1223
|
+
const policy = prior.autonomy.amend
|
|
1224
|
+
const change = classifyChange(prior, next.contract)
|
|
1203
1225
|
const allowed = policyAllows(policy, change)
|
|
1204
|
-
if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state,
|
|
1226
|
+
if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, prior, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
|
|
1205
1227
|
const outsidePolicy = policy.length ? allowed.reasons : []
|
|
1206
1228
|
// A proposal is not permission to mutate the oracle. Stop the target
|
|
1207
1229
|
// under its OLD contract while the exact diff is being reviewed.
|
|
@@ -1574,18 +1596,28 @@ export class GoalApp {
|
|
|
1574
1596
|
const state = this.runs.get(request.targetSessionID)
|
|
1575
1597
|
const disk = this.store.readContract(request.slug)
|
|
1576
1598
|
const next = parseContract(request.nextText, { slug: request.slug })
|
|
1577
|
-
|
|
1599
|
+
// A lock match is what binds the review to this generation; the disk
|
|
1600
|
+
// contract may still be unreadable under a tightened validator when the
|
|
1601
|
+
// reviewed change repairs it (D9 repair path).
|
|
1602
|
+
if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
|
|
1578
1603
|
this.amendmentReviews.update(request.id, { status: "failed", result: "Target/run/contract changed since review; no amendment applied." })
|
|
1579
1604
|
this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "review binding changed" })
|
|
1580
1605
|
this.noticeM(request.sessionID, M.amendmentChanged())
|
|
1581
1606
|
return
|
|
1582
1607
|
}
|
|
1608
|
+
const prior = disk.contract ?? this.priorContract(disk.text, request.slug)
|
|
1609
|
+
if (!prior) {
|
|
1610
|
+
this.amendmentReviews.update(request.id, { status: "failed", result: "The locked contract is invalid and cannot be read; no amendment applied." })
|
|
1611
|
+
this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "locked contract unreadable" })
|
|
1612
|
+
this.noticeM(request.sessionID, M.amendmentChanged())
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1583
1615
|
const reserved = this.amendmentReviews.reserve(request.id)
|
|
1584
1616
|
if (!reserved) return
|
|
1585
1617
|
const acceptedAt = Date.now()
|
|
1586
1618
|
const stopped = structuredClone(state)
|
|
1587
1619
|
setStatus(stopped, "paused", "owner accepted amendment; resume when ready")
|
|
1588
|
-
const result = amendedState(stopped,
|
|
1620
|
+
const result = amendedState(stopped, prior, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
|
|
1589
1621
|
const approved = this.amendmentReviews.writeAhead(request.id, { state: result, acceptedAt })
|
|
1590
1622
|
try {
|
|
1591
1623
|
const applied = this.store.commitAmendment(approved)
|
|
@@ -3580,6 +3612,16 @@ export class GoalApp {
|
|
|
3580
3612
|
const divergent = liveGoal && this.divergenceOf(liveGoal)
|
|
3581
3613
|
if (liveGoal && divergent)
|
|
3582
3614
|
return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal), message: `${this.divergenceRefusal(liveGoal, divergent)} No ${action} was executed.` }
|
|
3615
|
+
// T163/D9 repair: the locked bytes themselves may fail the CURRENT
|
|
3616
|
+
// validator even though the disk was never edited (a generation locked
|
|
3617
|
+
// by an older, laxer parser). That is not a read-only draft and not a
|
|
3618
|
+
// divergence; name the invalid key and the one supported fix.
|
|
3619
|
+
if (liveGoal) {
|
|
3620
|
+
const disk = this.store.readContract(liveGoal.slug)
|
|
3621
|
+
if (disk && disk.lock === liveGoal.lock && !disk.contract)
|
|
3622
|
+
return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal),
|
|
3623
|
+
message: `The locked contract of "${liveGoal.slug}" is invalid under this plugin version, so no ${action} can run:\n${formatIssues(disk.issues)}\nThe disk bytes ARE the locked generation (SHA256 ${liveGoal.lock.slice(0, 12)}…), so there is nothing to recover. Fix only the named key(s) and stage a validated replacement with goal_propose_amendment {"slug":"${liveGoal.slug}","expectedLock":"${liveGoal.lock}","yaml":"<full corrected goal.yaml>"}; the owner reviews and accepts it, which re-locks the run. No hand-edit and no ${action} was executed.` }
|
|
3624
|
+
}
|
|
3583
3625
|
return { ok: false, message: "This selected goal is read-only (draft, archived or invalid); review/launch a valid draft instead." }
|
|
3584
3626
|
}
|
|
3585
3627
|
const state = [...this.runs.values()].find((s) => s.slug === selected.slug && s.runId === selected.runId) ?? this.store.readRun(selected.slug)
|