@cleocode/goal 0.0.0-stage → 0.9.0

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +319 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/cli/ocg.ts +149 -0
  5. package/dist/cli.js +8708 -0
  6. package/dist/tui.d.ts +2 -0
  7. package/dist/tui.js +1920 -0
  8. package/package.json +79 -4
  9. package/rpc.ts +2 -0
  10. package/server.ts +3 -0
  11. package/skills/write-goal/SKILL.md +211 -0
  12. package/skills/write-goal/assets/goal.template.yaml +38 -0
  13. package/skills/write-goal/references/examples.md +283 -0
  14. package/skills/write-goal/references/goal-schema.md +53 -0
  15. package/skills/write-goal/references/interview.md +36 -0
  16. package/src/cli/install.ts +125 -0
  17. package/src/cli/status.ts +116 -0
  18. package/src/contract/parse.ts +454 -0
  19. package/src/contract/render.ts +134 -0
  20. package/src/contract/types.ts +158 -0
  21. package/src/engine/amendment.ts +51 -0
  22. package/src/engine/amendments.ts +72 -0
  23. package/src/engine/cleo-bridge.ts +339 -0
  24. package/src/engine/cleo-link.ts +150 -0
  25. package/src/engine/continue-work.ts +131 -0
  26. package/src/engine/decisions.ts +44 -0
  27. package/src/engine/dogfood-record.ts +278 -0
  28. package/src/engine/handoff.ts +102 -0
  29. package/src/engine/interrupt-policy.ts +45 -0
  30. package/src/engine/inventory.ts +70 -0
  31. package/src/engine/launches.ts +82 -0
  32. package/src/engine/loop-stop.ts +50 -0
  33. package/src/engine/loop.ts +109 -0
  34. package/src/engine/next-work.ts +82 -0
  35. package/src/engine/owner-intent.ts +71 -0
  36. package/src/engine/owner-reviews.ts +178 -0
  37. package/src/engine/peer-announce.ts +67 -0
  38. package/src/engine/peer-transport.ts +78 -0
  39. package/src/engine/policy.ts +42 -0
  40. package/src/engine/registry.ts +119 -0
  41. package/src/engine/similarity.ts +65 -0
  42. package/src/engine/stale-decisions.ts +68 -0
  43. package/src/engine/state.ts +185 -0
  44. package/src/engine/store.ts +263 -0
  45. package/src/engine/tick-record.ts +129 -0
  46. package/src/engine/tick.ts +207 -0
  47. package/src/rpc.ts +182 -0
  48. package/src/server/app.ts +4320 -0
  49. package/src/server/help.ts +50 -0
  50. package/src/server/index.ts +23 -0
  51. package/src/server/intent.ts +22 -0
  52. package/src/server/messages.ts +228 -0
  53. package/src/summary/audit.ts +76 -0
  54. package/src/summary/build.ts +314 -0
  55. package/src/tui/dashboard.ts +322 -0
  56. package/src/tui/decision-form.ts +12 -0
  57. package/src/tui/format.ts +127 -0
  58. package/src/tui/index.tsx +718 -0
  59. package/src/tui/manager.ts +94 -0
  60. package/src/tui/navigation.ts +11 -0
  61. package/src/tui/pointer-select.ts +20 -0
  62. package/src/tui/tracer.ts +51 -0
  63. package/src/util/git.ts +102 -0
  64. package/src/util/ids.ts +20 -0
  65. package/src/util/shell.ts +42 -0
  66. package/src/verify/artifact.ts +95 -0
  67. package/src/verify/pipeline.ts +200 -0
  68. package/tui.tsx +2 -0
