@cleocode/goal 0.9.3 → 0.9.5

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.5 — 2026-10-28
4
+
5
+ - **Every run records which harness produced it (T125).** `RunState.harness` is `{name, version, capabilities[]}`: the OpenCode engine records `name: "opencode"`, its serving package version, and the surfaces it can actually drive. The ledger's `start` event carries the same identity, so a repaired run keeps its producer. `goal_status`, the RPC snapshot/view and `goal_provenance` expose it. The field is optional and additive — `schemaVersion` stays 1, and a run written before it existed parses unchanged and is not retro-labelled.
6
+ - **`cleo-goal status --json` reports the harness (T130).** Present when the run recorded one, omitted when it did not; no schema bump. A CLEO that has not yet landed T13875 ignores it.
7
+ - **The Stop-dispatched tick records the harness CLEO declares (T131).** `CLEO_HOOK_PROVIDER=claude-code` records `claude`, `codex` records `codex`, and absent or any other value records `unknown`. Version and capabilities stay `null` on that path because the dispatcher does not supply them, and the Stop payload/transcript is never consulted for identity. The first writer wins: a later tick never renames the harness that produced the run. No `OCGOAL_HARNESS` variable and no `--harness` flag.
8
+ - **`scripts/check-installed-goal.ts` works for a workspace-bound goal (T124).** The check now compares the serving runtime's location with the project checkout (where the plugin actually serves), not the linked worktree the host checks run in. A non-workspace goal is unaffected (`projectRoot === root`), and the check still rejects a runtime serving any other project.
9
+ - **Documentation.** `docs/research/harness-identity.md` records the field, the dispatcher signal, every capability's meaning, the CLEO T13875 contract quote, and the current `cleo doctor goal-runtime` output.
10
+
11
+ ## 0.9.4 — 2026-10-28
12
+
13
+ - **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.
14
+ - **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.
15
+
3
16
  ## 0.9.3 — 2026-10-28
4
17
 
