@cleocode/goal 0.9.4 → 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,13 @@
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
+
3
11
  ## 0.9.4 — 2026-10-28
4
12
 
5
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.
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.4",
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",
@@ -7706,6 +7706,7 @@ function goalStatus(store, now = Date.now()) {
7706
7706
  title: run.title,
7707
7707
  status: run.status,
7708
7708
  ...run.priority ? { priority: run.priority } : {},
7709
+ ...run.harness ? { harness: run.harness } : {},
7709
7710
  turn: run.turn,
7710
7711
  reason: run.reason ?? null,
7711
7712
  criteria,
@@ -8251,6 +8252,22 @@ function recordTurn(state, _contract, inputs) {
8251
8252
  return { turn: state.turn, changed, progress, claimHonoured, ...mined ? { mined } : {}, couldNotSee };
8252
8253
  }
8253
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
+
8254
8271
  // src/engine/tick.ts
8255
8272
  var TICK_LIMITS = { blockerRepeats: 3, stallTurns: 3, recoveryAt: 2, backstopTurns: 200 };
8256
8273
  var TICK_COOLDOWN_MS = 1500;
@@ -8316,6 +8333,14 @@ async function tick(deps) {
8316
8333
  const record = recordTurn(live, contract, { fingerprint: deps.fingerprint ?? (() => {
8317
8334
  return;
8318
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
+ }
8319
8344
  deps.store.ledger(live.slug, {
8320
8345
  type: "turn",
8321
8346
  turn: live.turn,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/goal",
3
- "version": "0.9.4",
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,
@@ -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
@@ -560,6 +561,7 @@ export class GoalApp {
560
561
  runId: state.runId,
561
562
  sessionID: state.sessionID,
562
563
  lock: state.lock,
564
+ ...(state.harness ? { harness: state.harness } : {}),
563
565
  target: this.targetOf(state),
564
566
  turn: state.turn,
565
567
  timeline: this.timelineOf(state),
@@ -1838,7 +1840,7 @@ export class GoalApp {
1838
1840
  const current = this.load(slug, true)
1839
1841
  if ("error" in current || current.lock !== options.expectedLock) return "Refused: contract changed during launch rehearsal; no run created. Prepare a new review."
1840
1842
  }
1841
- 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) })
1842
1844
  if (options.handoff) state.handoff = options.handoff
1843
1845
  state.fingerprint = fingerprint(exec)
1844
1846
  if (rehearsal.length) {
@@ -1851,7 +1853,7 @@ export class GoalApp {
1851
1853
  this.store.evidence(slug, state.runId, "contract", { lock, text })
1852
1854
  await Bun.write(join(this.store.dir(slug), "evidence", state.runId, "contract.yaml"), text)
1853
1855
  this.runs.set(sessionID, state)
1854
- 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 } : {}) })
1855
1857
  // T027 / v0.5.0: record the CLEO linkage and health (CLI-only). A project
1856
1858
  // with no CLEO CLI records nothing and behaves EXACTLY as before; a project
1857
1859
  // with the CLI gets an honest state, and nothing is ever installed here.
@@ -3224,7 +3226,7 @@ export class GoalApp {
3224
3226
  artifacts: this.artifacts,
3225
3227
  status: entry?.state?.status ?? "unknown",
3226
3228
  features: { server: entry?.features?.server === true, tui: entry?.features?.tui === true, rpc: entry?.features?.rpc === true },
3227
- ...(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 } : {}) } : {}),
3228
3230
  }
3229
3231
  // T115: this is a READ-ONLY probe. It returns the runtime identity and
3230
3232
  // writes NOTHING under .opencode/goals, so no read can look like an agent
@@ -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"]