@@ -0,0 +1,185 @@
1
+ // Run state for one goal and the rules about who may change it. The worker
2
+ // model can report progress, claim, block, flag and wait; only the owner (or the
3
+ // host acting on owner policy) can start, resume, pause, abort or approve.
4
+ import type { Contract } from "../contract/types"
5
+
6
+ export type Status =
7
+ | "running"
8
+ | "waiting"
9
+ | "verifying"
10
+ | "paused"
11
+ | "blocked"
12
+ | "needs_review"
13
+ | "budget_limited"
14
+ | "complete"
15
+ | "failed"
16
+ | "aborted"
17
+ | "superseded"
18
+
19
+ export const ACTIVE: readonly Status[] = ["running", "waiting", "verifying"]
20
+ export const TERMINAL: readonly Status[] = ["complete", "failed", "aborted", "superseded"]
21
+ export const isActive = (s: Status) => ACTIVE.includes(s)
22
+ export const isTerminal = (s: Status) => TERMINAL.includes(s)
23
+
24
+ export type CriterionState = {
25
+ status: "unknown" | "pass" | "fail" | "claimed"
26
+ by?: "host" | "verifier" | "verifier-fallback" | "human"
27
+ at?: number
28
+ detail?: string
29
+ rejections: number
30
+ }
31
+
32
+ export type PendingKind = "kickoff" | "continue" | "verdict" | "recovery" | "wrapup" | "resume"
33
+ export type Handoff = { fromSessionID: string; toSessionID: string; agent?: string; model?: { providerID: string; id: string; variant?: string }; fallback?: string; /** v0.8.0: the generated handoff document, relative to the goal folder. */ file?: string }
34
+
35
+ export type RunState = {
36
+ version: 1
37
+ slug: string
38
+ title: string
39
+ /** T061: copied from the contract at start so the registry/rebuild keep it. */
40
+ priority?: "low" | "medium" | "high"
41
+ sessionID: string
42
+ runId: string
43
+ lock: string
44
+ status: Status
45
+ reason?: string
46
+ createdAt: number
47
+ updatedAt: number
48
+ turn: number
49
+ activeMs: number
50
+ activeSince: number | null
51
+ usage: { tokens: number; cost: number; baseTokens: number | null; baseCost: number | null }
52
+ base: { commit?: string }
53
+ fingerprint?: string
54
+ counters: { noProgress: number; failures: number }
55
+ wrapup: boolean
56
+ blocker?: { key: string; count: number; reason: string; needs?: string; lastTurn: number }
57
+ criteria: Record<string, CriterionState>
58
+ steps: Record<string, "pending" | "active" | "done">
59
+ progress?: { note: string; next?: string; at: number; turn: number }
60
+ verdict?: { at: number; turn: number; passed: boolean; lines: string[] }
61
+ claim?: { at: number; turn: number; summary: string; evidence: Record<string, string> }
62
+ wait?: { until: number; reason: string }
63
+ pending?: { messageID: string; turn: number; at: number; kind: PendingKind }
64
+ turnFacts: { tools: number; goalCalls: number; stepsChanged: boolean; progressRecorded: boolean }
65
+ steers: number
66
+ compacted: boolean
67
+ /** last wall-clock ms the TUI/rpc was refreshed by a usage.recorded burst (T013) */
68
+ lastUsageEmit?: number
69
+ /** T053: what the engine is doing right now — feeds the live activity tracer. */
70
+ activity?: { kind: "turn" | "verifying" | "verifier-child" | "cooldown" | "waiting"; since: number; until?: number; detail?: string }
71
+ flags: Array<{ criterion?: string; kind: string; reason: string; at: number }>
72
+ amendments: Array<{ change: string; rationale: string; at: number; status: "proposed" | "accepted" | "rejected" }>
73
+ handoff?: Handoff
74
+ /** v0.8.0: peer availability announcements and the candidates they produced. */
75
+ peer?: {
76
+ lastAt?: number
77
+ outstanding?: { id: string; at: number }
78
+ lastReplyAt?: number
79
+ /** Bounded best-effort polling of the outstanding announcement. */
80
+ polls?: number
81
+ candidates?: Array<{ source: "peer"; project: string; taskId?: string; title: string; from?: string; messageId?: string; at?: number }>
82
+ }
83
+ }
84
+
85
+ export function initialRun(contract: Contract, args: { sessionID: string; runId: string; lock: string; commit?: string; now?: number }): RunState {
86
+ const now = args.now ?? Date.now()
87
+ return {
88
+ version: 1,
89
+ slug: contract.id,
90
+ title: contract.title,
91
+ ...(contract.priority ? { priority: contract.priority } : {}),
92
+ sessionID: args.sessionID,
93
+ runId: args.runId,
94
+ lock: args.lock,
95
+ status: "running",
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ turn: 0,
99
+ activeMs: 0,
100
+ activeSince: now,
101
+ usage: { tokens: 0, cost: 0, baseTokens: null, baseCost: null },
102
+ base: args.commit ? { commit: args.commit } : {},
103
+ counters: { noProgress: 0, failures: 0 },
104
+ wrapup: false,
105
+ criteria: Object.fromEntries([...contract.criteria, ...contract.invariants].map((c) => [c.id, { status: "unknown", rejections: 0 } as CriterionState])),
106
+ steps: Object.fromEntries(contract.plan.map((s, i) => [s.id, i === 0 ? "active" : "pending"])),
107
+ turnFacts: { tools: 0, goalCalls: 0, stepsChanged: false, progressRecorded: false },
108
+ steers: 0,
109
+ compacted: false,
110
+ flags: [],
111
+ amendments: [],
112
+ }
113
+ }
114
+
115
+ /** Accumulate active (running/waiting/verifying) wall time. */
116
+ export function account(state: RunState, now = Date.now()): RunState {
117
+ if (state.activeSince !== null) {
118
+ state.activeMs += Math.max(0, now - state.activeSince)
119
+ state.activeSince = isActive(state.status) ? now : null
120
+ } else if (isActive(state.status)) state.activeSince = now
121
+ state.updatedAt = now
122
+ return state
123
+ }
124
+
125
+ /** Move to a new status, keeping the active clock honest. */
126
+ export function setStatus(state: RunState, status: Status, reason?: string, now = Date.now()): RunState {
127
+ account(state, now)
128
+ state.status = status
129
+ state.reason = reason
130
+ state.activeSince = isActive(status) ? now : null
131
+ if (!isActive(status)) state.pending = undefined
132
+ return state
133
+ }
134
+
135
+ export type OwnerAction = "pause" | "resume" | "abort" | "verify" | "approve" | "reject" | "amend" | "archive" | "revert"
136
+
137
+ /** Owner transitions; returns an error string when the action does not apply. */
138
+ export function ownerCan(state: RunState, action: OwnerAction): string | undefined {
139
+ switch (action) {
140
+ case "pause":
141
+ return isActive(state.status) ? undefined : `goal is ${state.status}`
142
+ case "resume":
143
+ return ["paused", "blocked", "needs_review", "budget_limited"].includes(state.status) ? undefined : `goal is ${state.status}; only a stopped goal can resume`
144
+ case "abort":
145
+ return isTerminal(state.status) ? `goal is already ${state.status}` : undefined
146
+ case "verify":
147
+ return isTerminal(state.status) ? `goal is ${state.status}` : undefined
148
+ case "revert":
149
+ return isTerminal(state.status) ? `goal is ${state.status}` : state.status === "verifying" ? "goal is verifying; wait for its verdict" : undefined
150
+ case "amend":
151
+ // T038: amendment re-locks a live contract; a terminal goal gets a new
152
+ // goal instead (supersession), never a rewrite of history.
153
+ return isTerminal(state.status) ? `goal is ${state.status}; write a new goal or supersede it instead` : undefined
154
+ case "archive":
155
+ // T032: demote-never-delete applies to finished work; a live goal is
156
+ // aborted first, never archived mid-flight.
157
+ return isTerminal(state.status) ? undefined : `goal is ${state.status}; abort it before archiving`
158
+ case "approve":
159
+ case "reject":
160
+ return isTerminal(state.status) ? `goal is ${state.status}` : undefined
161
+ }
162
+ }
163
+
164
+ export type ModelAction = "progress" | "claim" | "block" | "flag" | "wait" | "amend"
165
+
166
+ /** The worker may only act on a goal that is running in its own session. */
167
+ export function modelCan(state: RunState | undefined, action: ModelAction): string | undefined {
168
+ if (!state) return "no goal is running in this session"
169
+ if (isTerminal(state.status)) return `the goal is ${state.status}`
170
+ if (state.status === "verifying" && action !== "progress") return "the host is verifying your last claim; end your turn"
171
+ if (!["running", "waiting", "verifying"].includes(state.status)) return `the goal is ${state.status}; the owner must resume it`
172
+ return undefined
173
+ }
174
+
175
+ export const budgetUse = (state: RunState, contract: Contract, now = Date.now()) => {
176
+ const active = state.activeMs + (state.activeSince !== null ? Math.max(0, now - state.activeSince) : 0)
177
+ const b = contract.budget
178
+ const parts: Array<{ name: "turns" | "wall" | "tokens" | "cost"; used: number; limit: number }> = []
179
+ if (b.turns) parts.push({ name: "turns", used: state.turn, limit: b.turns })
180
+ if (b.wallMs) parts.push({ name: "wall", used: active, limit: b.wallMs })
181
+ if (b.tokens) parts.push({ name: "tokens", used: state.usage.tokens, limit: b.tokens })
182
+ if (b.cost_usd) parts.push({ name: "cost", used: state.usage.cost, limit: b.cost_usd })
183
+ const ratio = parts.reduce((max, p) => Math.max(max, p.used / p.limit), 0)
184
+ return { active, parts, ratio }
185
+ }
@@ -0,0 +1,263 @@
1
+ // A goal is a folder: .opencode/goals/<slug>/ holds the owner's contract
2
+ // (goal.yaml) and the plugin's run.json, ledger.jsonl and evidence/.
3
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, writeFileSync, writeSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { parseContract, type ParseResult } from "../contract/parse"
6
+ import type { RunState } from "./state"
7
+ import type { AmendmentRequest } from "./amendments"
8
+
9
+ export type LedgerEvent = { t: number; type: string; [key: string]: unknown }
10
+
11
+ export class Store {
12
+ readonly goalsDir: string
13
+ constructor(readonly root: string) {
14
+ this.goalsDir = join(root, ".opencode", "goals")
15
+ }
16
+
17
+ dir(slug: string) {
18
+ return join(this.goalsDir, slug)
19
+ }
20
+
21
+ contractPath(slug: string) {
22
+ return join(this.dir(slug), "goal.yaml")
23
+ }
24
+
25
+ slugs(): string[] {
26
+ if (!existsSync(this.goalsDir)) return []
27
+ return readdirSync(this.goalsDir, { withFileTypes: true })
28
+ .filter((d) => d.isDirectory() && existsSync(join(this.goalsDir, d.name, "goal.yaml")))
29
+ .map((d) => d.name)
30
+ .sort()
31
+ }
32
+
33
+ /** T032: the archive tier sits beside goals/ — demoted, never deleted. */
34
+ get archiveDir() {
35
+ return join(this.root, ".opencode", "goals-archive")
36
+ }
37
+
38
+ archivedSlugs(): string[] {
39
+ if (!existsSync(this.archiveDir)) return []
40
+ return readdirSync(this.archiveDir, { withFileTypes: true })
41
+ .filter((d) => d.isDirectory() && existsSync(join(this.archiveDir, d.name, "goal.yaml")))
42
+ .map((d) => d.name)
43
+ .sort()
44
+ }
45
+
46
+ archivedPath(slug: string) {
47
+ return join(this.archiveDir, slug)
48
+ }
49
+
50
+ /** Move a goal folder into the archive tier (ledger and evidence travel along). */
51
+ archive(slug: string) {
52
+ mkdirSync(this.archiveDir, { recursive: true })
53
+ renameSync(this.dir(slug), this.archivedPath(slug))
54
+ }
55
+
56
+ readContract(slug: string, options: { archived?: boolean } = {}): (ParseResult & { text: string }) | undefined {
57
+ const path = options.archived ? join(this.archivedPath(slug), "goal.yaml") : this.contractPath(slug)
58
+ if (!existsSync(path)) return undefined
59
+ const text = readFileSync(path, "utf8")
60
+ return { ...parseContract(text, { slug }), text }
61
+ }
62
+
63
+ /** T108: the raw bytes plus any duplicated object keys. JSON.parse silently
64
+ * keeps the last duplicate, so a hand merge (the reported shape: two
65
+ * "updatedAt", "tokens", "tools", "lastUsageEmit") would otherwise become the
66
+ * run's state without anyone writing it on purpose. */
67
+ readRunRaw(slug: string, options: { archived?: boolean } = {}): { text: string; duplicates: string[] } | undefined {
68
+ const path = join(options.archived ? this.archivedPath(slug) : this.dir(slug), "run.json")
69
+ if (!existsSync(path)) return undefined
70
+ const text = readFileSync(path, "utf8")
71
+ return { text, duplicates: duplicateJsonKeys(text) }
72
+ }
73
+
74
+ readRun(slug: string, options: { archived?: boolean } = {}): RunState | undefined {
75
+ const raw = this.readRunRaw(slug, options)
76
+ if (!raw || raw.duplicates.length) return undefined
77
+ try {
78
+ const value = JSON.parse(raw.text)
79
+ return value?.version === 1 ? (value as RunState) : undefined
80
+ } catch {
81
+ return undefined
82
+ }
83
+ }
84
+
85
+ /** Keep the exact bytes a corrupt run.json had, beside the run. */
86
+ keepCorruptRun(slug: string, text: string, at = Date.now()): string {
87
+ const dir = this.dir(slug)
88
+ mkdirSync(dir, { recursive: true })
89
+ const path = join(dir, `run.json.corrupt-${at}.json`)
90
+ writeFileSync(path, text, { mode: 0o600 })
91
+ return path
92
+ }
93
+
94
+ writeRun(state: RunState) {
95
+ const dir = this.dir(state.slug)
96
+ mkdirSync(dir, { recursive: true })
97
+ const path = join(dir, "run.json")
98
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
99
+ const fd = openSync(tmp, "w", 0o600)
100
+ try {
101
+ writeSync(fd, `${JSON.stringify(state, null, 2)}\n`)
102
+ fsyncSync(fd)
103
+ } finally {
104
+ closeSync(fd)
105
+ }
106
+ renameSync(tmp, path)
107
+ }
108
+
109
+ writeContractSnapshot(slug: string, runId: string, lock: string, text: string) {
110
+ const dir = join(this.dir(slug), "evidence", runId)
111
+ mkdirSync(dir, { recursive: true })
112
+ const path = join(dir, `contract-${lock}.yaml`)
113
+ if (existsSync(path)) return
114
+ this.writeAtomic(path, text)
115
+ }
116
+
117
+ /** Redo an accepted generation from a durable host write-ahead receipt.
118
+ * Recovery runs before hooks admit work: consumers cannot see half of a
119
+ * generation. Neither a new run nor an unexpected third lock is overwritten. */
120
+ commitAmendment(request: AmendmentRequest): RunState {
121
+ if (!request.commit || request.status !== "executing") throw new Error("Amendment has no host-recorded acceptance")
122
+ const read = this.readContract(request.slug), state = this.readRun(request.slug)
123
+ const next = parseContract(request.nextText, { slug: request.slug })
124
+ if (!next.contract || next.lock !== request.nextLock) throw new Error("Approved amendment payload is invalid")
125
+ if (!state || state.runId !== request.runId || state.sessionID !== request.targetSessionID) throw new Error("Amendment target identity changed")
126
+ if (state.lock !== request.lock && state.lock !== request.nextLock) throw new Error("Amendment run lock conflicts with a different generation")
127
+ if (!read || read.lock !== request.lock && read.lock !== request.nextLock) throw new Error("Amendment disk contract changed outside the reviewed generation")
128
+ if (request.commit.state.lock !== request.nextLock || request.commit.state.runId !== state.runId) throw new Error("Invalid host write-ahead state")
129
+ const result = state.lock === request.nextLock ? state : request.commit.state
130
+ this.writeContractSnapshot(request.slug, request.runId, request.lock, read.lock === request.lock ? read.text : this.lockedSnapshot(request.slug, request.runId, request.lock))
131
+ this.writeContractSnapshot(request.slug, request.runId, request.nextLock, request.nextText)
132
+ if (read.lock !== request.nextLock) this.writeAtomic(this.contractPath(request.slug), request.nextText)
133
+ if (state.lock !== request.nextLock) this.writeRun(result)
134
+ const generation = result.amendments.filter((a) => a.status === "accepted").length
135
+ this.evidence(request.slug, request.runId, `amendment-${generation}-${request.id}`, {
136
+ decisionId: request.id, generation, oldLock: request.lock, newLock: request.nextLock,
137
+ by: request.appliedBy ?? "owner", ...(request.policyClasses ? { policyClasses: request.policyClasses } : {}), at: request.commit.acceptedAt, rationale: request.rationale,
138
+ ...request.diff, contract: request.nextText,
139
+ })
140
+ if (!this.recent(request.slug, 1000).some((e) => e.type === "amended" && e.decisionId === request.id))
141
+ this.ledger(request.slug, { type: "amended", decisionId: request.id, generation, oldLock: request.lock, newLock: request.nextLock, summary: request.diff.summary, by: request.appliedBy ?? "owner", ...(request.policyClasses ? { policyClasses: request.policyClasses } : {}), ...(request.recovery ? { recovery: true } : {}) })
142
+ return result
143
+ }
144
+
145
+ /** Owner-chosen divergence restore: keep the divergent disk bytes as
146
+ * evidence, then write the locked generation's exact bytes back. */
147
+ restoreLocked(slug: string, runId: string, lock: string, discarded: { text: string; lock: string }): string {
148
+ const locked = this.lockedSnapshot(slug, runId, lock)
149
+ const dir = join(this.dir(slug), "evidence", runId)
150
+ mkdirSync(dir, { recursive: true })
151
+ const kept = join(dir, `divergent-${discarded.lock}-${Date.now()}.yaml`)
152
+ this.writeAtomic(kept, discarded.text)
153
+ const current = this.readContract(slug)
154
+ if (!current || current.lock !== discarded.lock) throw new Error("Disk contract changed again during restore; nothing written")
155
+ this.writeAtomic(this.contractPath(slug), locked)
156
+ return kept
157
+ }
158
+
159
+ lockedText(slug: string, runId: string, lock: string): string { return this.lockedSnapshot(slug, runId, lock) }
160
+
161
+ private lockedSnapshot(slug: string, runId: string, lock: string): string {
162
+ const keyed = join(this.dir(slug), "evidence", runId, `contract-${lock}.yaml`)
163
+ const first = join(this.dir(slug), "evidence", runId, "contract.yaml")
164
+ const text = readFileSync(existsSync(keyed) ? keyed : first, "utf8")
165
+ if (parseContract(text, { slug }).lock !== lock) throw new Error("Prior locked amendment snapshot is unavailable")
166
+ return text
167
+ }
168
+
169
+ private writeAtomic(path: string, text: string) {
170
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
171
+ const fd = openSync(tmp, "wx", 0o600)
172
+ try { writeSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
173
+ renameSync(tmp, path)
174
+ }
175
+
176
+ /** The last n ledger events, oldest first; tolerant of a torn final line. */
177
+ recent(slug: string, n: number): LedgerEvent[] {
178
+ const path = join(this.dir(slug), "ledger.jsonl")
179
+ if (!existsSync(path)) return []
180
+ const lines = readFileSync(path, "utf8").trimEnd().split("\n")
181
+ const out: LedgerEvent[] = []
182
+ for (const line of lines.slice(-n)) {
183
+ try {
184
+ out.push(JSON.parse(line) as LedgerEvent)
185
+ } catch {
186
+ // a half-written trailing line is skipped, never fatal
187
+ }
188
+ }
189
+ return out
190
+ }
191
+
192
+ ledger(slug: string, event: Omit<LedgerEvent, "t"> & { t?: number }, options: { archived?: boolean } = {}) {
193
+ const dir = options.archived ? this.archivedPath(slug) : this.dir(slug)
194
+ mkdirSync(dir, { recursive: true })
195
+ appendFileSync(join(dir, "ledger.jsonl"), `${JSON.stringify({ t: Date.now(), ...event })}\n`, { mode: 0o600 })
196
+ }
197
+
198
+ readLedger(slug: string, limit = 50): LedgerEvent[] {
199
+ const path = join(this.dir(slug), "ledger.jsonl")
200
+ if (!existsSync(path)) return []
201
+ return readFileSync(path, "utf8")
202
+ .split("\n")
203
+ .filter(Boolean)
204
+ .slice(-limit)
205
+ .flatMap((line) => {
206
+ try {
207
+ return [JSON.parse(line) as LedgerEvent]
208
+ } catch {
209
+ return []
210
+ }
211
+ })
212
+ }
213
+
214
+ evidence(slug: string, runId: string, name: string, data: unknown): string {
215
+ const dir = join(this.dir(slug), "evidence", runId)
216
+ mkdirSync(dir, { recursive: true })
217
+ const file = join(dir, `${name}.json`)
218
+ const fd = openSync(file, "w", 0o600)
219
+ try {
220
+ writeSync(fd, `${JSON.stringify(data, null, 2)}\n`)
221
+ } finally {
222
+ closeSync(fd)
223
+ }
224
+ return file
225
+ }
226
+ }
227
+
228
+ /** Duplicated keys inside any JSON object, as dotted paths. A tiny scanner,
229
+ * because JSON itself cannot report them. */
230
+ export function duplicateJsonKeys(text: string): string[] {
231
+ const duplicates: string[] = []
232
+ const stack: Array<{ keys: Set<string>; path: string }> = []
233
+ let i = 0, pending = ""
234
+ while (i < text.length) {
235
+ const ch = text[i]!
236
+ if (ch === '"') {
237
+ let j = i + 1, raw = ""
238
+ while (j < text.length) {
239
+ if (text[j] === "\\") { raw += text[j + 1] ?? ""; j += 2; continue }
240
+ if (text[j] === '"') break
241
+ raw += text[j]
242
+ j++
243
+ }
244
+ i = j + 1
245
+ // A string is a key when the next non-space character is ':'.
246
+ let k = i
247
+ while (k < text.length && /\s/.test(text[k]!)) k++
248
+ if (text[k] === ":" && stack.length) {
249
+ const level = stack[stack.length - 1]!
250
+ const path = level.path ? `${level.path}.${raw}` : raw
251
+ if (level.keys.has(raw)) duplicates.push(path)
252
+ level.keys.add(raw)
253
+ pending = path
254
+ }
255
+ continue
256
+ }
257
+ if (ch === "{") { stack.push({ keys: new Set(), path: pending }); pending = ""; i++; continue }
258
+ if (ch === "}") { stack.pop(); pending = ""; i++; continue }
259
+ if (ch === "," || ch === ":" || ch === "[" || ch === "]") { if (ch === ",") pending = ""; i++; continue }
260
+ i++
261
+ }
262
+ return [...new Set(duplicates)]
263
+ }
@@ -0,0 +1,129 @@
1
+ // v0.7.0 - RECORDING a turn at Stop time, for a harness that is not OpenCode.
2
+ //
3
+ // The OpenCode adapter records a turn from its own event stream: the turn
4
+ // counter, the repository fingerprint, whether the agent called the progress
5
+ // tool, and whether a plan step moved. A CLI at a Stop event cannot see the
6
+ // session - so this module records ONLY what a Stop-time observer can honestly
7
+ // know, and says what it could not see:
8
+ //
9
+ // always the turn increment; the repository fingerprint (a move counts
10
+ // as progress); a claim the run already carries
11
+ // from the a transcript the Stop payload points at, mined best-effort for
12
+ // payload tool calls and a progress call - OPTIONAL, and its absence is
13
+ // never an error
14
+ //
15
+ // It never invents a tool call, a progress call or a live-check transition that
16
+ // it did not observe. Everything is written through OCG's own store afterwards,
17
+ // so records, ledger and schema stay identical to the adapter's.
18
+
19
+ import type { Contract } from "../contract/types"
20
+ import type { RunState } from "./state"
21
+
22
+ /** What a Stop-time observer can see, injected so the recorder is testable. */
23
+ export type RecordInputs = {
24
+ /** The repository fingerprint now (git), or undefined when it cannot be taken. */
25
+ fingerprint: () => string | undefined
26
+ /** The raw harness Stop payload, if the harness passed one. */
27
+ payload?: string
28
+ /** Read a transcript the payload points at. Absent/unreadable is fine. */
29
+ readTranscript?: (path: string) => string | undefined
30
+ now?: number
31
+ }
32
+
33
+ export type TurnRecord = {
34
+ turn: number
35
+ changed: boolean
36
+ progress: boolean
37
+ claimHonoured: boolean
38
+ /** What the payload mining recovered, when a readable transcript was named. */
39
+ mined?: { transcriptPath: string; toolCalls: number; ownerMessages: number; progressCall: boolean }
40
+ /** What this observer could NOT see, named rather than assumed. */
41
+ couldNotSee: string[]
42
+ }
43
+
44
+ /** The names a progress call may appear under in a transcript. */
45
+ const PROGRESS_TOOLS = /goal_progress|progress|goal-status/i
46
+
47
+ /**
48
+ * Record one turn on `state` (mutating it) and return what was recorded.
49
+ * A missing payload, transcript or fingerprint is a degradation, never an error.
50
+ */
51
+ export function recordTurn(state: RunState, _contract: Contract, inputs: RecordInputs): TurnRecord {
52
+ const couldNotSee: string[] = []
53
+ state.turn = (state.turn ?? 0) + 1
54
+
55
+ // The fingerprint is the only progress the observer can measure itself.
56
+ let changed = false
57
+ try {
58
+ const fp = inputs.fingerprint()
59
+ changed = fp !== undefined && fp !== state.fingerprint
60
+ state.fingerprint = fp
61
+ } catch {
62
+ couldNotSee.push("the repository fingerprint (it could not be taken)")
63
+ }
64
+
65
+ // A claim already on the run is honoured: it is the agent's own statement.
66
+ const claimHonoured = Boolean(state.claim)
67
+
68
+ // Best-effort payload mining. A harness without a transcript simply does not
69
+ // get tool-call accounting - and that is stated, not guessed.
70
+ let mined: TurnRecord["mined"]
71
+ let progressCall = false
72
+ const payload = inputs.payload?.trim()
73
+ if (!payload) {
74
+ couldNotSee.push("the agent's tool calls and progress call (the harness passed no Stop payload)")
75
+ } else {
76
+ let transcriptPath: string | undefined
77
+ try {
78
+ const parsed = JSON.parse(payload)
79
+ transcriptPath = typeof parsed?.transcript_path === "string" ? parsed.transcript_path : typeof parsed?.transcriptPath === "string" ? parsed.transcriptPath : undefined
80
+ } catch {
81
+ couldNotSee.push("the Stop payload (it was not JSON)")
82
+ }
83
+ if (!transcriptPath) {
84
+ couldNotSee.push("the agent's tool calls and progress call (the Stop payload named no transcript)")
85
+ } else if (!inputs.readTranscript) {
86
+ couldNotSee.push("the transcript (this build cannot read one)")
87
+ } else {
88
+ let text: string | undefined
89
+ try {
90
+ text = inputs.readTranscript(transcriptPath)
91
+ } catch {
92
+ text = undefined
93
+ }
94
+ if (text === undefined) {
95
+ couldNotSee.push(`the transcript at ${transcriptPath} (absent or unreadable)`)
96
+ } else {
97
+ let toolCalls = 0
98
+ let ownerMessages = 0
99
+ for (const line of text.split("\n")) {
100
+ const trimmed = line.trim()
101
+ if (!trimmed) continue
102
+ let entry: any
103
+ try {
104
+ entry = JSON.parse(trimmed)
105
+ } catch {
106
+ continue // a transcript line we cannot parse is skipped, not counted
107
+ }
108
+ const parts: any[] = Array.isArray(entry?.content) ? entry.content : Array.isArray(entry?.message?.content) ? entry.message.content : []
109
+ if (String(entry?.type ?? entry?.role ?? entry?.message?.role ?? "") === "user") ownerMessages += 1
110
+ for (const part of parts) {
111
+ if (part?.type !== "tool_use" && part?.type !== "tool") continue
112
+ toolCalls += 1
113
+ if (PROGRESS_TOOLS.test(String(part?.name ?? ""))) progressCall = true
114
+ }
115
+ }
116
+ mined = { transcriptPath, toolCalls, ownerMessages, progressCall }
117
+ if (toolCalls === 0 && ownerMessages === 0) couldNotSee.push("any content in the transcript (it parsed but held no tool calls or messages)")
118
+ }
119
+ }
120
+ }
121
+
122
+ const progress = changed || progressCall
123
+ state.counters.noProgress = progress ? 0 : (state.counters.noProgress ?? 0) + 1
124
+ state.pending = undefined
125
+ state.turnFacts = { tools: mined?.toolCalls ?? 0, goalCalls: progressCall ? 1 : 0, stepsChanged: false, progressRecorded: progressCall }
126
+ state.updatedAt = inputs.now ?? Date.now()
127
+ state.activity = { kind: "turn", since: state.updatedAt, detail: "tick" }
128
+ return { turn: state.turn, changed, progress, claimHonoured, ...(mined ? { mined } : {}), couldNotSee }
129
+ }