5
18
  - **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.5",
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) {
@@ -7703,6 +7706,7 @@ function goalStatus(store, now = Date.now()) {
7703
7706
  title: run.title,
7704
7707
  status: run.status,
7705
7708
  ...run.priority ? { priority: run.priority } : {},
7709
+ ...run.harness ? { harness: run.harness } : {},
7706
7710
  turn: run.turn,
7707
7711
  reason: run.reason ?? null,
7708
7712
  criteria,
@@ -8248,6 +8252,22 @@ function recordTurn(state, _contract, inputs) {
8248
8252
  return { turn: state.turn, changed, progress, claimHonoured, ...mined ? { mined } : {}, couldNotSee };
8249
8253
  }
8250
8254
 
8255
+ // src/engine/harness-identity.ts
8256
+ var CLEO_HOOK_PROVIDER = "CLEO_HOOK_PROVIDER";
8257
+ function harnessFromHookProvider(value) {
8258
+ switch ((value ?? "").trim()) {
8259
+ case "claude-code":
8260
+ return "claude";
8261
+ case "codex":
8262
+ return "codex";
8263
+ default:
8264
+ return "unknown";
8265
+ }
8266
+ }
8267
+ function dispatcherIdentity(env = {}) {
8268
+ return { name: harnessFromHookProvider(env[CLEO_HOOK_PROVIDER]), version: null, capabilities: null };
8269
+ }
8270
+
8251
8271
  // src/engine/tick.ts
8252
8272
  var TICK_LIMITS = { blockerRepeats: 3, stallTurns: 3, recoveryAt: 2, backstopTurns: 200 };
8253
8273
  var TICK_COOLDOWN_MS = 1500;
@@ -8313,6 +8333,14 @@ async function tick(deps) {
8313
8333
  const record = recordTurn(live, contract, { fingerprint: deps.fingerprint ?? (() => {
8314
8334
  return;
8315
8335
  }), ...deps.payload ? { payload: deps.payload } : {}, ...deps.readTranscript ? { readTranscript: deps.readTranscript } : {}, now });
8336
+ const identity = dispatcherIdentity(deps.env ?? process.env);
8337
+ if (!live.harness) {
8338
+ live.harness = identity;
8339
+ deps.store.ledger(live.slug, { type: "harness", name: identity.name, version: identity.version, capabilities: identity.capabilities, source: "tick" });
8340
+ log.push(`recorded harness ${identity.name}${identity.name === "unknown" ? " (no CLEO_HOOK_PROVIDER declared)" : ""}`);
8341
+ } else if (live.harness.name !== identity.name && identity.name !== "unknown") {
8342
+ log.push(`harness is already recorded as ${live.harness.name}; the dispatcher declared ${identity.name} - keeping the producer`);
8343
+ }
8316
8344
  deps.store.ledger(live.slug, {
8317
8345
  type: "turn",
8318
8346
  turn: live.turn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/goal",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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/cli/status.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { parseContract } from "../contract/parse"
5
5
  import type { Contract } from "../contract/types"
6
6
  import { budgetUse, isTerminal, type RunState, type Status } from "../engine/state"
7
+ import type { HarnessIdentity } from "../engine/harness-identity"
7
8
  import type { Store } from "../engine/store"
8
9
 
9
10
  export const STATUS_SCHEMA_VERSION = 1
@@ -25,6 +26,12 @@ export type GoalStatusJson = {
25
26
  title: string
26
27
  status: Status | "none"
27
28
  priority?: "low" | "medium" | "high"
29
+ /**
30
+ * v0.9.5: which harness produced this run and what it can do. Optional and
31
+ * additive: `schemaVersion` stays 1 and a run that predates the field simply
32
+ * omits it, so an older CLEO reads the same shape it always did.
33
+ */
34
+ harness?: HarnessIdentity
28
35
  turn: number
29
36
  reason: string | null
30
37
  criteria: StatusCriterion[]
@@ -103,6 +110,7 @@ export function goalStatus(store: Store, now = Date.now()): GoalStatusJson {
103
110
  title: run.title,
104
111
  status: run.status,
105
112
  ...(run.priority ? { priority: run.priority } : {}),
113
+ ...(run.harness ? { harness: run.harness } : {}),
106
114
  turn: run.turn,
107
115
  reason: run.reason ?? null,
108
116
  criteria,
@@ -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) {
@@ -0,0 +1,95 @@
1
+ // v0.9.5 - which harness produced a run, and what that harness can actually do.
2
+ //
3
+ // Every run records a declared identity: `{name, version, capabilities[]}`. It is
4
+ // additive and optional — `schemaVersion` stays 1 and a run written before this
5
+ // field existed still parses. Two paths declare it, and neither ever guesses:
6
+ //
7
+ // opencode the in-process engine knows its own harness, its serving package
8
+ // version and the surfaces it can actually drive (`OPENCODE_CAPABILITIES`)
9
+ // claude / CLEO's Stop dispatcher names the harness in `CLEO_HOOK_PROVIDER`
10
+ // codex (CLEO T13884). It knows nothing else, so version and capabilities
11
+ // stay null — the run says "not known on this path", never an empty
12
+ // array pretending to be a set
13
+ //
14
+ // Anything else is `unknown`. A transcript (a Stop payload) is data the harness
15
+ // wrote; it never renames the harness. There is deliberately no second
16
+ // convention: no `OCGOAL_HARNESS` variable and no `--harness` flag.
17
+ //
18
+ // The capability names are the seam vocabulary the adapter-seam goal (0.10.0)
19
+ // formalises into a matrix; this goal declares the OpenCode set only. The list
20
+ // is documented in `docs/research/harness-identity.md`.
21
+
22
+ /** The harnesses this version names. `unknown` is a real answer, not a fallback. */
23
+ export type HarnessName = "opencode" | "claude" | "codex" | "unknown"
24
+
25
+ /** The identity a run records. Optional and additive: old runs never had one. */
26
+ export type HarnessIdentity = {
27
+ name: HarnessName
28
+ /** The serving harness's own version, when that path knows it. */
29
+ version: string | null
30
+ /** What the harness can deliver here; null means "not known on this path". */
31
+ capabilities: string[] | null
32
+ }
33
+
34
+ /**
35
+ * What the OpenCode in-process engine can actually deliver. Each name is a
36
+ * surface this code base really drives:
37
+ *
38
+ * start admit a goal into a session (goal_start, owner-approved)
39
+ * continuation keep the agent working after a turn (session.execution.* → admit)
40
+ * events a live turn/usage event stream (not just a Stop-time notification)
41
+ * tools the agent calls the goal_* tools by name
42
+ * forms a native owner form in the composer (ask tool)
43
+ * verifier an independent verifier child session
44
+ * tui the in-process dashboard/manager UI
45
+ * steering an owner message reaches the run mid-turn
46
+ * version the harness reports its own serving version
47
+ *
48
+ * `transcript` is absent on purpose: OpenCode reports events, so the engine never
49
+ * reads a transcript to learn what happened.
50
+ */
51
+ export const OPENCODE_CAPABILITIES: readonly string[] = ["start", "continuation", "events", "tools", "forms", "verifier", "tui", "steering", "version"]
52
+
53
+ /** The identity the OpenCode engine records at goal start. */
54
+ export function opencodeIdentity(version: string | null | undefined): HarnessIdentity {
55
+ return { name: "opencode", version: version ?? null, capabilities: [...OPENCODE_CAPABILITIES] }
56
+ }
57
+
58
+ /** CLEO's dispatcher sets this on the `cleo-goal tick` child (CLEO T13884). */
59
+ export const CLEO_HOOK_PROVIDER = "CLEO_HOOK_PROVIDER"
60
+
61
+ /**
62
+ * Map the dispatcher's provider id onto a harness name. Only the two values CLEO
63
+ * actually sets are mapped; everything else — including absent — is `unknown`.
64
+ * Nothing is inferred from the Stop payload or the transcript.
65
+ */
66
+ export function harnessFromHookProvider(value: string | undefined): HarnessName {
67
+ switch ((value ?? "").trim()) {
68
+ case "claude-code":
69
+ return "claude"
70
+ case "codex":
71
+ return "codex"
72
+ default:
73
+ return "unknown"
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The identity a Stop-dispatched tick records: the declared name only. CLEO does
79
+ * not tell the dispatcher the harness version or its capabilities, so they stay
80
+ * null rather than being invented from what the payload happens to contain.
81
+ */
82
+ export function dispatcherIdentity(env: Record<string, string | undefined> = {}): HarnessIdentity {
83
+ return { name: harnessFromHookProvider(env[CLEO_HOOK_PROVIDER]), version: null, capabilities: null }
84
+ }
85
+
86
+ /** A recorded identity is usable only if it has the documented shape. */
87
+ export function isHarnessIdentity(value: unknown): value is HarnessIdentity {
88
+ if (!value || typeof value !== "object") return false
89
+ const identity = value as Partial<HarnessIdentity>
90
+ return (
91
+ (identity.name === "opencode" || identity.name === "claude" || identity.name === "codex" || identity.name === "unknown") &&
92
+ (identity.version === null || typeof identity.version === "string") &&
93
+ (identity.capabilities === null || (Array.isArray(identity.capabilities) && identity.capabilities.every((c) => typeof c === "string")))
94
+ )
95
+ }
@@ -2,6 +2,7 @@
2
2
  // model can report progress, claim, block, flag and wait; only the owner (or the
3
3
  // host acting on owner policy) can start, resume, pause, abort or approve.
4
4
  import type { Contract } from "../contract/types"
5
+ import type { HarnessIdentity } from "./harness-identity"
5
6
 
6
7
  export type Status =
7
8
  | "running"
@@ -39,6 +40,12 @@ export type RunState = {
39
40
  title: string
40
41
  /** T061: copied from the contract at start so the registry/rebuild keep it. */
41
42
  priority?: "low" | "medium" | "high"
43
+ /**
44
+ * v0.9.5: which harness produced this run and what it can deliver. Optional
45
+ * and additive — a run written before this field existed has none and still
46
+ * parses as `version: 1`.
47
+ */
48
+ harness?: HarnessIdentity
42
49
  sessionID: string
43
50
  runId: string
44
51
  lock: string
@@ -83,13 +90,14 @@ export type RunState = {
83
90
  }
84
91
  }
85
92
 
86
- export function initialRun(contract: Contract, args: { sessionID: string; runId: string; lock: string; commit?: string; now?: number }): RunState {
93
+ export function initialRun(contract: Contract, args: { sessionID: string; runId: string; lock: string; commit?: string; harness?: HarnessIdentity; now?: number }): RunState {
87
94
  const now = args.now ?? Date.now()
88
95
  return {
89
96
  version: 1,
90
97
  slug: contract.id,
91
98
  title: contract.title,
92
99
  ...(contract.priority ? { priority: contract.priority } : {}),
100
+ ...(args.harness ? { harness: args.harness } : {}),
93
101
  sessionID: args.sessionID,
94
102
  runId: args.runId,
95
103
  lock: args.lock,
@@ -22,6 +22,7 @@ import { renderTailNote } from "../contract/render"
22
22
  import { budgetUse, setStatus, TERMINAL as TERMINAL_STATUSES, type RunState } from "./state"
23
23
  import { continuationTail, decide, type LoopLimits, type LoopSignals } from "./loop"
24
24
  import { recordTurn } from "./tick-record"
25
+ import { dispatcherIdentity } from "./harness-identity"
25
26
  import type { VerifyOutcome } from "../verify/pipeline"
26
27
 
27
28
  /** The plugin's own defaults, so a tick agrees with the adapter unless overridden. */
@@ -49,6 +50,8 @@ export type TickDeps = {
49
50
  verify?: (state: RunState, contract: Contract, contractText: string) => Promise<VerifyOutcome>
50
51
  /** The raw harness Stop payload, when the harness passed one. */
51
52
  payload?: string
53
+ /** The dispatcher's environment. Defaults to the real one. */
54
+ env?: Record<string, string | undefined>
52
55
  readTranscript?: (path: string) => string | undefined
53
56
  limits?: LoopLimits
54
57
  cooldownMs?: number
@@ -130,6 +133,18 @@ export async function tick(deps: TickDeps): Promise<TickResult> {
130
133
 
131
134
  // ── 1. RECORD the turn, through OCG's own store, in the adapter's own shape.
132
135
  const record = recordTurn(live, contract, { fingerprint: deps.fingerprint ?? (() => undefined), ...(deps.payload ? { payload: deps.payload } : {}), ...(deps.readTranscript ? { readTranscript: deps.readTranscript } : {}), now })
136
+ // ── 1a. DECLARE the harness that produced this run, once. CLEO's dispatcher
137
+ // names it (`CLEO_HOOK_PROVIDER`); absent or unrecognised is `unknown`, never a
138
+ // guess from the payload. A run already carries its producer, so it is never
139
+ // renamed by a later tick.
140
+ const identity = dispatcherIdentity(deps.env ?? process.env)
141
+ if (!live.harness) {
142
+ live.harness = identity
143
+ deps.store.ledger(live.slug, { type: "harness", name: identity.name, version: identity.version, capabilities: identity.capabilities, source: "tick" })
144
+ log.push(`recorded harness ${identity.name}${identity.name === "unknown" ? " (no CLEO_HOOK_PROVIDER declared)" : ""}`)
145
+ } else if (live.harness.name !== identity.name && identity.name !== "unknown") {
146
+ log.push(`harness is already recorded as ${live.harness.name}; the dispatcher declared ${identity.name} - keeping the producer`)
147
+ }
133
148
  deps.store.ledger(live.slug, {
134
149
  type: "turn",
135
150
  turn: live.turn,
package/src/rpc.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // function), so neither side needs @opencode/plugin at runtime.
3
3
  const anyObject = { type: "object", additionalProperties: true } as const
4
4
  import type { Handoff } from "./engine/state"
5
+ import type { HarnessIdentity } from "./engine/harness-identity"
5
6
  import type { LaunchView } from "./engine/launches"
6
7
  export type { LaunchView } from "./engine/launches"
7
8
  // V2 RPC's schema adapter rejects regex patterns. Length is declared here;
@@ -120,6 +121,8 @@ export type GoalView = {
120
121
  /** T106: the tuple goal_manage accepts, so a caller never loses a round to
121
122
  * discovery after a generation change — key, runId, sessionID and lock. */
122
123
  target?: GoalTarget
124
+ /** v0.9.5: the harness that produced this run (absent on older runs). */
125
+ harness?: HarnessIdentity
123
126
  turn: number
124
127
  timeline: TimelineEntry[]
125
128
  activeMs: number
package/src/server/app.ts CHANGED
@@ -12,6 +12,7 @@ import type { Contract } from "../contract/types"
12
12
  import { isHostCheck } from "../contract/types"
13
13
  import { GoalRpc, type GoalSummary, type GoalTarget, type GoalView, type ManageAction } from "../rpc"
14
14
  import { account, budgetUse, initialRun, isActive, isTerminal, modelCan, ownerCan, setStatus, type Handoff, type PendingKind, type RunState, type Status } from "../engine/state"
15
+ import { isHarnessIdentity, opencodeIdentity } from "../engine/harness-identity"
15
16
  import { Store } from "../engine/store"
16
17
  import { Registry, byPriorityThenRecency } from "../engine/registry"
17
18
  import { LaunchStore, launchChoices, launchView, processAlive, type LaunchActivity, type LaunchRequest } from "../engine/launches"
@@ -329,7 +330,7 @@ export class GoalApp {
329
330
 
330
331
  const kept = this.store.keepCorruptRun(slug, text)
331
332
  const lastTurn = this.store.recent(slug, 5000).filter((e) => e.type === "turn" && typeof e.turn === "number").at(-1)
332
- const state = initialRun(parsed.contract, { sessionID, runId, lock, ...(typeof start.commit === "string" ? { commit: start.commit } : {}) })
333
+ const state = initialRun(parsed.contract, { sessionID, runId, lock, ...(typeof start.commit === "string" ? { commit: start.commit } : {}), ...(isHarnessIdentity(start.harness) ? { harness: start.harness } : {}) })
333
334
  state.turn = Number(lastTurn?.turn ?? 0)
334
335
  for (const id of Object.keys(state.criteria)) {
335
336
  const recorded = this.store.recent(slug, 5000).findLast((e) => e.type === "criterion" && e.criterion === id) as any
@@ -365,6 +366,18 @@ export class GoalApp {
365
366
  return value
366
367
  }
367
368
 
369
+ /** T163/D9 repair: a generation locked under an older, laxer validator can
370
+ * become unreadable when the validator tightens (for example a free-text
371
+ * `budget.rationale` key). The strict parser stays the default; this helper
372
+ * is used only to form the "prior" side of a repair amendment so the owner
373
+ * can stage a valid generation instead of hand-editing goal.yaml. It
374
+ * tolerates unknown `budget.*` keys and nothing else. */
375
+ private priorContract(text: string, slug: string): Contract | undefined {
376
+ const strict = parseContract(text, { slug })
377
+ if (strict.contract) return strict.contract
378
+ return parseContract(text, { slug, tolerateUnknownBudgetKeys: true }).contract
379
+ }
380
+
368
381
  private contractOf(state: RunState): Contract | undefined {
369
382
  const cached = this.loaded.get(state.slug)
370
383
  if (cached && cached.lock === state.lock) return cached.contract
@@ -399,7 +412,10 @@ export class GoalApp {
399
412
  * — never infer it from the presence of a pending proposal. */
400
413
  private divergenceOf(state: RunState): { diskLock: string; diskText: string } | undefined {
401
414
  const disk = this.store.readContract(state.slug)
402
- if (!disk?.contract || disk.lock === state.lock) return undefined
415
+ // Differing bytes are a divergence even when the disk copy does not parse:
416
+ // recovery must at least be reachable so it can name an invalid edit
417
+ // instead of reporting "nothing to recover" while staging refuses.
418
+ if (!disk || disk.lock === state.lock) return undefined
403
419
  return { diskLock: disk.lock, diskText: disk.text }
404
420
  }
405
421
 
@@ -545,6 +561,7 @@ export class GoalApp {
545
561
  runId: state.runId,
546
562
  sessionID: state.sessionID,
547
563
  lock: state.lock,
564
+ ...(state.harness ? { harness: state.harness } : {}),
548
565
  target: this.targetOf(state),
549
566
  turn: state.turn,
550
567
  timeline: this.timelineOf(state),
@@ -1190,18 +1207,25 @@ export class GoalApp {
1190
1207
  // fresh diff there is not execution or transfer of the old consent.
1191
1208
  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
1209
  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")
1210
+ if (!disk) throw new Error("No goal.yaml on disk for the selected goal")
1211
+ if (disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1212
+ // D9 repair: when the lock still matches but the current validator now
1213
+ // rejects a metadata key, the locked generation is unreadable. Read it
1214
+ // leniently so the owner can stage a valid repair generation instead of
1215
+ // hitting a wall where propose refuses and recover says nothing differs.
1216
+ const prior = disk.contract ?? this.priorContract(disk.text, state.slug)
1217
+ if (!prior) throw new Error(`The locked contract is invalid and cannot be read for repair:\n${formatIssues(disk.issues)}`)
1194
1218
  const next = parseContract(input.yaml, { slug: state.slug })
1195
1219
  if (!next.contract) throw new Error(`Invalid amendment; current contract unchanged:\n${formatIssues(next.issues)}`)
1196
1220
  if (next.lock === state.lock) throw new Error("Nothing changed; no amendment staged")
1197
- const diff = amendmentDiff(disk.contract, next.contract)
1221
+ const diff = amendmentDiff(prior, next.contract)
1198
1222
  // Owner-set autonomy: inside the launched policy the agent applies the
1199
1223
  // change itself (journalled, owner-revertible). Anything outside it,
1200
1224
  // 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)
1225
+ const policy = prior.autonomy.amend
1226
+ const change = classifyChange(prior, next.contract)
1203
1227
  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) }
1228
+ if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, prior, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
1205
1229
  const outsidePolicy = policy.length ? allowed.reasons : []
1206
1230
  // A proposal is not permission to mutate the oracle. Stop the target
1207
1231
  // under its OLD contract while the exact diff is being reviewed.
@@ -1574,18 +1598,28 @@ export class GoalApp {
1574
1598
  const state = this.runs.get(request.targetSessionID)
1575
1599
  const disk = this.store.readContract(request.slug)
1576
1600
  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) {
1601
+ // A lock match is what binds the review to this generation; the disk
1602
+ // contract may still be unreadable under a tightened validator when the
1603
+ // reviewed change repairs it (D9 repair path).
1604
+ 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
1605
  this.amendmentReviews.update(request.id, { status: "failed", result: "Target/run/contract changed since review; no amendment applied." })
1579
1606
  this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "review binding changed" })
1580
1607
  this.noticeM(request.sessionID, M.amendmentChanged())
1581
1608
  return
1582
1609
  }
1610
+ const prior = disk.contract ?? this.priorContract(disk.text, request.slug)
1611
+ if (!prior) {
1612
+ this.amendmentReviews.update(request.id, { status: "failed", result: "The locked contract is invalid and cannot be read; no amendment applied." })
1613
+ this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "locked contract unreadable" })
1614
+ this.noticeM(request.sessionID, M.amendmentChanged())
1615
+ return
1616
+ }
1583
1617
  const reserved = this.amendmentReviews.reserve(request.id)
1584
1618
  if (!reserved) return
1585
1619
  const acceptedAt = Date.now()
1586
1620
  const stopped = structuredClone(state)
1587
1621
  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)
1622
+ const result = amendedState(stopped, prior, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
1589
1623
  const approved = this.amendmentReviews.writeAhead(request.id, { state: result, acceptedAt })
1590
1624
  try {
1591
1625
  const applied = this.store.commitAmendment(approved)
@@ -1806,7 +1840,7 @@ export class GoalApp {
1806
1840
  const current = this.load(slug, true)
1807
1841
  if ("error" in current || current.lock !== options.expectedLock) return "Refused: contract changed during launch rehearsal; no run created. Prepare a new review."
1808
1842
  }
1809
- const state = initialRun(contract, { sessionID, runId: newRunId(), lock, commit: headCommit(exec) })
1843
+ const state = initialRun(contract, { sessionID, runId: newRunId(), lock, commit: headCommit(exec), harness: opencodeIdentity(this.packageVersion) })
1810
1844
  if (options.handoff) state.handoff = options.handoff
1811
1845
  state.fingerprint = fingerprint(exec)
1812
1846
  if (rehearsal.length) {
@@ -1819,7 +1853,7 @@ export class GoalApp {
1819
1853
  this.store.evidence(slug, state.runId, "contract", { lock, text })
1820
1854
  await Bun.write(join(this.store.dir(slug), "evidence", state.runId, "contract.yaml"), text)
1821
1855
  this.runs.set(sessionID, state)
1822
- this.store.ledger(slug, { type: "start", runId: state.runId, sessionID, source, lock, commit: state.base.commit, ...(options.decisionId ? { decisionId: options.decisionId } : {}) })
1856
+ this.store.ledger(slug, { type: "start", runId: state.runId, sessionID, source, lock, commit: state.base.commit, ...(state.harness ? { harness: state.harness } : {}), ...(options.decisionId ? { decisionId: options.decisionId } : {}) })
1823
1857
  // T027 / v0.5.0: record the CLEO linkage and health (CLI-only). A project
1824
1858
  // with no CLEO CLI records nothing and behaves EXACTLY as before; a project
1825
1859
  // with the CLI gets an honest state, and nothing is ever installed here.
@@ -3192,7 +3226,7 @@ export class GoalApp {
3192
3226
  artifacts: this.artifacts,
3193
3227
  status: entry?.state?.status ?? "unknown",
3194
3228
  features: { server: entry?.features?.server === true, tui: entry?.features?.tui === true, rpc: entry?.features?.rpc === true },
3195
- ...(state ? { sessionID: state.sessionID, runId: state.runId, lock: state.lock } : {}),
3229
+ ...(state ? { sessionID: state.sessionID, runId: state.runId, lock: state.lock, ...(state.harness ? { harness: state.harness } : {}) } : {}),
3196
3230
  }
3197
3231
  // T115: this is a READ-ONLY probe. It returns the runtime identity and
3198
3232
  // writes NOTHING under .opencode/goals, so no read can look like an agent
@@ -3580,6 +3614,16 @@ export class GoalApp {
3580
3614
  const divergent = liveGoal && this.divergenceOf(liveGoal)
3581
3615
  if (liveGoal && divergent)
3582
3616
  return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal), message: `${this.divergenceRefusal(liveGoal, divergent)} No ${action} was executed.` }
3617
+ // T163/D9 repair: the locked bytes themselves may fail the CURRENT
3618
+ // validator even though the disk was never edited (a generation locked
3619
+ // by an older, laxer parser). That is not a read-only draft and not a
3620
+ // divergence; name the invalid key and the one supported fix.
3621
+ if (liveGoal) {
3622
+ const disk = this.store.readContract(liveGoal.slug)
3623
+ if (disk && disk.lock === liveGoal.lock && !disk.contract)
3624
+ return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal),
3625
+ 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.` }
3626
+ }
3583
3627
  return { ok: false, message: "This selected goal is read-only (draft, archived or invalid); review/launch a valid draft instead." }
3584
3628
  }
3585
3629
  const state = [...this.runs.values()].find((s) => s.slug === selected.slug && s.runId === selected.runId) ?? this.store.readRun(selected.slug)
@@ -5,6 +5,7 @@
5
5
  import { createHash } from "node:crypto"
6
6
  import { lstatSync, readFileSync, readdirSync } from "node:fs"
7
7
  import { join } from "node:path"
8
+ import type { HarnessIdentity } from "../engine/harness-identity"
8
9
 
9
10
  export type ArtifactHashes = Record<string, string>
10
11
  export type ArtifactSource = { type: string; target?: string; version?: string; outdated?: boolean; path?: string }
@@ -24,6 +25,8 @@ export type RuntimeProvenance = {
24
25
  sessionID?: string
25
26
  runId?: string
26
27
  lock?: string
28
+ /** v0.9.5: the harness serving this run, as the run recorded it. */
29
+ harness?: HarnessIdentity
27
30
  }
28
31
 
29
32
  const entrypoints = ["package.json", "server.ts", "tui.tsx", "rpc.ts"]