@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 +13 -0
- package/README.md +1 -1
- package/dist/cli.js +31 -3
- package/package.json +1 -1
- package/src/cli/status.ts +8 -0
- package/src/contract/parse.ts +8 -2
- package/src/engine/harness-identity.ts +95 -0
- package/src/engine/state.ts +9 -1
- package/src/engine/tick.ts +15 -0
- package/src/rpc.ts +3 -0
- package/src/server/app.ts +56 -12
- package/src/verify/artifact.ts +3 -0
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.
|
|
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.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 (
|
|
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) {
|
|
@@ -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
|
+
"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,
|
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) {
|
|
@@ -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
|
+
}
|
package/src/engine/state.ts
CHANGED
|
@@ -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,
|
package/src/engine/tick.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
1202
|
-
const change = classifyChange(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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)
|
package/src/verify/artifact.ts
CHANGED
|
@@ -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"]
|