@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 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.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).
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.3",
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 (!BUDGET_KEYS.includes(key))
7417
- err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`);
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",
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",
@@ -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 (!(BUDGET_KEYS as readonly string[]).includes(key)) err(`budget.${key}`, `unknown key; allowed: ${BUDGET_KEYS.join(", ")}`)
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
- if (!disk?.contract || disk.lock === state.lock) return undefined
414
+ // Differing bytes are a divergence even when the disk copy does not parse:
415
+ // recovery must at least be reachable so it can name an invalid edit
416
+ // instead of reporting "nothing to recover" while staging refuses.
417
+ if (!disk || disk.lock === state.lock) return undefined
403
418
  return { diskLock: disk.lock, diskText: disk.text }
404
419
  }
405
420
 
@@ -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?.contract || disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1208
+ if (!disk) throw new Error("No goal.yaml on disk for the selected goal")
1209
+ if (disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1210
+ // D9 repair: when the lock still matches but the current validator now
1211
+ // rejects a metadata key, the locked generation is unreadable. Read it
1212
+ // leniently so the owner can stage a valid repair generation instead of
1213
+ // hitting a wall where propose refuses and recover says nothing differs.
1214
+ const prior = disk.contract ?? this.priorContract(disk.text, state.slug)
1215
+ if (!prior) throw new Error(`The locked contract is invalid and cannot be read for repair:\n${formatIssues(disk.issues)}`)
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(disk.contract, next.contract)
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 = disk.contract.autonomy.amend
1202
- const change = classifyChange(disk.contract, next.contract)
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, disk.contract, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
1226
+ if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, prior, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
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
- if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk?.contract || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
1599
+ // A lock match is what binds the review to this generation; the disk
1600
+ // contract may still be unreadable under a tightened validator when the
1601
+ // reviewed change repairs it (D9 repair path).
1602
+ if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
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, disk.contract, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
1620
+ const result = amendedState(stopped, prior, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
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)