@cleocode/goal 0.0.0-stage → 0.9.2
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 +328 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/cli/ocg.ts +149 -0
- package/dist/cli.js +8708 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +1920 -0
- package/package.json +79 -4
- package/rpc.ts +2 -0
- package/server.ts +3 -0
- package/skills/write-goal/SKILL.md +211 -0
- package/skills/write-goal/assets/goal.template.yaml +38 -0
- package/skills/write-goal/references/examples.md +283 -0
- package/skills/write-goal/references/goal-schema.md +53 -0
- package/skills/write-goal/references/interview.md +36 -0
- package/src/cli/install.ts +125 -0
- package/src/cli/status.ts +116 -0
- package/src/contract/parse.ts +454 -0
- package/src/contract/render.ts +134 -0
- package/src/contract/types.ts +158 -0
- package/src/engine/amendment.ts +51 -0
- package/src/engine/amendments.ts +72 -0
- package/src/engine/cleo-bridge.ts +339 -0
- package/src/engine/cleo-link.ts +150 -0
- package/src/engine/continue-work.ts +131 -0
- package/src/engine/decisions.ts +44 -0
- package/src/engine/dogfood-record.ts +278 -0
- package/src/engine/handoff.ts +102 -0
- package/src/engine/interrupt-policy.ts +45 -0
- package/src/engine/inventory.ts +70 -0
- package/src/engine/launches.ts +82 -0
- package/src/engine/loop-stop.ts +50 -0
- package/src/engine/loop.ts +109 -0
- package/src/engine/next-work.ts +82 -0
- package/src/engine/owner-intent.ts +71 -0
- package/src/engine/owner-reviews.ts +178 -0
- package/src/engine/peer-announce.ts +67 -0
- package/src/engine/peer-transport.ts +78 -0
- package/src/engine/policy.ts +42 -0
- package/src/engine/registry.ts +119 -0
- package/src/engine/similarity.ts +65 -0
- package/src/engine/stale-decisions.ts +68 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/store.ts +263 -0
- package/src/engine/tick-record.ts +129 -0
- package/src/engine/tick.ts +207 -0
- package/src/rpc.ts +182 -0
- package/src/server/app.ts +4320 -0
- package/src/server/help.ts +50 -0
- package/src/server/index.ts +23 -0
- package/src/server/intent.ts +22 -0
- package/src/server/messages.ts +228 -0
- package/src/summary/audit.ts +76 -0
- package/src/summary/build.ts +314 -0
- package/src/tui/dashboard.ts +322 -0
- package/src/tui/decision-form.ts +12 -0
- package/src/tui/format.ts +127 -0
- package/src/tui/index.tsx +718 -0
- package/src/tui/manager.ts +94 -0
- package/src/tui/navigation.ts +11 -0
- package/src/tui/pointer-select.ts +20 -0
- package/src/tui/tracer.ts +51 -0
- package/src/util/git.ts +102 -0
- package/src/util/ids.ts +20 -0
- package/src/util/shell.ts +42 -0
- package/src/verify/artifact.ts +95 -0
- package/src/verify/pipeline.ts +200 -0
- 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
|
+
}
|