@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +328 -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,4320 @@
1
+ // The goal engine: owns run state for this location, drives continuations from
2
+ // session.execution.* events, injects the contract into every request, and runs
3
+ // verification. Spike findings referenced as S1-S9 live in docs/spikes.md.
4
+ import { createHash } from "node:crypto"
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"
6
+ import { dirname, join, relative, resolve } from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+ import type { Plugin } from "@opencode/plugin"
9
+ import { formatIssues, parseContract } from "../contract/parse"
10
+ import { renderSystemBlock, renderTailNote, triggerText, type TailKind } from "../contract/render"
11
+ import type { Contract } from "../contract/types"
12
+ import { isHostCheck } from "../contract/types"
13
+ import { GoalRpc, type GoalSummary, type GoalTarget, type GoalView, type ManageAction } from "../rpc"
14
+ import { account, budgetUse, initialRun, isActive, isTerminal, modelCan, ownerCan, setStatus, type Handoff, type PendingKind, type RunState, type Status } from "../engine/state"
15
+ import { Store } from "../engine/store"
16
+ import { Registry, byPriorityThenRecency } from "../engine/registry"
17
+ import { LaunchStore, launchChoices, launchView, processAlive, type LaunchActivity, type LaunchRequest } from "../engine/launches"
18
+ import { amendmentDiff, amendedState } from "../engine/amendment"
19
+ import { AmendmentStore, recoveryChoices, type AmendmentRequest } from "../engine/amendments"
20
+ import { classifyChange, policyAllows } from "../engine/policy"
21
+ import { selectedReviewChoice, reviewExpired } from "../engine/owner-reviews"
22
+ import { DecisionStore, decisionChoices, shouldReaskDecision, type DecisionKind, type DecisionRequest, type DecisionTarget } from "../engine/decisions"
23
+ import { projectInventory } from "../engine/inventory"
24
+ import { admissionNote } from "../engine/similarity"
25
+ import { goalHelp } from "./help"
26
+ import { decide } from "../engine/loop"
27
+ import { restartDecision } from "../engine/interrupt-policy"
28
+ import { staleDecision } from "../engine/stale-decisions"
29
+ import { cleoFacts, cleoHealth, cleoHealthReport, cleoLinkEvent, CLEO_INIT_COMMAND, CLEO_INSTALL_COMMAND, type Runner } from "../engine/cleo-link"
30
+ import { bridgeReport, acsByGateFromProves, junitToVitestJson, runBridge, sha256Hex, suggestLinkedTasks, type BridgeReport, type CleoRun, type TaskOutcome } from "../engine/cleo-bridge"
31
+ import type { CleoTaskLink } from "../contract/types"
32
+ import { buildPostGoalSummary } from "../summary/build"
33
+ import { completionAudit } from "../summary/audit"
34
+ import { M, renderMessage, type EngineMessage } from "./messages"
35
+ import { directGoalCommand } from "./intent"
36
+ import { ownerAmendmentMessage, ownerLaunchMessage, ownerResumeMessage } from "../engine/owner-intent"
37
+ import { nextWork, type WorkCandidate } from "../engine/next-work"
38
+ import { candidateFromReply, announcementText, type PeerChannel } from "../engine/peer-announce"
39
+ import { createPeerChannel } from "../engine/peer-transport"
40
+ import { readyCleoTask, continuationContractYaml, continuationSlug } from "../engine/continue-work"
41
+ import { buildHandoff, handoffDigest } from "../engine/handoff"
42
+
43
+ /** Decisions whose native form has already been presented in THIS process.
44
+ * Kept at module scope so a re-run of `setup` (which creates a new GoalApp)
45
+ * cannot silently ask again; a real process reload clears it, which is the
46
+ * documented "reload restores it once" path. */
47
+ const askedDecisionIds = new Set<string>()
48
+
49
+ /** Spawn the cleo CLI with a hard timeout; all failures resolve safely. */const defaultRunner: Runner = (command, args, timeoutMs = 2500) =>
50
+ new Promise((resolve) => {
51
+ const child = Bun.spawn([command, ...args], { stdout: "pipe", stderr: "ignore" })
52
+ const timer = setTimeout(() => child.kill(), timeoutMs)
53
+ Promise.all([new Response(child.stdout).text(), child.exited])
54
+ .then(([stdout, exit]) => resolve({ stdout, exit: Number(exit) }))
55
+ .catch(() => resolve({ stdout: "", exit: 1 }))
56
+ .finally(() => clearTimeout(timer))
57
+ })
58
+ import { ascendingId, runId as newRunId } from "../util/ids"
59
+ import { fingerprint, gitDigest, headCommit, topLevel } from "../util/git"
60
+ import { runHostCheck, verifyClaim, type VerifierVerdict } from "../verify/pipeline"
61
+ import { runShell } from "../util/shell"
62
+ import { captureArtifactHashes, type RuntimeProvenance } from "../verify/artifact"
63
+
64
+ type Context = Plugin.Context
65
+
66
+ export const LAUNCH_LABEL = "Start goal now"
67
+
68
+ export type Options = {
69
+ /** Hard ceiling on goal-driven turns when the contract sets no turn budget. */
70
+ backstopTurns: number
71
+ cooldownMs: number
72
+ /** Consecutive turns without a worktree or criterion change before pausing. */
73
+ stallTurns: number
74
+ /** No-progress count at which the continuation switches to the recovery prompt. */
75
+ recoveryAt: number
76
+ blockerRepeats: number
77
+ maxPromptFailures: number
78
+ verifierAgent: string
79
+ /** Verifier round budget in ms (each of the 2 rounds gets this; 240s default; config-overridable). */
80
+ verifierTimeoutMs: number
81
+ /** T110: the floor a round budget is clamped to. 60s by default, config-overridable so a host test can exercise verdict handling far below the 60s floor. */
82
+ verifierMinRoundMs: number
83
+ /** Grace after the per-round budget during which a late goal_verdict counts. */
84
+ verifierGraceMs: number
85
+ liveChecks: boolean
86
+ launchApprovalMs: number
87
+ }
88
+
89
+ export const DEFAULTS: Options = {
90
+ backstopTurns: 200,
91
+ cooldownMs: 1500,
92
+ stallTurns: 3,
93
+ recoveryAt: 2,
94
+ blockerRepeats: 3,
95
+ maxPromptFailures: 3,
96
+ verifierAgent: "goal-verifier",
97
+ verifierTimeoutMs: 240_000,
98
+ verifierMinRoundMs: 60_000,
99
+ verifierGraceMs: 30_000,
100
+ liveChecks: true,
101
+ launchApprovalMs: 10 * 60_000,
102
+ }
103
+
104
+ type Loaded = { contract: Contract; lock: string; text: string }
105
+ type SessionFacts = { busy: boolean; forms: Set<string>; launchApprovedAt?: number; cancelledAt?: number; lastFormAnswer?: { labels: string[]; at: number }; deferred: string[] }
106
+ type LegacyLaunchApproval = { at: number; label: string; slug?: string; lock?: string; fresh?: false; formID?: string }
107
+ type StartOptions = { acknowledgeSupersede?: boolean; expectedLock?: string; decisionId?: string; handoff?: Handoff }
108
+
109
+ const canonical = (p: string) => {
110
+ try {
111
+ return realpathSync(p)
112
+ } catch {
113
+ return p
114
+ }
115
+ }
116
+
117
+ const json = (value: unknown) => JSON.stringify(value, null, 2)
118
+
119
+ export class GoalApp {
120
+ readonly root: string
121
+ readonly rootReal: string
122
+ readonly store: Store
123
+ readonly registry: Registry
124
+ readonly options: Options
125
+ private readonly launches: LaunchStore
126
+ private readonly amendmentReviews: AmendmentStore
127
+ private readonly decisions: DecisionStore
128
+ private runs = new Map<string, RunState>()
129
+ private loaded = new Map<string, Loaded>()
130
+ private notes = new Map<string, string>()
131
+ private facts = new Map<string, SessionFacts>()
132
+ private legacyLaunchForms = new Map<string, { sessionID: string; slug: string; lock: string; fieldKey: string; value: string; label: string }>()
133
+ /** T066: the session's offered-but-unanswered decision, for resolution pairing. */
134
+ private pendingDecisions = new Map<string, { id: string; kind: string; slug: string; at: number; message: EngineMessage }>()
135
+ /** T075: the decision whose native form is (being) asked in the composer —
136
+ * a reply dispatches the same act the TUI dialog would. */
137
+ private pendingFormDecisions = new Map<string, { decisionId: string; kind: string; slug: string; choices: Array<{ label: string; act?: string; arg?: string }>; formID?: string; askedAt: number }>()
138
+ private verifierInbox = new Map<string, VerifierVerdict[]>()
139
+ /** Verifier child → extra READ-ONLY roots the contract grants, and the
140
+ * external paths Core denied it (to name the cause of a stuck round). */
141
+ private verifierScopes = new Map<string, { allow: string[] }>()
142
+ /** Per-scope verifier agents (base agent + contract-listed read roots). */
143
+ private scopedVerifierAgents = new Set<string>()
144
+ private timers = new Map<string, ReturnType<typeof setTimeout>>()
145
+ private registrations: Array<{ dispose: () => Promise<void> | void }> = []
146
+ private rpc?: { events: { emit: (name: any, data: any) => Promise<void> } }
147
+ private controller = new AbortController()
148
+ private disposed = false
149
+ private queue = Promise.resolve()
150
+ private readonly packageRoot = join(dirname(fileURLToPath(import.meta.url)), "../..")
151
+ private readonly capturedAt = Date.now()
152
+ private readonly artifacts = captureArtifactHashes(this.packageRoot)
153
+ private readonly packageVersion: string = JSON.parse(readFileSync(join(this.packageRoot, "package.json"), "utf8")).version
154
+ private launchRecoveryTimer?: ReturnType<typeof setTimeout>
155
+ /** Runs a clean restart decided to resume; admitted after setup completes. */
156
+ private pendingResumes: RunState[] = []
157
+ /** Runs whose owner-accepted amendment was committed during startup recovery. */
158
+ private recoveredAmendmentRuns = new Set<string>()
159
+ /** v0.8.0: lazily created AgentMBX transport (only with an identity). */
160
+ private peerChannelInstance?: PeerChannel
161
+
162
+ constructor(private ctx: Context) {
163
+ this.root = ctx.location.directory
164
+ this.rootReal = canonical(this.root)
165
+ this.store = new Store(this.root)
166
+ this.launches = new LaunchStore(join(this.root, ".opencode/goals/.owner-launches.json"))
167
+ this.amendmentReviews = new AmendmentStore(join(this.root, ".opencode/goals/.owner-amendments.json"))
168
+ this.decisions = new DecisionStore(join(this.root, ".opencode/goals/.owner-decisions.json"))
169
+ this.registry = new Registry()
170
+ this.options = { ...DEFAULTS, ...(ctx.options as Partial<Options>) }
171
+ }
172
+
173
+ // ───────────────────────────── lifecycle
174
+
175
+ async start() {
176
+ this.recoverAmendmentCommits()
177
+ this.recover()
178
+ await this.registerAgents()
179
+ await this.registerSkill()
180
+ await this.registerTools()
181
+ await this.registerCommand()
182
+ await this.registerHooks()
183
+ await this.registerRpc()
184
+ void this.eventLoop()
185
+ // Prompt admission waits for location plugins to finish setup. Awaiting
186
+ // a recovery prompt here would wait for our own setup and deadlock.
187
+ this.launchRecoveryTimer = setTimeout(() => {
188
+ if (!this.disposed) void this.recoverOwnerReviews().catch((error) => this.store.ledger("_errors", { type: "owner-review-recovery-error", error: String(error) }))
189
+ if (!this.disposed) this.resumeRestarted()
190
+ }, 0)
191
+ }
192
+
193
+ /**
194
+ * v0.8.0 C1: a clean restart's runs resume without an owner decision. The
195
+ * work is deferred until setup is complete (admitting during setup would wait
196
+ * on our own hooks). A run that was verifying a claim re-verifies; a run that
197
+ * was waiting re-arms its deadline; anything else receives the same
198
+ * continuation it had pending, or an ordinary continue.
199
+ */
200
+ private resumeRestarted() {
201
+ for (const state of this.pendingResumes.splice(0)) {
202
+ void this.serial(async () => {
203
+ const live = this.runs.get(state.sessionID)
204
+ if (this.disposed || !live || live.runId !== state.runId) return
205
+ if (!isActive(live.status)) return
206
+ const contract = this.contractOf(live)
207
+ if (!contract) {
208
+ setStatus(live, "paused", "contract unavailable after restart; resume when ready")
209
+ this.persist(live)
210
+ return
211
+ }
212
+ this.store.ledger(live.slug, { type: "restart-resume", runId: live.runId, lock: live.lock, status: live.status, turn: live.turn })
213
+ // A claim that was mid-verification is re-verified, never skipped.
214
+ if (live.claim) return this.verify(live, contract, "claim")
215
+ if (live.status === "verifying") {
216
+ // No claim survives: the checks were interrupted and their partial
217
+ // results are not evidence. Pause rather than invent a verdict.
218
+ setStatus(live, "paused", "host restarted during verification; resume when ready")
219
+ this.store.ledger(live.slug, { type: "restart-verify-interrupted", turn: live.turn })
220
+ this.persist(live)
221
+ return
222
+ }
223
+ if (live.status === "waiting" && live.wait && live.wait.until > Date.now())
224
+ return this.schedule(live, live.wait.until - Date.now(), "continue")
225
+ if (live.status === "waiting") {
226
+ live.wait = undefined
227
+ setStatus(live, "running")
228
+ }
229
+ await this.admit(live, live.pending?.kind ?? "continue")
230
+ })
231
+ }
232
+ }
233
+
234
+ async stop() {
235
+ this.disposed = true
236
+ this.controller.abort()
237
+ clearTimeout(this.launchRecoveryTimer)
238
+ for (const t of this.timers.values()) clearTimeout(t)
239
+ this.timers.clear()
240
+ for (const r of this.registrations.splice(0)) await Promise.resolve(r.dispose()).catch(() => {})
241
+ }
242
+
243
+ /**
244
+ * Running goals from a previous process: v0.8.0 resumes a clean restart by
245
+ * itself (unchanged lock, intact run) and still pauses on a changed contract,
246
+ * a repaired/divergent run or an interrupted write. The decision is the pure
247
+ * `restartDecision`; this method only gathers the facts and applies it.
248
+ */
249
+ private recover() {
250
+ for (const slug of this.store.slugs()) {
251
+ const { state, intact } = this.recoverRunState(slug)
252
+ if (!state) continue
253
+ if (isActive(state.status)) {
254
+ let disk: { lock: string } | undefined
255
+ try { disk = this.store.readContract(slug) } catch { disk = undefined }
256
+ const decision = restartDecision({
257
+ status: state.status,
258
+ lock: state.lock,
259
+ ...(disk ? { diskLock: disk.lock } : {}),
260
+ runIntact: intact,
261
+ interruptedWrite: this.amendmentReviews.all().some((p) => p.slug === slug && p.runId === state.runId && p.status === "executing"),
262
+ })
263
+ const recoveredAccept = this.recoveredAmendmentRuns.has(state.runId) && state.status === "paused" && intact && !!disk && disk.lock === state.lock
264
+ if (decision.action === "resume" || recoveredAccept) {
265
+ this.store.ledger(slug, { type: "recovered", status: "resumed", reason: recoveredAccept ? "an owner-accepted amendment was committed before the restart" : decision.reason })
266
+ this.store.writeRun(state)
267
+ this.pendingResumes.push(state)
268
+ } else if (decision.action === "pause") {
269
+ setStatus(state, "paused", `host restarted; ${decision.reason}`)
270
+ this.store.ledger(slug, { type: "recovered", status: "paused", code: decision.code, reason: decision.reason })
271
+ this.store.writeRun(state)
272
+ }
273
+ }
274
+ // T072: EVERY goal on disk — terminal ones included — is mirrored in
275
+ // memory. A re-attaching TUI's snapshot then reads the disk truth
276
+ // (complete/N) instead of nothing, so a server reload can never strand
277
+ // the sidebar card at its last pre-restart view.
278
+ // One session can own several goals over time (finish one, start the
279
+ // next). `runs` is keyed by session, so the mirror must never let a
280
+ // terminal sibling shadow the live run — that made goal_status, the
281
+ // injected contract and provenance report a finished goal after reload.
282
+ const held = this.runs.get(state.sessionID)
283
+ if (held && held.runId !== state.runId && !isTerminal(held.status) && isTerminal(state.status)) {
284
+ this.store.ledger(state.slug, { type: "recovery-shadow-avoided", sessionID: state.sessionID, kept: held.slug, ignored: state.slug })
285
+ continue
286
+ }
287
+ this.runs.set(state.sessionID, state)
288
+ }
289
+ }
290
+
291
+ /** T108: a run.json that is malformed or carries duplicate keys is never
292
+ * silently trusted (JSON would keep the last duplicate). Repair exactly what
293
+ * the run's own ledger and evidence prove; otherwise refuse and leave it to
294
+ * the owner. */
295
+ private recoverRunState(slug: string): { state?: RunState; intact: boolean } {
296
+ const raw = this.store.readRunRaw(slug)
297
+ if (!raw) return { intact: true }
298
+ let parsed: RunState | undefined
299
+ try {
300
+ const value = JSON.parse(raw.text)
301
+ parsed = value?.version === 1 ? (value as RunState) : undefined
302
+ } catch { parsed = undefined }
303
+ if (parsed && !raw.duplicates.length) return { state: parsed, intact: true }
304
+
305
+ const reason = raw.duplicates.length
306
+ ? `run.json carried duplicate keys (${raw.duplicates.join(", ")}); JSON keeps the last value silently`
307
+ : "run.json is not valid JSON"
308
+ this.store.ledger(slug, { type: "run-state-corruption", duplicateKeys: raw.duplicates, reason })
309
+
310
+ const repaired = this.repairRunState(slug, raw.text, raw.duplicates)
311
+ if (repaired) return { state: repaired, intact: false }
312
+ this.store.ledger(slug, { type: "run-state-unprovable", duplicateKeys: raw.duplicates, reason,
313
+ detail: "the run's own ledger/evidence cannot prove its runId, lock and session; owner decision required" })
314
+ return { intact: false }
315
+ }
316
+
317
+ /** Prove the run's identity from its own bytes: the ledger's last start event
318
+ * plus the locked contract snapshot. Nothing is taken from the corrupt file. */
319
+ private repairRunState(slug: string, text: string, duplicates: string[]): RunState | undefined {
320
+ const start = this.store.recent(slug, 5000).filter((e) => e.type === "start" && typeof e.runId === "string" && typeof e.sessionID === "string" && typeof e.lock === "string").at(-1)
321
+ if (!start) return undefined
322
+ const runId = String(start.runId), sessionID = String(start.sessionID), lock = String(start.lock)
323
+ const snapshot = join(this.store.dir(slug), "evidence", runId, `contract-${lock}.yaml`)
324
+ const plain = join(this.store.dir(slug), "evidence", runId, "contract.yaml")
325
+ const source = existsSync(snapshot) ? snapshot : existsSync(plain) ? plain : undefined
326
+ if (!source) return undefined
327
+ const parsed = parseContract(readFileSync(source, "utf8"), { slug })
328
+ if (!parsed.contract || parsed.lock !== lock) return undefined
329
+
330
+ const kept = this.store.keepCorruptRun(slug, text)
331
+ 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
+ state.turn = Number(lastTurn?.turn ?? 0)
334
+ for (const id of Object.keys(state.criteria)) {
335
+ const recorded = this.store.recent(slug, 5000).findLast((e) => e.type === "criterion" && e.criterion === id) as any
336
+ if (recorded?.status) state.criteria[id] = { status: recorded.status, rejections: 0, ...(recorded.by ? { by: recorded.by } : {}) }
337
+ }
338
+ setStatus(state, "paused", "run state was repaired from its own ledger and evidence; resume when ready")
339
+ this.store.writeRun(state)
340
+ this.store.ledger(slug, { type: "run-state-repaired", kept, runId, lock, sessionID, duplicateKeys: duplicates, turn: state.turn })
341
+ this.noticeM(sessionID, M.runStateRepaired(slug, kept))
342
+ return state
343
+ }
344
+
345
+ /** Serialize state mutations so concurrent events cannot interleave writes. */
346
+ private serial<T>(fn: () => Promise<T> | T): Promise<T> {
347
+ const next = this.queue.then(fn, fn)
348
+ this.queue = next.then(
349
+ () => undefined,
350
+ () => undefined,
351
+ )
352
+ return next
353
+ }
354
+
355
+ // ───────────────────────────── contracts and state helpers
356
+
357
+ private load(slug: string, fresh = false): Loaded | { error: string } {
358
+ const cached = this.loaded.get(slug)
359
+ if (cached && !fresh) return cached
360
+ const read = this.store.readContract(slug)
361
+ if (!read) return { error: `no goal named "${slug}" (expected .opencode/goals/${slug}/goal.yaml)` }
362
+ if (!read.contract) return { error: `goal.yaml for "${slug}" is invalid:\n${formatIssues(read.issues)}` }
363
+ const value = { contract: read.contract, lock: read.lock, text: read.text }
364
+ this.loaded.set(slug, value)
365
+ return value
366
+ }
367
+
368
+ private contractOf(state: RunState): Contract | undefined {
369
+ const cached = this.loaded.get(state.slug)
370
+ if (cached && cached.lock === state.lock) return cached.contract
371
+ // The locked contract is the one the run started with; if the file changed,
372
+ // keep using the locked copy from the evidence of the run start.
373
+ const locked = this.store.readContract(state.slug)
374
+ if (locked?.contract && locked.lock === state.lock) {
375
+ this.loaded.set(state.slug, { contract: locked.contract, lock: locked.lock, text: locked.text })
376
+ return locked.contract
377
+ }
378
+ const currentSnapshot = join(this.store.dir(state.slug), "evidence", state.runId, `contract-${state.lock}.yaml`)
379
+ const snapshot = existsSync(currentSnapshot) ? currentSnapshot : join(this.store.dir(state.slug), "evidence", state.runId, "contract.yaml")
380
+ if (existsSync(snapshot)) {
381
+ const parsed = parseContract(readFileSync(snapshot, "utf8"), { slug: state.slug })
382
+ if (parsed.contract && parsed.lock === state.lock) return parsed.contract
383
+ }
384
+ return undefined
385
+ }
386
+
387
+ /** The exact supported recovery call for a divergent generation. */
388
+ private recoveryCall(slug: string, lock: string): string {
389
+ return `goal_recover_divergence {"slug":"${slug}","expectedLock":"${lock}"}`
390
+ }
391
+
392
+ /** The run's live target in the shape goal_manage accepts. */
393
+ private targetOf(state: RunState): GoalTarget {
394
+ return { key: `live:${state.slug}:${state.runId}`, lock: state.lock, runId: state.runId, sessionID: state.sessionID }
395
+ }
396
+
397
+ /** T106: the disk contract differing from its locked generation is a
398
+ * distinct, detectable cause. Read the locked snapshot versus the disk bytes
399
+ * — never infer it from the presence of a pending proposal. */
400
+ private divergenceOf(state: RunState): { diskLock: string; diskText: string } | undefined {
401
+ const disk = this.store.readContract(state.slug)
402
+ if (!disk?.contract || disk.lock === state.lock) return undefined
403
+ return { diskLock: disk.lock, diskText: disk.text }
404
+ }
405
+
406
+ /** T106: name the real cause, both hashes and the exact recovery call so no
407
+ * unauthorizing answer is retried and no round is lost to discovery. */
408
+ private divergenceRefusal(state: RunState, disk: { diskLock: string }): string {
409
+ return [
410
+ `The disk goal.yaml of "${state.slug}" differs from its locked generation, so no amendment answer can be applied.`,
411
+ `Locked SHA256 ${state.lock}; disk SHA256 ${disk.diskLock}.`,
412
+ `Run ${this.recoveryCall(state.slug, state.lock)} and choose Adopt disk version or Restore locked version (Leave unchanged changes nothing).`,
413
+ "No earlier proposal, byte match or owner message is counted as acceptance.",
414
+ ].join(" ")
415
+ }
416
+
417
+ private persist(state: RunState) {
418
+ account(state)
419
+ if (!isActive(state.status)) state.activity = undefined // T053: no stale tracer on stopped/terminal states
420
+ this.store.writeRun(state)
421
+ // T026: every persisted transition refreshes the derived machine-level
422
+ // index (skipped when nothing changed, since usage bursts persist often).
423
+ // T061: priority rides the registry entry (absent stays absent).
424
+ this.registry.update(this.rootReal, state.slug, { title: state.title, status: state.status, ...(state.runId ? { runId: state.runId } : {}), ...(state.lock ? { lock: state.lock } : {}), ...(state.priority ? { priority: state.priority } : {}), updatedAt: Date.now() })
425
+ this.emitUpdate(state)
426
+ }
427
+
428
+ private factsOf(sessionID: string): SessionFacts {
429
+ let f = this.facts.get(sessionID)
430
+ if (!f) this.facts.set(sessionID, (f = { busy: false, forms: new Set(), deferred: [] }))
431
+ return f
432
+ }
433
+
434
+ /** T100: the run's exact execution root. With a contract `workspace` the host
435
+ * checks, the protect/diff base and the fingerprint resolve here; otherwise
436
+ * this is the project root (today's behaviour). `.opencode/goals/**` always
437
+ * resolves at the project root. */
438
+ private executionRoot(contract: Contract | undefined): string {
439
+ const ws = contract?.workspace?.trim()
440
+ return ws && ws !== "." ? resolve(this.root, ws) : this.root
441
+ }
442
+
443
+ /** T069: /goal commands issued while the session is mid-turn (or holding a
444
+ * form) used to land invisibly — the owner sent them and nothing visibly
445
+ * happened until the turn ended. They now acknowledge IMMEDIATELY (a
446
+ * notice) and execute when the turn/form resolves — never silent. */
447
+ private deferIfHeld(sessionID: string, text: string, sub: string): boolean {
448
+ const f = this.factsOf(sessionID)
449
+ if (!f.busy && f.forms.size === 0) return false
450
+ // control + read + decision-answer commands stay immediate: pause/abort
451
+ // are how you stop a runaway turn; approve/reject ARE the decision's
452
+ // answers (deferring them behind the decision-form's own ask turn would
453
+ // be circular); status/list/help just read state
454
+ if (["", "status", "list", "help", "pause", "abort", "approve", "reject"].includes(sub)) return false
455
+ // A direct OWNER command can resume this session's stopped run while
456
+ // an unrelated inspection turn is still busy. ownerAct still validates
457
+ // the exact state/lock/reviews and never admits a competing kickoff.
458
+ // Do not bypass an actual native form or a cross-session hold.
459
+ const own = this.runs.get(sessionID)
460
+ if (sub === "resume" && f.forms.size === 0 && own && !ownerCan(own, "resume")) return false
461
+ f.deferred.push(text)
462
+ this.noticeM(sessionID, M.commandDeferred(sub, f.busy ? "turn" : "form"))
463
+ return true
464
+ }
465
+
466
+ /** Runs whatever /goal commands deferred while the session was busy. */
467
+ private flushDeferred(sessionID: string) {
468
+ const f = this.factsOf(sessionID)
469
+ if (f.busy || f.forms.size > 0 || !f.deferred.length) return
470
+ for (const text of f.deferred.splice(0)) void this.command(sessionID, text)
471
+ }
472
+
473
+ // T068: launch approvals persist across plugin/server restarts inside the
474
+ // TTL — in-memory state died with the process, so a legitimately approved
475
+ // launch refused after a restart (live: two restarts voided one). The
476
+ // sidecar lives beside the goal folders; a file there never matches a slug
477
+ // (directories with goal.yaml only).
478
+ private get approvalsFile() {
479
+ return join(this.root, ".opencode", "goals", ".launch-approvals.json")
480
+ }
481
+ private readLaunchApprovals(): Record<string, LegacyLaunchApproval> {
482
+ try {
483
+ const parsed = JSON.parse(readFileSync(this.approvalsFile, "utf8"))
484
+ return typeof parsed === "object" && parsed ? (parsed as Record<string, LegacyLaunchApproval>) : {}
485
+ } catch {
486
+ return {}
487
+ }
488
+ }
489
+ private writeLaunchApproval(sessionID: string, approval: LegacyLaunchApproval) {
490
+ const all = this.readLaunchApprovals()
491
+ all[sessionID] = approval
492
+ writeFileSync(this.approvalsFile, JSON.stringify(all, null, 2))
493
+ }
494
+ private clearLaunchApproval(sessionID: string) {
495
+ const all = this.readLaunchApprovals()
496
+ if (!(sessionID in all)) return
497
+ delete all[sessionID]
498
+ writeFileSync(this.approvalsFile, JSON.stringify(all, null, 2))
499
+ }
500
+ /** The approval state for a session: fresh in memory, else fresh on disk, else none. */
501
+ private launchApproval(sessionID: string): LegacyLaunchApproval | undefined {
502
+ const disk = this.readLaunchApprovals()[sessionID]
503
+ if (disk && Date.now() - disk.at <= this.options.launchApprovalMs) return disk
504
+ return undefined
505
+ }
506
+
507
+ private bindLegacyLaunchForm(form: any) {
508
+ // Compatibility with the shipped skill's old launch question: only an
509
+ // explicitly OFFERED launch choice, a launch-specific form and one
510
+ // unambiguous validated candidate. Modern flows use nonce-bound review.
511
+ if (this.launches.all(form?.sessionID).length || this.amendmentReviews.all(form?.sessionID).length) return
512
+ if (!/\blaunch\b.*\bgoal\b/i.test(String(form?.title ?? "")) || form.fields?.length !== 1) return
513
+ const field = form.fields[0]
514
+ const choice = field.options?.find((o: any) => o.label === LAUNCH_LABEL || o.label === `${LAUNCH_LABEL} (Recommended)`)
515
+ if (!choice || typeof field.key !== "string" || typeof choice.value !== "string") return
516
+ const candidates = this.store.slugs().flatMap((slug) => {
517
+ const state = this.store.readRun(slug)
518
+ if (state && !isTerminal(state.status)) return []
519
+ const parsed = this.store.readContract(slug)
520
+ return parsed?.contract ? [{ slug, lock: parsed.lock }] : []
521
+ })
522
+ if (candidates.length !== 1) return
523
+ this.legacyLaunchForms.set(form.id, { sessionID: form.sessionID, slug: candidates[0]!.slug, lock: candidates[0]!.lock, fieldKey: field.key, value: choice.value, label: choice.label })
524
+ }
525
+
526
+ /** Recent ledger events for the dashboard's Timeline section, oldest first. */
527
+ private timelineOf(state: RunState) {
528
+ return this.store
529
+ .recent(state.slug, 30)
530
+ .slice(-18)
531
+ .map((e) => ({ t: e.t, kind: e.type, text: timelineText(e) }))
532
+ }
533
+
534
+ view(state: RunState): GoalView | undefined {
535
+ const contract = this.contractOf(state)
536
+ if (!contract) return undefined
537
+ const use = budgetUse(state, contract)
538
+ const all = [...contract.criteria.map((c) => ({ c, invariant: false })), ...contract.invariants.map((c) => ({ c, invariant: true }))]
539
+ return {
540
+ slug: state.slug,
541
+ title: contract.title,
542
+ outcome: contract.outcome,
543
+ status: state.status,
544
+ ...(state.reason ? { reason: state.reason } : {}),
545
+ runId: state.runId,
546
+ sessionID: state.sessionID,
547
+ lock: state.lock,
548
+ target: this.targetOf(state),
549
+ turn: state.turn,
550
+ timeline: this.timelineOf(state),
551
+ activeMs: state.activeMs,
552
+ activeSince: state.activeSince,
553
+ usage: { tokens: state.usage.tokens, cost: state.usage.cost },
554
+ budget: use.parts,
555
+ budgetRatio: use.ratio,
556
+ criteria: all.map(({ c, invariant }) => {
557
+ const s = state.criteria[c.id] ?? { status: "unknown" as const, rejections: 0 }
558
+ return { id: c.id, statement: c.statement, essential: c.essential, invariant, kind: c.check.kind, status: s.status, ...(s.by ? { by: s.by } : {}), ...(s.detail ? { detail: s.detail.slice(0, 400) } : {}), ...(s.at ? { at: s.at } : {}) }
559
+ }),
560
+ steps: contract.plan.map((s) => ({ id: s.id, title: s.title, status: state.steps[s.id] ?? "pending" })),
561
+ ...(state.progress ? { progress: state.progress } : {}),
562
+ ...(state.verdict ? { verdict: { passed: state.verdict.passed, lines: state.verdict.lines.slice(0, 6), at: state.verdict.at, turn: state.verdict.turn } } : {}),
563
+ ...(state.blocker ? { blocker: { key: state.blocker.key, count: state.blocker.count, reason: state.blocker.reason, ...(state.blocker.needs ? { needs: state.blocker.needs } : {}) } } : {}),
564
+ ...(state.wait ? { wait: state.wait } : {}),
565
+ ...(state.activity ? { activity: state.activity } : {}),
566
+ awaitingUser: this.factsOf(state.sessionID).forms.size > 0,
567
+ // T052: derived from the persisted status — the sidebar keeps showing it
568
+ // until the owner resolves the decision, never a transient toast.
569
+ ...(state.status === "needs_review"
570
+ ? { actionRequired: `sign-off needed: ${state.reason?.replace("owner sign-off needed: ", "") ?? "criteria await your decision"} (/goal approve <C#>)` }
571
+ : state.status === "blocked"
572
+ ? { actionRequired: `blocked on ${state.blocker?.key ?? "owner decision"}: ${state.blocker?.reason ?? state.reason ?? ""}${state.blocker?.needs ? ` — needs ${state.blocker.needs}` : ""}` }
573
+ : state.status === "budget_limited"
574
+ ? { actionRequired: `budget used: raise the budget and amend, or abort (${state.reason ?? ""})` }
575
+ : {}),
576
+ amendments: state.amendments.filter((a) => a.status === "proposed").length,
577
+ flags: state.flags.length,
578
+ updatedAt: state.updatedAt,
579
+ }
580
+ }
581
+
582
+ private emitUpdate(state: RunState) {
583
+ const view = this.view(state)
584
+ void this.rpc?.events.emit("updated", { sessionID: state.sessionID, view: view ?? null }).catch(() => {})
585
+ }
586
+
587
+ /**
588
+ * T045: the post-goal summary, emitted on every complete / needs_review /
589
+ * budget_limited transition. Built from data the run already has, shown to
590
+ * the owner (rpc event → TUI dialog) and the agent (one-line transcript
591
+ * notice), and persisted under evidence/ with a ledger event.
592
+ */
593
+ private async summarize(state: RunState, contract: Contract, continued = false) {
594
+ try {
595
+ const summary = buildPostGoalSummary({
596
+ contract,
597
+ state,
598
+ ledgerEvents: this.store.recent(state.slug, 200) as unknown as Array<Record<string, unknown>>,
599
+ cleo: await cleoFacts(this.root, defaultRunner),
600
+ scopeAudit: completionAudit(contract, state),
601
+ continued,
602
+ })
603
+ const file = this.store.evidence(state.slug, state.runId, `summary-turn-${state.turn}-${Date.now()}`, summary)
604
+ this.store.ledger(state.slug, { type: "summary", turn: state.turn, status: state.status, headline: summary.headline, file })
605
+ void this.rpc?.events.emit("summary", { sessionID: state.sessionID, slug: state.slug, status: state.status, headline: summary.headline, title: summary.title, digest: summary.digest, text: summary.text }).catch(() => {})
606
+ this.noticeM(state.sessionID, M.goalSummary(summary.headline, summary.caveats.length + summary.scopeAudit.length, state.status))
607
+ await this.transcript(state.sessionID, `◎ goal.summary (${state.status}) — ${summary.headline}. ${summary.followUps[0] ?? ""}`)
608
+ this.persist(state)
609
+ return summary
610
+ } catch {
611
+ // the summary is a reporting layer; a failure here never changes the run
612
+ return undefined
613
+ }
614
+ }
615
+
616
+ private notice(sessionID: string | undefined, text: string, level: "info" | "success" | "warning" | "error" = "info", attention?: "done" | "question" | "error") {
617
+ void this.rpc?.events.emit("notice", { ...(sessionID ? { sessionID } : {}), level, text, ...(attention ? { attention } : {}) }).catch(() => {})
618
+ }
619
+
620
+ /** T054: every engine notice is an actionable message — cause plus concrete
621
+ * choices, rendered inline for toasts and attached as structured choices
622
+ * for the T050 decision dialogs (wired to rpc.act). */
623
+ private noticeM(sessionID: string | undefined, message: EngineMessage) {
624
+ void this.rpc?.events
625
+ .emit("notice", {
626
+ ...(sessionID ? { sessionID } : {}),
627
+ level: message.level,
628
+ text: renderMessage(message),
629
+ choices: message.choices,
630
+ ...(message.attention ? { attention: message.attention } : {}),
631
+ })
632
+ .catch(() => {})
633
+ }
634
+
635
+ /**
636
+ * T050: every owner decision point emits a DECISION payload — rpc event,
637
+ * actionable notice with desktop attention — that the TUI renders as a
638
+ * selectable dialog wired to rpc.act. Slash commands remain the power-user
639
+ * path; nobody has to type an approval by hand.
640
+ */
641
+ /** T066: every decision emission leaves a ledger trace — the payload, and
642
+ * later its resolution (the next owner act on that session). */
643
+ private decisionTarget(slug: string): DecisionTarget {
644
+ const state = [...this.runs.values()].find((s) => s.slug === slug) ?? this.store.readRun(slug)
645
+ const draft = this.store.readContract(slug)
646
+ return { slug, lock: state?.lock ?? draft?.lock ?? "", status: state?.status ?? "draft", ...(state ? { runId: state.runId, sessionID: state.sessionID } : {}) }
647
+ }
648
+
649
+ private decide(sessionID: string | undefined, slug: string, kind: DecisionKind, message: EngineMessage, extra: Record<string, unknown> = {}) {
650
+ if (!sessionID) { this.noticeM(sessionID, message); return }
651
+ const now = Date.now()
652
+ const target = this.decisionTarget(slug)
653
+ const request = this.decisions.create({ sessionID, slug, kind, lock: target.lock, target,
654
+ candidates: Object.fromEntries(message.choices.filter((c) => c.act === "start" && c.arg).map((c) => [c.arg!, this.decisionTarget(c.arg!)])),
655
+ title: message.id, message, extra, createdAt: now, expiresAt: 0, expiryPolicy: "target", issuerPID: process.pid,
656
+ question: `${message.id}\n${renderMessage(message)}\nGoal: ${slug}; run: ${target.runId ?? "draft"}; lock: ${target.lock || "picker"}.\nOnly an offered option executes its described operation; commentary is not consent.`,
657
+ })
658
+ const decisionId = request.id
659
+ this.publishDecision(request)
660
+ // T070: guidance-only rows (no act) are MARKED as guidance in the payload
661
+ // itself — every surface that renders the decision (TUI dialog, native
662
+ // form) shows them as advice, not buttons. Keys are only present when
663
+ // set: an explicit `act: undefined` fails the event schema.
664
+ this.store.ledger(slug, {
665
+ type: "decision",
666
+ decisionId,
667
+ turn: this.runs.get(sessionID ?? "")?.turn ?? 0,
668
+ kind,
669
+ message: renderMessage(message),
670
+ choices: message.choices.map((c) => (c.act ? { label: c.label, act: c.act, ...(c.arg ? { arg: c.arg } : {}) } : { label: c.label, guidance: true })),
671
+ })
672
+ this.noticeM(sessionID, message)
673
+ // The sole decision surface is the session's native Composer form.
674
+ // Notifications are not answers and elapsed time is not revocation;
675
+ // exact target/generation, offered values and one-use reservation remain.
676
+ void this.askDecisionForm(sessionID, slug, kind, decisionId, message)
677
+ }
678
+
679
+ private publishDecision(request: DecisionRequest) {
680
+ this.pendingDecisions.set(request.sessionID, { id: request.id, kind: request.kind, slug: request.slug, at: request.createdAt, message: request.message })
681
+ const choices = request.message.choices.map((c) => ({ label: c.label, run: c.run, ...(c.act ? { act: c.act } : { guidance: true }), ...(c.arg ? { arg: c.arg } : {}) }))
682
+ void this.rpc?.events.emit("decision", { sessionID: request.sessionID, slug: request.slug, kind: request.kind, decisionId: request.id, title: request.title, message: renderMessage(request.message), question: request.question,
683
+ ...(request.expiryPolicy === "target" ? { expiryPolicy: "target" } : { expiresAt: request.expiresAt, expiryPolicy: "time" }), choices, ...request.extra }).catch(() => {})
684
+ }
685
+
686
+ /**
687
+ * T075: re-ask the native form for a still-unanswered decision whose first
688
+ * ask was skipped because the session was mid-turn (e.g. the decision
689
+ * landed while the owner was answering the previous form).
690
+ */
691
+ /**
692
+ * After a reload the in-memory pending map is empty. Recover the latest
693
+ * genuinely pending decision for this session so the owner's next turn
694
+ * re-presents it once (the explicit "reload restores it" path).
695
+ */
696
+ private restorePendingDecision(sessionID: string) {
697
+ if (this.pendingDecisions.has(sessionID)) return
698
+ const latest = this.decisions.all(sessionID)
699
+ .filter((p) => p.status === "pending" && !reviewExpired(p))
700
+ .sort((a, b) => b.createdAt - a.createdAt)[0]
701
+ if (!latest) return
702
+ this.pendingDecisions.set(sessionID, { id: latest.id, kind: latest.kind, slug: latest.slug, at: latest.createdAt, message: latest.message })
703
+ }
704
+
705
+ private reaskDecisionForm(sessionID: string) {
706
+ this.restorePendingDecision(sessionID)
707
+ const pending = this.pendingDecisions.get(sessionID)
708
+ if (!pending || this.pendingFormDecisions.has(sessionID)) return
709
+ if (this.factsOf(sessionID).forms.size > 0) return
710
+ const request = this.decisions.get(pending.id)
711
+ if (!request) return
712
+ const stale = staleDecision(request, request.target, this.decisionTarget(request.slug))
713
+ if (stale.stale) {
714
+ this.silenceDecision(request, stale)
715
+ if (this.pendingDecisions.get(sessionID)?.id === request.id) this.pendingDecisions.delete(sessionID)
716
+ return
717
+ }
718
+ // A dismissal/comment is an unresolved review, not permission to trap
719
+ // the owner in an immediately re-opening form. Reload or goal_review
720
+ // explicitly restores it; ordinary execution events do not. An
721
+ // unanswered ask is not stale either, but it is asked ONCE per process:
722
+ // only goal_review (which calls askDecisionForm directly) or a reload
723
+ // (new process, empty set) presents it again.
724
+ const policy = shouldReaskDecision({
725
+ alreadyAsked: askedDecisionIds.has(request.id),
726
+ status: request.status,
727
+ stale: false,
728
+ formOpen: this.pendingFormDecisions.has(sessionID) || this.factsOf(sessionID).forms.size > 0,
729
+ })
730
+ if (!policy.ask) return
731
+ void this.askDecisionForm(sessionID, pending.slug, pending.kind, pending.id, pending.message)
732
+ }
733
+
734
+ /**
735
+ * T075: dispatch the question-tool prompt for a decision. The model asks
736
+ * the owner in the composer; form.replied maps the picked label back to
737
+ * the choice's act and dispatches it (see onEvent). Skipped when the
738
+ * session is busy or already holding a form — the TUI dialog remains the
739
+ * other surface either way.
740
+ */
741
+ private async askDecisionForm(sessionID: string | undefined, slug: string, kind: string, decisionId: string, message: EngineMessage) {
742
+ if (!sessionID) return
743
+ const f = this.factsOf(sessionID)
744
+ if (f.busy || f.forms.size > 0 || this.pendingFormDecisions.has(sessionID)) return
745
+ const actable = message.choices.filter((c) => c.act)
746
+ if (!actable.length) return // guidance-only: nothing to wire a form to
747
+ const request = this.decisions.get(decisionId)
748
+ if (!request || request.status !== "pending" || request.formID || reviewExpired(request)) return
749
+ this.pendingFormDecisions.set(sessionID, { decisionId, kind, slug, choices: message.choices.map((c) => ({ label: c.label, act: c.act, arg: c.arg })), askedAt: Date.now() })
750
+ askedDecisionIds.add(decisionId)
751
+ const payload = { questions: [{ header: "Goal", question: request.question, options: decisionChoices(request).map((c) => ({ label: c.label, description: c.description })) }] }
752
+ const presentation = newRunId()
753
+ const text = [
754
+ `◎ goal-decision (kind=${kind}, slug=${slug}) — the goal engine needs the OWNER's decision now.`,
755
+ `Decision: ${renderMessage(message)}`,
756
+ "Ask the owner NOW with the question tool using the EXACT JSON below. Keep the question's nonce, target, labels and descriptions unchanged.",
757
+ `GOAL_DECISION_JSON:${JSON.stringify(payload)}`,
758
+ `GOAL_DECISION_ASK:${presentation}`,
759
+ `Answer with the question tool call only — no other work this turn. The owner's pick dispatches the engine act directly.`,
760
+ ].join("\n")
761
+ this.store.ledger(slug, { type: "decision-form-asked", decision: decisionId, kind, presentation })
762
+ await this.ctx.session
763
+ .prompt({ sessionID, text, resume: true } as any)
764
+ .catch((error) => this.store.ledger(slug, { type: "decision-form-failed", decision: decisionId, error: String(error).slice(0, 300) }))
765
+ }
766
+
767
+ private async transcript(sessionID: string, text: string) {
768
+ await this.ctx.session.synthetic({ sessionID, text, resume: false } as any).catch(() => {})
769
+ }
770
+
771
+ private publishLaunch(request: LaunchRequest) {
772
+ void this.rpc?.events.emit("updated", { sessionID: request.sessionID, launch: launchView(request) }).catch(() => {})
773
+ }
774
+
775
+ private async launchDisposition(request: LaunchRequest, status: LaunchRequest["status"], result: string, type: string) {
776
+ const settled = this.launches.update(request.id, { status, result })
777
+ this.store.ledger(request.slug, { type, decisionId: request.id, sessionID: request.sessionID, lock: request.lock, fresh: request.fresh, status, result })
778
+ this.publishLaunch(settled)
779
+ this.noticeM(request.sessionID, M.launchOutcome(request.slug, status, result))
780
+ await this.transcript(request.sessionID, `◎ Launch ${request.slug} (${status}; decision ${request.id}): ${result}`)
781
+ }
782
+
783
+ private launchActivity(decisionId: string | undefined, activity: LaunchActivity) {
784
+ if (!decisionId || !this.launches.get(decisionId)) return
785
+ const request = this.launches.activity(decisionId, activity)
786
+ this.store.ledger(request.slug, { type: "launch-activity", decisionId, sessionID: request.sessionID, lock: request.lock, fresh: request.fresh, ...activity })
787
+ this.publishLaunch(request)
788
+ this.noticeM(request.sessionID, M.launchActivity(request.slug, activity.detail))
789
+ }
790
+
791
+ /** Preparation is not execution or consent. The complete target/hash/mode
792
+ * is persisted before asking so a lost model/tool result cannot lose the
793
+ * owner's choice, and the model never needs to call start afterwards. */
794
+ private async prepareLaunch(sessionID: string, slug: string, fresh: boolean) {
795
+ const loaded = this.load(slug, true)
796
+ if ("error" in loaded) throw new Error(loaded.error)
797
+ const here = this.runs.get(sessionID)
798
+ if (!fresh && here && !isTerminal(here.status)) throw new Error(`This session owns ${here.slug}; choose a fresh session rather than replacing a live goal.`)
799
+ if ([...this.runs.values()].some((s) => s.slug === slug && !isTerminal(s.status))) throw new Error(`Goal ${slug} is already live; manage it instead of launching again.`)
800
+ if (this.launches.all().some((p) => p.status === "executing" && (p.slug === slug || p.sessionID === sessionID))) throw new Error("A launch admission is already starting; inspect its receipt before preparing another review.")
801
+ const { contract, lock } = loaded
802
+ const question = [
803
+ `Review and launch: ${contract.title}`,
804
+ `Goal: ${slug}. Contract SHA256: ${lock}.`,
805
+ `Outcome: ${contract.outcome}`,
806
+ `Mode: ${fresh ? "new root session; the original session and history are NOT cleared" : "this session"}.`,
807
+ "Criteria:", ...contract.criteria.map((c) => `- ${c.id}: ${c.statement}`),
808
+ `Non-goals: ${contract.non_goals.join("; ")}`,
809
+ "Revise or save instead if anything should change. Only choosing the offered launch option starts this exact contract.",
810
+ ].join("\n")
811
+ const now = Date.now()
812
+ const request = this.launches.create({ sessionID, slug, lock, fresh, title: contract.title, question, createdAt: now, expiresAt: now + this.options.launchApprovalMs, issuerPID: process.pid })
813
+ this.store.ledger(slug, { type: "launch-proposed", decisionId: request.id, sessionID, lock, fresh, expiresAt: request.expiresAt })
814
+ this.publishLaunch(request)
815
+ let transport: "native-form" | "question-tool"
816
+ try { transport = await this.presentLaunch(request) }
817
+ catch (error) {
818
+ await this.launchDisposition(request, "failed", `Launch review could not be presented; nothing started. ${String(error)}`, "launch-presentation-failed")
819
+ throw error
820
+ }
821
+ return { decisionId: request.id, slug, lock, fresh, status: "pending", transport, message: "Launch review offered natively. The host, not a later model call, executes the owner's choice." }
822
+ }
823
+
824
+ private async presentLaunch(request: LaunchRequest): Promise<"native-form" | "question-tool"> {
825
+ const sessionID = request.sessionID, slug = request.slug
826
+ const nativeForm = (this.ctx.session as any).form
827
+ if (typeof nativeForm?.create === "function") {
828
+ const form = await nativeForm.create({
829
+ sessionID, title: `Launch ${slug}?`, metadata: { goalDecisionID: request.id },
830
+ fields: [{ key: "action", title: request.question, type: "string", custom: true, options: launchChoices }],
831
+ })
832
+ this.launches.bind(form)
833
+ return "native-form"
834
+ } else {
835
+ const payload = { questions: [{ header: "Goal", question: request.question, options: launchChoices.map((c) => ({ label: c.label, description: c.description })) }] }
836
+ await this.ctx.session.prompt({
837
+ sessionID,
838
+ text: `Ask the owner with the question tool using the exact JSON below. The host executes the choice; do not call goal_start afterwards.\nGOAL_FORM_JSON:${JSON.stringify(payload)}`,
839
+ metadata: { goal: { kind: "launch-review", decisionId: request.id } },
840
+ resume: true,
841
+ } as any)
842
+ return "question-tool"
843
+ }
844
+ }
845
+
846
+ private async recoverLaunchReviews() {
847
+ const api = (this.ctx.session as any).form
848
+ for (const p of this.launches.all()) {
849
+ if (p.status === "executing" && (p.issuerPID === process.pid || !processAlive(p.issuerPID))) {
850
+ const state = this.store.readRun(p.slug)
851
+ const start = this.store.recent(p.slug, 1000).find((e) => e.type === "start" && e.decisionId === p.id && e.lock === p.lock && e.runId === state?.runId)
852
+ const clean = p.fresh ? this.store.recent(p.slug, 1000).find((e) => e.type === "clean-start" && e.decisionId === p.id && e.from === p.sessionID && e.to === state?.sessionID) : undefined
853
+ const succeeded = !!state && !!start && (p.fresh ? !!clean || state.handoff?.fromSessionID === p.sessionID && state.handoff?.toSessionID === state.sessionID : state.sessionID === p.sessionID)
854
+ await this.launchDisposition(p, succeeded ? "succeeded" : "failed", succeeded ? `Recovered already-started run ${state!.runId}; approval was not replayed.` : "Launch was interrupted with no matching run receipt. Inspect state, then prepare a new review; approval was not replayed.", "launch-recovery-disposition")
855
+ this.store.ledger(p.slug, { type: "launch-recovery", decisionId: p.id, ok: succeeded, ...(succeeded ? { runId: state!.runId } : {}) })
856
+ continue
857
+ }
858
+ if (!["pending", "interrupted"].includes(p.status) || p.issuerPID !== process.pid && processAlive(p.issuerPID)) continue
859
+ if (p.expiresAt < Date.now()) {
860
+ await this.launchDisposition(p, "expired", "Launch review expired. Prepare a fresh review; nothing started.", "launch-expired")
861
+ continue
862
+ }
863
+ const loaded = this.load(p.slug, true)
864
+ if ("error" in loaded || loaded.lock !== p.lock) {
865
+ await this.launchDisposition(p, "failed", "The draft changed or is invalid after reload; nothing launched. Prepare a new review of the current contract.", "launch-changed")
866
+ continue
867
+ }
868
+ if (p.formID && typeof api?.get === "function") {
869
+ try {
870
+ const form = await api.get({ sessionID: p.sessionID, formID: p.formID })
871
+ if (form.state?.status === "answered") { await this.resolveLaunch(p, form.state.answer); continue }
872
+ if (form.state?.status === "pending") { this.factsOf(p.sessionID).forms.add(p.formID); continue }
873
+ if (form.state?.status === "cancelled") this.launches.interruptForm(p.id)
874
+ } catch {
875
+ // Core form IDs may disappear on location reload. The consent
876
+ // request's nonce/hash/mode persist; re-present the actual form.
877
+ }
878
+ }
879
+ const next = this.launches.reopenForm(p.id)
880
+ if (next) {
881
+ this.store.ledger(p.slug, { type: "launch-review-restored", decisionId: p.id, previousFormID: p.formID, lock: p.lock, fresh: p.fresh })
882
+ try { await this.presentLaunch(next) }
883
+ catch (error) { await this.launchDisposition(next, "failed", `Launch review could not be restored; nothing started. ${String(error)}`, "launch-presentation-failed") }
884
+ }
885
+ }
886
+ }
887
+
888
+ private async recoverOwnerReviews() {
889
+ await this.recoverLaunchReviews()
890
+ await this.recoverAmendmentReviews()
891
+ await this.recoverDecisions()
892
+ }
893
+
894
+ private async reviewLaunch(sessionID: string, request: LaunchRequest) {
895
+ if (request.sessionID !== sessionID) throw new Error("That launch review belongs to another authoring session; nothing was reused.")
896
+ if (request.issuerPID !== process.pid && processAlive(request.issuerPID)) throw new Error("Another live server owns this launch review; inspect it there.")
897
+ if (!["pending", "interrupted", "expired"].includes(request.status)) throw new Error(`Launch review is ${request.status}; inspect its disposition, then prepare a new review rather than replaying approval.`)
898
+ const loaded = this.load(request.slug, true)
899
+ if ("error" in loaded) throw new Error(loaded.error)
900
+ if (loaded.lock !== request.lock) throw new Error("The reviewed draft changed; old choices cannot be reused. Prepare a new launch review of the current contract.")
901
+ if (request.status === "expired" || request.expiresAt < Date.now()) {
902
+ if (request.status !== "expired") await this.launchDisposition(request, "expired", "Launch review expired. Prepare a fresh review; nothing started.", "launch-expired")
903
+ const next = await this.prepareLaunch(sessionID, request.slug, request.fresh)
904
+ this.store.ledger(request.slug, { type: "launch-review-replaced", decisionId: request.id, replacementDecisionId: next.decisionId, sessionID, lock: request.lock, fresh: request.fresh })
905
+ return `Expired launch review replaced by new native review ${next.decisionId}. A NEW real owner selection is required; the old approval was not replayed and nothing started.`
906
+ }
907
+ const api = (this.ctx.session as any).form
908
+ // Server plugins do not always expose Core's form.get capability. The
909
+ // exact bound form's authenticated creation/cancel/reply events are
910
+ // still tracked locally; do not enqueue a duplicate native question.
911
+ if (request.formID && this.factsOf(sessionID).forms.has(request.formID))
912
+ return "The exact launch review is already present in the native composer. Nothing started."
913
+ if (request.formID && typeof api?.get === "function") {
914
+ try {
915
+ const form = await api.get({ sessionID, formID: request.formID })
916
+ if (form.state?.status === "pending") return "The exact launch review is already present in the native composer. Nothing started."
917
+ } catch { /* Core form can disappear; re-present, never consume an answer here. */ }
918
+ }
919
+ const next = this.launches.reopenForm(request.id)
920
+ if (!next) throw new Error("Launch review changed while restoring it; refresh the receipt.")
921
+ this.store.ledger(request.slug, { type: "launch-review-restored", decisionId: request.id, previousFormID: request.formID, lock: request.lock, fresh: request.fresh })
922
+ try { await this.presentLaunch(next) }
923
+ catch (error) {
924
+ await this.launchDisposition(next, "failed", `Launch review could not be restored; nothing started. ${String(error)}`, "launch-presentation-failed")
925
+ throw error
926
+ }
927
+ this.publishLaunch(next)
928
+ return "The same exact launch review has been re-presented natively. No approval was inferred and nothing started."
929
+ }
930
+
931
+ private async launchFromOwnerChat(sessionID: string, slug: string, fresh: boolean): Promise<string | undefined> {
932
+ const admission = await this.serial(async () => {
933
+ const loaded = this.load(slug, true)
934
+ if ("error" in loaded) throw new Error(loaded.error)
935
+ const actor = await this.ctx.session.get({ sessionID })
936
+ if (canonical(actor.location.directory) !== this.rootReal) throw new Error("The launch author is not in this project; nothing started.")
937
+ if (actor.parentID) throw new Error("Goal launch requires a root authoring session; no child goal was created.")
938
+ const reviews = this.launches.all(sessionID)
939
+ const displayed = reviews.flatMap((p) => {
940
+ const bound = this.store.recent(p.slug, 1000).findLast((event) => event.type === "launch-form-bound" && event.decisionId === p.id && event.lock === p.lock)
941
+ return bound ? [{ id: p.id, slug: p.slug, lock: p.lock, fresh: p.fresh, at: Number(bound.t) }] : []
942
+ })
943
+ const source = ownerLaunchMessage(await this.ctx.session.context({ sessionID }), displayed, { slug, lock: loaded.lock, fresh })
944
+ if (!source) return undefined
945
+ const reviewed = this.launches.get(source.reviewId)!
946
+ if (!["pending", "interrupted", "expired"].includes(reviewed.status)) throw new Error(`The displayed launch review is ${reviewed.status}; no old instruction was replayed.`)
947
+ if (reviewed.issuerPID !== process.pid && processAlive(reviewed.issuerPID)) throw new Error("Another live server owns the displayed launch review; nothing started.")
948
+ if ([...this.launches.all(), ...this.decisions.all(), ...this.amendmentReviews.all()].some((p) => p.ownerMessageID === source.id))
949
+ throw new Error("That actual owner message was already consumed; no launch replayed.")
950
+ if ([...this.runs.values()].some((state) => state.slug === slug && !isTerminal(state.status))) throw new Error("That goal is already live; no new run or approval was created.")
951
+ const here = this.runs.get(sessionID)
952
+ if (here && !isTerminal(here.status)) throw new Error("This session owns a live goal; manage it or choose an exact native fresh-session review. No chat launch or replacement was inferred.")
953
+ if (this.factsOf(sessionID).forms.size) throw new Error("An owner form is still pending; its dismissal is not launch consent.")
954
+ const now = Date.now()
955
+ // A distinct fresh owner message creates a new authorization receipt.
956
+ // The expired timed review and its unconsumed form answer stay spent.
957
+ const request = this.launches.create({ sessionID, slug, lock: loaded.lock, fresh, title: loaded.contract.title,
958
+ question: reviewed.question, createdAt: now, expiresAt: now + this.options.launchApprovalMs, issuerPID: process.pid })
959
+ const bound = this.launches.bindOwnerMessage(request.id, source.id)
960
+ if (!bound) {
961
+ this.launches.update(request.id, { status: "failed", result: "Owner launch instruction could not be reserved; nothing started." })
962
+ throw new Error("Owner launch instruction could not be reserved; nothing started.")
963
+ }
964
+ this.store.ledger(slug, { type: "owner-message-consumed", ownerMessageID: source.id, decisionId: bound.id,
965
+ reviewedDecisionId: reviewed.id, actorSessionID: sessionID, lock: bound.lock, fresh, action: "launch" })
966
+ return bound
967
+ })
968
+ if (!admission) return undefined
969
+ await this.resolveLaunch(admission, {}, true)
970
+ return this.launches.get(admission.id)?.result ?? "Inspect the launch receipt; no success was inferred."
971
+ }
972
+
973
+ private async resolveLaunch(request: LaunchRequest, answer: Record<string, unknown>, ownerChat = false) {
974
+ request = this.launches.get(request.id) ?? request
975
+ if (request.status !== "pending") return // replay, including during execution
976
+ if (request.issuerPID !== process.pid && processAlive(request.issuerPID)) return // another live session server owns it
977
+ const value = request.fieldKey ? answer[request.fieldKey] : undefined
978
+ const selected = ownerChat && request.ownerMessageID ? launchChoices[0] : selectedReviewChoice(request, launchChoices, answer)
979
+ if (request.expiresAt < Date.now()) {
980
+ await this.launchDisposition(request, "expired", "Launch review expired. Prepare a fresh review; nothing started.", "launch-expired")
981
+ return
982
+ }
983
+ if (!selected || selected.value === "revise") {
984
+ const comment = typeof value === "string" ? value : "Owner requested revision."
985
+ this.launches.update(request.id, { status: "commented", comment })
986
+ await this.launchDisposition(request, "commented", `Draft revision requested; nothing launched. Commentary: ${comment}`, "launch-revision-requested")
987
+ await this.ctx.session.prompt({ sessionID: request.sessionID, text: `Revise the draft goal ${request.slug} with the write-goal interview. Owner commentary: ${JSON.stringify(comment)}. Nothing launched; show the revised contract before another launch review.`, skills: [{ id: "write-goal" }] } as any)
988
+ return
989
+ }
990
+ if (selected.value === "save") {
991
+ await this.launchDisposition(request, "rejected", "Draft saved; nothing launched.", "launch-declined")
992
+ return
993
+ }
994
+ const loaded = this.load(request.slug, true)
995
+ if ("error" in loaded || loaded.lock !== request.lock) {
996
+ await this.launchDisposition(request, "failed", "The draft changed since review; nothing launched. Prepare a new review of the current contract.", "launch-changed")
997
+ return
998
+ }
999
+ try {
1000
+ const reserved = this.launches.reserve(request.id)
1001
+ if (!reserved) {
1002
+ const current = this.launches.get(request.id)!
1003
+ if (current.status === "expired") {
1004
+ await this.launchDisposition(current, "expired", "Launch review expired at reservation. Prepare a fresh review; nothing started.", "launch-expired")
1005
+ } else if (current.status === "pending") {
1006
+ await this.launchDisposition(current, "failed", "Launch reservation refused: another admission owns this draft/session. No second launch started; inspect the active launch receipt.", "launch-reservation-refused")
1007
+ } else {
1008
+ const result = `Launch reservation refused: review is ${current.status}. Approval was not replayed.`
1009
+ this.store.ledger(request.slug, { type: "launch-reservation-refused", decisionId: request.id, status: current.status, result })
1010
+ this.noticeM(request.sessionID, M.launchOutcome(request.slug, current.status, result))
1011
+ await this.transcript(request.sessionID, `◎ ${result}`)
1012
+ }
1013
+ return
1014
+ }
1015
+ this.store.ledger(request.slug, { type: "launch-reserved", decisionId: request.id, sessionID: request.sessionID, lock: request.lock, fresh: request.fresh })
1016
+ this.launchActivity(request.id, { phase: "starting", since: Date.now(), detail: "Owner-approved admission starting; no new run exists yet.", completed: 0, total: 0 })
1017
+ await this.transcript(request.sessionID, `◎ Launch ${request.slug}: owner-approved admission starting (decision ${request.id}). Rehearsing checks before creating a new run; this is not running yet.`)
1018
+ const binding = { expectedLock: request.lock, decisionId: request.id }
1019
+ const result = request.fresh ? await this.startClean(request.sessionID, request.slug, binding) : await this.startGoal(request.sessionID, request.slug, "tool", binding)
1020
+ const ok = result.startsWith('Goal "') || result.startsWith("Started clean")
1021
+ await this.launchDisposition(request, ok ? "succeeded" : "failed", result, ok ? "launch-succeeded" : "launch-failed")
1022
+ this.store.ledger(request.slug, { type: "launch-resolved", decisionId: request.id, sessionID: request.sessionID, lock: request.lock, fresh: request.fresh, ok, result })
1023
+ } catch (error) {
1024
+ const result = String(error)
1025
+ await this.launchDisposition(request, "failed", result, "launch-failed")
1026
+ this.store.ledger(request.slug, { type: "launch-resolved", decisionId: request.id, ok: false, result })
1027
+ }
1028
+ }
1029
+
1030
+ private ownedLaunchQuestion(sessionID: string, input: any): boolean {
1031
+ const q = input?.questions
1032
+ if (!Array.isArray(q) || q.length !== 1 || !Array.isArray(q[0]?.options)) return false
1033
+ return this.launches.all(sessionID).some((p) => p.status === "pending" && p.expiresAt >= Date.now() && p.question === q[0].question && q[0].options.length === launchChoices.length && launchChoices.every((c) => q[0].options.some((o: any) => o.label === c.label)))
1034
+ }
1035
+
1036
+ private ownedAmendmentQuestion(sessionID: string, input: any): boolean {
1037
+ const q = input?.questions
1038
+ if (!Array.isArray(q) || q.length !== 1 || !Array.isArray(q[0]?.options)) return false
1039
+ return this.amendmentReviews.all(sessionID).some((p) => { const choices = this.amendmentReviews.choicesFor(p); return p.status === "pending" && !reviewExpired(p) && p.question === q[0].question && q[0].options.length === choices.length && choices.every((c) => q[0].options.some((o: any) => o.label === c.label)) })
1040
+ }
1041
+
1042
+ private ownedDecisionQuestion(sessionID: string, input: any): boolean {
1043
+ const q = input?.questions
1044
+ if (!Array.isArray(q) || q.length !== 1 || !Array.isArray(q[0]?.options)) return false
1045
+ return this.decisions.all(sessionID).some((p) => p.status === "pending" && !reviewExpired(p) && p.question === q[0].question && q[0].options.length === p.message.choices.length && p.message.choices.every((c) => q[0].options.some((o: any) => o.label === c.label)))
1046
+ }
1047
+
1048
+ private async resolveDecisionForm(request: DecisionRequest, answer: Record<string, unknown>) {
1049
+ if (!["pending", "expired"].includes(request.status) || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return
1050
+ if (request.status === "expired" || reviewExpired(request)) {
1051
+ const message = "Decision review expired; nothing executed. Request the exact review again."
1052
+ this.decisions.update(request.id, { status: "expired", result: message })
1053
+ this.store.ledger(request.slug, { type: "decision-form-operation-failed", decision: request.id, ok: false, message })
1054
+ this.noticeM(request.sessionID, M.ownerOperationFailed(message))
1055
+ void this.rpc?.events.emit("decision.failed", { sessionID: request.sessionID, decisionId: request.id, message }).catch(() => {})
1056
+ return
1057
+ }
1058
+ const selected = selectedReviewChoice(request, decisionChoices(request), answer)
1059
+ const chosen = selected ? request.message.choices[Number(selected.value)] : undefined
1060
+ this.store.ledger(request.slug, { type: "decision-form-answered", decision: request.id, kind: request.kind, picked: chosen?.label ?? "(commentary)", ...(chosen?.act ? { act: chosen.act } : {}) })
1061
+ if (chosen?.act) {
1062
+ void this.ownerAct(request.sessionID, chosen.act as any, chosen.arg, "form", request.id).then((result) => {
1063
+ if (!result.ok) {
1064
+ this.store.ledger(request.slug, { type: "decision-form-operation-failed", decision: request.id, action: chosen.act, ok: false, message: result.message })
1065
+ this.noticeM(request.sessionID, M.ownerOperationFailed(result.message))
1066
+ void this.rpc?.events.emit("decision.failed", { sessionID: request.sessionID, decisionId: request.id, message: result.message }).catch(() => {})
1067
+ }
1068
+ }).catch((error) => this.noticeM(request.sessionID, M.ownerOperationFailed(String(error))))
1069
+ } else {
1070
+ const value = request.fieldKey ? answer[request.fieldKey] : undefined
1071
+ if (request.kind === "owner-operation" && chosen?.label === "Cancel operation") {
1072
+ this.decisions.update(request.id, { status: "rejected", result: "Owner cancelled; no operation executed." })
1073
+ if (this.pendingDecisions.get(request.sessionID)?.id === request.id) this.pendingDecisions.delete(request.sessionID)
1074
+ if (this.pendingFormDecisions.get(request.sessionID)?.decisionId === request.id) this.pendingFormDecisions.delete(request.sessionID)
1075
+ this.store.ledger(request.slug, { type: "decision-declined", decision: request.id, actionExecuted: false })
1076
+ void this.rpc?.events.emit("decision.resolved", { sessionID: request.sessionID, kind: request.kind, decisionId: request.id, via: "form-guidance" }).catch(() => {})
1077
+ return
1078
+ }
1079
+ this.decisions.interruptForm(request.id)
1080
+ this.decisions.update(request.id, { comment: typeof value === "string" ? value : "Invalid answer; no operation authorized.", result: "Guidance/commentary only; the owner decision is still open." })
1081
+ this.pendingFormDecisions.delete(request.sessionID)
1082
+ // Do not claim resolution or close the other surface. The same
1083
+ // unresolved decision remains recoverable and will be re-presented.
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * v0.8.0 C3: a stale decision is silenced, never re-presented. "Stale" is
1089
+ * answered/expired/superseded or bound to a generation that moved; a merely
1090
+ * dismissed review (interrupted) is still unanswered and is restored.
1091
+ */
1092
+ private silenceDecision(request: DecisionRequest, stale: { code: string; reason: string }) {
1093
+ if (stale.code === "answered") return
1094
+ const status = stale.code === "expired" ? "expired" : stale.code === "superseded" ? "cancelled" : "failed"
1095
+ this.decisions.update(request.id, { status: status as any, result: stale.reason })
1096
+ this.store.ledger(request.slug, { type: "decision-silenced", decision: request.id, kind: request.kind, code: stale.code, reason: stale.reason, actionExecuted: false })
1097
+ }
1098
+
1099
+ private async recoverDecisions() {
1100
+ const api = (this.ctx.session as any).form
1101
+ for (const p of this.decisions.all()) {
1102
+ if (p.issuerPID !== process.pid && processAlive(p.issuerPID)) continue
1103
+ if (p.status === "executing") {
1104
+ // Lifecycle actions are not blindly replayable after a crash.
1105
+ this.decisions.update(p.id, { status: "failed", result: "Operation interrupted with an uncertain outcome. Inspect target state before a new owner operation." })
1106
+ this.store.ledger(p.slug, { type: "decision-operation-interrupted", decision: p.id, ok: false })
1107
+ continue
1108
+ }
1109
+ if (!["pending", "interrupted"].includes(p.status)) continue
1110
+ const current = this.decisionTarget(p.slug)
1111
+ const stale = staleDecision(p, p.target, current)
1112
+ if (stale.stale) { this.silenceDecision(p, stale); continue }
1113
+ this.publishDecision(p)
1114
+ if (p.formID && typeof api?.get === "function") {
1115
+ try {
1116
+ const form = await api.get({ sessionID: p.sessionID, formID: p.formID })
1117
+ if (form.state?.status === "answered") { await this.resolveDecisionForm(p, form.state.answer); continue }
1118
+ if (form.state?.status === "pending") { this.factsOf(p.sessionID).forms.add(p.formID); continue }
1119
+ } catch { /* Core question forms can disappear on reload. */ }
1120
+ }
1121
+ const next = this.decisions.reopenForm(p.id)
1122
+ if (next) await this.askDecisionForm(next.sessionID, next.slug, next.kind, next.id, next.message)
1123
+ }
1124
+ }
1125
+
1126
+ private async reviewDecision(sessionID: string, decisionId?: string) {
1127
+ const amendment = decisionId ? this.amendmentReviews.get(decisionId) : this.amendmentReviews.all(sessionID).filter((p) => ["pending", "interrupted", "expired"].includes(p.status)).sort((a, b) => b.createdAt - a.createdAt)[0]
1128
+ const launch = decisionId ? this.launches.get(decisionId) : this.launches.all(sessionID).filter((p) => ["pending", "interrupted", "expired"].includes(p.status)).sort((a, b) => b.createdAt - a.createdAt)[0]
1129
+ const decision = decisionId ? this.decisions.get(decisionId) : this.decisions.all(sessionID).filter((p) => ["pending", "interrupted", "expired"].includes(p.status)).sort((a, b) => b.createdAt - a.createdAt)[0]
1130
+ if (amendment && (decisionId || amendment.createdAt >= Math.max(launch?.createdAt ?? 0, decision?.createdAt ?? 0))) return this.reviewAmendment(sessionID, amendment)
1131
+ if (launch && (decisionId || !decision || launch.createdAt >= decision.createdAt)) return this.reviewLaunch(sessionID, launch)
1132
+ const request = decisionId ? this.decisions.get(decisionId) : this.decisions.all(sessionID).filter((p) => ["pending", "interrupted", "expired"].includes(p.status)).sort((a, b) => b.createdAt - a.createdAt)[0]
1133
+ if (!request || request.sessionID !== sessionID) throw new Error("No pending engine decision in this session; inspect the selected goal's status.")
1134
+ // A settled operation can legitimately change target status; report its
1135
+ // receipt before comparing the still-unanswered review's mutable target.
1136
+ if (!["pending", "interrupted", "expired"].includes(request.status))
1137
+ throw new Error(`Decision review is ${request.status}; old choices cannot be replayed. ${request.result ?? "Inspect the recorded outcome; no operation executed."}`)
1138
+ const current = this.decisionTarget(request.slug)
1139
+ // v0.8.0 C3: expired / superseded / already-answered decisions are never
1140
+ // re-presented. The caller can request a fresh review only through the
1141
+ // operation that creates a new decision, never by replaying this one.
1142
+ const stale = staleDecision(request, request.target, current)
1143
+ if (stale.stale) {
1144
+ this.silenceDecision(request, stale)
1145
+ throw new Error(`${stale.reason}. No decision was re-presented; inspect the current goal status or request a fresh review.`)
1146
+ }
1147
+ if (current.lock !== request.target.lock || current.runId !== request.target.runId || current.sessionID !== request.target.sessionID || current.status !== request.target.status) throw new Error("The reviewed target changed; old choices cannot be reused. Inspect current goal status.")
1148
+ // Legacy rejected-criterion forms offered approval without its ID. A
1149
+ // failed choice is not consent for a newly inferred argument: replace
1150
+ // only the exact stopped target with a fresh criterion-bound question.
1151
+ if (request.kind === "needs_review" && request.message.choices.some((c) => c.act === "approve" && !c.arg)) {
1152
+ if (!["pending", "interrupted", "expired"].includes(request.status)) throw new Error("The old review has an uncertain outcome; inspect status rather than replay it.")
1153
+ const state = [...this.runs.values()].find((s) => s.slug === request.slug)
1154
+ const contract = state && this.contractOf(state)
1155
+ const ids = request.extra.criteria
1156
+ if (!state || state.status !== "needs_review" || !contract || !Array.isArray(ids) || !ids.length
1157
+ || ids.some((id) => typeof id !== "string" || !contract.criteria.some((c) => c.id === id)))
1158
+ throw new Error("The legacy review lacks validated criterion identities; no approval or new review was inferred.")
1159
+ if (request.formID) await (this.ctx.session as any).form?.cancel?.({ sessionID, formID: request.formID }).catch(() => {})
1160
+ this.pendingFormDecisions.delete(sessionID)
1161
+ this.decide(sessionID, request.slug, "needs_review", M.needsReview(state.reason ?? "criterion review", [...new Set(ids)]), request.extra)
1162
+ this.store.ledger(request.slug, { type: "decision-review-replaced", previousDecision: request.id, reason: "legacy approval missing criterion; new explicit owner choice required" })
1163
+ return "Legacy unbound approval replaced with a new criterion-specific native review. The old answer was not replayed; nothing was approved or executed."
1164
+ }
1165
+ if (request.status === "pending" && request.formID) return "The exact review is already present in the native composer. No operation was authorized or executed."
1166
+ this.decisions.reopenForm(request.id)
1167
+ this.pendingFormDecisions.delete(sessionID)
1168
+ // An explicit review request is a NEW presentation even if this process
1169
+ // already asked once. Clear the once-per-process marker so the direct ask
1170
+ // (or the deferred re-ask at turn end, when this call is mid-turn) really
1171
+ // presents it. Without this, a dismissal could never be restored.
1172
+ askedDecisionIds.delete(request.id)
1173
+ this.publishDecision(request)
1174
+ await this.askDecisionForm(sessionID, request.slug, request.kind, request.id, request.message)
1175
+ return "The same pending decision has been re-presented natively. No choice, approval, rejection or operation was inferred."
1176
+ }
1177
+
1178
+ private async proposeAmendment(sessionID: string, input: { slug: string; expectedLock: string; yaml: string; rationale: string }) {
1179
+ const outcome = await this.serial(() => {
1180
+ const state = [...this.runs.values()].find((s) => s.slug === input.slug)
1181
+ if (!state) throw new Error("No live run for the selected goal")
1182
+ const unavailable = ownerCan(state, "amend")
1183
+ if (unavailable) throw new Error(unavailable)
1184
+ if (this.amendmentReviews.all().some((p) => p.slug === state.slug && p.status === "executing")) throw new Error("An owner-accepted amendment is committing; recover it before another proposal")
1185
+ if (state.lock !== input.expectedLock) throw new Error("Amendment targets a stale lock; re-read the selected goal")
1186
+ if (state.status === "verifying") throw new Error("The selected goal is verifying; wait for its verdict before proposing a generation")
1187
+ // An unanswered review holds a paused author's turn too; offering a
1188
+ // fresh diff there is not execution or transfer of the old consent.
1189
+ 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")
1190
+ const disk = this.store.readContract(state.slug)
1191
+ if (!disk?.contract || disk.lock !== state.lock) throw new Error("Disk contract differs from the locked goal; resolve the existing edit before staging")
1192
+ const next = parseContract(input.yaml, { slug: state.slug })
1193
+ if (!next.contract) throw new Error(`Invalid amendment; current contract unchanged:\n${formatIssues(next.issues)}`)
1194
+ if (next.lock === state.lock) throw new Error("Nothing changed; no amendment staged")
1195
+ const diff = amendmentDiff(disk.contract, next.contract)
1196
+ // Owner-set autonomy: inside the launched policy the agent applies the
1197
+ // change itself (journalled, owner-revertible). Anything outside it,
1198
+ // including the policy itself, is routed to the owner below.
1199
+ const policy = disk.contract.autonomy.amend
1200
+ const change = classifyChange(disk.contract, next.contract)
1201
+ const allowed = policyAllows(policy, change)
1202
+ if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, disk.contract, next.contract, next.lock, input.yaml, input.rationale, change.classes, false) }
1203
+ const outsidePolicy = policy.length ? allowed.reasons : []
1204
+ // A proposal is not permission to mutate the oracle. Stop the target
1205
+ // under its OLD contract while the exact diff is being reviewed.
1206
+ const now = Date.now()
1207
+ const p = this.amendmentReviews.create({
1208
+ sessionID, targetSessionID: state.sessionID, slug: state.slug, runId: state.runId,
1209
+ lock: state.lock, nextLock: next.lock, nextText: input.yaml, rationale: input.rationale,
1210
+ diff, title: next.contract.title, createdAt: now, expiresAt: 0, expiryPolicy: "target", issuerPID: process.pid,
1211
+ question: [
1212
+ `Amend ${state.slug} (run ${state.runId})?`,
1213
+ `From SHA256 ${state.lock} to ${next.lock}.`, `Rationale: ${input.rationale}`,
1214
+ diff.summary, diff.details,
1215
+ "Only acceptance re-locks this exact diff. Reject/comment leaves the current contract unchanged; no choice resumes the run.",
1216
+ ].join("\n\n"),
1217
+ })
1218
+ // Only stop/mutate the run AFTER the proposal receipt is durable. A
1219
+ // failed store reservation must not leave memory paused and disk running.
1220
+ clearTimeout(this.timers.get(state.sessionID))
1221
+ setStatus(state, "paused", "amendment staged; awaiting owner review")
1222
+ state.amendments.push({ change: diff.summary, rationale: `staged proposal ${p.id}`, at: now, status: "proposed" })
1223
+ this.persist(state)
1224
+ this.invalidateOldDecision(state, "superseded by a staged amendment review; old contract remains locked")
1225
+ this.store.ledger(state.slug, { type: "amendment-staged", decisionId: p.id, sessionID, runId: p.runId, lock: p.lock, nextLock: p.nextLock, summary: diff.summary, ...(outsidePolicy.length ? { outsidePolicy } : {}) })
1226
+ return { staged: p, outsidePolicy }
1227
+ })
1228
+ if (!outcome) throw new Error("Amendment staging produced no outcome; nothing changed")
1229
+ if (outcome.applied) return outcome.applied
1230
+ const proposal = outcome.staged!
1231
+ await this.presentAmendment(proposal)
1232
+ return { decisionId: proposal.id, slug: proposal.slug, runId: proposal.runId, lock: proposal.lock, nextLock: proposal.nextLock, diff: proposal.diff, status: "pending",
1233
+ ...(outcome.outsidePolicy?.length ? { outsidePolicy: outcome.outsidePolicy } : {}),
1234
+ message: `${outcome.outsidePolicy?.length ? `This change is outside the owner-set autonomy policy (${outcome.outsidePolicy.join("; ")}), so it was routed to the owner. ` : ""}Validated amendment staged without changing goal.yaml. Show the exact diff in the Composer; resolve the owner's actual chat with goal_resolve_amendment or the native review. No sidebar Accept or manual command is required; acceptance alone does not resume.` }
1235
+ }
1236
+
1237
+ /** Apply an in-policy change for the agent: the same write-ahead journal
1238
+ * and commit as an owner acceptance, recorded as appliedBy agent-policy.
1239
+ * Runs inside serial(). The owner can undo it with /goal revert. */
1240
+ private applyByPolicy(sessionID: string, state: RunState, prior: Contract, next: Contract, nextLock: string, nextText: string, rationale: string, classes: string[], recovery: boolean) {
1241
+ const now = Date.now(), oldLock = state.lock
1242
+ const diff = amendmentDiff(prior, next)
1243
+ const kind = recovery ? "divergence recovery" : "amendment"
1244
+ const p = this.amendmentReviews.create({
1245
+ sessionID, targetSessionID: state.sessionID, slug: state.slug, runId: state.runId, lock: oldLock, nextLock, nextText, rationale,
1246
+ diff, title: next.title, createdAt: now, expiresAt: 0, expiryPolicy: "target", issuerPID: process.pid,
1247
+ question: `Agent-applied ${kind} of ${state.slug} under the owner-set autonomy policy (${classes.join(", ")}).`,
1248
+ appliedBy: "agent-policy", policyClasses: classes,
1249
+ }, { keepOthers: true })
1250
+ if (!this.amendmentReviews.reserve(p.id)) throw new Error("Could not reserve the policy amendment; nothing applied")
1251
+ const result = amendedState(state, prior, next, nextLock, `agent policy (${classes.join(", ")}): ${recovery ? "adopted out-of-band disk contract" : rationale} [${p.id}]`, now)
1252
+ const approved = this.amendmentReviews.writeAhead(p.id, { state: result, acceptedAt: now })
1253
+ try {
1254
+ const applied = this.store.commitAmendment(approved)
1255
+ Object.assign(state, applied)
1256
+ delete state.claim
1257
+ delete state.verdict
1258
+ if (!next.priority) delete state.priority
1259
+ this.loaded.set(state.slug, { contract: next, lock: nextLock, text: nextText })
1260
+ this.persist(state)
1261
+ } catch (error) {
1262
+ this.store.ledger(state.slug, { type: "amendment-commit-interrupted", decisionId: p.id, error: String(error) })
1263
+ throw new Error(`Policy amendment commit interrupted; its journal is kept for recovery on reload: ${String(error)}`)
1264
+ }
1265
+ this.amendmentReviews.update(p.id, { status: "succeeded", result: `Applied by the agent under the owner-set policy (${classes.join(", ")}); the owner may undo it with /goal revert ${state.slug}.` })
1266
+ this.retireAmendmentReviews(approved)
1267
+ this.invalidateOldDecision(state)
1268
+ if (recovery) this.store.ledger(state.slug, { type: "divergence-adopted", by: "agent-policy", decisionId: p.id, oldLock, newLock: nextLock, policyClasses: classes })
1269
+ this.noticeM(state.sessionID, M.policyApplied(kind, classes.join(", "), diff.summary, state.slug))
1270
+ this.emitUpdate(state)
1271
+ return { ok: true, applied: true, appliedBy: "agent-policy", decisionId: p.id, slug: state.slug, runId: state.runId, lock: oldLock, nextLock, policyClasses: classes, diff,
1272
+ message: `Applied under the owner-set autonomy policy (${classes.join(", ")}); journalled as ${p.id} and revertible by the owner. Re-read the contract before continuing.` }
1273
+ }
1274
+
1275
+ private async divergenceProvenance(state: RunState, diskLock: string): Promise<string[]> {
1276
+ const lines: string[] = []
1277
+ try { lines.push(`goal.yaml last modified ${new Date(statSync(this.store.contractPath(state.slug)).mtimeMs).toISOString()}`) } catch { lines.push("goal.yaml modification time unknown") }
1278
+ const journalled = this.store.recent(state.slug, 5000).some((e) => e.type === "amended" && e.newLock === diskLock)
1279
+ lines.push(journalled ? "a journalled amendment previously produced these bytes" : "no journalled acceptance produced these bytes; the writer is unknown (edited outside the engine)")
1280
+ const matching = this.amendmentReviews.all().filter((p) => p.slug === state.slug && p.nextLock === diskLock && !p.recovery)
1281
+ for (const p of matching) {
1282
+ let author = "unavailable"
1283
+ try { const info: any = await this.ctx.session.get({ sessionID: p.sessionID } as any); if ((info?.data ?? info)?.id) author = "live" } catch { /* removed or unknown */ }
1284
+ lines.push(`proposal ${p.id} (status ${p.status}; ${p.commit ? "commit recorded" : "no commit: it was never accepted"}) from session ${p.sessionID} (author session ${author}) proposed exactly these bytes`)
1285
+ }
1286
+ if (!matching.length) lines.push("no staged proposal matches these bytes")
1287
+ return lines
1288
+ }
1289
+
1290
+ /** Disk goal.yaml differs from the locked generation (an unjournalled edit).
1291
+ * Any live session of the project may ask; the owner reviews the exact
1292
+ * difference and adopts, restores or leaves it. Within an owner-set policy
1293
+ * that includes `recover`, an in-policy difference is adopted directly. */
1294
+ private async recoverDivergence(sessionID: string, input: { slug: string; expectedLock: string }) {
1295
+ const outcome = await this.serial(async () => {
1296
+ const state = [...this.runs.values()].find((s) => s.slug === input.slug && !isTerminal(s.status))
1297
+ if (!state) throw new Error("No live run for the selected goal")
1298
+ if (state.lock !== input.expectedLock) throw new Error("Recovery targets a stale lock; re-read the selected goal")
1299
+ if (state.status === "verifying") throw new Error("The selected goal is verifying; wait for its verdict before recovering")
1300
+ if (this.amendmentReviews.all().some((p) => p.slug === state.slug && p.status === "executing")) throw new Error("An accepted amendment is committing; reload to recover it first")
1301
+ const disk = this.store.readContract(state.slug)
1302
+ if (!disk) throw new Error("No goal.yaml on disk for the selected goal")
1303
+ if (disk.lock === state.lock) throw new Error("The disk contract does not differ from the locked generation; nothing to recover.")
1304
+ if (!disk.contract) throw new Error(`The disk contract is invalid, so it cannot be adopted:\n${formatIssues(disk.issues)}`)
1305
+ const locked = this.contractOf(state)
1306
+ if (!locked) throw new Error("The locked contract snapshot is unavailable; nothing changed")
1307
+ const change = classifyChange(locked, disk.contract)
1308
+ const allowed = policyAllows(locked.autonomy.amend, change, true)
1309
+ if (allowed.ok) return { applied: this.applyByPolicy(sessionID, state, locked, disk.contract, disk.lock, disk.text, "out-of-band disk edit", change.classes, true) }
1310
+ const provenance = await this.divergenceProvenance(state, disk.lock)
1311
+ const diff = amendmentDiff(locked, disk.contract)
1312
+ const now = Date.now()
1313
+ const p = this.amendmentReviews.create({
1314
+ sessionID, targetSessionID: state.sessionID, slug: state.slug, runId: state.runId, lock: state.lock, nextLock: disk.lock, nextText: disk.text,
1315
+ rationale: "recover an out-of-band disk contract", diff, title: disk.contract.title, createdAt: now, expiresAt: 0, expiryPolicy: "target", issuerPID: process.pid,
1316
+ recovery: { provenance },
1317
+ question: [
1318
+ `Recover the divergent contract of ${state.slug} (run ${state.runId})?`,
1319
+ `Locked SHA256 ${state.lock}; disk SHA256 ${disk.lock}. The disk file differs from the locked generation.`,
1320
+ `Provenance:\n${provenance.map((l) => `- ${l}`).join("\n")}`,
1321
+ diff.summary, diff.details,
1322
+ "Adopt makes the disk bytes a new journalled generation; Restore writes the locked bytes back and keeps the disk bytes as evidence; Leave changes nothing. No earlier proposal or approval counts as acceptance; the goal stays stopped.",
1323
+ ].join("\n\n"),
1324
+ })
1325
+ if (isActive(state.status)) {
1326
+ clearTimeout(this.timers.get(state.sessionID))
1327
+ setStatus(state, "paused", "divergent disk contract under owner review")
1328
+ }
1329
+ this.persist(state)
1330
+ this.invalidateOldDecision(state, "superseded by a divergence recovery review")
1331
+ this.store.ledger(state.slug, { type: "divergence-staged", decisionId: p.id, sessionID, runId: state.runId, lock: state.lock, diskLock: disk.lock, provenance, ...(locked.autonomy.amend.length ? { outsidePolicy: allowed.reasons } : {}) })
1332
+ return { staged: p, outsidePolicy: locked.autonomy.amend.length ? allowed.reasons : [] }
1333
+ })
1334
+ if (outcome.applied) return outcome.applied
1335
+ const p = outcome.staged!
1336
+ await this.presentAmendment(p)
1337
+ const live = [...this.runs.values()].find((s) => s.slug === p.slug && s.runId === p.runId)
1338
+ return { decisionId: p.id, slug: p.slug, runId: p.runId, lock: p.lock, diskLock: p.nextLock, ...(live ? { target: this.targetOf(live) } : {}), diff: p.diff, provenance: p.recovery!.provenance, status: "pending",
1339
+ ...(outcome.outsidePolicy?.length ? { outsidePolicy: outcome.outsidePolicy } : {}),
1340
+ message: "Divergence recovery review offered natively (Adopt disk version / Restore locked version / Leave unchanged). Show it to the owner; no earlier proposal or approval is counted, and the goal stays stopped." }
1341
+ }
1342
+
1343
+ private async resolveRecovery(request: AmendmentRequest, answer: Record<string, unknown>) {
1344
+ const choice = selectedReviewChoice(request, recoveryChoices, answer)
1345
+ if (!choice || choice.value === "leave") {
1346
+ this.amendmentReviews.update(request.id, { status: "rejected", result: "Owner left the divergence unchanged; nothing applied." })
1347
+ this.store.ledger(request.slug, { type: "divergence-left", by: "owner", decisionId: request.id, lock: request.lock, diskLock: request.nextLock })
1348
+ return
1349
+ }
1350
+ await this.serial(() => {
1351
+ const state = this.runs.get(request.targetSessionID), disk = this.store.readContract(request.slug)
1352
+ if (!state || isTerminal(state.status) || state.runId !== request.runId || state.lock !== request.lock || !disk || disk.lock !== request.nextLock) {
1353
+ this.amendmentReviews.update(request.id, { status: "failed", result: "Run or disk contract changed since review; nothing applied." })
1354
+ this.store.ledger(request.slug, { type: "divergence-recovery-failed", decisionId: request.id, reason: "review binding changed" })
1355
+ this.noticeM(request.sessionID, M.divergenceChanged())
1356
+ return
1357
+ }
1358
+ if (!this.amendmentReviews.reserve(request.id)) return
1359
+ const locked = this.contractOf(state)!
1360
+ if (choice.value === "adopt") {
1361
+ const next = parseContract(request.nextText, { slug: request.slug })
1362
+ const acceptedAt = Date.now()
1363
+ const stopped = structuredClone(state)
1364
+ setStatus(stopped, "paused", "owner adopted the disk contract; resume when ready")
1365
+ const result = amendedState(stopped, locked, next.contract!, request.nextLock, `owner adopted out-of-band disk contract (recovery ${request.id})`, acceptedAt)
1366
+ const approved = this.amendmentReviews.writeAhead(request.id, { state: result, acceptedAt })
1367
+ try {
1368
+ const applied = this.store.commitAmendment(approved)
1369
+ Object.assign(state, applied)
1370
+ delete state.claim
1371
+ delete state.verdict
1372
+ if (!next.contract!.priority) delete state.priority
1373
+ this.loaded.set(state.slug, { contract: next.contract!, lock: next.lock, text: request.nextText })
1374
+ this.persist(state)
1375
+ this.amendmentReviews.update(request.id, { status: "succeeded", result: `Adopted the disk contract as ${state.lock}; the goal stays paused.` })
1376
+ this.retireAmendmentReviews(approved)
1377
+ this.invalidateOldDecision(state)
1378
+ this.store.ledger(request.slug, { type: "divergence-adopted", by: "owner", decisionId: request.id, oldLock: request.lock, newLock: request.nextLock })
1379
+ this.noticeM(request.sessionID, M.divergenceAdopted(request.slug))
1380
+ this.emitUpdate(state)
1381
+ } catch (error) {
1382
+ this.store.ledger(request.slug, { type: "amendment-commit-interrupted", decisionId: request.id, error: String(error) })
1383
+ this.noticeM(request.sessionID, M.amendmentInterrupted())
1384
+ }
1385
+ return
1386
+ }
1387
+ try {
1388
+ const kept = this.store.restoreLocked(request.slug, request.runId, request.lock, { text: disk.text, lock: disk.lock })
1389
+ this.amendmentReviews.update(request.id, { status: "succeeded", result: `Restored the locked contract ${request.lock}; the disk bytes are kept at ${kept}.` })
1390
+ for (const other of this.amendmentReviews.all())
1391
+ if (other.id !== request.id && other.slug === request.slug && other.runId === request.runId && other.lock === request.lock
1392
+ && other.nextLock === request.nextLock && ["pending", "interrupted"].includes(other.status)) {
1393
+ this.amendmentReviews.update(other.id, { status: "cancelled", result: `Owner restored the locked generation (recovery ${request.id}); this proposal was not accepted.` })
1394
+ this.store.ledger(request.slug, { type: "amendment-review-superseded", decisionId: other.id, restoredBy: request.id, actionExecuted: false })
1395
+ }
1396
+ this.store.ledger(request.slug, { type: "divergence-restored", by: "owner", decisionId: request.id, lock: request.lock, discardedLock: disk.lock, kept })
1397
+ this.noticeM(request.sessionID, M.divergenceRestored(request.slug))
1398
+ this.emitUpdate(state)
1399
+ } catch (error) {
1400
+ this.amendmentReviews.update(request.id, { status: "failed", result: `Restore failed; nothing written: ${String(error)}` })
1401
+ this.store.ledger(request.slug, { type: "divergence-recovery-failed", decisionId: request.id, reason: String(error) })
1402
+ }
1403
+ })
1404
+ }
1405
+
1406
+ /** Owner undo of the latest agent-policy generation: the prior locked bytes
1407
+ * become a new journalled generation. Never available to the agent. */
1408
+ private revertPolicyChange(state: RunState): { ok: boolean; message: string } {
1409
+ const last = this.amendmentReviews.all().filter((p) => p.slug === state.slug && p.runId === state.runId && p.appliedBy === "agent-policy"
1410
+ && p.status === "succeeded" && p.commit && p.nextLock === state.lock).sort((a, b) => b.createdAt - a.createdAt)[0]
1411
+ if (!last) return { ok: false, message: "No agent-applied change is the current generation of this goal; nothing to revert." }
1412
+ const disk = this.store.readContract(state.slug), current = this.contractOf(state)
1413
+ if (!disk || disk.lock !== state.lock || !current) return { ok: false, message: disk && disk.lock !== state.lock ? this.divergenceRefusal(state, { diskLock: disk.lock }) : "The disk contract is unavailable; nothing reverted." }
1414
+ const priorText = this.store.lockedText(state.slug, state.runId, last.lock)
1415
+ const prior = parseContract(priorText, { slug: state.slug })
1416
+ if (!prior.contract || prior.lock !== last.lock) return { ok: false, message: "The prior generation snapshot is unavailable; nothing reverted." }
1417
+ const now = Date.now()
1418
+ const p = this.amendmentReviews.create({
1419
+ sessionID: state.sessionID, targetSessionID: state.sessionID, slug: state.slug, runId: state.runId, lock: state.lock, nextLock: prior.lock, nextText: priorText,
1420
+ rationale: `owner reverted agent-policy change ${last.id}`, diff: amendmentDiff(current, prior.contract), title: prior.contract.title, createdAt: now, expiresAt: 0,
1421
+ expiryPolicy: "target", issuerPID: process.pid, question: `Owner revert of agent-policy change ${last.id}.`, appliedBy: "owner-revert",
1422
+ }, { keepOthers: true })
1423
+ this.amendmentReviews.reserve(p.id)
1424
+ const result = amendedState(state, current, prior.contract, prior.lock, `owner reverted agent-policy change ${last.id}`, now)
1425
+ const approved = this.amendmentReviews.writeAhead(p.id, { state: result, acceptedAt: now })
1426
+ const applied = this.store.commitAmendment(approved)
1427
+ const from = state.lock
1428
+ Object.assign(state, applied)
1429
+ this.loaded.set(state.slug, { contract: prior.contract, lock: prior.lock, text: priorText })
1430
+ this.persist(state)
1431
+ this.amendmentReviews.update(p.id, { status: "succeeded", result: `Reverted to ${prior.lock}.` })
1432
+ this.store.ledger(state.slug, { type: "reverted", by: "owner", decisionId: p.id, revertedDecisionId: last.id, from, to: prior.lock })
1433
+ this.emitUpdate(state)
1434
+ return { ok: true, message: `Reverted ${state.slug} to ${prior.lock.slice(0, 12)} (undoing agent change ${last.id}); journalled as ${p.id}.` }
1435
+ }
1436
+
1437
+ private async presentAmendment(request: AmendmentRequest) {
1438
+ const nativeForm = (this.ctx.session as any).form
1439
+ try {
1440
+ if (typeof nativeForm?.create === "function") {
1441
+ const form = await nativeForm.create({ sessionID: request.sessionID, title: `${request.recovery ? "Recover" : "Amend"} ${request.slug}?`, metadata: { goalDecisionID: request.id }, fields: [{ key: "action", title: request.question, type: "string", custom: true, options: [...this.amendmentReviews.choicesFor(request)] }] })
1442
+ this.amendmentReviews.bind(form)
1443
+ this.store.ledger(request.slug, { type: "amendment-review-presented", decisionId: request.id, formID: (form as any)?.id ?? null, sessionID: request.sessionID })
1444
+ } else {
1445
+ const payload = { questions: [{ header: "Goal", question: request.question, options: this.amendmentReviews.choicesFor(request).map((c) => ({ label: c.label, description: c.description })) }] }
1446
+ await this.ctx.session.prompt({ sessionID: request.sessionID, text: `Ask the owner with the question tool using this exact JSON. The host alone applies the selected amendment; do not edit goal.yaml or call an owner action afterwards.\nGOAL_FORM_JSON:${JSON.stringify(payload)}`, resume: true, metadata: { goal: { kind: "amendment-review", decisionId: request.id } } } as any)
1447
+ this.store.ledger(request.slug, { type: "amendment-review-dispatched", decisionId: request.id, sessionID: request.sessionID })
1448
+ }
1449
+ } catch (error) {
1450
+ this.store.ledger(request.slug, { type: "amendment-review-present-failed", decisionId: request.id, sessionID: request.sessionID, error: String(error).slice(0, 400) })
1451
+ throw error
1452
+ }
1453
+ }
1454
+
1455
+ private async reviewAmendment(sessionID: string, request: AmendmentRequest) {
1456
+ if (request.sessionID !== sessionID) throw new Error("That amendment review belongs to another authoring session; nothing was reused.")
1457
+ if (request.issuerPID !== process.pid && processAlive(request.issuerPID)) throw new Error("Another live server owns this amendment review; inspect it there.")
1458
+ if (!["pending", "interrupted", "expired"].includes(request.status)) throw new Error(`Amendment review is ${request.status}; old approval cannot be replayed.`)
1459
+ const state = this.runs.get(request.targetSessionID), disk = this.store.readContract(request.slug)
1460
+ // T106/C2: when the disk differs from the lock the acceptance review
1461
+ // cannot apply, so re-presenting it only burns rounds. This cause is named
1462
+ // BEFORE the generic target-changed refusal, and only for this exact
1463
+ // run/lock generation.
1464
+ const divergent = state && state.runId === request.runId && state.lock === request.lock ? this.divergenceOf(state) : undefined
1465
+ if (state && divergent) {
1466
+ this.store.ledger(request.slug, { type: "amendment-review-divergent", decisionId: request.id, lock: request.lock, diskLock: divergent.diskLock, actionExecuted: false })
1467
+ throw new Error(this.divergenceRefusal(state, divergent))
1468
+ }
1469
+ if (!state || isTerminal(state.status) || state.runId !== request.runId || state.lock !== request.lock || !disk?.contract || disk.lock !== (request.recovery ? request.nextLock : request.lock))
1470
+ throw new Error("The reviewed amendment target changed; old choices cannot be reused.")
1471
+ if (request.status === "expired" || reviewExpired(request)) {
1472
+ if (request.status !== "expired") {
1473
+ this.amendmentReviews.update(request.id, { status: "expired", result: "Legacy timed review expired; nothing applied." })
1474
+ this.store.ledger(request.slug, { type: "amendment-review-expired", decisionId: request.id, actionExecuted: false })
1475
+ }
1476
+ const replacement = await this.proposeAmendment(sessionID, { slug: request.slug, expectedLock: request.lock, yaml: request.nextText, rationale: request.rationale })
1477
+ this.store.ledger(request.slug, { type: "amendment-review-replaced", decisionId: request.id, replacementDecisionId: replacement.decisionId, actionExecuted: false })
1478
+ return "Expired amendment review replaced by a validated native Composer review. A NEW real owner selection is required; no old approval was replayed."
1479
+ }
1480
+ if (request.formID && this.factsOf(sessionID).forms.has(request.formID)) return "The exact amendment review is already present in the native Composer; nothing applied."
1481
+ const next = this.amendmentReviews.reopenForm(request.id)
1482
+ if (!next) throw new Error("Amendment review changed while restoring it; inspect its receipt.")
1483
+ this.store.ledger(request.slug, { type: "amendment-review-restored", decisionId: request.id, previousFormID: request.formID, actionExecuted: false })
1484
+ await this.presentAmendment(next)
1485
+ return "The same exact amendment review is restored in the Composer. No approval, application or resume was inferred."
1486
+ }
1487
+
1488
+ private async resolveAmendmentChat(sessionID: string, input: { decisionId: string; action: "accept" | "reject" | "comment"; continueGoal?: boolean }) {
1489
+ const resolved = await this.serial(async () => {
1490
+ const request = this.amendmentReviews.get(input.decisionId)
1491
+ if (!request || request.sessionID !== sessionID)
1492
+ throw new Error("No current amendment review owned by this Composer session; nothing authorized.")
1493
+ if (request.recovery) throw new Error("A divergence recovery is answered in its native review (Adopt disk version / Restore locked version / Leave unchanged); chat text is not mapped to it. Nothing authorized.")
1494
+ if (request.status === "executing") throw new Error("Amendment review is executing; inspect its outcome before acting. No approval was replayed.")
1495
+ if (!["pending", "interrupted"].includes(request.status)) return {
1496
+ alreadyResolved: true as const, request,
1497
+ }
1498
+ if (reviewExpired(request)) throw new Error("Amendment review has expired; request a fresh review. No old owner reply was consumed or replayed.")
1499
+ const state = this.runs.get(request.targetSessionID)
1500
+ if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status))
1501
+ throw new Error("Reviewed target changed; no old owner reply can approve it.")
1502
+ const alternatives = [...this.amendmentReviews.all(sessionID), ...this.launches.all(sessionID), ...this.decisions.all(sessionID)]
1503
+ .filter((p) => p.id !== request.id && ["pending", "interrupted"].includes(p.status) && !reviewExpired(p))
1504
+ const divergent = this.divergenceOf(state)
1505
+ if (divergent) throw new Error(this.divergenceRefusal(state, divergent))
1506
+ const messages: any = await this.ctx.session.context({ sessionID } as any)
1507
+ const source = ownerAmendmentMessage(messages?.data ?? messages, request, input.action)
1508
+ if (!source || input.continueGoal && (input.action !== "accept" || !source.continueGoal))
1509
+ throw new Error("The latest actual owner chat does not authorize this action/continuation. Ask a genuine Composer clarification; no command or sidebar acceptance is required.")
1510
+ if (alternatives.length && (!source.explicitAmendment || alternatives.some((p) => this.amendmentReviews.get(p.id))))
1511
+ throw new Error("More than one review is pending; clarify the exact review in the Composer, not a sidebar.")
1512
+ const continueGoal = input.action === "accept" && source.continueGoal
1513
+ if ([...this.decisions.all(sessionID), ...this.launches.all(sessionID)].some((p) => p.ownerMessageID === source.id))
1514
+ throw new Error("That owner message was already consumed; no action replayed.")
1515
+ const bound = this.amendmentReviews.bindOwnerMessage(request.id, source.id)
1516
+ if (!bound) throw new Error("Owner message/review was already consumed or changed; no action executed.")
1517
+ this.store.ledger(request.slug, { type: "owner-message-consumed", ownerMessageID: source.id, decisionId: request.id,
1518
+ actorSessionID: sessionID, targetRunId: request.runId, lock: request.lock, nextLock: request.nextLock, action: input.action, continueGoal })
1519
+ return { request: bound, source, continueGoal }
1520
+ })
1521
+ if ("alreadyResolved" in resolved) return { ok: false, actionExecuted: false, alreadyResolved: true,
1522
+ decisionId: resolved.request.id, status: resolved.request.status,
1523
+ message: `Amendment review is ${resolved.request.status}; old approval cannot be replayed.`, ...(resolved.request.result ? { result: resolved.request.result } : {}) }
1524
+ await this.resolveAmendment(resolved.request, {}, { action: input.action, text: resolved.source.text, messageID: resolved.source.id })
1525
+ const after = this.amendmentReviews.get(resolved.request.id)!
1526
+ let continuation: { ok: boolean; message: string } | undefined
1527
+ if (resolved.continueGoal && after.status === "succeeded") {
1528
+ const live = this.runs.get(after.targetSessionID)
1529
+ const resumed = !!live && live.runId === after.runId && !isTerminal(live.status) && live.lock === after.nextLock
1530
+ continuation = { ok: resumed, message: resumed ? "Resumed the accepted generation." : "Accepted; the run was not resumable." }
1531
+ this.store.ledger(after.slug, { type: "amendment-chat-continuation", decisionId: after.id, ownerMessageID: resolved.source.id, lock: after.nextLock, ok: resumed, message: continuation.message })
1532
+ }
1533
+ if (resolved.request.formID) await (this.ctx.session as any).form?.cancel?.({ sessionID, formID: resolved.request.formID }).catch(() => {})
1534
+ return { ok: input.action === "accept" ? after.status === "succeeded" && (!continuation || continuation.ok) : ["rejected", "commented"].includes(after.status),
1535
+ decisionId: after.id, status: after.status, ownerMessageID: resolved.source.id, message: after.result, ...(continuation ? { continuation } : {}) }
1536
+ }
1537
+
1538
+ private async resolveAmendment(request: AmendmentRequest, answer: Record<string, unknown>, ownerChat?: { action: "accept" | "reject" | "comment"; text: string; messageID: string }) {
1539
+ request = this.amendmentReviews.get(request.id) ?? request
1540
+ if (request.status !== "pending" || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return
1541
+ if (request.recovery) {
1542
+ if (ownerChat) return
1543
+ return this.resolveRecovery(request, answer)
1544
+ }
1545
+ const value = ownerChat?.text ?? (request.fieldKey ? answer[request.fieldKey] : undefined)
1546
+ const choices = this.amendmentReviews.choicesFor(request)
1547
+ const selected = ownerChat ? choices.find((c) => c.value === ownerChat.action) : selectedReviewChoice(request, choices, answer)
1548
+ if (reviewExpired(request)) {
1549
+ this.amendmentReviews.update(request.id, { status: "expired", result: "Review expired; the locked contract is unchanged." })
1550
+ this.store.ledger(request.slug, { type: "amendment-review-expired", decisionId: request.id, sessionID: request.sessionID, actionExecuted: false })
1551
+ this.noticeM(request.sessionID, M.amendmentExpired())
1552
+ await this.transcript(request.sessionID, `◎ Amendment review ${request.id} expired; nothing applied. Request a fresh Composer review of this exact diff; the old approval cannot be replayed.`)
1553
+ return
1554
+ }
1555
+ if (selected?.value !== "accept") {
1556
+ const rejected = selected?.value === "reject"
1557
+ const comment = typeof value === "string" ? value : "Invalid response; revise and review again."
1558
+ this.amendmentReviews.update(request.id, { status: rejected ? "rejected" : "commented", comment, result: "No contract change authorized." })
1559
+ await this.serial(() => {
1560
+ const state = this.runs.get(request.targetSessionID)
1561
+ if (state?.runId === request.runId && state.lock === request.lock) {
1562
+ const entry = state.amendments.find((a) => a.rationale === `staged proposal ${request.id}` && a.status === "proposed")
1563
+ if (entry) { entry.status = "rejected"; entry.rationale = comment }
1564
+ this.persist(state)
1565
+ }
1566
+ this.store.ledger(request.slug, { type: "amendment-feedback", decisionId: request.id, status: rejected ? "rejected" : "commented", comment })
1567
+ })
1568
+ if (!rejected) await this.ctx.session.prompt({ sessionID: request.sessionID, text: `Owner commentary for amendment ${request.id} of ${request.slug}: ${JSON.stringify(comment)}. Nothing was accepted or resumed. Use goal_propose_amendment with a revised validated contract and the current lock; never edit goal.yaml directly.`, resume: true } as any)
1569
+ return
1570
+ }
1571
+ await this.serial(async () => {
1572
+ const state = this.runs.get(request.targetSessionID)
1573
+ const disk = this.store.readContract(request.slug)
1574
+ const next = parseContract(request.nextText, { slug: request.slug })
1575
+ if (!state || state.runId !== request.runId || state.lock !== request.lock || isTerminal(state.status) || !disk?.contract || disk.lock !== request.lock || !next.contract || next.lock !== request.nextLock) {
1576
+ this.amendmentReviews.update(request.id, { status: "failed", result: "Target/run/contract changed since review; no amendment applied." })
1577
+ this.store.ledger(request.slug, { type: "amendment-resolution-failed", decisionId: request.id, reason: "review binding changed" })
1578
+ this.noticeM(request.sessionID, M.amendmentChanged())
1579
+ return
1580
+ }
1581
+ const reserved = this.amendmentReviews.reserve(request.id)
1582
+ if (!reserved) return
1583
+ const acceptedAt = Date.now()
1584
+ const stopped = structuredClone(state)
1585
+ setStatus(stopped, "paused", "owner accepted amendment; resume when ready")
1586
+ const result = amendedState(stopped, disk.contract, next.contract, request.nextLock, `owner accepted decision ${request.id}`, acceptedAt)
1587
+ const approved = this.amendmentReviews.writeAhead(request.id, { state: result, acceptedAt })
1588
+ try {
1589
+ const applied = this.store.commitAmendment(approved)
1590
+ Object.assign(state, applied)
1591
+ delete state.claim
1592
+ delete state.verdict
1593
+ if (!next.contract.priority) delete state.priority
1594
+ this.loaded.set(state.slug, { contract: next.contract, lock: next.lock, text: request.nextText })
1595
+ this.persist(state)
1596
+ this.amendmentReviews.update(request.id, { status: "succeeded", result: `Amended ${state.slug} to ${state.lock}; the goal resumes.` })
1597
+ this.retireAmendmentReviews(approved)
1598
+ if (ownerChat) this.store.ledger(request.slug, { type: "amendment-chat-resolved", decisionId: request.id, ownerMessageID: ownerChat.messageID, lock: state.lock, ok: true })
1599
+ this.invalidateOldDecision(state)
1600
+ // v0.8.0 C2: an accepted amendment resumes in the same act. No second
1601
+ // owner decision is created; the acceptance was the decision.
1602
+ await this.resumeAccepted(state, request.id)
1603
+ this.noticeM(request.sessionID, M.amended(state.amendments.filter((a) => a.status === "accepted").length, request.diff.summary))
1604
+ } catch (error) {
1605
+ // Keep executing+write-ahead receipt for recovery. Never report a
1606
+ // half-written generation as failed/finished or allow a new proposal.
1607
+ this.store.ledger(request.slug, { type: "amendment-commit-interrupted", decisionId: request.id, error: String(error) })
1608
+ this.noticeM(request.sessionID, M.amendmentInterrupted())
1609
+ }
1610
+ })
1611
+ }
1612
+
1613
+ private recoverAmendmentCommits() {
1614
+ for (const p of this.amendmentReviews.all()) {
1615
+ if (p.status === "succeeded" && p.commit) { this.retireAmendmentReviews(p); continue }
1616
+ if (p.status !== "executing" || p.issuerPID !== process.pid && processAlive(p.issuerPID)) continue
1617
+ if (!p.commit) { this.amendmentReviews.update(p.id, { status: "failed", result: "Host interrupted before acceptance was journalled; review again." }); continue }
1618
+ // Conflicting third-generation bytes fail setup rather than admitting
1619
+ // a worker with an inconsistent approved contract/state pair.
1620
+ this.store.commitAmendment(p)
1621
+ this.amendmentReviews.update(p.id, { status: "succeeded", result: "Recovered owner-accepted amendment; the goal resumes." })
1622
+ this.recoveredAmendmentRuns.add(p.runId)
1623
+ this.retireAmendmentReviews(p)
1624
+ }
1625
+ }
1626
+
1627
+ /** Only an applied, journalled generation retires conflicting unanswered
1628
+ * proposals. Staging in a live session alone spends no other author's review. */
1629
+ private retireAmendmentReviews(accepted: AmendmentRequest) {
1630
+ const state = this.store.readRun(accepted.slug)
1631
+ if (!accepted.commit || this.amendmentReviews.get(accepted.id)?.status !== "succeeded"
1632
+ || state?.runId !== accepted.runId || state.lock !== accepted.nextLock) return
1633
+ for (const other of this.amendmentReviews.all()) {
1634
+ if (other.id === accepted.id || other.slug !== accepted.slug || other.runId !== accepted.runId || other.lock !== accepted.lock
1635
+ || !["pending", "interrupted"].includes(other.status)) continue
1636
+ this.amendmentReviews.update(other.id, { status: "cancelled", result: `Generation accepted by ${accepted.id}; this unanswered proposal was superseded, not replayed.` })
1637
+ this.store.ledger(accepted.slug, { type: "amendment-review-superseded", decisionId: other.id, acceptedDecisionId: accepted.id,
1638
+ lock: accepted.lock, nextLock: accepted.nextLock, actionExecuted: false })
1639
+ const forms = this.factsOf(other.sessionID).forms
1640
+ // Plugin contexts can lack Core's form.cancel capability. Release
1641
+ // only a sole nonce-bound obsolete review, never an unrelated form.
1642
+ if (other.formID && forms.size === 1 && forms.has(other.formID))
1643
+ void this.ctx.session.interrupt({ sessionID: other.sessionID, resume: true }).catch((error) => {
1644
+ this.store.ledger(accepted.slug, { type: "amendment-review-release-failed", decisionId: other.id, error: String(error) })
1645
+ })
1646
+ }
1647
+ }
1648
+
1649
+ private invalidateOldDecision(state: RunState, reason = "contract generation amended") {
1650
+ const pending = this.pendingDecisions.get(state.sessionID)
1651
+ this.pendingDecisions.delete(state.sessionID)
1652
+ this.pendingFormDecisions.delete(state.sessionID)
1653
+ if (pending) {
1654
+ const request = this.decisions.get(pending.id)
1655
+ if (request && ["pending", "interrupted"].includes(request.status)) this.decisions.update(request.id, { status: "cancelled", result: `Review invalidated: ${reason}.` })
1656
+ this.store.ledger(state.slug, { type: "decision-invalidated", decision: pending.id, reason })
1657
+ void this.rpc?.events.emit("decision.resolved", { sessionID: state.sessionID, kind: pending.kind, decisionId: pending.id, via: "amendment-invalidated" }).catch(() => {})
1658
+ }
1659
+ }
1660
+
1661
+ private async recoverAmendmentReviews() {
1662
+ const api = (this.ctx.session as any).form
1663
+ for (const p of this.amendmentReviews.all()) {
1664
+ if (!["pending", "interrupted"].includes(p.status) || p.issuerPID !== process.pid && processAlive(p.issuerPID)) continue
1665
+ if (reviewExpired(p)) {
1666
+ this.amendmentReviews.update(p.id, { status: "expired", result: "Legacy timed review expired; the locked contract is unchanged." })
1667
+ this.store.ledger(p.slug, { type: "amendment-review-expired", decisionId: p.id, actionExecuted: false })
1668
+ await this.transcript(p.sessionID, `◎ Amendment review ${p.id} expired after reload; nothing applied. Request a fresh Composer review; no old approval will be replayed.`)
1669
+ continue
1670
+ }
1671
+ if (p.formID && typeof api?.get === "function") {
1672
+ try {
1673
+ const form = await api.get({ sessionID: p.sessionID, formID: p.formID })
1674
+ if (form.state?.status === "answered") { await this.resolveAmendment(p, form.state.answer); continue }
1675
+ if (form.state?.status === "pending") { this.factsOf(p.sessionID).forms.add(p.formID); continue }
1676
+ if (form.state?.status === "cancelled") this.amendmentReviews.interruptForm(p.id)
1677
+ } catch { /* Core forms can disappear on reload; retain exact review. */ }
1678
+ }
1679
+ const next = this.amendmentReviews.reopenForm(p.id)
1680
+ if (next) await this.presentAmendment(next)
1681
+ }
1682
+ }
1683
+
1684
+ // ───────────────────────────── starting and admitting turns
1685
+
1686
+ /**
1687
+ * T059: start clean. The engine creates a NEW session owned by the goal,
1688
+ * pins the run to that session (T049: the run map keys the new session) and
1689
+ * the kickoff admission prompts there — a fresh context with none of the
1690
+ * writing session's accumulated history. The invoking session is never
1691
+ * cleared (OpenCode exposes no clear-in-place API; that is a TUI command)
1692
+ * and stays untouched apart from one notice naming where to look.
1693
+ */
1694
+ private async startClean(fromSessionID: string, slug: string, options: StartOptions = {}): Promise<string> {
1695
+ // Not wrapped in serial(): startGoal below takes the queue, and the queue
1696
+ // is not reentrant — nesting would deadlock. session.create is safe
1697
+ // outside the queue; everything stateful flows through startGoal.
1698
+ let agent: string | undefined, model: Handoff["model"], fallback: string | undefined
1699
+ try {
1700
+ const info: any = await this.ctx.session.get({ sessionID: fromSessionID } as any)
1701
+ const source = info?.data ?? info
1702
+ if (typeof source.agent === "string") agent = source.agent
1703
+ if (typeof source.model?.providerID === "string" && typeof source.model?.id === "string") model = { providerID: source.model.providerID, id: source.model.id, ...(typeof source.model.variant === "string" ? { variant: source.model.variant } : {}) }
1704
+ if (!agent || !model) fallback = "Source model/agent unavailable in session metadata; the missing selection uses host defaults."
1705
+ } catch { fallback = "Source session metadata unavailable; the fresh session uses host defaults." }
1706
+ const created: any = await this.ctx.session.create({ title: `goal kickoff · ${slug}`, ...(agent ? { agent } : {}), ...(model ? { model } : {}) } as any)
1707
+ const targetID: string | undefined = created?.id ?? created?.data?.id
1708
+ if (!targetID) return `Could not open a new session for "${slug}" — nothing started. Run /goal start ${slug} in this session instead.`
1709
+ const handoff: Handoff = { fromSessionID, toSessionID: targetID, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(fallback ? { fallback } : {}) }
1710
+ this.store.ledger(slug, { type: "clean-start-attempt", from: fromSessionID, to: targetID, ...(options.decisionId ? { decisionId: options.decisionId } : {}) })
1711
+ const message = await this.startGoal(targetID, slug, "command", { ...options, handoff })
1712
+ if (!message.startsWith("Goal \"")) return message
1713
+ const state = this.runs.get(targetID)!
1714
+ this.store.ledger(slug, { type: "clean-start", from: fromSessionID, to: targetID, runId: state.runId, ...(options.decisionId ? { decisionId: options.decisionId } : {}) })
1715
+ await this.transcript(fromSessionID, `◎ Goal ${slug} launched in a NEW root session ${targetID}. Original history remains here. Use the Goals manager or open that session to follow the run.${fallback ? ` Handoff fallback: ${fallback}` : ""}`)
1716
+ void this.rpc?.events.emit("handoff", { fromSessionID, toSessionID: targetID, slug, runId: state.runId, ...(fallback ? { fallback } : {}) }).catch(() => {})
1717
+ this.noticeM(fromSessionID, M.cleanStarted(targetID, slug))
1718
+ return `Started clean — ${message} The kickoff prompt landed in the new session "${targetID}"; this session stays untouched.`
1719
+ }
1720
+
1721
+ async startGoal(sessionID: string, slug: string, source: "command" | "tool", options: StartOptions = {}): Promise<string> {
1722
+ return this.serial(() => this.startGoalLocked(sessionID, slug, source, options))
1723
+ }
1724
+
1725
+ /** The start path without the queue lock — callable from inside ownerAct,
1726
+ * which already holds it (the queue is not reentrant). */
1727
+ private async startGoalLocked(sessionID: string, slug: string, source: "command" | "tool", options: StartOptions = {}): Promise<string> {
1728
+ {
1729
+ const current = this.runs.get(sessionID)
1730
+ if (current && !isTerminal(current.status)) return current.slug === slug
1731
+ ? `Launch refused: the existing run ${current.runId} for "${current.slug}" is ${current.status}; no new run was started and the existing run is unchanged. Continue or manage that goal in the Composer; do not abort it just to retry launch.`
1732
+ : `Launch refused: this session owns "${current.slug}" (${current.status}); that run is unchanged. Manage it or request a fresh-session launch for "${slug}" rather than aborting a working goal.`
1733
+ for (const other of this.runs.values())
1734
+ if (other.slug === slug && !isTerminal(other.status) && other.sessionID !== sessionID) return `Goal "${slug}" is already ${other.status} in another session (${other.sessionID}).`
1735
+ const loaded = this.load(slug, true)
1736
+ if ("error" in loaded) return loaded.error
1737
+ const { contract, lock, text } = loaded
1738
+ if (options.expectedLock && lock !== options.expectedLock) return "Refused: contract changed after launch review; review the new locked bytes before starting."
1739
+ // T031: the supersession admission rule — a supersedes pointer must
1740
+ // resolve, match the predecessor's lock, and the predecessor must be
1741
+ // terminal (or the owner explicitly acknowledges superseding mid-flight).
1742
+ if (contract.supersedes) {
1743
+ const [oldSlug, lockPrefix] = contract.supersedes.split("@") as [string, string]
1744
+ const predecessorRun = this.store.readRun(oldSlug)
1745
+ const predecessorArchived = !predecessorRun && this.store.archivedSlugs().includes(oldSlug)
1746
+ if (!predecessorRun && !predecessorArchived) return `Refused: supersedes points at "${oldSlug}", but no such goal exists here (live or archived).`
1747
+ const predecessorLock = predecessorRun?.lock ?? ""
1748
+ if (predecessorRun && !predecessorLock.startsWith(lockPrefix!)) return `Refused: supersedes references ${oldSlug}@${lockPrefix}, but its run lock is ${predecessorLock.slice(0, 12)}… — the predecessor changed since the pointer was written; update the pointer.`
1749
+ if (predecessorRun && !isTerminal(predecessorRun.status) && !options.acknowledgeSupersede) {
1750
+ // T050: superseding a live predecessor is an explicit owner decision
1751
+ this.decide(sessionID, slug, "supersede-ack", M.supersedeAck(slug, oldSlug, predecessorRun.status), { predecessor: oldSlug })
1752
+ return `Refused: "${oldSlug}" is still ${predecessorRun.status}. Superseding a live predecessor forks the audit trail — abort it first, or start with /goal start ${slug} acknowledge-supersede to record your explicit acknowledgment.`
1753
+ }
1754
+ // flip the predecessor terminal, archive it (demote never delete), ledger both sides
1755
+ if (predecessorRun) {
1756
+ const liveState = [...this.runs.values()].find((s) => s.slug === oldSlug)
1757
+ if (liveState) {
1758
+ clearTimeout(this.timers.get(liveState.sessionID))
1759
+ setStatus(liveState, "superseded", `superseded by ${slug}`)
1760
+ this.runs.delete(liveState.sessionID)
1761
+ } else setStatus(predecessorRun, "superseded", `superseded by ${slug}`)
1762
+ const finalState = liveState ?? predecessorRun
1763
+ this.store.writeRun(finalState)
1764
+ this.store.ledger(oldSlug, { type: "superseded-by", by: slug, atLock: lock, acknowledged: !!options.acknowledgeSupersede })
1765
+ this.registry.update(this.rootReal, oldSlug, { title: finalState.title, status: "superseded", ...(finalState.runId ? { runId: finalState.runId } : {}), archived: true, updatedAt: Date.now() })
1766
+ this.store.archive(oldSlug)
1767
+ } else {
1768
+ this.store.ledger(oldSlug, { type: "superseded-by", by: slug, atLock: lock, acknowledged: !!options.acknowledgeSupersede, note: "predecessor was already archived" })
1769
+ }
1770
+ this.store.ledger(slug, { type: "supersession", of: oldSlug, atLock: lockPrefix, acknowledged: !!options.acknowledgeSupersede })
1771
+ }
1772
+ // T036: start-time rehearsal — run every command check once BEFORE
1773
+ // locking. A check whose text cannot run at all refuses the start; a
1774
+ // legitimate red check is recorded as the baseline (a bug-fix goal
1775
+ // SHOULD start red). The unrunnable signature is the shell's own
1776
+ // failure (exit 126/127 or a zsh:/bash: diagnostic) — never a tool's
1777
+ // stderr, so `grep missing-file` stays a valid red baseline.
1778
+ const commandChecks = [...contract.criteria, ...contract.invariants].filter((c) => c.check.kind === "command")
1779
+ // T100: resolve the exact-run execution workspace. Checks, the protect base
1780
+ // and the fingerprint all depend on git, so a workspace that is not itself a
1781
+ // git work-tree root inside the project refuses the start.
1782
+ const exec = this.executionRoot(contract)
1783
+ if (exec !== this.root && canonical(topLevel(exec) ?? "") !== canonical(exec))
1784
+ return `Refused to start "${slug}": workspace "${contract.workspace}" is not a git work-tree root inside the project. Use a linked worktree (e.g. .worktrees/<slug>) or omit workspace. Fix the contract and /goal start again.`
1785
+ const rehearsal: Array<{ id: string; pass: boolean; unrunnable: boolean; ms: number; detail: string }> = []
1786
+ this.launchActivity(options.decisionId, { phase: "rehearsal", since: Date.now(), detail: `Rehearsing ${commandChecks.length} command checks before run creation.`, completed: 0, total: commandChecks.length })
1787
+ for (const c of commandChecks) {
1788
+ const started = Date.now()
1789
+ this.launchActivity(options.decisionId, { phase: "check", since: started, detail: `Rehearsing ${c.id} (${rehearsal.length + 1}/${commandChecks.length}); no run yet.`, checkId: c.id, completed: rehearsal.length, total: commandChecks.length })
1790
+ const result = await runHostCheck(c.check, exec, headCommit(exec))
1791
+ if (this.disposed) return "Launch admission interrupted by host reload; no run created. Inspect the receipt and prepare a new review."
1792
+ const last = Array.isArray(result.raw) ? (result.raw as unknown[]).at(-1) as { exit?: number; stderr?: string } | undefined : undefined
1793
+ const unrunnable = Number(last?.exit) === 126 || Number(last?.exit) === 127 || /\b(zsh|bash|sh|dash):(\d+:)?\s.*(permission denied|command not found|no such file or directory)/i.test(String(last?.stderr ?? ""))
1794
+ rehearsal.push({ id: c.id, pass: result.pass, unrunnable, ms: Date.now() - started, detail: result.detail.slice(0, 400) })
1795
+ this.launchActivity(options.decisionId, { phase: "rehearsal", since: Date.now(), detail: `Rehearsed ${c.id}: ${unrunnable ? "unrunnable" : result.pass ? "pass" : "red baseline"} (${rehearsal.length}/${commandChecks.length}).`, completed: rehearsal.length, total: commandChecks.length })
1796
+ if (unrunnable) {
1797
+ this.store.ledger(slug, { type: "rehearsal-refused", id: c.id, exit: last?.exit, detail: result.detail.split("\n").slice(0, 3).join(" | ").slice(0, 300) })
1798
+ const diagnostic = String(last?.stderr ?? "").split("\n").find((l) => /permission denied|command not found|no such file/i.test(l)) ?? `exit ${last?.exit}`
1799
+ return `Refused to start "${slug}": the check command for ${c.id} cannot run — ${diagnostic.trim()}. Command checks must be runnable from the project root; keep each on ONE line (YAML folded scalars keep newlines before indented lines) and rehearse the stored command. Fix goal.yaml and /goal start again.`
1800
+ }
1801
+ }
1802
+ if (this.disposed) return "Launch admission interrupted by host reload; no run created."
1803
+ if (options.expectedLock) {
1804
+ const current = this.load(slug, true)
1805
+ if ("error" in current || current.lock !== options.expectedLock) return "Refused: contract changed during launch rehearsal; no run created. Prepare a new review."
1806
+ }
1807
+ const state = initialRun(contract, { sessionID, runId: newRunId(), lock, commit: headCommit(exec) })
1808
+ if (options.handoff) state.handoff = options.handoff
1809
+ state.fingerprint = fingerprint(exec)
1810
+ if (rehearsal.length) {
1811
+ this.store.evidence(slug, state.runId, "rehearsal", { at: Date.now(), results: rehearsal })
1812
+ this.store.ledger(slug, { type: "rehearsal", turn: 0, baseline: rehearsal.map((r) => ({ id: r.id, pass: r.pass })) })
1813
+ }
1814
+ // v0.8.0 D1: a fresh launch carries a handoff built from what the host
1815
+ // already knows, so the new session does not have to rediscover it.
1816
+ if (options.handoff) this.writeHandoff(state, contract, options.handoff, exec, rehearsal)
1817
+ this.store.evidence(slug, state.runId, "contract", { lock, text })
1818
+ await Bun.write(join(this.store.dir(slug), "evidence", state.runId, "contract.yaml"), text)
1819
+ this.runs.set(sessionID, state)
1820
+ this.store.ledger(slug, { type: "start", runId: state.runId, sessionID, source, lock, commit: state.base.commit, ...(options.decisionId ? { decisionId: options.decisionId } : {}) })
1821
+ // T027 / v0.5.0: record the CLEO linkage and health (CLI-only). A project
1822
+ // with no CLEO CLI records nothing and behaves EXACTLY as before; a project
1823
+ // with the CLI gets an honest state, and nothing is ever installed here.
1824
+ const health = await cleoHealth(this.root, defaultRunner)
1825
+ if (health.state !== "cli-absent") {
1826
+ const link = cleoLinkEvent(await cleoFacts(this.root, defaultRunner))
1827
+ if (link) this.store.ledger(slug, link)
1828
+ this.store.ledger(slug, { type: "cleo-health", state: health.state, ...(health.cli.version ? { version: health.cli.version } : {}), ...(health.projectId ? { projectId: health.projectId } : {}) })
1829
+ }
1830
+ this.persist(state)
1831
+ this.noticeM(sessionID, M.goalStarted(contract.title))
1832
+ if (!this.factsOf(sessionID).busy) await this.admit(state, "kickoff")
1833
+ return `Goal "${contract.title}" started (run ${state.runId}). The host drives the loop from here: work toward the outcome, record progress with goal_progress, and call goal_claim when every criterion holds.`
1834
+ }
1835
+ }
1836
+
1837
+ /**
1838
+ * T038: owner-approved amendment. The owner edits goal.yaml out-of-band, then
1839
+ * `/goal amend` proposes (diff summary) and `/goal amend confirm` re-locks:
1840
+ * atomic generation-bound records, ledger trail, and the explicit branch
1841
+ * decision — prior evidence is retained for unchanged oracle semantics;
1842
+ * changed statements, checks or roles reset to unknown and re-verify.
1843
+ */
1844
+ private async amendGoal(state: RunState, confirmed: boolean): Promise<string> {
1845
+ const read = this.store.readContract(state.slug)
1846
+ if (!read) return `no contract on disk for ${state.slug}`
1847
+ if (!read.contract) return `goal.yaml for "${state.slug}" is invalid:\n${formatIssues(read.issues)}\nThe run keeps its current lock; fix goal.yaml and amend again.`
1848
+ const next = read.contract
1849
+ const nextLock = read.lock
1850
+ if (nextLock === state.lock) return "Nothing to amend: goal.yaml is byte-identical to the locked contract."
1851
+
1852
+ const prior = this.contractOf(state)!
1853
+ const { unchanged, changed, added, removed, summary } = amendmentDiff(prior, next)
1854
+
1855
+ if (!confirmed) {
1856
+ state.amendments.push({ change: summary, rationale: "proposed; awaiting /goal amend confirm", at: Date.now(), status: "proposed" })
1857
+ this.store.ledger(state.slug, { type: "amend-proposed", turn: state.turn, summary })
1858
+ this.persist(state)
1859
+ this.decide(state.sessionID, state.slug, "amend-proposed", M.amendProposed(summary))
1860
+ return `Proposed amendment: ${summary}\nReview the diff, then run /goal amend confirm to re-lock (generation ${state.amendments.filter((a) => a.status === "accepted").length + 1}).`
1861
+ }
1862
+
1863
+ const generation = state.amendments.filter((a) => a.status === "accepted").length + 1
1864
+ const at = Date.now()
1865
+ const oldLock = state.lock
1866
+ // atomic, generation-bound record + evidence snapshot
1867
+ Object.assign(state, amendedState(state, prior, next, nextLock, "owner-confirmed via /goal amend confirm", at))
1868
+ delete state.claim
1869
+ delete state.verdict
1870
+ if (!next.priority) delete state.priority
1871
+ this.store.evidence(state.slug, state.runId, `amendment-${generation}-${at}`, { generation, oldLock, newLock: nextLock, at, by: "owner", summary, changed, added, removed, unchanged, contract: read.text })
1872
+ this.store.writeContractSnapshot(state.slug, state.runId, nextLock, read.text)
1873
+ // the in-memory contract cache must serve the amended contract from the next request on
1874
+ this.loaded.set(state.slug, { contract: next, lock: nextLock, text: read.text })
1875
+ this.store.ledger(state.slug, { type: "amended", turn: state.turn, generation, oldLock, newLock: nextLock, summary })
1876
+ this.persist(state)
1877
+ this.emitUpdate(state)
1878
+ this.invalidateOldDecision(state)
1879
+ this.noticeM(state.sessionID, M.amended(generation, summary))
1880
+ await this.transcript(state.sessionID, `◎ owner amended the contract (generation ${generation}): ${summary}. Changed criteria reset to unknown and re-verify on the next claim; re-read the contract before acting.`)
1881
+ return `Amended ${state.slug} (generation ${generation}): ${summary}. Changed criteria reset to unknown and re-verify on the next claim.`
1882
+ }
1883
+
1884
+ /**
1885
+ * v0.8.0 C2: the accepted generation continues immediately. The run was
1886
+ * paused while its review was staged; this clears that pause, records the
1887
+ * resume, and admits the ordinary continuation — all in the acceptance act.
1888
+ * A verifying run is left alone (its verdict decides), and a terminal run is
1889
+ * never revived.
1890
+ */
1891
+ private async resumeAccepted(state: RunState, decisionId: string) {
1892
+ const live = this.runs.get(state.sessionID)
1893
+ if (!live || live.runId !== state.runId || isTerminal(live.status) || live.status === "verifying") return false
1894
+ if (!this.contractOf(live)) return false
1895
+ live.wait = undefined
1896
+ setStatus(live, "running", "owner accepted an amendment")
1897
+ this.store.ledger(live.slug, { type: "resumed", by: "owner amendment", decisionId, lock: live.lock, turn: live.turn })
1898
+ this.store.ledger(live.slug, { type: "amendment-resumed", decisionId, lock: live.lock, turn: live.turn })
1899
+ this.persist(live)
1900
+ await this.admit(live, "resume")
1901
+ return true
1902
+ }
1903
+
1904
+ // ───────────────────────────── continuation (v0.8.0 C4/C5)
1905
+
1906
+ private peerChannel(): PeerChannel | undefined {
1907
+ const identity = process.env.OCGOAL_PEER_IDENTITY
1908
+ if (!identity) return undefined
1909
+ this.peerChannelInstance ??= createPeerChannel({
1910
+ bin: process.env.OCGOAL_PEER_BIN ?? "agentmbx",
1911
+ identity,
1912
+ cli: "opencode",
1913
+ ...(Number(process.env.OCGOAL_PEER_TIMEOUT_MS) > 0 ? { timeoutMs: Number(process.env.OCGOAL_PEER_TIMEOUT_MS) } : {}),
1914
+ })
1915
+ return this.peerChannelInstance
1916
+ }
1917
+
1918
+ private peerCooldownMs(): number {
1919
+ const value = Number(process.env.OCGOAL_PEER_COOLDOWN_MS)
1920
+ return Number.isFinite(value) && value >= 0 ? value : 15 * 60_000
1921
+ }
1922
+
1923
+ private peerPollMs(): number {
1924
+ const value = Number(process.env.OCGOAL_PEER_POLL_MS)
1925
+ return Number.isFinite(value) && value > 0 ? value : 30_000
1926
+ }
1927
+
1928
+ /**
1929
+ * v0.8.0 C4/C5: after a goal completes the loop looks for the next work
1930
+ * before it stops. Sources, in order: a ready CLEO item in this project, then
1931
+ * peer replies to an availability announcement. The decision is the pure
1932
+ * `nextWork`; this method only gathers the facts and applies the action.
1933
+ *
1934
+ * Returns true only when a NEW goal was actually started — that is the case
1935
+ * where the completion must not create an owner question.
1936
+ */
1937
+ private async continueWork(state: RunState, contract: Contract): Promise<boolean> {
1938
+ const candidates: WorkCandidate[] = []
1939
+
1940
+ // 1. A ready CLEO item. Only probe when this project is CLEO-linked; an
1941
+ // unlinked project spawns nothing and keeps today's behaviour.
1942
+ try {
1943
+ if (await cleoFacts(this.root, defaultRunner)) {
1944
+ const ready = await readyCleoTask((command, args, timeoutMs) => defaultRunner(process.env.OCGOAL_CLEO_BIN ?? command, args, timeoutMs))
1945
+ if (ready.skipped.length) this.store.ledger(state.slug, { type: "continue-parent-skipped", tasks: ready.skipped, reason: "a parent's acceptance is a rollup, not work; leaves are preferred" })
1946
+ if (ready.task) candidates.push({ source: "cleo", project: this.root, taskId: ready.task.id, title: ready.task.title, acceptance: ready.task.acceptance })
1947
+ }
1948
+ } catch (error) {
1949
+ this.store.ledger(state.slug, { type: "continue-cleo-error", error: String(error).slice(0, 300) })
1950
+ }
1951
+
1952
+ // 2. Peer replies since the last poll. A reply is candidate work, never consent.
1953
+ const channel = this.peerChannel()
1954
+ if (channel) {
1955
+ const replies = await channel.replies({ since: state.peer?.lastReplyAt ?? 0, sessionID: state.sessionID }).catch(() => [])
1956
+ if (replies.length) {
1957
+ const now = Date.now()
1958
+ state.peer = { ...(state.peer ?? {}), outstanding: undefined, lastReplyAt: now,
1959
+ candidates: [...(state.peer?.candidates ?? []), ...replies.map((r) => candidateFromReply(r, this.root))] }
1960
+ this.persist(state)
1961
+ this.store.ledger(state.slug, { type: "peer-reply", count: replies.length, from: replies.map((r) => r.from), candidates: replies.map((r) => r.text.slice(0, 200)) })
1962
+ for (const reply of replies) {
1963
+ for (const candidate of [candidateFromReply(reply, this.root)])
1964
+ this.noticeM(state.sessionID, M.peerReplied(candidate.from ?? reply.from, candidate.taskId))
1965
+ }
1966
+ }
1967
+ for (const candidate of state.peer?.candidates ?? [])
1968
+ if (!candidates.some((c) => c.messageId === candidate.messageId)) candidates.push(candidate as WorkCandidate)
1969
+ }
1970
+
1971
+ const decision = nextWork({
1972
+ candidates,
1973
+ currentProject: this.root,
1974
+ visitedProjects: [this.root],
1975
+ budgetExhausted: budgetUse(state, contract).ratio >= 1,
1976
+ allowBudgetExhaustion: contract.autonomy.continue_past_budget === true,
1977
+ allowNewProjects: contract.autonomy.visit_new_projects === true,
1978
+ ...(state.peer ? { announce: { ...(state.peer.lastAt !== undefined ? { lastAt: state.peer.lastAt } : {}), ...(state.peer.outstanding ? { outstanding: state.peer.outstanding } : {}) } } : { announce: {} }),
1979
+ now: Date.now(),
1980
+ cooldownMs: this.peerCooldownMs(),
1981
+ })
1982
+
1983
+ switch (decision.kind) {
1984
+ case "start":
1985
+ return this.startContinuation(state, contract, decision.candidate)
1986
+ case "announce":
1987
+ await this.announceAvailability(state, contract, channel)
1988
+ return false
1989
+ case "stop":
1990
+ this.store.ledger(state.slug, { type: "continue-stopped", code: decision.code, reason: decision.reason })
1991
+ this.noticeM(state.sessionID, M.continueStopped(decision.reason))
1992
+ return false
1993
+ default:
1994
+ return false
1995
+ }
1996
+ }
1997
+
1998
+ /** Write the continuation contract and open it in a fresh session. */
1999
+ private startContinuation(state: RunState, contract: Contract, candidate: WorkCandidate): boolean {
2000
+ if (candidate.source !== "cleo" || !candidate.taskId) {
2001
+ this.store.ledger(state.slug, { type: "continue-candidate", source: candidate.source, title: candidate.title, from: candidate.from ?? null, taskId: candidate.taskId ?? null })
2002
+ return false
2003
+ }
2004
+ const taken = new Set([...this.store.slugs(), ...this.store.archivedSlugs()])
2005
+ const slug = continuationSlug(candidate.taskId, taken)
2006
+ const text = continuationContractYaml({
2007
+ slug,
2008
+ task: { id: candidate.taskId, title: candidate.title, description: "", acceptance: candidate.acceptance ?? [] },
2009
+ sourceSlug: state.slug,
2010
+ autonomy: contract.autonomy,
2011
+ })
2012
+ const parsed = parseContract(text, { slug })
2013
+ if (!parsed.contract) {
2014
+ this.store.ledger(state.slug, { type: "continue-refused", taskId: candidate.taskId, slug, issues: parsed.issues })
2015
+ this.noticeM(state.sessionID, M.continueStopped(`the contract generated from ${candidate.taskId} did not validate`))
2016
+ return false
2017
+ }
2018
+ const dir = join(this.root, ".opencode", "goals", slug)
2019
+ mkdirSync(dir, { recursive: true })
2020
+ writeFileSync(join(dir, "goal.yaml"), text)
2021
+ this.store.ledger(state.slug, { type: "continue-work", taskId: candidate.taskId, slug, title: candidate.title, lock: parsed.lock })
2022
+ this.noticeM(state.sessionID, M.continueWork(state.title, slug, candidate.taskId))
2023
+ void this.transcript(state.sessionID, `◎ Goal complete — "${state.title}". Started the next ready CLEO item ${candidate.taskId} as "${slug}" in a new session; no owner decision was created. The new contract is .opencode/goals/${slug}/goal.yaml.`)
2024
+ // startClean creates the session outside the serial queue and queues the
2025
+ // start behind this completion turn; called synchronously it would re-enter
2026
+ // the queue and deadlock.
2027
+ setTimeout(() => {
2028
+ if (this.disposed) return
2029
+ void this.startClean(state.sessionID, slug, {}).catch((error) =>
2030
+ this.store.ledger(state.slug, { type: "continue-start-failed", taskId: candidate.taskId, slug, error: String(error).slice(0, 300) }))
2031
+ }, 0)
2032
+ return true
2033
+ }
2034
+
2035
+ /** One availability announcement, rate-limited by the pure policy. */
2036
+ private async announceAvailability(state: RunState, contract: Contract, channel: PeerChannel | undefined) {
2037
+ if (!channel) {
2038
+ this.store.ledger(state.slug, { type: "peer-announce-skipped", reason: "no peer identity configured (set OCGOAL_PEER_IDENTITY)" })
2039
+ return
2040
+ }
2041
+ const now = Date.now()
2042
+ const taskId = contract.cleo?.tasks[0]?.id
2043
+ const sent = await channel.announce({
2044
+ text: announcementText({ project: this.root, ...(taskId ? { taskId } : {}), slug: state.slug }),
2045
+ sessionID: state.sessionID,
2046
+ }).catch(() => undefined)
2047
+ if (!sent) {
2048
+ this.store.ledger(state.slug, { type: "peer-announce-failed" })
2049
+ return
2050
+ }
2051
+ state.peer = { ...(state.peer ?? {}), lastAt: now, outstanding: { id: sent.id, at: now }, polls: 0 }
2052
+ this.persist(state)
2053
+ this.store.ledger(state.slug, { type: "peer-announce", id: sent.id, taskId: taskId ?? null })
2054
+ this.noticeM(state.sessionID, M.peerAnnounced(taskId))
2055
+ this.schedulePeerPoll(state)
2056
+ }
2057
+
2058
+ /** Best-effort: turn replies to the outstanding announcement into candidates. */
2059
+ private schedulePeerPoll(state: RunState) {
2060
+ const channel = this.peerChannel()
2061
+ if (!channel) return
2062
+ const key = `peer:${state.sessionID}`
2063
+ clearTimeout(this.timers.get(key))
2064
+ const timer = setTimeout(() => {
2065
+ void this.serial(async () => {
2066
+ const replies = await channel.replies({ since: state.peer?.lastReplyAt ?? 0, sessionID: state.sessionID }).catch(() => [])
2067
+ if (!replies.length) {
2068
+ // Keep watching while the announcement is outstanding, but never
2069
+ // forever: 40 polls at the configured interval is a bounded wait.
2070
+ const polls = (state.peer?.polls ?? 0) + 1
2071
+ state.peer = { ...(state.peer ?? {}), polls }
2072
+ if (state.peer.outstanding && polls < 40) this.schedulePeerPoll(state)
2073
+ else if (state.peer.outstanding) this.store.ledger(state.slug, { type: "peer-poll-expired", id: state.peer.outstanding.id, polls })
2074
+ return
2075
+ }
2076
+ const now = Date.now()
2077
+ const candidates = replies.map((r) => candidateFromReply(r, this.root))
2078
+ state.peer = { ...(state.peer ?? {}), outstanding: undefined, lastReplyAt: now, candidates: [...(state.peer?.candidates ?? []), ...candidates] }
2079
+ this.persist(state)
2080
+ this.store.ledger(state.slug, { type: "peer-reply", count: replies.length, candidates: candidates.map((c) => ({ from: c.from, taskId: c.taskId ?? null, title: c.title })) })
2081
+ for (const candidate of candidates) this.noticeM(state.sessionID, M.peerReplied(candidate.from ?? "a peer", candidate.taskId))
2082
+ })
2083
+ }, this.peerPollMs())
2084
+ timer.unref?.()
2085
+ this.timers.set(key, timer)
2086
+ }
2087
+
2088
+ /**
2089
+ * v0.8.0 D1: write the launch handoff beside the contract. Facts only, with
2090
+ * their sources; a failed probe is omitted, never invented.
2091
+ */
2092
+ private writeHandoff(state: RunState, contract: Contract, handoff: Handoff, exec: string, rehearsal: Array<{ id: string; pass: boolean }>) {
2093
+ try {
2094
+ const prior = this.store.slugs()
2095
+ .filter((s) => s !== state.slug)
2096
+ .map((s) => ({ slug: s, run: this.store.readRun(s) }))
2097
+ .filter((x): x is { slug: string; run: RunState } => !!x.run && isTerminal(x.run.status) && x.run.sessionID === handoff.fromSessionID)
2098
+ .sort((a, b) => (b.run.updatedAt ?? 0) - (a.run.updatedAt ?? 0))[0]
2099
+ const branch = Bun.spawnSync(["git", "branch", "--show-current"], { cwd: exec, stdout: "pipe", stderr: "ignore" })
2100
+ const status = Bun.spawnSync(["git", "status", "--porcelain"], { cwd: exec, stdout: "pipe", stderr: "ignore" })
2101
+ const dirty = status.exitCode === 0
2102
+ ? status.stdout.toString().split("\n").map((l) => l.trim()).filter(Boolean).filter((l) => !l.includes(".opencode/goals"))
2103
+ : []
2104
+ const doc = buildHandoff({
2105
+ slug: state.slug,
2106
+ title: contract.title,
2107
+ fromSessionID: handoff.fromSessionID,
2108
+ toSessionID: handoff.toSessionID,
2109
+ ...(prior ? { prior: {
2110
+ slug: prior.slug, runId: prior.run.runId, lock: prior.run.lock, status: prior.run.status, turn: prior.run.turn,
2111
+ evidence: `.opencode/goals/${prior.slug}/evidence/${prior.run.runId}/`,
2112
+ } } : {}),
2113
+ repo: { ...(state.base.commit ? { head: state.base.commit } : {}), ...(branch.exitCode === 0 && branch.stdout.toString().trim() ? { branch: branch.stdout.toString().trim() } : {}), dirty },
2114
+ baseline: rehearsal.map((r) => ({ id: r.id, pass: r.pass })),
2115
+ ...(contract.plan[0] ? { nextAction: { step: contract.plan[0].id, title: contract.plan[0].title } } : {}),
2116
+ authority: {
2117
+ trust: ["goal.yaml (the locked contract)", ...(existsSync(join(this.root, "docs", "HANDOFF-v0.8.0.md")) ? ["docs/HANDOFF-v0.8.0.md"] : [])],
2118
+ stale: ["docs/HANDOFF.md (its historical body; read the current-state banner before trusting it)"],
2119
+ },
2120
+ evidence: [`.opencode/goals/${state.slug}/evidence/${state.runId}/`],
2121
+ })
2122
+ writeFileSync(join(this.store.dir(state.slug), "handoff.md"), `${doc.markdown}\n`)
2123
+ writeFileSync(join(this.store.dir(state.slug), "handoff.json"), `${JSON.stringify({ schema: doc.schema, generatedAt: doc.generatedAt, ...doc.data }, null, 2)}\n`)
2124
+ state.handoff = { ...handoff, file: "handoff.md" }
2125
+ this.store.ledger(state.slug, { type: "handoff-written", file: "handoff.md", prior: prior?.slug ?? null })
2126
+ } catch (error) {
2127
+ this.store.ledger(state.slug, { type: "handoff-failed", error: String(error).slice(0, 300) })
2128
+ }
2129
+ }
2130
+
2131
+ private async admit(state: RunState, kind: PendingKind) {
2132
+ if (this.disposed || !isActive(state.status)) return
2133
+ const contract = this.contractOf(state)
2134
+ if (!contract) {
2135
+ setStatus(state, "paused", "contract unavailable")
2136
+ this.persist(state)
2137
+ return
2138
+ }
2139
+ if (!state.pending || state.pending.kind !== kind) {
2140
+ state.turn += 1
2141
+ state.pending = { messageID: ascendingId("msg"), turn: state.turn, at: Date.now(), kind }
2142
+ }
2143
+ if (state.status !== "running") setStatus(state, "running")
2144
+ state.activity = { kind: "turn", since: Date.now(), detail: kind }
2145
+ let note = renderTailNote(state, contract, kind as TailKind)
2146
+ // v0.8.0 D1: the kickoff carries the handoff digest. It is volatile tail
2147
+ // content for turn 1 only, so the system block stays byte-stable.
2148
+ if (kind === "kickoff" && state.handoff?.file) {
2149
+ try {
2150
+ const digest = handoffDigest(readFileSync(join(this.store.dir(state.slug), state.handoff.file), "utf8"))
2151
+ note = `<goal_handoff>\n${digest}\n</goal_handoff>\n\n${note}`
2152
+ } catch { /* the handoff is optional context; never block the kickoff */ }
2153
+ }
2154
+ this.notes.set(state.sessionID, note)
2155
+ state.compacted = false
2156
+ this.store.ledger(state.slug, { type: "admit", kind, turn: state.turn, messageID: state.pending.messageID })
2157
+ this.persist(state)
2158
+ try {
2159
+ // S2 + T041: a fixed id makes a retried admission idempotent, and the
2160
+ // continuation lands as ONE synthetic notice row in the transcript
2161
+ // (research §0.6) instead of a user message; resume drives the turn.
2162
+ // Falls back to a plain prompt on hosts without synthetic resume.
2163
+ const payload = {
2164
+ sessionID: state.sessionID,
2165
+ id: state.pending.messageID,
2166
+ text: triggerText(state, contract, kind as TailKind),
2167
+ description: `goal ${kind} · ${state.slug} · turn ${state.turn}`,
2168
+ metadata: { goal: { slug: state.slug, run: state.runId, turn: state.turn, kind } },
2169
+ resume: true,
2170
+ }
2171
+ try {
2172
+ await this.ctx.session.synthetic(payload as any)
2173
+ } catch {
2174
+ await this.ctx.session.prompt(payload as any)
2175
+ }
2176
+ // An accepted prompt is not a successful model execution. Reducing
2177
+ // the failure streak here cancelled every execution failure and made
2178
+ // provider errors retry forever (observed turns 10–36 in dogfood).
2179
+ } catch (error) {
2180
+ state.counters.failures += 1
2181
+ this.store.ledger(state.slug, { type: "admit-failed", turn: state.turn, error: String(error) })
2182
+ if (state.counters.failures >= this.options.maxPromptFailures) {
2183
+ setStatus(state, "paused", `could not send a continuation: ${String(error).slice(0, 200)}`)
2184
+ this.noticeM(state.sessionID, M.pausedAdmissionError(state.reason ?? "admission failed"))
2185
+ } else this.schedule(state, 2000 * 2 ** state.counters.failures, kind)
2186
+ this.persist(state)
2187
+ }
2188
+ }
2189
+
2190
+ private schedule(state: RunState, delayMs: number, kind: PendingKind) {
2191
+ const key = state.sessionID
2192
+ const waiting = state.wait !== undefined
2193
+ clearTimeout(this.timers.get(key))
2194
+ // T053: the tracer counts down the cooldown before the next turn
2195
+ if (isActive(state.status) && state.status !== "waiting") {
2196
+ state.activity = { kind: "cooldown", since: Date.now(), until: Date.now() + delayMs, detail: kind }
2197
+ this.persist(state)
2198
+ }
2199
+ this.timers.set(
2200
+ key,
2201
+ setTimeout(() => {
2202
+ this.timers.delete(key)
2203
+ void this.serial(async () => {
2204
+ const live = this.runs.get(key)
2205
+ if (this.disposed || !live || live.runId !== state.runId || !isActive(live.status) || live.status === "verifying" || (waiting && !live.wait)) return
2206
+ // Keep one quiet timer while execution/forms hold the session;
2207
+ // an expired deadline must not disappear without a future wake.
2208
+ const facts = this.factsOf(key)
2209
+ if (facts.busy || facts.forms.size > 0) return this.schedule(live, 1000, kind)
2210
+ // A pending claim wins over a wake/retry once execution releases
2211
+ // ownership; never admit another worker to satisfy that claim.
2212
+ if (live.claim) {
2213
+ const contract = this.contractOf(live)
2214
+ if (contract) {
2215
+ live.pending = undefined
2216
+ this.notes.delete(key)
2217
+ await this.verify(live, contract, "claim")
2218
+ }
2219
+ return
2220
+ }
2221
+ if (live.status === "waiting" && live.wait && live.wait.until > Date.now())
2222
+ return this.schedule(live, live.wait.until - Date.now(), kind)
2223
+ if (live.status === "waiting") {
2224
+ live.wait = undefined
2225
+ setStatus(live, "running")
2226
+ }
2227
+ await this.admit(live, kind)
2228
+ })
2229
+ }, delayMs),
2230
+ )
2231
+ }
2232
+
2233
+ // ───────────────────────────── events (S1: filter to this location)
2234
+
2235
+ private async eventLoop() {
2236
+ while (!this.disposed) {
2237
+ try {
2238
+ for await (const event of this.ctx.event.subscribe({ signal: this.controller.signal } as any) as AsyncIterable<any>) {
2239
+ if (this.disposed) return
2240
+ const dir = event?.location?.directory
2241
+ if (dir && dir !== this.root && canonical(dir) !== this.rootReal) continue
2242
+ // Never await slow work (verification) here: state changes are already
2243
+ // serialized by `serial`, and the stream must keep flowing.
2244
+ void this.onEvent(event).catch((error) => this.store.ledger("_errors", { type: "event-error", event: event?.type, error: String(error) }))
2245
+ }
2246
+ } catch (error) {
2247
+ if (this.disposed) return
2248
+ await Bun.sleep(1000)
2249
+ }
2250
+ }
2251
+ }
2252
+
2253
+ private async onEvent(event: any) {
2254
+ const type: string = event.type
2255
+ const data = event.data ?? {}
2256
+ switch (type) {
2257
+ case "form.created": {
2258
+ const sid = data.form?.sessionID
2259
+ if (!sid) return
2260
+ this.factsOf(sid).forms.add(data.form.id)
2261
+ const launch = this.launches.bind(data.form)
2262
+ if (launch) {
2263
+ this.store.ledger(launch.slug, { type: "launch-form-bound", decisionId: launch.id, formID: launch.formID, sessionID: sid, lock: launch.lock, fresh: launch.fresh })
2264
+ await this.unholdLaunchChat(launch)
2265
+ return
2266
+ }
2267
+ const amendment = this.amendmentReviews.bind(data.form)
2268
+ if (amendment) {
2269
+ this.store.ledger(amendment.slug, { type: "amendment-form-bound", decisionId: amendment.id, formID: amendment.formID })
2270
+ await this.unholdAmendmentChat(amendment)
2271
+ return
2272
+ }
2273
+ const decision = this.decisions.bind(data.form)
2274
+ if (decision) {
2275
+ const pending = this.pendingFormDecisions.get(sid)
2276
+ if (pending?.decisionId === decision.id) pending.formID = data.form.id
2277
+ this.store.ledger(decision.slug, { type: "decision-form-bound", decision: decision.id, formID: data.form.id })
2278
+ return
2279
+ }
2280
+ this.bindLegacyLaunchForm(data.form)
2281
+ // T075: this form answers the session's pending decision (the
2282
+ // question-tool prompt askDecisionForm sent) — remember the id so
2283
+ // only ITS reply dispatches the act
2284
+ const state = this.runs.get(sid)
2285
+ if (state && isActive(state.status)) {
2286
+ this.noticeM(sid, M.awaitingAnswer(String(data.form?.question ?? "a question from the worker")))
2287
+ this.emitUpdate(state)
2288
+ }
2289
+ return
2290
+ }
2291
+ case "form.replied": {
2292
+ const f = this.factsOf(data.sessionID)
2293
+ f.forms.delete(data.id)
2294
+ const answers = Object.values(data.answer ?? {}).flatMap((v) => (Array.isArray(v) ? v : [v])).map(String)
2295
+ f.lastFormAnswer = { labels: answers, at: Date.now() }
2296
+ const launch = this.launches.byForm(data.sessionID, data.id)
2297
+ if (launch) {
2298
+ await this.resolveLaunch(launch, data.answer ?? {})
2299
+ this.flushDeferred(data.sessionID)
2300
+ return // never mint a second legacy approval from this answer
2301
+ }
2302
+ const amendment = this.amendmentReviews.byForm(data.sessionID, data.id)
2303
+ if (amendment) {
2304
+ await this.resolveAmendment(amendment, data.answer ?? {})
2305
+ this.flushDeferred(data.sessionID)
2306
+ return
2307
+ }
2308
+ // T075: a reply to a DECISION form dispatches the same rpc act the
2309
+ // TUI dialog carries — answering either surface resolves both.
2310
+ const decision = this.decisions.byForm(data.sessionID, data.id)
2311
+ if (decision) {
2312
+ await this.resolveDecisionForm(decision, data.answer ?? {})
2313
+ this.flushDeferred(data.sessionID)
2314
+ return
2315
+ }
2316
+ // T069: commands that deferred behind the form run now — unless the
2317
+ // reply itself started a new turn (busy flips before the flush)
2318
+ this.flushDeferred(data.sessionID)
2319
+ // The recommended suffix remains supported, but commentary or a
2320
+ // label in a different field/form cannot mint approval. Seal the
2321
+ // legacy candidate at form creation, never at the later start call.
2322
+ const legacy = this.legacyLaunchForms.get(data.id)
2323
+ this.legacyLaunchForms.delete(data.id)
2324
+ if (legacy && legacy.sessionID === data.sessionID && data.answer?.[legacy.fieldKey] === legacy.value) {
2325
+ f.launchApprovedAt = Date.now()
2326
+ this.writeLaunchApproval(data.sessionID, { at: f.launchApprovedAt, label: legacy.label, slug: legacy.slug, lock: legacy.lock, fresh: false, formID: data.id })
2327
+ }
2328
+ const state = this.runs.get(data.sessionID)
2329
+ if (state) this.emitUpdate(state)
2330
+ return
2331
+ }
2332
+ case "form.cancelled": {
2333
+ const f = this.factsOf(data.sessionID)
2334
+ f.forms.delete(data.id)
2335
+ this.legacyLaunchForms.delete(data.id)
2336
+ f.cancelledAt = Date.now()
2337
+ const launch = this.launches.byForm(data.sessionID, data.id)
2338
+ if (launch?.status === "pending") {
2339
+ // Core also emits this on host reload, with no owner-rejection
2340
+ // signal. Preserve an explicit interrupted review, never consent
2341
+ // or a fabricated owner refusal. Recovery asks the same review.
2342
+ this.launches.interruptForm(launch.id)
2343
+ this.store.ledger(launch.slug, { type: "launch-interrupted", decisionId: launch.id, previousFormID: data.id })
2344
+ }
2345
+ const amendment = this.amendmentReviews.byForm(data.sessionID, data.id)
2346
+ if (amendment?.status === "pending" && !amendment.ownerMessageID) {
2347
+ this.amendmentReviews.interruptForm(amendment.id)
2348
+ this.store.ledger(amendment.slug, { type: "amendment-review-interrupted", decisionId: amendment.id, previousFormID: data.id })
2349
+ }
2350
+ const decision = this.decisions.byForm(data.sessionID, data.id)
2351
+ if (decision?.status === "pending") {
2352
+ this.decisions.interruptForm(decision.id)
2353
+ this.pendingFormDecisions.delete(data.sessionID)
2354
+ this.store.ledger(decision.slug, { type: "decision-form-interrupted", decision: decision.id, previousFormID: data.id })
2355
+ }
2356
+ this.flushDeferred(data.sessionID)
2357
+ return
2358
+ }
2359
+ }
2360
+ const sid: string | undefined = data.sessionID
2361
+ if (!sid) return
2362
+ if (type === "session.execution.started") this.factsOf(sid).busy = true
2363
+ if (type === "session.execution.succeeded" || type === "session.execution.failed" || type === "session.execution.interrupted") {
2364
+ this.factsOf(sid).busy = false
2365
+ // T069: deferred /goal commands run the moment the turn ends — the
2366
+ // ack promised exactly that. Before any goal handling so commands for
2367
+ // goal-less sessions flush too.
2368
+ this.flushDeferred(sid)
2369
+ // T075: a decision whose form-ask was skipped because the session was
2370
+ // mid-turn gets its native form now (the decision is still unanswered)
2371
+ this.reaskDecisionForm(sid)
2372
+ }
2373
+ const state = this.runs.get(sid)
2374
+ if (!state) return
2375
+ switch (type) {
2376
+ case "session.inbox.enqueued":
2377
+ return this.serial(() => this.onEnqueued(state, data))
2378
+ case "session.tool.called":
2379
+ state.turnFacts.tools += 1
2380
+ return
2381
+ case "session.usage.updated": {
2382
+ // T013: this event fires per message with cumulative session totals
2383
+ // (probed live on 2.0.22 — see docs/dogfood-1.md). The old handler
2384
+ // updated memory only, so run.json and the TUI froze at the values
2385
+ // captured at goal start (the sentinel 1) for the whole kickoff
2386
+ // execution of run 0ffe5ac6e001. Baseline = the cumulative total at
2387
+ // goal start; persist and re-render throttled so counters stay live.
2388
+ const total = (data.tokens?.input ?? 0) + (data.tokens?.output ?? 0) + (data.tokens?.reasoning ?? 0)
2389
+ if (state.usage.baseTokens === null) {
2390
+ state.usage.baseTokens = total
2391
+ state.usage.baseCost = data.cost ?? 0
2392
+ }
2393
+ state.usage.tokens = Math.max(0, total - (state.usage.baseTokens ?? 0))
2394
+ state.usage.cost = Math.max(0, (data.cost ?? 0) - (state.usage.baseCost ?? 0))
2395
+ const now = Date.now()
2396
+ if (state.lastUsageEmit === undefined || now - state.lastUsageEmit > 1000) {
2397
+ state.lastUsageEmit = now
2398
+ this.persist(state)
2399
+ this.emitUpdate(state)
2400
+ }
2401
+ return
2402
+ }
2403
+ case "session.compaction.ended":
2404
+ state.compacted = true
2405
+ this.store.ledger(state.slug, { type: "compacted", turn: state.turn })
2406
+ return
2407
+ case "session.execution.succeeded":
2408
+ return this.serial(() => this.onTurnEnd(state))
2409
+ case "session.execution.failed":
2410
+ return this.serial(() => this.onFailed(state, data.error))
2411
+ case "session.execution.interrupted":
2412
+ return this.serial(() => this.onInterrupted(state, data.reason))
2413
+ case "session.deleted":
2414
+ return this.serial(() => {
2415
+ if (isTerminal(state.status)) return
2416
+ setStatus(state, "aborted", "session deleted")
2417
+ this.store.ledger(state.slug, { type: "aborted", reason: "session deleted" })
2418
+ this.persist(state)
2419
+ })
2420
+ }
2421
+ }
2422
+
2423
+ /** A prompt entered the session: ours (tagged) or the owner's (a steer). */
2424
+ private async onEnqueued(state: RunState, data: any) {
2425
+ const meta = data.item?.payload?.metadata?.goal
2426
+ if (meta) return
2427
+ if (data.item?.type !== "user") return
2428
+ const contract = this.contractOf(state)
2429
+ if (isActive(state.status)) {
2430
+ if (contract?.autonomy.on_user_message === "pause") {
2431
+ setStatus(state, "paused", "owner sent a message")
2432
+ this.store.ledger(state.slug, { type: "paused", reason: "owner message" })
2433
+ this.noticeM(state.sessionID, M.pausedUserMessage())
2434
+ } else {
2435
+ state.steers += 1
2436
+ this.store.ledger(state.slug, { type: "steer", text: String(data.item?.payload?.text ?? "").slice(0, 1000) })
2437
+ }
2438
+ this.persist(state)
2439
+ } else if (state.status === "paused" && state.reason?.startsWith("interrupted") && contract?.autonomy.on_interrupt === "resume-on-message") {
2440
+ setStatus(state, "running")
2441
+ this.store.ledger(state.slug, { type: "resumed", by: "owner message" })
2442
+ this.persist(state)
2443
+ }
2444
+ }
2445
+
2446
+ private async onTurnEnd(state: RunState) {
2447
+ // Only the host's actual execution.succeeded signal clears the streak.
2448
+ // This also covers successful goal_wait turns, whose status is waiting.
2449
+ if (isActive(state.status)) state.counters.failures = 0
2450
+ if (state.status !== "running" && !(state.status === "waiting" && state.claim)) return
2451
+ const contract = this.contractOf(state)
2452
+ if (!contract) return
2453
+ const kind = state.pending?.kind
2454
+ state.pending = undefined
2455
+ this.notes.delete(state.sessionID)
2456
+
2457
+ if (state.claim) return this.verify(state, contract, "claim")
2458
+ // Started from a tool call while the session was busy: kick off now.
2459
+ if (state.turn === 0) return this.admit(state, "kickoff")
2460
+
2461
+ // Progress is authoritative state change, not activity (R3).
2462
+ const fp = fingerprint(this.executionRoot(contract))
2463
+ const changed = fp !== undefined && fp !== state.fingerprint
2464
+ state.fingerprint = fp
2465
+ // T108: recording progress with goal_progress is the supported way for an
2466
+ // agent to say what it did; it must count, or agents are pushed to editing
2467
+ // run.json to make their work visible. Host-visible activity is recorded
2468
+ // too, so a stalled-but-working run is never described as idle.
2469
+ const turnFacts = { ...state.turnFacts }
2470
+ let progress = changed || turnFacts.stepsChanged || turnFacts.progressRecorded
2471
+ if (changed && this.options.liveChecks) progress = (await this.liveChecks(state, contract)) || progress
2472
+ state.counters.noProgress = progress ? 0 : state.counters.noProgress + 1
2473
+ this.store.ledger(state.slug, { type: "turn", turn: state.turn, kind, tools: turnFacts.tools, activity: turnFacts.tools > 0, changed, progress, noProgress: state.counters.noProgress })
2474
+ state.turnFacts = { tools: 0, goalCalls: 0, stepsChanged: false, progressRecorded: false }
2475
+
2476
+ // v0.6.0: the DECISION is the extracted pure function (src/engine/loop.ts).
2477
+ // This method only APPLIES its side effects, which are unchanged - the
2478
+ // OpenCode adapter and the `ocg tick` entry point share one decision.
2479
+ const use = budgetUse(state, contract)
2480
+ const backstop = !contract.budget.turns && state.turn >= this.options.backstopTurns
2481
+ const stallTurns = state.counters.noProgress
2482
+ const stalled = stallTurns >= this.options.stallTurns
2483
+ const active = stalled && this.store.recent(state.slug, 100).filter((e) => e.type === "turn").slice(-stallTurns).some((e) => e.activity === true || Number(e.tools) > 0)
2484
+ const stallReason = active
2485
+ ? `stalled: no visible change for ${stallTurns} turns while the agent kept working (host-visible activity)`
2486
+ : `stalled: no change the host could see for ${stallTurns} turns`
2487
+ const action = decide(
2488
+ state,
2489
+ {
2490
+ turn: "succeeded",
2491
+ pending: kind,
2492
+ changed,
2493
+ progressRecorded: turnFacts.progressRecorded,
2494
+ stepsChanged: turnFacts.stepsChanged,
2495
+ activeStalled: active,
2496
+ stallReason,
2497
+ onStall: contract.autonomy.on_stall,
2498
+ budgetRatio: use.ratio,
2499
+ backstop,
2500
+ cooldownMs: this.options.cooldownMs,
2501
+ },
2502
+ {
2503
+ blockerRepeats: this.options.blockerRepeats,
2504
+ stallTurns: this.options.stallTurns,
2505
+ recoveryAt: this.options.recoveryAt,
2506
+ backstopTurns: this.options.backstopTurns,
2507
+ },
2508
+ )
2509
+
2510
+ switch (action.kind) {
2511
+ case "none":
2512
+ return
2513
+ case "admit":
2514
+ if (action.tail === "wrapup") {
2515
+ state.wrapup = true
2516
+ this.store.ledger(state.slug, { type: "wrapup", ratio: use.ratio, backstop })
2517
+ return this.admit(state, "wrapup")
2518
+ }
2519
+ return this.admit(state, action.tail)
2520
+ case "schedule": {
2521
+ if (action.waiting) {
2522
+ setStatus(state, "waiting", action.waiting.reason)
2523
+ state.activity = { kind: "waiting", since: Date.now(), until: state.wait!.until, detail: action.waiting.reason }
2524
+ this.persist(state)
2525
+ return this.schedule(state, action.delayMs, action.tail)
2526
+ }
2527
+ if (stalled) {
2528
+ this.store.ledger(state.slug, { type: "stall", turns: stallTurns, activity: active, reason: stallReason, policy: contract.autonomy.on_stall })
2529
+ state.counters.noProgress = 0
2530
+ this.store.ledger(state.slug, { type: "stall-report", turns: stallTurns, action: "continued", policy: contract.autonomy.on_stall })
2531
+ this.noticeM(state.sessionID, M.stallReported(stallTurns))
2532
+ this.persist(state)
2533
+ return this.schedule(state, action.delayMs, action.tail)
2534
+ }
2535
+ state.wait = undefined
2536
+ this.persist(state)
2537
+ return this.schedule(state, action.delayMs, action.tail)
2538
+ }
2539
+ case "stop": {
2540
+ switch (action.code) {
2541
+ case "blocked":
2542
+ setStatus(state, "blocked", action.reason)
2543
+ this.store.ledger(state.slug, { type: "blocked", blocker: state.blocker })
2544
+ this.decide(state.sessionID, state.slug, "blocked", M.blocked(String(state.blocker?.key), String(state.blocker?.reason), state.blocker?.needs), { blocker: state.blocker })
2545
+ await this.transcript(state.sessionID, `◎ blocked on ${state.blocker?.key}: ${state.blocker?.reason}${state.blocker?.needs ? ` (needs ${state.blocker.needs})` : ""}. Only the owner can resolve this; stop working around it and wait.`)
2546
+ return this.persist(state)
2547
+ case "wrapup":
2548
+ setStatus(state, "budget_limited", action.reason)
2549
+ this.store.ledger(state.slug, { type: "budget_limited" })
2550
+ this.decide(state.sessionID, state.slug, "budget_limited", M.budgetStopped(contract.title))
2551
+ await this.transcript(state.sessionID, `◎ budget used (${contract.title}). The owner decides: raise the budget in goal.yaml and amend, or abort. Do not start new work.`)
2552
+ await this.summarize(state, contract)
2553
+ return this.persist(state)
2554
+ case "budget":
2555
+ setStatus(state, "budget_limited", action.reason)
2556
+ this.decide(state.sessionID, state.slug, "budget_limited", M.budgetStopped(contract.title))
2557
+ await this.transcript(state.sessionID, `◎ budget used (${contract.title}). The owner decides: raise the budget in goal.yaml and amend, or abort. Do not start new work.`)
2558
+ await this.summarize(state, contract)
2559
+ return this.persist(state)
2560
+ case "stalled":
2561
+ this.store.ledger(state.slug, { type: "stall", turns: stallTurns, activity: active, reason: stallReason, policy: contract.autonomy.on_stall })
2562
+ setStatus(state, "paused", action.reason)
2563
+ this.store.ledger(state.slug, { type: "paused", reason: state.reason, activity: active })
2564
+ this.noticeM(state.sessionID, M.pausedStalled(state.reason ?? "stalled", contract.autonomy.on_stall))
2565
+ return this.persist(state)
2566
+ }
2567
+ }
2568
+ }
2569
+ }
2570
+
2571
+ /** Re-run cheap `live: true` command checks after a turn that changed files. */
2572
+ private async liveChecks(state: RunState, contract: Contract): Promise<boolean> {
2573
+ let transitioned = false
2574
+ for (const c of [...contract.criteria, ...contract.invariants]) {
2575
+ if (c.check.kind !== "command" || !c.check.live) continue
2576
+ const r = await runHostCheck({ ...c.check, runs: 1 }, this.executionRoot(contract), state.base.commit)
2577
+ const prev = state.criteria[c.id]
2578
+ const status = r.pass ? "pass" : "fail"
2579
+ if (prev?.status !== status) transitioned = true
2580
+ state.criteria[c.id] = { status, by: "host", at: Date.now(), detail: r.detail.slice(0, 600), rejections: prev?.rejections ?? 0 }
2581
+ }
2582
+ return transitioned
2583
+ }
2584
+
2585
+ private async onFailed(state: RunState, error: any) {
2586
+ if (!isActive(state.status)) return
2587
+ const message = String(error?.message ?? error?.type ?? error ?? "unknown error")
2588
+ state.pending = undefined
2589
+ this.notes.delete(state.sessionID)
2590
+ state.counters.failures += 1
2591
+ this.store.ledger(state.slug, { type: "turn-failed", error: message, failures: state.counters.failures })
2592
+ let reason: string | undefined
2593
+ if (/\bmodel\b[^\n]*(?:unavailable|not found|not available|does not exist|disabled)/i.test(message)) reason = `model unavailable: ${message}`
2594
+ else if (/quota|insufficient|billing|credit|usage limit/i.test(message)) reason = `provider usage limit: ${message}`
2595
+ else if (/context|too long|maximum.*tokens/i.test(message)) reason = `context overflow: ${message}`
2596
+ else if (/auth|unauthorized|forbidden|api key/i.test(message)) reason = `provider auth error: ${message}`
2597
+ else {
2598
+ if (state.counters.failures < this.options.maxPromptFailures) {
2599
+ this.persist(state)
2600
+ return this.schedule(state, 5000 * 2 ** state.counters.failures, "continue")
2601
+ }
2602
+ reason = `repeated errors: ${message}`
2603
+ }
2604
+ clearTimeout(this.timers.get(state.sessionID))
2605
+ this.timers.delete(state.sessionID)
2606
+ setStatus(state, "paused", reason.slice(0, 300))
2607
+ this.store.ledger(state.slug, { type: "paused", reason })
2608
+ this.noticeM(state.sessionID, M.pausedInterrupted(reason))
2609
+ this.persist(state)
2610
+ }
2611
+
2612
+ /** S8: Esc → reason "user"; a dismissed question → form.cancelled then "shutdown". */
2613
+ private async onInterrupted(state: RunState, reason: string) {
2614
+ if (!isActive(state.status)) return
2615
+ if (reason === "superseded") return
2616
+ const f = this.factsOf(state.sessionID)
2617
+ const why =
2618
+ reason === "user"
2619
+ ? "interrupted by you (Esc)"
2620
+ : reason === "shutdown" && f.cancelledAt && Date.now() - f.cancelledAt < 10_000
2621
+ ? "interrupted: a question was dismissed"
2622
+ : reason === "inactivity"
2623
+ ? "interrupted: host evicted the idle project"
2624
+ : `interrupted: ${reason}`
2625
+ clearTimeout(this.timers.get(state.sessionID))
2626
+ setStatus(state, "paused", why)
2627
+ this.store.ledger(state.slug, { type: "paused", reason: why })
2628
+ this.noticeM(state.sessionID, M.pausedStalled(why))
2629
+ this.persist(state)
2630
+ }
2631
+
2632
+ // ───────────────────────────── verification
2633
+
2634
+ private async verify(state: RunState, contract: Contract, source: "claim" | "owner") {
2635
+ clearTimeout(this.timers.get(state.sessionID))
2636
+ this.timers.delete(state.sessionID)
2637
+ state.wait = undefined
2638
+ setStatus(state, "verifying", source === "claim" ? "checking your claim" : "owner requested verification")
2639
+ state.activity = { kind: "verifying", since: Date.now() }
2640
+ this.store.ledger(state.slug, { type: "verify-start", source, turn: state.turn })
2641
+ this.persist(state)
2642
+ this.noticeM(state.sessionID, M.verifying())
2643
+ const text = existsSync(this.store.contractPath(state.slug)) ? readFileSync(this.store.contractPath(state.slug), "utf8") : ""
2644
+ // The host records its own serving artifact before checks start. The
2645
+ // shell check is explicitly bound to this run and process, not the
2646
+ // globally discoverable OpenCode service (which may be another server).
2647
+ // T115: provenance() itself is read-only; the host owns this evidence write.
2648
+ const runtime = await this.provenance(state.sessionID)
2649
+ if (!isTerminal(state.status)) this.store.evidence(state.slug, state.runId, "engine-provenance", runtime)
2650
+ const checkContext = JSON.stringify({ slug: state.slug, runId: state.runId, sessionID: state.sessionID, lock: state.lock, serverPID: process.pid })
2651
+ const outcome = await verifyClaim(contract, state, {
2652
+ root: this.executionRoot(contract),
2653
+ contractText: text,
2654
+ shell: (command, cwd, timeout) => runShell(command, cwd, timeout, { OCGOAL_CHECK_CONTEXT: checkContext }),
2655
+ verifier: (input) => this.runVerifier(input),
2656
+ // T063: every host check refreshes the activity so the tracer shows
2657
+ // live progress (check C7 · 7/22) instead of a static "verifying" line
2658
+ onProgress: (activity) => {
2659
+ state.activity = activity
2660
+ this.persist(state)
2661
+ },
2662
+ })
2663
+ const now = Date.now()
2664
+ for (const r of outcome.results) {
2665
+ const prev = state.criteria[r.id] ?? { status: "unknown" as const, rejections: 0 }
2666
+ const claimed = source === "claim" && !r.pass && r.by !== "human"
2667
+ state.criteria[r.id] = { status: r.pass ? "pass" : r.by === "human" ? prev.status : "fail", by: r.by, at: now, detail: r.detail.slice(0, 600), rejections: r.pass ? 0 : prev.rejections + (claimed ? 1 : 0) }
2668
+ }
2669
+ this.store.evidence(state.slug, state.runId, `verify-turn-${state.turn}-${now}`, { source, claim: state.claim ?? null, outcome: { ...outcome, results: outcome.results.map((r) => ({ ...r, raw: undefined })) } })
2670
+ state.verdict = { at: now, turn: state.turn, passed: outcome.passed, lines: outcome.lines }
2671
+ state.claim = undefined
2672
+ this.store.ledger(state.slug, { type: "verdict", passed: outcome.passed, lines: outcome.lines.slice(0, 10) })
2673
+
2674
+ if (outcome.passed) {
2675
+ setStatus(state, "complete", "all required criteria verified")
2676
+ this.store.ledger(state.slug, { type: "complete", turn: state.turn })
2677
+ // T072: the terminal write happens BEFORE the reporting layer. The
2678
+ // complete state used to reach disk only inside summarize()'s try —
2679
+ // a reporting failure stranded the run as a zombie "running" on disk,
2680
+ // and the next server reload recovered it into "paused" while the run
2681
+ // had actually finished: the stale paused/20-of-22 card. persist() also
2682
+ // pushes the final `updated` event, so a live TUI converges even if it
2683
+ // never sees another transition.
2684
+ this.persist(state)
2685
+ // v0.5.0: automatic at goal completion — a contract that names CLEO tasks
2686
+ // records genuine per-gate evidence and completes them through the cleo CLI
2687
+ // only. A refusal is journalled and shown verbatim; a closure is never faked.
2688
+ const linkedCleo = contract.cleo?.tasks ?? []
2689
+ if (linkedCleo.length) {
2690
+ const outcomes = await this.runCleoBridge(linkedCleo.map((t) => t.id), new Map(linkedCleo.map((t) => [t.id, this.cleoLinkFor(contract, t)] as const)))
2691
+ this.store.ledger(state.slug, { type: "cleo-bridge", results: outcomes.map((o) => ({ task: o.task, completed: o.completed, blocker: o.blocker ?? null, openAcs: o.openAcs })) })
2692
+ await this.transcript(state.sessionID, `◎ CLEO bridge — ${bridgeReport(outcomes)}`)
2693
+ }
2694
+ // v0.8.0 C4/C5: look for the next work BEFORE the completion is reported.
2695
+ // A started CLEO item means no owner question; otherwise the announcement
2696
+ // policy runs and the existing completion decision stands.
2697
+ const continued = await this.continueWork(state, contract)
2698
+ const summary = await this.summarize(state, contract, continued)
2699
+ this.noticeM(state.sessionID, M.complete(contract.title, summary ? summary.caveats.length + summary.scopeAudit.length : 0))
2700
+ // v0.8.0 C4: a started next goal is not an owner question. Without one,
2701
+ // the existing completion decision remains the surface.
2702
+ if (!continued) this.decide(state.sessionID, state.slug, "complete", M.completeDecision(contract.title, Boolean(await cleoFacts(this.root, defaultRunner)), summary?.scopeAudit.length ?? 0))
2703
+ await this.transcript(state.sessionID, `◎ Goal complete — "${contract.title}". Every required criterion was verified by the host${contract.verification.mode !== "host" ? " and the independent verifier" : ""}. The loop has stopped; follow-ups live in the summary dialog and .opencode/goals/${state.slug}/.`)
2704
+ return
2705
+ }
2706
+ const onlyHuman = outcome.integrity.length === 0 && outcome.results.every((r) => r.pass || r.by === "human" || !this.required(contract).has(r.id))
2707
+ if (onlyHuman && outcome.needsHuman.length) {
2708
+ setStatus(state, "needs_review", `owner sign-off needed: ${outcome.needsHuman.join(", ")}`)
2709
+ this.decide(state.sessionID, state.slug, "needs_review", M.needsSignOff(outcome.needsHuman), { criteria: outcome.needsHuman })
2710
+ await this.summarize(state, contract)
2711
+ return this.persist(state)
2712
+ }
2713
+ const stuck = Object.entries(state.criteria).filter(([, s]) => s.rejections >= contract.verification.max_rejections).map(([id]) => id)
2714
+ if (stuck.length) {
2715
+ setStatus(state, "needs_review", `${stuck.join(", ")} rejected ${contract.verification.max_rejections} times`)
2716
+ this.decide(state.sessionID, state.slug, "needs_review", M.needsReview(state.reason ?? "repeated rejections", stuck), { criteria: stuck })
2717
+ await this.summarize(state, contract)
2718
+ return this.persist(state)
2719
+ }
2720
+ if (source === "owner") {
2721
+ setStatus(state, "paused", "verification did not pass; resume to keep working")
2722
+ // T050: an owner-requested verify that fails is a decision point —
2723
+ // resume with the verdict in hand, amend the check, or abort.
2724
+ this.decide(state.sessionID, state.slug, "paused-after-verdict", M.pausedAfterVerdict(state.verdict?.lines[0] ?? "see the verdict lines"))
2725
+ return this.persist(state)
2726
+ }
2727
+ setStatus(state, "running")
2728
+ this.noticeM(state.sessionID, M.claimRejected(state.verdict?.lines?.find((l) => !l.startsWith("INTEGRITY"))?.split("\n")[0] ?? "see the HOST VERDICT lines"))
2729
+ // T052: the verdict lands as a transcript notice row the agent reads next
2730
+ // turn — persistent, not a toast.
2731
+ await this.transcript(state.sessionID, `◎ HOST VERDICT — did not pass: ${state.verdict?.lines.find((l) => !l.startsWith("INTEGRITY"))?.split("\n")[0] ?? "see the verdict lines"}.\n${state.verdict?.lines.slice(0, 5).join("\n")}\nFix the failed criteria, then goal_claim again.`)
2732
+ await this.admit(state, "verdict")
2733
+ }
2734
+
2735
+ private required(contract: Contract) {
2736
+ return new Set([...contract.criteria.filter((c) => c.essential).map((c) => c.id), ...contract.invariants.map((c) => c.id)])
2737
+ }
2738
+
2739
+ /** S7: a hidden read-only agent in a child session; its only extra tool is goal_verdict. */
2740
+ private async runVerifier(input: { contract: Contract; state: RunState; criteria: Contract["criteria"]; host: Array<{ id: string; pass: boolean; detail: string; by: string }> }) {
2741
+ const { contract, state, criteria, host } = input
2742
+ // T044: the child request carries an explicitly resolved model. On the
2743
+ // owner's main host the hidden agent's implicit resolution produced four
2744
+ // entirely empty exchanges (no text, no tool call) with GLM via zai;
2745
+ // an explicit model rides on every child create so the request can never
2746
+ // depend on the agent's own (possibly absent) resolution.
2747
+ // T107: the contract may name the verifier's model and effort. A name that
2748
+ // cannot be parsed falls back to the resolved default with the reason
2749
+ // journalled, so a typo never blocks verification.
2750
+ const named = contractVerifierModel(contract.verification)
2751
+ let model: { ref: { providerID: string; id: string; variant?: string }; label: string } | undefined = named.ref ? { ref: named.ref, label: named.label! } : undefined
2752
+ if (!model) {
2753
+ model = await this.resolveVerifierModel(state.sessionID)
2754
+ if (named.reason) this.store.ledger(state.slug, { type: "verifier-model-fallback", turn: state.turn, requested: named.requested ?? contract.verification.model ?? null, reason: named.reason, used: model?.label ?? null })
2755
+ }
2756
+ // T053: the tracer shows the child running while it works
2757
+ state.activity = { kind: "verifier-child", since: Date.now(), ...(model ? { detail: model.label } : {}) }
2758
+ this.persist(state)
2759
+ // Contract-listed extra READ-ONLY roots get their own agent: the same
2760
+ // deny-all rules plus external_directory allows for exactly those roots.
2761
+ const readRoots = contract.verification.read.map((p) => resolve(this.root, p))
2762
+ const agentName = readRoots.length ? await this.scopedVerifierAgent(readRoots) : this.options.verifierAgent
2763
+ const child: any = await this.ctx.session.create({ parentID: state.sessionID, agent: agentName, title: `goal verify · ${contract.id}`, ...(model ? { model: model.ref } : {}) } as any)
2764
+ const childID: string = child?.id ?? child?.data?.id
2765
+ this.verifierInbox.delete(childID)
2766
+ this.verifierScopes.set(childID, { allow: readRoots })
2767
+ this.store.ledger(state.slug, { type: "verifier-model", turn: state.turn, model: model?.label ?? null })
2768
+ // T109: the verifier has only read/glob/grep, so a criterion that asks about
2769
+ // commit history was unanswerable and burned the round budget. The host
2770
+ // supplies a digest in the prompt AND saves a quotable copy inside the
2771
+ // project, so the verifier needs no shell and no external path.
2772
+ const digest = gitDigest(this.root)
2773
+ let digestRel: string | undefined
2774
+ try {
2775
+ digestRel = relative(this.root, this.store.evidence(state.slug, state.runId, `git-history-turn-${state.turn}-${Date.now()}`, { schema: "opencode-goal/git-history/v1", digest }))
2776
+ } catch { digestRel = undefined }
2777
+ const lines: string[] = []
2778
+ lines.push("You are an independent completion verifier. You did not do this work and you gain nothing if it passes. Fail closed: a criterion is proven only if you can quote file text that proves it.")
2779
+ lines.push(`Goal outcome: ${contract.outcome}`)
2780
+ lines.push("Criteria to judge:")
2781
+ for (const c of criteria) lines.push(`- ${c.id}: ${c.statement}${c.check.kind === "verifier" ? `\n question: ${c.check.ask}` : "\n (the host check passed; confirm the work genuinely satisfies the statement, not just the check)"}`)
2782
+ lines.push("Host check results (trusted):")
2783
+ for (const r of host) lines.push(`- ${r.id} ${r.pass ? "PASS" : "FAIL"}: ${r.detail.split("\n")[0]}`)
2784
+ if (state.claim) {
2785
+ lines.push("The worker's claim (untrusted; check it, do not believe it):")
2786
+ lines.push(` ${state.claim.summary}`)
2787
+ for (const [id, note] of Object.entries(state.claim.evidence)) lines.push(` ${id}: ${note}`)
2788
+ }
2789
+ lines.push(`Repository history digest (host-provided and authoritative; you have no shell):\n${digest}`)
2790
+ if (digestRel) lines.push(`A quotable copy is saved inside the project at ${digestRel}; read it and quote a line from it when a criterion is about commit history.`)
2791
+ if (readRoots.length) lines.push(`Besides the project, the owner lets you READ (never write) these paths: ${readRoots.join(", ")}. Every other path outside the project is denied.`)
2792
+ lines.push("Use read, glob and grep to inspect the repository. Then call goal_verdict exactly once with one entry per criterion above: verdict proven, not_proven or contradicted; a one-line reason; and for proven, evidence items with the file path (relative to the project root) and a verbatim quote of at least one full line. Do not answer in prose instead of calling the tool. If the tool is unavailable or fails, reply with ONLY a fenced json block as your final message: ```json {\"verdicts\":[{\"id\":\"C1\",\"verdict\":\"proven\",\"reason\":\"...\",\"evidence\":[{\"path\":\"relative/path\",\"quote\":\"verbatim line\"}]}]} ```")
2793
+ try {
2794
+ // T044: empty exchanges are retried once and always recorded — a silent
2795
+ // empty round is never accepted as an answer. Housekeeping (v0.3.1):
2796
+ // verifierTimeoutMs is the PER-ROUND budget (240s default — the two
2797
+ // provider hangs of the last run each burned a 120s round and were
2798
+ // retried too late); each of the two rounds gets the full budget, and
2799
+ // plugin/project config overrides it (ctx.options merge in the ctor).
2800
+ const perRoundMs = Math.max(Math.max(1, this.options.verifierMinRoundMs), this.options.verifierTimeoutMs)
2801
+ let round = 0
2802
+ let verdicts: VerifierVerdict[] = []
2803
+ let by: "verifier" | "verifier-fallback" = "verifier"
2804
+ // T016: persist the child transcript so a silent or prose-answering
2805
+ // verifier is diagnosable from the evidence, never a mystery again.
2806
+ let messages: unknown[] = []
2807
+ let outcome: "done" | "timeout" = "done"
2808
+ let retried = false
2809
+ while (round < 2) {
2810
+ round++
2811
+ const text =
2812
+ round === 1
2813
+ ? lines.join("\n")
2814
+ : "Your previous reply was empty — no text and no tool call came back. Answer now. Call goal_verdict exactly once with one entry per criterion from the original request (verdict proven, not_proven or contradicted; a one-line reason; for proven, evidence items with the file path relative to the project root and a verbatim quote of at least one full line). If the tool is unavailable or fails, reply with ONLY a fenced json block in the sanctioned shape. Another empty reply is a failure."
2815
+ await this.ctx.session.prompt({ sessionID: childID, text, resume: true } as any)
2816
+ const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), perRoundMs))
2817
+ outcome = await Promise.race([this.ctx.session.wait({ sessionID: childID } as any).then(() => "done" as const), timeout])
2818
+ if (outcome === "timeout") {
2819
+ // A verifier that CALLS goal_verdict just after the budget must still
2820
+ // be heard: one real round finished and was thrown away as a timeout.
2821
+ const grace = new Promise<void>((resolve) => setTimeout(resolve, this.options.verifierGraceMs))
2822
+ await Promise.race([this.ctx.session.wait({ sessionID: childID } as any).then(() => undefined).catch(() => undefined), grace])
2823
+ try { messages = ((await this.ctx.session.context({ sessionID: childID } as any)) ?? []) as unknown[] } catch { /* recorded as unavailable below */ }
2824
+ verdicts = this.verifierInbox.get(childID) ?? []
2825
+ if (verdicts.length) { outcome = "done"; break }
2826
+ await this.ctx.session.interrupt({ sessionID: childID } as any).catch(() => {})
2827
+ break
2828
+ }
2829
+ verdicts = this.verifierInbox.get(childID) ?? []
2830
+ try {
2831
+ messages = ((await this.ctx.session.context({ sessionID: childID } as any)) ?? []) as unknown[]
2832
+ } catch {
2833
+ // transcript unavailable on this host; the fallback simply has less to work with
2834
+ }
2835
+ if (verdicts.length) break
2836
+ if (exchangeEmpty(messages)) {
2837
+ this.store.ledger(state.slug, { type: "verifier-empty", turn: state.turn, round })
2838
+ if (round === 1) {
2839
+ retried = true
2840
+ continue
2841
+ }
2842
+ }
2843
+ break
2844
+ }
2845
+ if (!verdicts.length) {
2846
+ // A child that DID call goal_verdict but landed after the round budget
2847
+ // is still an answer: read it back from the captured transcript.
2848
+ const fromTranscript = verdictsFromTranscript(messages, new Set(criteria.map((c) => c.id)))
2849
+ if (fromTranscript.length) {
2850
+ verdicts = fromTranscript
2851
+ by = "verifier-fallback"
2852
+ this.store.ledger(state.slug, { type: "verifier-fallback", turn: state.turn, count: fromTranscript.length, from: "transcript" })
2853
+ }
2854
+ }
2855
+ if (!verdicts.length) {
2856
+ const parsed = parseFallbackVerdicts(messages, new Set(criteria.map((c) => c.id)))
2857
+ if (parsed.length) {
2858
+ verdicts = parsed
2859
+ by = "verifier-fallback"
2860
+ this.store.ledger(state.slug, { type: "verifier-fallback", turn: state.turn, count: parsed.length })
2861
+ }
2862
+ }
2863
+ const cause = verifierCause(messages, [this.root, ...readRoots])
2864
+ const transcript = this.store.evidence(state.slug, state.runId, `verifier-transcript-turn-${state.turn}-${Date.now()}`, { childID, done: outcome, by, rounds: round, retried, model: model?.label ?? null, ...(cause ? { cause } : {}), messages: compactTranscript(messages) })
2865
+ this.store.ledger(state.slug, { type: "verifier-transcript", turn: state.turn, by, rounds: round, retried, model: model?.label ?? null, file: transcript, ...(cause ? { cause } : {}) })
2866
+ const error = verdicts.length
2867
+ ? undefined
2868
+ : outcome === "timeout"
2869
+ ? `verifier timed out${cause ? ` — ${cause}` : messages.length ? ` after ${messages.length} transcript message(s); see ${transcript}` : " with no transcript available"}`
2870
+ : exchangeEmpty(messages)
2871
+ ? `verifier exchange was empty after ${round} round(s); recorded under evidence/ — never silent`
2872
+ : "verifier did not call goal_verdict"
2873
+ return { verdicts, by, ...(verdicts.length ? {} : { error: error! }) }
2874
+ } finally {
2875
+ this.verifierInbox.delete(childID)
2876
+ this.verifierScopes.delete(childID)
2877
+ await this.ctx.session.remove({ sessionID: childID } as any).catch(() => {})
2878
+ }
2879
+ }
2880
+
2881
+ /**
2882
+ * T044: resolve the model the verifier child runs on, explicitly. Prefers
2883
+ * the parent session's live model (it is answering the worker's turns, so
2884
+ * it works on this host), then the location default. Returns the ref for
2885
+ * session.create plus a human-readable label for the ledger.
2886
+ */
2887
+ private async resolveVerifierModel(sessionID: string): Promise<{ ref: { providerID: string; id: string; variant?: string }; label: string } | undefined> {
2888
+ const of = (m: unknown) => {
2889
+ const model = m as { providerID?: string; id?: string; variant?: string } | null | undefined
2890
+ if (!model?.providerID || !model?.id) return undefined
2891
+ const variant = model.variant ? { variant: String(model.variant) } : {}
2892
+ return { ref: { providerID: String(model.providerID), id: String(model.id), ...variant }, label: `${model.providerID}/${model.id}${model.variant ? `#${model.variant}` : ""}` }
2893
+ }
2894
+ try {
2895
+ const info: any = await this.ctx.session.get({ sessionID } as any)
2896
+ const resolved = of(info?.model ?? info?.data?.model)
2897
+ if (resolved) return resolved
2898
+ } catch {
2899
+ // parent session info unavailable; try the location default
2900
+ }
2901
+ try {
2902
+ const picked: any = await this.ctx.model.default()
2903
+ const resolved = of(picked?.data ?? picked)
2904
+ if (resolved) return resolved
2905
+ } catch {
2906
+ // no default resolvable either; create falls back to the host's implicit resolution
2907
+ }
2908
+ return undefined
2909
+ }
2910
+
2911
+ // ───────────────────────────── owner actions (command, RPC)
2912
+
2913
+ async ownerAct(sessionID: string, action: "pause" | "resume" | "abort" | "verify" | "approve" | "reject" | "amend" | "archive" | "attach" | "start" | "start-next" | "decompose" | "revert", arg?: string, via: "form" | "dialog" = "dialog", decisionId?: string): Promise<{ ok: boolean; message: string }> {
2914
+ return this.serial(async () => {
2915
+ const pending = this.pendingDecisions.get(sessionID)
2916
+ const request = this.decisions.get(decisionId ?? pending?.id ?? "")
2917
+ if (decisionId && (!request || request.sessionID !== sessionID)) return { ok: false, message: "Unknown decision or wrong owner session; nothing executed." }
2918
+ const offered = request?.message.choices.some((c) => c.act === action && (c.arg ?? "") === (arg ?? ""))
2919
+ if (decisionId && !offered) return { ok: false, message: "That operation was not offered by this decision; nothing executed." }
2920
+ let reserved: DecisionRequest | undefined
2921
+ if (request && offered) {
2922
+ if (!["pending", "interrupted"].includes(request.status)) return { ok: false, message: "Decision already resolved, expired or executing; nothing executed." }
2923
+ const target = action === "start" && arg ? request.candidates[arg] ?? request.target : request.target
2924
+ const current = this.decisionTarget(target.slug)
2925
+ if (current.lock !== target.lock || current.runId !== target.runId || current.sessionID !== target.sessionID || current.status !== target.status) {
2926
+ this.decisions.update(request.id, { status: "failed", result: "Decision target changed since review; inspect and request a new decision." })
2927
+ return { ok: false, message: "Decision target changed since review; nothing executed." }
2928
+ }
2929
+ if (request.status === "interrupted") this.decisions.reopenForm(request.id)
2930
+ reserved = this.decisions.reserve(request.id)
2931
+ if (!reserved) return { ok: false, message: "Decision expired or reserved by another server; nothing executed." }
2932
+ }
2933
+ let result: { ok: boolean; message: string }
2934
+ const targeted = typeof reserved?.extra.targetKey === "string" ? this.list().find((g) => g.key === reserved.extra.targetKey) : undefined
2935
+ if (reserved?.extra.targetKey && !targeted) {
2936
+ this.decisions.update(reserved.id, { status: "failed", result: "Selected goal generation no longer exists." })
2937
+ return { ok: false, message: "Selected goal generation no longer exists; nothing executed." }
2938
+ }
2939
+ try { result = await this.ownerActLocked(sessionID, action, arg, reserved && action === "start" && arg ? reserved.candidates[arg]?.lock : undefined, targeted) }
2940
+ catch (error) { result = { ok: false, message: `Owner operation failed: ${String(error)}` } }
2941
+ if (reserved) {
2942
+ this.decisions.update(reserved.id, { status: result.ok ? "succeeded" : "interrupted", result: result.message })
2943
+ this.store.ledger(reserved.slug, { type: result.ok ? "decision-resolved" : "decision-attempt", decision: reserved.id, kind: reserved.kind, action, ...(arg ? { arg } : {}), ok: result.ok, message: result.message }, { archived: result.ok && action === "archive" })
2944
+ if (result.ok) {
2945
+ if (this.pendingDecisions.get(sessionID)?.id === reserved.id) this.pendingDecisions.delete(sessionID)
2946
+ if (this.pendingFormDecisions.get(sessionID)?.decisionId === reserved.id) this.pendingFormDecisions.delete(sessionID)
2947
+ void this.rpc?.events.emit("decision.resolved", { sessionID, kind: reserved.kind, decisionId: reserved.id, via, message: result.message }).catch(() => {})
2948
+ } else {
2949
+ this.decisions.reopenForm(reserved.id)
2950
+ if (this.pendingFormDecisions.get(sessionID)?.decisionId === reserved.id) this.pendingFormDecisions.delete(sessionID)
2951
+ }
2952
+ } else if (pending && !result.ok) {
2953
+ this.store.ledger(pending.slug, { type: "decision-attempt", decision: pending.id, kind: pending.kind, action, ok: false, message: result.message })
2954
+ }
2955
+ return result
2956
+ })
2957
+ }
2958
+
2959
+ private async ownerActLocked(sessionID: string, action: Parameters<GoalApp["ownerAct"]>[1], arg?: string, expectedLock?: string, selected?: GoalSummary): Promise<{ ok: boolean; message: string }> {
2960
+ // T067: the complete decision's "start the next goal" dispatches the
2961
+ // write-goal interview right here — the owner picks the next goal from
2962
+ // the dialog instead of typing /goal new.
2963
+ if (action === "start-next") {
2964
+ await this.ctx.session.prompt({ sessionID, text: `Start the next goal: interview the owner for what they want done next (the write-goal skill guides the frontier rounds; no draft exists yet).`, skills: [{ id: "write-goal" }] } as any).catch(() => {})
2965
+ return { ok: true, message: "Dispatched the write-goal interview — the next question lands in this session." }
2966
+ }
2967
+ // T070: "Decompose with CLEO" was a dead-end guidance row at the
2968
+ // complete decision — picking it only toasted. It is a real act now:
2969
+ // the decomposition prompt lands in THIS session, and the next turn
2970
+ // files the completed goal's follow-ups as CLEO tasks.
2971
+ if (action === "decompose") {
2972
+ const state0 = this.runs.get(sessionID)
2973
+ const slug = state0?.slug ?? (arg ?? "").trim()
2974
+ const cleo = await cleoFacts(this.root, defaultRunner)
2975
+ if (!cleo) return { ok: false, message: "No .cleo workspace in this project — install CLEO first, then Decompose works as an act." }
2976
+ if (!slug) return { ok: false, message: "Which goal? /goal decompose <slug> (or run it from the goal's session)." }
2977
+ this.store.ledger(slug, { type: "decompose-dispatched", by: "owner", from: sessionID })
2978
+ await this.ctx.session
2979
+ .prompt({
2980
+ sessionID,
2981
+ text: [
2982
+ `CLEO-DECOMPOSE the goal "${slug}": read .opencode/goals/${slug}/ (the latest summary under evidence/, the ledger, and goal.yaml) and decompose the follow-ups into CLEO tasks.`,
2983
+ "For each follow-up (the summary's Follow-ups section, any discussed-but-unproven plan steps, and the non-goals the owner may want next): run `cleo add --type task --title <summary> --acceptance <how to prove it>` (plus `--parent <epic>` when the board has one), then link related tasks with `cleo relates add` where order matters.",
2984
+ "The goal folder is owner-owned history — read it, never edit it. Report the filed task ids when done.",
2985
+ ].join("\n"),
2986
+ } as any)
2987
+ .catch(() => {})
2988
+ return { ok: true, message: `Dispatched the CLEO decomposition of "${slug}" — the next turn files the follow-ups as tasks.` }
2989
+ }
2990
+ // T060: the start picker's dialog dispatches act "start" with the slug
2991
+ // as arg — the standard start path, in THIS session. startGoalLocked,
2992
+ // not startGoal: ownerAct already holds the queue (not reentrant).
2993
+ if (action === "start") {
2994
+ const slug = selected?.slug ?? (arg ?? "").trim().split(/\s+/)[0]
2995
+ if (!slug) return { ok: false, message: "Which goal? /goal start with no argument opens the picker." }
2996
+ const message = await this.startGoalLocked(sessionID, slug, "command", { expectedLock })
2997
+ return { ok: message.startsWith('Goal "'), message }
2998
+ }
2999
+ // T049: attach pulls a live goal into THIS session so every surface
3000
+ // (palette, card, panel, /goal commands) works here. The run keeps its
3001
+ // ledger, evidence and progress; only the owning session moves.
3002
+ if (action === "attach") {
3003
+ const currentHere = this.runs.get(sessionID)
3004
+ if (currentHere && !isTerminal(currentHere.status)) return { ok: false, message: `This session already has goal "${currentHere.slug}" (${currentHere.status}). Abort it first with /goal abort.` }
3005
+ const slug = selected?.slug ?? (arg ?? "").trim().split(/\s+/)[0]
3006
+ if (!slug) {
3007
+ const attachable = this.list().filter((g) => g.attachable)
3008
+ return { ok: false, message: attachable.length ? `Which goal? ${attachable.map((g) => `${g.slug} (${g.status})`).join(", ")} — /goal attach <slug>` : "No attachable goal: none is live in .opencode/goals/." }
3009
+ }
3010
+ const target = [...this.runs.values()].find((r) => r.slug === slug && !isTerminal(r.status))
3011
+ if (!target) return { ok: false, message: `No live goal "${slug}" here. /goal list shows what exists.` }
3012
+ if (this.amendmentReviews.all().some((p) => p.slug === target.slug && ["pending", "interrupted", "executing"].includes(p.status))) return { ok: false, message: "Resolve the selected goal's staged amendment before moving its owning session." }
3013
+ if (isActive(target.status)) return { ok: false, message: `"${slug}" is ${target.status} mid-turn; wait for the turn to end (or pause it from the goal's session), then attach.` }
3014
+ const from = target.sessionID
3015
+ this.invalidateOldDecision(target, "goal ownership attached into a different session")
3016
+ this.runs.delete(from)
3017
+ const timer = this.timers.get(from)
3018
+ if (timer) {
3019
+ this.timers.delete(from)
3020
+ this.timers.set(sessionID, timer)
3021
+ }
3022
+ const note = this.notes.get(from)
3023
+ if (note) {
3024
+ this.notes.delete(from)
3025
+ this.notes.set(sessionID, note)
3026
+ }
3027
+ target.sessionID = sessionID
3028
+ this.runs.set(sessionID, target)
3029
+ this.store.ledger(target.slug, { type: "attached", from, to: sessionID, by: "owner" })
3030
+ this.noticeM(sessionID, M.attached(target.title, target.status))
3031
+ await this.transcript(sessionID, `◎ owner attached the goal "${target.title}" (${target.status}) to this session. It continues here from now on — re-read the contract and run.json before acting.`)
3032
+ this.persist(target)
3033
+ this.emitUpdate(target)
3034
+ return { ok: true, message: `Goal "${target.slug}" attached to this session (${target.status}).` }
3035
+ }
3036
+ let state = selected ? [...this.runs.values()].find((s) => s.slug === selected.slug && s.runId === selected.runId) ?? this.store.readRun(selected.slug) : this.runs.get(sessionID)
3037
+ if (selected && !state) return { ok: false, message: "The selected goal has no live run to manage." }
3038
+ if (!state) {
3039
+ // T049: goals escape their session. With no goal here, act on the
3040
+ // project's unique non-terminal goal, or the slug the owner names
3041
+ // (… <slug> for most actions, /goal approve <C#> <slug> for verdicts).
3042
+ const tokens = (arg ?? "").trim().split(/\s+/).filter(Boolean)
3043
+ const slugHint = action === "approve" || action === "reject" ? tokens[1] : tokens[0]
3044
+ const live = [...this.runs.values()].filter((r) => !isTerminal(r.status))
3045
+ const named = slugHint ? live.find((r) => r.slug === slugHint) : undefined
3046
+ if (named) state = named
3047
+ else if (slugHint)
3048
+ return { ok: false, message: `No live goal "${slugHint}". Live goal(s): ${live.map((r) => r.slug).join(", ") || "none"}.` }
3049
+ else if (live.length === 1) state = live[0]!
3050
+ else if (live.length > 1)
3051
+ return { ok: false, message: `Several live goals: ${live.map((r) => `${r.slug} (${r.status})`).join(", ")}. Name one — /goal ${action} <slug>${action === "approve" || action === "reject" ? " <C#>" : ""} — or /goal attach <slug> to work on it here.` }
3052
+ else return { ok: false, message: "No goal in this session. Start one with /goal start <slug> or write one with /goal new <what you want>." }
3053
+ }
3054
+ const why = ownerCan(state, action)
3055
+ if (why) return { ok: false, message: why }
3056
+ if (action === "amend" && arg?.startsWith("review:")) {
3057
+ const review = this.amendmentReviews.get(arg.slice("review:".length))
3058
+ if (!review || review.sessionID !== sessionID || review.slug !== state.slug || review.runId !== state.runId || review.lock !== state.lock)
3059
+ return { ok: false, message: "The prerequisite review changed or belongs to another author; nothing applied." }
3060
+ return { ok: true, message: await this.reviewAmendment(sessionID, review) }
3061
+ }
3062
+ if (this.amendmentReviews.all().some((p) => p.slug === state!.slug && p.status === "executing"))
3063
+ return { ok: false, message: "An accepted amendment is committing. Reload to recover its recorded acceptance before managing this goal." }
3064
+ if (["resume", "verify", "amend", "approve", "reject"].includes(action) && this.amendmentReviews.all().some((p) => p.slug === state!.slug && ["pending", "interrupted"].includes(p.status)))
3065
+ return { ok: false, message: "This goal has an unresolved staged amendment. Resolve that exact review in Composer chat with goal_resolve_amendment before resuming or verifying; there is no sidebar Accept." }
3066
+ const contract = this.contractOf(state)
3067
+ switch (action) {
3068
+ case "pause":
3069
+ clearTimeout(this.timers.get(state.sessionID))
3070
+ this.timers.delete(state.sessionID)
3071
+ // T092: a paused run keeps no pending wake. Leaving state.wait set let
3072
+ // a later resume's turn-end resurrect the stale wait and strand the run.
3073
+ state.wait = undefined
3074
+ setStatus(state, "paused", "paused by you")
3075
+ this.store.ledger(state.slug, { type: "paused", reason: "owner" })
3076
+ this.persist(state)
3077
+ await this.transcript(state.sessionID, `◎ owner paused the goal (${state.title}). Stand by; the owner resumes it with /goal resume.`)
3078
+ return { ok: true, message: `Goal paused: ${state.title} — /goal resume · /goal abort` }
3079
+ case "resume": {
3080
+ if (state.status === "budget_limited") return { ok: false, message: "The goal used its budget. Raise budget in goal.yaml and start a new run, or abort it." }
3081
+ // T091: a resume is idempotent — an already-running run journals no
3082
+ // second receipt and is not admitted twice.
3083
+ if (state.status === "running" && !state.wait) return { ok: true, message: `Goal already running: ${state.title} — /goal status · /goal pause` }
3084
+ // T091/T092: clear the obsolete wakeup and any leftover wait before
3085
+ // resuming. Otherwise the busy turn's end resurrects the stale wait
3086
+ // and the run never gets its exactly-one continuation.
3087
+ clearTimeout(this.timers.get(state.sessionID))
3088
+ this.timers.delete(state.sessionID)
3089
+ state.wait = undefined
3090
+ state.counters = { noProgress: 0, failures: 0 }
3091
+ state.blocker = undefined
3092
+ state.wrapup = false
3093
+ setStatus(state, "running")
3094
+ state.fingerprint = fingerprint(this.executionRoot(contract))
3095
+ this.store.ledger(state.slug, { type: "resumed", by: "owner" })
3096
+ this.persist(state)
3097
+ await this.transcript(state.sessionID, `◎ owner resumed the goal. Re-read the contract and the current repository state, then continue from the first unfinished step.`)
3098
+ // While a turn is still busy the end-of-turn continuation admits
3099
+ // exactly once; otherwise kick off now.
3100
+ if (!this.factsOf(state.sessionID).busy) await this.admit(state, "resume")
3101
+ return { ok: true, message: `Goal resumed: ${state.title} — /goal status · /goal pause` }
3102
+ }
3103
+ case "abort":
3104
+ clearTimeout(this.timers.get(state.sessionID))
3105
+ setStatus(state, "aborted", "aborted by you")
3106
+ this.store.ledger(state.slug, { type: "aborted", reason: "owner" })
3107
+ this.persist(state)
3108
+ await this.transcript(state.sessionID, `◎ owner aborted the goal (${state.title}). Stop working on it; the ledger and evidence stay on disk.`)
3109
+ return { ok: true, message: `Goal aborted: ${state.title} — /goal archive (history intact) · /goal new <next>` }
3110
+ case "amend": {
3111
+ if (!contract) return { ok: false, message: "contract unavailable" }
3112
+ if (arg === "interview") {
3113
+ await this.goalIntent(sessionID, `amend the check for goal ${state.slug}; interview me about the change, then stage a validated diff with goal_propose_amendment. Do not edit the live contract or infer acceptance.`)
3114
+ return { ok: true, message: "Composer amendment interview started for the selected goal; no contract change or acceptance authorized." }
3115
+ }
3116
+ const confirmed = (arg?.split(/\s+/) ?? []).includes("confirm")
3117
+ const message = await this.amendGoal(state, confirmed)
3118
+ return { ok: message.startsWith("Amended") || message.startsWith("Proposed"), message }
3119
+ }
3120
+ case "revert":
3121
+ return this.revertPolicyChange(state)
3122
+ case "archive": {
3123
+ // T032: demote, never delete — the folder moves to goals-archive/
3124
+ // with ledger and evidence intact; the registry keeps answering yes.
3125
+ clearTimeout(this.timers.get(state.sessionID))
3126
+ const finalStatus = state.status
3127
+ this.store.ledger(state.slug, { type: "archived", from: finalStatus })
3128
+ this.registry.update(this.rootReal, state.slug, { title: state.title, status: finalStatus, ...(state.runId ? { runId: state.runId } : {}), ...(state.lock ? { lock: state.lock } : {}), archived: true, updatedAt: Date.now() })
3129
+ this.store.archive(state.slug)
3130
+ this.loaded.delete(state.slug)
3131
+ if (this.runs.get(state.sessionID)?.runId === state.runId) this.runs.delete(state.sessionID)
3132
+ this.noticeM(sessionID, M.archived(state.title, state.slug))
3133
+ return { ok: true, message: `Goal archived: ${state.title} — /goal list all · /goal new <next>` }
3134
+ }
3135
+ case "verify":
3136
+ if (!contract) return { ok: false, message: "contract unavailable" }
3137
+ await this.verify(state, contract, "owner")
3138
+ return { ok: true, message: `Verification ${state.status === "complete" ? "passed" : "did not pass"}: ${state.verdict?.lines[0] ?? ""} — /goal approve <C#> · /goal status` }
3139
+ case "approve":
3140
+ case "reject": {
3141
+ const id = (arg ?? "").trim().split(/\s+/)[0]?.toUpperCase()
3142
+ const criterion = contract?.criteria.find((c) => c.id === id)
3143
+ if (!criterion) return { ok: false, message: `Unknown criterion "${id}".` }
3144
+ state.criteria[criterion.id] = { status: action === "approve" ? "pass" : "fail", by: "human", at: Date.now(), detail: action === "approve" ? "approved by the owner" : `rejected by the owner: ${(arg ?? "").slice(id.length).trim() || "no reason given"}`, rejections: 0 }
3145
+ this.store.ledger(state.slug, { type: action, criterion: criterion.id })
3146
+ if (state.status === "needs_review" && action === "approve" && contract) {
3147
+ this.persist(state)
3148
+ await this.transcript(state.sessionID, `◎ owner approved ${criterion.id} (final — never re-checked). ${criterion.statement}`)
3149
+ await this.verify(state, contract, "owner")
3150
+ return { ok: true, message: `${criterion.id} approved; goal is ${state.status} — /goal verify · /goal status` }
3151
+ }
3152
+ this.persist(state)
3153
+ await this.transcript(
3154
+ state.sessionID,
3155
+ action === "approve"
3156
+ ? `◎ owner approved ${criterion.id} (final — never re-checked). ${criterion.statement}`
3157
+ : `◎ owner rejected ${criterion.id}: ${(arg ?? "").slice(id.length).trim() || "no reason given"}. Address the reason or flag the criterion if it is impossible as written.`,
3158
+ )
3159
+ return { ok: true, message: `${criterion.id} ${action === "approve" ? "approved" : "rejected"} — /goal verify · /goal status` }
3160
+ }
3161
+ }
3162
+ }
3163
+
3164
+ // ───────────────────────────── registrations
3165
+
3166
+ /** Read from THIS plugin's server/location, never the shell's unrelated
3167
+ * shared service. Pin bytes captured at setup rather than trusting a
3168
+ * version label, or whichever cache folder has the newest timestamp. */
3169
+ private async provenance(sessionID?: string): Promise<RuntimeProvenance> {
3170
+ const listed: any = await this.ctx.plugin.list({ location: { directory: this.root } } as any)
3171
+ const entries = Array.isArray(listed) ? listed : listed?.data ?? []
3172
+ const entry = entries.find((p: any) => p.id === "opencode-goal")
3173
+ const source = entry?.source ?? { type: "unknown" }
3174
+ const state = sessionID ? this.runs.get(sessionID) : undefined
3175
+ const result: RuntimeProvenance = {
3176
+ schema: "opencode-goal/provenance/v1", serverPID: process.pid,
3177
+ openCodeVersion: this.ctx.app.version, location: this.root,
3178
+ packageRoot: this.packageRoot,
3179
+ packageVersion: this.packageVersion,
3180
+ capturedAt: this.capturedAt, observedAt: Date.now(),
3181
+ source: { type: source.type, ...(source.target ? { target: source.target } : {}), ...(source.version ? { version: source.version } : {}), ...(source.outdated !== undefined ? { outdated: source.outdated } : {}) },
3182
+ artifacts: this.artifacts,
3183
+ status: entry?.state?.status ?? "unknown",
3184
+ features: { server: entry?.features?.server === true, tui: entry?.features?.tui === true, rpc: entry?.features?.rpc === true },
3185
+ ...(state ? { sessionID: state.sessionID, runId: state.runId, lock: state.lock } : {}),
3186
+ }
3187
+ // T115: this is a READ-ONLY probe. It returns the runtime identity and
3188
+ // writes NOTHING under .opencode/goals, so no read can look like an agent
3189
+ // hand-edit. The host records the evidence itself during verification
3190
+ // (verify() → recordProvenance), where a write is expected and owned.
3191
+ return result
3192
+ }
3193
+
3194
+ private async scopedVerifierAgent(roots: string[]): Promise<string> {
3195
+ const name = `${this.options.verifierAgent}-${createHash("sha256").update(roots.join("\n")).digest("hex").slice(0, 10)}`
3196
+ if (this.scopedVerifierAgents.has(name)) return name
3197
+ const variants = (root: string) => { const plain = root.replace(/^\/private(?=\/(?:var|tmp)\/)/, ""); return /^\/(?:var|tmp)\//.test(plain) ? [plain, `/private${plain}`] : [root] }
3198
+ this.registrations.push(await this.ctx.agent.transform((editor: any) => {
3199
+ editor.update(name, (agent: any) => {
3200
+ agent.name = "Goal verifier (scoped)"
3201
+ agent.description = "Independent read-only verifier with owner-listed extra read roots."
3202
+ agent.mode = "subagent"
3203
+ agent.hidden = true
3204
+ agent.permissions.push(
3205
+ { action: "*", resource: "*", effect: "deny" },
3206
+ { action: "read", resource: "*", effect: "allow" },
3207
+ { action: "glob", resource: "*", effect: "allow" },
3208
+ { action: "grep", resource: "*", effect: "allow" },
3209
+ { action: "goal_verdict", resource: "*", effect: "allow" },
3210
+ ...roots.flatMap((root) => variants(root).flatMap((path) => [
3211
+ { action: "external_directory", resource: path, effect: "allow" },
3212
+ { action: "external_directory", resource: `${path}/*`, effect: "allow" },
3213
+ ])),
3214
+ )
3215
+ })
3216
+ }))
3217
+ this.scopedVerifierAgents.add(name)
3218
+ return name
3219
+ }
3220
+
3221
+ private async registerAgents() {
3222
+ const name = this.options.verifierAgent
3223
+ this.registrations.push(
3224
+ await this.ctx.agent.transform((editor: any) => {
3225
+ editor.update(name, (agent: any) => {
3226
+ agent.name = "Goal verifier"
3227
+ agent.description = "Independent read-only verifier for goal completion claims."
3228
+ agent.mode = "subagent"
3229
+ agent.hidden = true
3230
+ agent.permissions.push(
3231
+ { action: "*", resource: "*", effect: "deny" },
3232
+ { action: "read", resource: "*", effect: "allow" },
3233
+ { action: "glob", resource: "*", effect: "allow" },
3234
+ { action: "grep", resource: "*", effect: "allow" },
3235
+ { action: "goal_verdict", resource: "*", effect: "allow" },
3236
+ )
3237
+ })
3238
+ for (const agent of editor.list()) {
3239
+ if (String(agent.id) === name || String(agent.id).startsWith(`${name}-`)) continue
3240
+ editor.update(String(agent.id), (item: any) => item.permissions.push({ action: "goal_verdict", resource: "*", effect: "deny" }))
3241
+ }
3242
+ }),
3243
+ )
3244
+ }
3245
+
3246
+ private async registerSkill() {
3247
+ const path = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills", "write-goal", "SKILL.md")
3248
+ if (!existsSync(path)) return
3249
+ const raw = readFileSync(path, "utf8")
3250
+ const front = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
3251
+ const meta = front ? ((Bun as any).YAML.parse(front[1]) ?? {}) : {}
3252
+ const content = front ? raw.slice(front[0].length) : raw
3253
+ this.registrations.push(
3254
+ await this.ctx.skill.transform((editor: any) => {
3255
+ if (editor.get("write-goal")) return
3256
+ editor.add({ id: "write-goal", name: String(meta.name ?? "write-goal"), description: String(meta.description ?? "Write a goal contract for goal mode."), path, content })
3257
+ }),
3258
+ )
3259
+ }
3260
+
3261
+ /** Run git and return stdout; never throws (empty string on failure). */
3262
+ private async gitText(args: string[]): Promise<string> {
3263
+ try {
3264
+ const child = Bun.spawn(["git", ...args], { cwd: this.root, stdout: "pipe", stderr: "ignore" })
3265
+ const [out] = await Promise.all([new Response(child.stdout).text(), child.exited])
3266
+ return out
3267
+ } catch {
3268
+ return ""
3269
+ }
3270
+ }
3271
+
3272
+ /** The cleo CLI as a subprocess — the engine NEVER opens .cleo or its database. */
3273
+ private cleoRun: CleoRun = async (argv) => {
3274
+ const { stdout, exit } = await defaultRunner("cleo", argv, 120_000)
3275
+ return { stdout, exit }
3276
+ }
3277
+
3278
+ /** Run a task's changed test files and emit the vitest-shaped JSON `test-run:` parses. */
3279
+ private async scopedCleoReport(testFiles: string[]): Promise<BridgeReport | undefined> {
3280
+ if (!testFiles.length) return undefined
3281
+ const dir = join(this.root, ".opencode", "cleo-reports")
3282
+ try { mkdirSync(dir, { recursive: true }) } catch { return undefined }
3283
+ const stamp = String(Date.now())
3284
+ const xml = join(dir, `bun-${stamp}.xml`)
3285
+ const json = join(dir, `report-${stamp}.json`)
3286
+ const quote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`
3287
+ const command = `bun test ${testFiles.map(quote).join(" ")} --reporter=junit --reporter-outfile=${quote(xml)}`
3288
+ await runShell(command, this.root, 1800)
3289
+ let report
3290
+ try { report = junitToVitestJson(readFileSync(xml, "utf8"), Date.now()) } catch { return undefined }
3291
+ const bytes = JSON.stringify(report, null, 2)
3292
+ try { writeFileSync(json, bytes) } catch { return undefined }
3293
+ return { reportPath: relative(this.root, json), reportSha256: sha256Hex(bytes), passed: report.success, total: report.numTotalTests, failed: report.numFailedTests }
3294
+ }
3295
+
3296
+ /**
3297
+ * The goal→CLEO evidence bridge. The orchestration lives in the shipped
3298
+ * `runBridge` so the engine, the `/goal cleo sync` command and the CLI all
3299
+ * share one code path. The engine supplies the runners; the bridge stays
3300
+ * CLI-only and never opens `.cleo`.
3301
+ */
3302
+ private async runCleoBridge(tasks: string[], links: Map<string, CleoTaskLink>, commitByTask: Record<string, string> = {}): Promise<TaskOutcome[]> {
3303
+ return runBridge({ tasks, links, runCleo: this.cleoRun, git: (args: string[]) => this.gitText(args), report: (testFiles: string[]) => this.scopedCleoReport(testFiles), commitByTask })
3304
+ }
3305
+
3306
+ /**
3307
+ * The link for a task: prefer the PRECISE per-criterion `proves` binding over
3308
+ * the older per-gate `acs` map. Nothing is inferred from position.
3309
+ */
3310
+ private cleoLinkFor(contract: Contract | undefined, task: CleoTaskLink): CleoTaskLink {
3311
+ const fromProves = acsByGateFromProves([...(contract?.criteria ?? []), ...(contract?.invariants ?? [])], task.id)
3312
+ const acs = Object.keys(fromProves).length ? fromProves : task.acs
3313
+ return { id: task.id, ...(acs ? { acs } : {}) }
3314
+ }
3315
+
3316
+ private async registerCommand() {
3317
+ this.registrations.push(
3318
+ await this.ctx.command.transform((editor: any) => {
3319
+ editor.add({
3320
+ name: "goal",
3321
+ description: "Goal mode: /goal new <what you want> · start <slug> · status · pause · resume · verify · abort · approve <C#> · list",
3322
+ execute: async ({ sessionID, prompt }: any) => {
3323
+ const text = String(prompt?.text ?? "").trim()
3324
+ await this.command(sessionID, text)
3325
+ },
3326
+ })
3327
+ }),
3328
+ )
3329
+ }
3330
+
3331
+ async command(sessionID: string, text: string) {
3332
+ const known = [...this.store.slugs(), ...this.store.archivedSlugs()]
3333
+ if (!directGoalCommand(text, known)) return this.goalIntent(sessionID, text)
3334
+ const [head = "", ...rest] = text.split(/\s+/)
3335
+ const arg = rest.join(" ").trim()
3336
+ const sub = head.toLowerCase()
3337
+ // T069: acknowledge + defer while a turn or form holds the session
3338
+ if (this.deferIfHeld(sessionID, text, sub)) return
3339
+ // Results go to the TUI as notices, never into the transcript: a synthetic
3340
+ // message here would land inside the turn the command just started.
3341
+ const say = async (message: string, level: "info" | "success" | "warning" | "error" = "info") => this.notice(sessionID, message, level)
3342
+ switch (sub) {
3343
+ case "":
3344
+ case "status": {
3345
+ const state = this.runs.get(sessionID)
3346
+ if (!state) return say(this.listText() || "No goal in this session. /goal new <what you want> writes one; /goal start <slug> runs an existing one.")
3347
+ void this.rpc?.events.emit("notice", { sessionID, level: "info", text: this.statusText(state), panel: true }).catch(() => {})
3348
+ return
3349
+ }
3350
+ case "list": {
3351
+ const archived = this.registry.list().filter((g) => g.project === this.rootReal && g.archived)
3352
+ const live = this.listText() || "No live goals in .opencode/goals/."
3353
+ const all = arg.toLowerCase() === "all"
3354
+ const archivedText = all
3355
+ ? archived.map((g) => `${g.slug} — archived (${g.status})`).join("\n")
3356
+ : archived.length
3357
+ ? `${archived.length} archived goal(s): ${archived.map((g) => g.slug).join(", ")} (/goal list all)`
3358
+ : ""
3359
+ return say([live, archivedText].filter(Boolean).join("\n"))
3360
+ }
3361
+ case "new":
3362
+ case "write": {
3363
+ if (!arg) return this.goalIntent(sessionID, "")
3364
+ // T033: admission probe before the interview — exact existence and
3365
+ // ranked similarity over live + archived goals, prepended for the
3366
+ // write-goal interview to surface as owner options.
3367
+ const candidates = [
3368
+ ...this.store.slugs().map((slug) => {
3369
+ const runState = this.store.readRun(slug)
3370
+ const read = this.store.readContract(slug)
3371
+ return { slug, title: runState?.title ?? read?.contract?.title ?? slug, ...(runState ? { status: runState.status } : {}) }
3372
+ }),
3373
+ ...this.store.archivedSlugs().map((slug) => {
3374
+ const read = this.store.readContract(slug)
3375
+ return { slug, title: read?.contract?.title ?? slug, status: "archived", archived: true as const }
3376
+ }),
3377
+ ...this.registry.list().filter((g) => g.project === this.rootReal).map((g) => ({ slug: g.slug, title: g.title, ...(g.status ? { status: g.status } : {}), ...(g.archived ? { archived: true as const } : {}) })),
3378
+ ]
3379
+ const deduped = [...new Map(candidates.map((c) => [c.slug, c])).values()]
3380
+ const slugHint = arg.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64)
3381
+ const note = admissionNote(arg, slugHint, deduped)
3382
+ const first = note ? `${arg}\n\n${note}` : arg
3383
+ await this.ctx.session.prompt({ sessionID, text: first, skills: [{ id: "write-goal" }] } as any)
3384
+ return
3385
+ }
3386
+ case "start": {
3387
+ if (!arg) {
3388
+ // T060: no-arg /goal start used to be a dead usage line; it now
3389
+ // opens the startable-goal picker — a keyboard-selectable dialog
3390
+ // (the T050 decision machinery) wired to the start path via
3391
+ // act "start". Startable = contract here, not archived, no live
3392
+ // run anywhere (starting a goal another session owns is refused).
3393
+ const startable = this.store.slugs()
3394
+ .filter((slug) => {
3395
+ const live = [...this.runs.values()].some((r) => r.slug === slug && !isTerminal(r.status))
3396
+ if (live) return false
3397
+ const saved = this.store.readRun(slug)
3398
+ return !saved || isTerminal(saved.status)
3399
+ })
3400
+ .map((slug) => {
3401
+ const read = this.store.readContract(slug)
3402
+ const valid = Boolean(read?.contract) && read!.issues.every((i) => i.level !== "error")
3403
+ // T061: drafts order by priority then the goal.yaml mtime.
3404
+ let updatedAt = 0
3405
+ try {
3406
+ updatedAt = statSync(join(this.root, ".opencode", "goals", slug, "goal.yaml")).mtimeMs
3407
+ } catch {}
3408
+ return { slug, title: read?.contract?.title ?? slug, valid: valid as boolean, priority: read?.contract?.priority as "low" | "medium" | "high" | undefined, updatedAt }
3409
+ })
3410
+ .sort((a, b) => byPriorityThenRecency(a, b))
3411
+ if (!startable.length) {
3412
+ this.decide(sessionID, "(none)", "start-picker", M.startPickerEmpty(this.store.slugs().length))
3413
+ return say(`No startable goal — /goal new <what you want done> writes one.`, "warning")
3414
+ }
3415
+ this.decide(sessionID, "(picker)", "start-picker", M.startPicker(startable))
3416
+ return say(startable.map((g) => `${g.slug}${g.priority ? ` [${g.priority}]` : ""} — ${g.title}${g.valid ? "" : " (validation problems)"}`).join("\n"), "info")
3417
+ }
3418
+ const tokens = arg.split(/\s+/)
3419
+ const slug0 = tokens[0]!
3420
+ const acknowledgeSupersede = tokens.includes("acknowledge-supersede")
3421
+ if (!this.store.slugs().includes(slug0) && this.store.archivedSlugs().includes(slug0))
3422
+ return say(`"${slug0}" exists, archived at .opencode/goals-archive/${slug0}/ (history intact; it WAS a goal here). Move it back or write a superseding goal.`, "warning")
3423
+ // T059: start clean — the engine opens a NEW session owned by the
3424
+ // goal, pins the run to it and prompts the kickoff there. Nothing
3425
+ // clears the current session (no such API exists); it stays untouched.
3426
+ if (tokens.includes("fresh") && this.store.slugs().includes(slug0)) {
3427
+ const message = await this.startClean(sessionID, slug0)
3428
+ return say(message, message.startsWith("Started") ? "success" : "error")
3429
+ }
3430
+ const message = await this.startGoal(sessionID, slug0, "command", { acknowledgeSupersede })
3431
+ return say(message, message.startsWith("Goal \"") ? "success" : "error")
3432
+ }
3433
+ case "validate": {
3434
+ const slug = arg.split(/\s+/)[0]
3435
+ if (!slug) return say("Usage: /goal validate <slug>", "warning")
3436
+ const read = this.store.readContract(slug)
3437
+ return say(read ? formatIssues(read.issues) : `no goal named ${slug}`, read?.contract ? "success" : "error")
3438
+ }
3439
+ case "pause":
3440
+ case "resume":
3441
+ case "abort":
3442
+ case "verify":
3443
+ case "approve":
3444
+ case "reject":
3445
+ case "amend":
3446
+ case "archive":
3447
+ case "attach":
3448
+ case "revert":
3449
+ case "decompose": {
3450
+ const result = await this.ownerAct(sessionID, sub as any, arg)
3451
+ return say(result.message, result.ok ? "success" : "error")
3452
+ }
3453
+ case "cleo": {
3454
+ const parts = arg.trim().split(/\s+/).filter(Boolean)
3455
+ const what = (parts[0] ?? "").toLowerCase()
3456
+ const health = await cleoHealth(this.root, defaultRunner)
3457
+ if (!what) return say(cleoHealthReport(health))
3458
+ if (what === "install" || what === "init") {
3459
+ if (health.state === "initialized") return say(`CLEO is already initialized in this project — there is nothing to ${what}. /goal cleo re-checks.`)
3460
+ if (what === "init" && health.state === "cli-absent") return say(`Cannot initialize: the cleo CLI is not on PATH. Run /goal cleo install first, or install it yourself: ${CLEO_INSTALL_COMMAND}`, "warning")
3461
+ const command = what === "install" ? CLEO_INSTALL_COMMAND : CLEO_INIT_COMMAND
3462
+ const result = await runShell(command, this.root, 600)
3463
+ const after = await cleoHealth(this.root, defaultRunner)
3464
+ const detail = (result.stderr || result.stdout).trim().split("\n").slice(-6).join("\n")
3465
+ return say([`Ran on your explicit choice: ${command}`, `exit ${result.exit}${result.timedOut ? " (timed out)" : ""}`, after.detail, detail].filter(Boolean).join("\n"), result.exit === 0 ? "success" : "error")
3466
+ }
3467
+ if (what === "sync") {
3468
+ const rest = parts.slice(1)
3469
+ // T### links a task; T###@<sha> supplies the implementing commit for a
3470
+ // legacy task no commit message names (the case that defeated the
3471
+ // manual pass).
3472
+ const refs = rest.filter((p) => /^[A-Z]{1,4}\d+(?:@[0-9a-f]{4,40})?$/.test(p))
3473
+ const taskIds = refs.map((r) => r.split("@")[0]!)
3474
+ const commitByTask: Record<string, string> = {}
3475
+ for (const r of refs) { const [id, sha] = r.split("@"); if (id && sha) commitByTask[id] = sha }
3476
+ const slugHint = rest.find((p) => !refs.includes(p))
3477
+ const state = slugHint ? [...this.runs.values()].find((r) => r.slug === slugHint && !isTerminal(r.status)) ?? this.store.readRun(slugHint) : this.runs.get(sessionID)
3478
+ const contract = state ? this.contractOf(state) : undefined
3479
+ const links = new Map((contract?.cleo?.tasks ?? []).map((t) => [t.id, this.cleoLinkFor(contract, t)] as const))
3480
+ const tasks = taskIds.length ? taskIds : [...links.keys()]
3481
+ if (!tasks.length) {
3482
+ const subjects = (await this.gitText(["log", "--format=%s", "-n", "200"])).split("\n").filter(Boolean)
3483
+ const suggestions = suggestLinkedTasks(subjects)
3484
+ return say([`Which CLEO tasks? /goal cleo sync <slug> T123[ @sha] … — or name them in the goal's cleo.tasks block.`, suggestions.length ? `This project's commits reference: ${suggestions.join(", ")}` : "No commit here references a CLEO task id."].join("\n"), "warning")
3485
+ }
3486
+ const outcomes = await this.runCleoBridge(tasks, links, commitByTask)
3487
+ return say(bridgeReport(outcomes), outcomes.every((o) => o.completed) ? "success" : "error")
3488
+ }
3489
+ return say("Usage: /goal cleo [install|init|sync [<slug>] [T### …]]", "warning")
3490
+ }
3491
+ case "help":
3492
+ return say(goalHelp({ version: (this.ctx.options as any)?.version ?? "v0.2.0" }), "info")
3493
+ default:
3494
+ // Anything else is a request to write a goal from these words.
3495
+ await this.ctx.session.prompt({ sessionID, text, skills: [{ id: "write-goal" }] } as any)
3496
+ }
3497
+ }
3498
+
3499
+ private async goalIntent(sessionID: string, text: string) {
3500
+ const state = this.runs.get(sessionID)
3501
+ const inventory = this.list().map((g) => ({ slug: g.slug, title: g.title, status: g.status, sessionID: g.sessionID }))
3502
+ const prompt = [
3503
+ "Goal entry — interpret the owner's intent, not merely the first word.",
3504
+ text ? `Owner's original words: ${JSON.stringify(text)}` : "No idea was supplied. Ask with native selectable options whether to create a goal or manage an existing goal, then ask for the idea if creating.",
3505
+ `Current session goal: ${state ? `${state.slug} (${state.status})` : "none"}. Project inventory: ${JSON.stringify(inventory)}`,
3506
+ "For a new idea, use the write-goal interview, validate and review the exact contract, then prepare a host-bound launch decision. For an existing goal request, resolve the target explicitly and use guarded typed goal operations; ask only for a genuine decision or unresolved target. For amendments, read the selected goal's current contract and run lock, then call goal_propose_amendment with the replacement YAML and rationale. Resolve actual owner chat accept/reject/revision with goal_resolve_amendment for the exact pending decisionId; the host fetches and consumes genuine consent once. Never invent a sidebar Accept, ask for manual goal commands, hand-edit a live contract, or treat review restoration as acceptance.",
3507
+ "Preserve the original words in intent.verbatim. Never execute this text as a shell command. A fresh start means a new session; the original history is not cleared.",
3508
+ ].join("\n")
3509
+ await this.ctx.session.prompt({ sessionID, text: prompt, skills: [{ id: "write-goal" }], metadata: { goal: { kind: "owner-intent" } } } as any)
3510
+ }
3511
+
3512
+ private statusText(state: RunState): string {
3513
+ const v = this.view(state)
3514
+ if (!v) return `${state.slug}: ${state.status}`
3515
+ const proven = v.criteria.filter((c) => c.status === "pass").length
3516
+ return [`◎ ${v.title} — ${v.status}${v.reason ? ` (${v.reason})` : ""}`, `criteria ${proven}/${v.criteria.length} · turn ${v.turn}`, ...v.criteria.map((c) => `${c.status === "pass" ? "✓" : c.status === "fail" ? "✗" : "·"} ${c.id} ${c.statement}`)].join("\n")
3517
+ }
3518
+
3519
+ private listText(): string {
3520
+ return this.list()
3521
+ .map((g) => `${g.slug} — ${g.status}${g.priority ? ` [${g.priority}]` : ""}${g.total ? ` (${g.proven}/${g.total})` : ""}`)
3522
+ .join("\n")
3523
+ }
3524
+
3525
+ list(): GoalSummary[] {
3526
+ return projectInventory(this.store, this.runs.values(), { launches: this.launches.all() })
3527
+ }
3528
+
3529
+ private async inventory(): Promise<GoalSummary[]> {
3530
+ return this.withSessionMetadata(this.list())
3531
+ }
3532
+
3533
+ private async withSessionMetadata(rows: GoalSummary[]): Promise<GoalSummary[]> {
3534
+ // Read metadata from THIS connected server. A configured agent in YAML
3535
+ // is not proof of the agent actually attached to the recorded session.
3536
+ const info = new Map<string, any>()
3537
+ for (const sessionID of new Set(rows.flatMap((g) => g.sessionID ? [g.sessionID] : []))) {
3538
+ try { info.set(sessionID, await this.ctx.session.get({ sessionID } as any)) }
3539
+ catch (error: any) { info.set(sessionID, { unavailable: true, missing: /not.found/i.test(String(error?.type ?? error?.message ?? error)) }) }
3540
+ }
3541
+ return rows.map((row): GoalSummary => {
3542
+ if (!row.sessionID) return row
3543
+ const got = info.get(row.sessionID), session = got?.data ?? got
3544
+ if (!session || session.unavailable) return { ...row, sessionState: session?.missing ? "missing" : "unknown" }
3545
+ const directory = session.location?.directory
3546
+ const sessionState = typeof directory === "string" ? canonical(directory) === this.rootReal ? "present" : "elsewhere" : "unknown"
3547
+ return { ...row, sessionState, ...(typeof session.agent === "string" ? { agent: session.agent } : {}), ...(typeof session.title === "string" ? { sessionTitle: session.title } : {}) }
3548
+ })
3549
+ }
3550
+
3551
+ private async manageGoal(sessionID: string, action: ManageAction, target: GoalTarget, forceReview = false) {
3552
+ return this.serial(async () => {
3553
+ if (!/^[a-f0-9]{64}$/.test(target.lock)) return { ok: false, message: "Invalid target lock; nothing executed." }
3554
+ try {
3555
+ const actor: any = await this.ctx.session.get({ sessionID } as any)
3556
+ if (canonical((actor?.data ?? actor)?.location?.directory ?? "") !== this.rootReal) return { ok: false, message: "The managing session is not in this project; nothing executed." }
3557
+ } catch { return { ok: false, message: "The managing session is unavailable; nothing executed." } }
3558
+ const selected = this.list().find((g) => g.key === target.key)
3559
+ const missing = selected ? (["runId", "sessionID"] as const).filter((field) => selected[field] !== undefined && target[field] === undefined) : []
3560
+ if (missing.length) return { ok: false, actionExecuted: false,
3561
+ message: `Missing target identity: ${missing.join(", ")}. Supply the complete key/runId/lock/sessionID from fresh goal_inventory; nothing executed.` }
3562
+ if (!selected || selected.lock !== target.lock || selected.runId !== target.runId || selected.sessionID !== target.sessionID)
3563
+ return { ok: false, actionExecuted: false, ...(selected ? { target: { key: selected.key, runId: selected.runId, sessionID: selected.sessionID, lock: selected.lock } } : {}),
3564
+ message: `Selected goal identity changed; the goal is a NEW generation since the tuple you passed${selected?.runId === target.runId ? ` (lock ${target.lock.slice(0, 12)} → ${String(selected?.lock).slice(0, 12)})` : ""}. The goal KEY is live:<slug>:<runId>, not the bare slug. Retry goal_manage with the refreshed target above, or read it from goal_status.` }
3565
+ if (!selected.valid || selected.archived || !selected.runId) {
3566
+ // T106: a goal whose only fault is that the disk differs from its lock
3567
+ // is not "read-only"; the divergence has exactly one supported fix and
3568
+ // it must be named here instead of a generic refusal.
3569
+ const liveGoal = [...this.runs.values()].find((s) => s.slug === selected.slug && !isTerminal(s.status))
3570
+ const divergent = liveGoal && this.divergenceOf(liveGoal)
3571
+ if (liveGoal && divergent)
3572
+ return { ok: false, actionExecuted: false, target: this.targetOf(liveGoal), message: `${this.divergenceRefusal(liveGoal, divergent)} No ${action} was executed.` }
3573
+ return { ok: false, message: "This selected goal is read-only (draft, archived or invalid); review/launch a valid draft instead." }
3574
+ }
3575
+ const state = [...this.runs.values()].find((s) => s.slug === selected.slug && s.runId === selected.runId) ?? this.store.readRun(selected.slug)
3576
+ if (!state) return { ok: false, message: "The selected run is unavailable; no other goal was used as a fallback." }
3577
+ const unavailable = action === "attach" ? !selected.attachable ? "Only a stopped live goal can attach." : undefined : ownerCan(state, action)
3578
+ if (unavailable) return { ok: false, message: unavailable }
3579
+ if (action === "verify" && state.status === "verifying") return { ok: false, message: "The selected goal is already verifying." }
3580
+ if (action === "resume" || action === "verify") {
3581
+ // T106/C2: a divergence is its own cause with its own fix; offering
3582
+ // the impossible amendment review (or a resume choice) first wastes
3583
+ // the round. The staged review stays pending and revives after recovery.
3584
+ const divergent = this.divergenceOf(state)
3585
+ if (divergent) return { ok: false, actionExecuted: false, target: this.targetOf(state),
3586
+ message: `${this.divergenceRefusal(state, divergent)} No ${action} question or amendment review was offered.` }
3587
+ const amendment = this.amendmentReviews.all().find((p) => p.slug === state.slug && ["pending", "interrupted", "executing"].includes(p.status))
3588
+ if (amendment) {
3589
+ // An owned review never actually presented can offer restoration,
3590
+ // not the impossible resume. Foreign/held reviews get no question.
3591
+ if (forceReview && amendment.sessionID === sessionID && !amendment.formID && ["pending", "interrupted"].includes(amendment.status)
3592
+ && !this.decisions.all(sessionID).some((p) => ["pending", "interrupted"].includes(p.status) && p.extra.amendmentId === amendment.id))
3593
+ this.decide(sessionID, state.slug, "owner-operation", { id: "goal.amendment.prerequisite", level: "warning",
3594
+ text: "An amendment is awaiting review; resume is not available until its disposition. Restore the exact review without accepting or resuming it.",
3595
+ choices: [{ label: "Review pending amendment", act: "amend", arg: `review:${amendment.id}`, run: "show the exact staged diff; no approval or resume" },
3596
+ { label: "Keep stopped", run: "leave the run and staged contract unchanged" }] }, { targetKey: selected.key, amendmentId: amendment.id })
3597
+ return { ok: false, actionExecuted: false, requiredReview: { id: amendment.id, sessionID: amendment.sessionID, kind: "amendment" },
3598
+ message: amendment.status === "executing"
3599
+ ? "An accepted amendment is committing; inspect its recovery receipt. No impossible resume review was offered."
3600
+ : `Resolve amendment ${amendment.id} in its authoring session ${amendment.sessionID} before ${action}. If that session is unavailable, stage the validated diff in a live session for a NEW owner review; no old approval transfers. No ${action} question was offered.` }
3601
+ }
3602
+ }
3603
+ if (forceReview && action === "resume" && state.sessionID === sessionID) {
3604
+ const generationAt = Math.max(state.createdAt, ...state.amendments.filter((a) => a.status === "accepted").map((a) => a.at),
3605
+ ...this.store.recent(state.slug, 1000).filter((e) => e.type === "paused").map((e) => Number(e.t) || 0))
3606
+ const messages: any = await this.ctx.session.context({ sessionID } as any)
3607
+ const review = this.decisions.all(sessionID).filter((p) => ["pending", "interrupted"].includes(p.status) && !reviewExpired(p)
3608
+ && p.kind === "owner-operation" && p.extra.targetKey === selected.key && p.target.lock === state.lock
3609
+ && p.target.runId === state.runId && p.target.sessionID === sessionID && p.target.status === state.status
3610
+ && p.message.choices.some((c) => c.act === "resume")).sort((a, b) => b.createdAt - a.createdAt)[0]
3611
+ const consent = ownerResumeMessage(messages?.data ?? messages, generationAt, review)
3612
+ if (consent && !this.decisions.all(sessionID).some((p) => p.ownerMessageID === consent.id)) {
3613
+ const message = M.managerOperation("resume", selected.title), now = Date.now()
3614
+ const request = this.decisions.create({ sessionID, slug: state.slug, kind: "owner-operation", lock: state.lock,
3615
+ target: this.decisionTarget(state.slug), candidates: {}, title: message.id, message,
3616
+ extra: { targetKey: selected.key }, ownerMessageID: consent.id, question: consent.text,
3617
+ createdAt: now, expiresAt: 0, expiryPolicy: "target", issuerPID: process.pid })
3618
+ const reserved = this.decisions.reserve(request.id)
3619
+ if (!reserved) return { ok: false, message: "The owner request was already consumed; no operation executed." }
3620
+ // Consume BEFORE attempting: a failed operation cannot turn an old
3621
+ // sentence into reusable worker authority after a restart.
3622
+ this.store.ledger(state.slug, { type: "owner-message-consumed", ownerMessageID: consent.id, decisionId: request.id,
3623
+ actorSessionID: sessionID, targetRunId: state.runId, lock: state.lock, action: "resume" })
3624
+ let result: { ok: boolean; message: string }
3625
+ try { result = await this.ownerActLocked(sessionID, "resume", undefined, undefined, selected) }
3626
+ catch (error) { result = { ok: false, message: `Owner operation failed: ${String(error)}` } }
3627
+ this.decisions.update(request.id, { status: result.ok ? "succeeded" : "failed", result: result.message })
3628
+ this.store.ledger(state.slug, { type: result.ok ? "decision-resolved" : "decision-attempt", decision: request.id,
3629
+ kind: request.kind, action: "resume", via: "owner-message", ok: result.ok, message: result.message })
3630
+ return result
3631
+ }
3632
+ }
3633
+ if (forceReview || action === "abort" || action === "archive") {
3634
+ const existing = this.decisions.all(sessionID).find((p) => ["pending", "interrupted"].includes(p.status) && !reviewExpired(p)
3635
+ && p.kind === "owner-operation" && p.extra.targetKey === selected.key && p.target.lock === state.lock
3636
+ && p.target.runId === state.runId && p.target.sessionID === state.sessionID && p.target.status === state.status
3637
+ && p.message.choices.some((c) => c.act === action))
3638
+ if (existing) {
3639
+ this.publishDecision(existing)
3640
+ if (existing.status === "pending") void this.askDecisionForm(sessionID, selected.slug, existing.kind, existing.id, existing.message)
3641
+ return { ok: true, pending: true, decisionId: existing.id, message: "The exact native decision is already pending in the Composer; no second review or operation was created." }
3642
+ }
3643
+ this.decide(sessionID, selected.slug, "owner-operation", M.managerOperation(action, selected.title), { targetKey: selected.key })
3644
+ const decisionId = this.pendingDecisions.get(sessionID)?.id
3645
+ return { ok: true, pending: true, decisionId, message: `Native ${action} confirmation offered for ${selected.slug}; no operation executed yet.` }
3646
+ }
3647
+ const result = await this.ownerActLocked(sessionID, action, action === "attach" ? selected.slug : undefined, undefined, selected)
3648
+ this.store.ledger(selected.slug, { type: "manager-action", actorSessionID: sessionID, targetRunId: selected.runId, action, ok: result.ok, message: result.message })
3649
+ return result
3650
+ })
3651
+ }
3652
+
3653
+ private async unholdAmendmentChat(request: AmendmentRequest) {
3654
+ const current = this.amendmentReviews.get(request.id), state = this.runs.get(request.targetSessionID)
3655
+ const forms = this.factsOf(request.sessionID).forms
3656
+ if (!current?.chatAdmissionMessageID || !current.formID || forms.size !== 1 || !forms.has(current.formID)
3657
+ || !state || isTerminal(state.status) || state.runId !== current.runId || state.lock !== current.lock) return
3658
+ const messageID = current.chatAdmissionMessageID
3659
+ if (!this.amendmentReviews.takeChatAdmission(current.id, messageID)) return
3660
+ // The owner's admitted message may already be queued behind this form.
3661
+ // Stopping dispatch as well would strand that same authentic reply.
3662
+ await this.ctx.session.interrupt({ sessionID: current.sessionID, resume: true } as any)
3663
+ this.store.ledger(current.slug, { type: "amendment-chat-unheld", decisionId: current.id, previousFormID: current.formID,
3664
+ sourceMessageID: messageID, actionExecuted: false, reason: "explicit owner chat admitted; typed persisted-message validation still required" })
3665
+ }
3666
+
3667
+ private async unholdLaunchChat(request: LaunchRequest) {
3668
+ const current = this.launches.get(request.id), forms = this.factsOf(request.sessionID).forms
3669
+ if (!current?.chatAdmissionMessageID || !current.formID || forms.size !== 1 || !forms.has(current.formID)) return
3670
+ const loaded = this.load(current.slug, true)
3671
+ if ("error" in loaded || loaded.lock !== current.lock) return
3672
+ const messageID = current.chatAdmissionMessageID
3673
+ if (!this.launches.takeChatAdmission(current.id, messageID)) return
3674
+ await this.ctx.session.interrupt({ sessionID: current.sessionID, resume: true })
3675
+ this.store.ledger(current.slug, { type: "launch-chat-unheld", decisionId: current.id, sourceMessageID: messageID,
3676
+ previousFormID: current.formID, actionExecuted: false, reason: "explicit chat admitted; actual persisted owner validation still required" })
3677
+ }
3678
+
3679
+ private async registerHooks() {
3680
+ // A pending question holds generation. Let a genuine explicit chat reply
3681
+ // reach the agent; interruption is not acceptance and spends no consent.
3682
+ this.registrations.push(await this.ctx.session.hook("prompt", async (event: any) => {
3683
+ if (event.metadata?.goal) return
3684
+ const launch = this.launches.all(event.sessionID).filter((p) => ["pending", "interrupted", "expired"].includes(p.status))
3685
+ .sort((a, b) => b.createdAt - a.createdAt)[0]
3686
+ if (launch) {
3687
+ const loaded = this.load(launch.slug, true)
3688
+ const shown = this.store.recent(launch.slug, 1000).findLast((e) => e.type === "launch-form-bound" && e.decisionId === launch.id)
3689
+ const candidate = [{ id: event.messageID, type: "user", text: event.prompt.text, time: { created: Date.now() } }]
3690
+ const intent = shown && !("error" in loaded) && loaded.lock === launch.lock
3691
+ ? ownerLaunchMessage(candidate, [{ id: launch.id, slug: launch.slug, lock: launch.lock, fresh: launch.fresh, at: Number(shown.t) }], launch) : undefined
3692
+ const noted = this.launches.noteChatAdmission(launch.id, intent ? event.messageID : undefined)
3693
+ if (intent && noted) await this.unholdLaunchChat(noted)
3694
+ }
3695
+ const requests = this.amendmentReviews.all(event.sessionID).filter((p) => ["pending", "interrupted"].includes(p.status) && !reviewExpired(p) && !p.recovery)
3696
+ if (requests.length !== 1) return
3697
+ const request = requests[0]!, state = this.runs.get(request.targetSessionID)
3698
+ if (!state || isTerminal(state.status)
3699
+ || state.runId !== request.runId || state.lock !== request.lock || request.issuerPID !== process.pid && processAlive(request.issuerPID)) return
3700
+ const candidate = [{ id: event.messageID, type: "user", text: event.prompt.text, time: { created: Date.now() } }]
3701
+ const intent = ownerAmendmentMessage(candidate, request, "accept") ?? ownerAmendmentMessage(candidate, request, "reject")
3702
+ ?? (/^(?:please\s+)?(?:change|amend|revise|instead)\b/i.test(event.prompt.text.trim()) ? ownerAmendmentMessage(candidate, request, "comment") : undefined)
3703
+ const noted = this.amendmentReviews.noteChatAdmission(request.id, intent ? event.messageID : undefined)
3704
+ if (!intent || !noted) return
3705
+ this.store.ledger(request.slug, { type: "amendment-chat-admission", decisionId: request.id, sourceMessageID: event.messageID, actionExecuted: false })
3706
+ await this.unholdAmendmentChat(noted)
3707
+ }))
3708
+ // R5 + S3: render the contract into every request for sessions with a live goal.
3709
+ this.registrations.push(
3710
+ await this.ctx.session.hook("context", (event: any) => {
3711
+ const state = this.runs.get(event.sessionID)
3712
+ if (!state || isTerminal(state.status) || state.status === "aborted") return
3713
+ const contract = this.contractOf(state)
3714
+ if (!contract) return
3715
+ event.system.push({ type: "text", text: renderSystemBlock(contract, state.lock) })
3716
+ const note = this.notes.get(event.sessionID)
3717
+ if (!note || !isActive(state.status)) return
3718
+ // Just before the turn's own prompt, so the note keeps one position for
3719
+ // every step of the turn and the cached prefix before it stays warm.
3720
+ let at = event.messages.length
3721
+ for (let i = event.messages.length - 1; i >= 0; i--)
3722
+ if (event.messages[i]?.role === "user") {
3723
+ at = i
3724
+ break
3725
+ }
3726
+ event.messages.splice(at, 0, makeUserMessage(event.messages, note))
3727
+ }),
3728
+ )
3729
+ this.registrations.push(
3730
+ await this.ctx.session.hook("compaction", (event: any) => {
3731
+ const state = this.runs.get(event.sessionID)
3732
+ const contract = state && this.contractOf(state)
3733
+ if (!state || !contract || isTerminal(state.status)) return
3734
+ event.system.push({ type: "text", text: `${renderSystemBlock(contract, state.lock)}\n\nPreserve in the summary: the goal "${contract.title}" is ${state.status}; progress so far: ${state.progress?.note ?? "none recorded"}; next: ${state.progress?.next ?? "continue the plan"}.` })
3735
+ }),
3736
+ )
3737
+ this.registrations.push(
3738
+ await this.ctx.tool.hook("execute.before", (event: any) => {
3739
+ const state = this.runs.get(event.sessionID)
3740
+ const live = [...this.runs.values()].filter((s) => !isTerminal(s.status))
3741
+ const contract = state && this.contractOf(state)
3742
+ if (event.tool === "question" && state && contract && isActive(state.status) && contract.autonomy.questions === "defer" && !this.ownedDecisionQuestion(event.sessionID, event.input) && !this.ownedLaunchQuestion(event.sessionID, event.input) && !this.ownedAmendmentQuestion(event.sessionID, event.input))
3743
+ // T075 exception: the ENGINE-initiated decision form (askDecisionForm's
3744
+ // prompt) is the owner's ask — the worker's own questions stay deferred.
3745
+ throw new Error("Goal mode defers questions to the owner during a run. Make the most reasonable reversible choice and note it as an assumption in goal_progress, or call goal_block if this truly needs the owner.")
3746
+ const paths = targetPaths(event.tool, event.input)
3747
+ for (const p of paths) {
3748
+ const rel = relative(this.root, resolve(this.root, p))
3749
+ if (rel === ".." || rel.startsWith("../")) continue
3750
+ // T037: the goal folder stays untouchable for the whole life of the
3751
+ // run — including stopped states (paused/needs_review/budget_limited),
3752
+ // the unguarded amendment window the council flagged. Terminal runs
3753
+ // release the folder so the next contract can be written.
3754
+ const ownedRunPath = live.some((s) => rel === `.opencode/goals/${s.slug}` || rel.startsWith(`.opencode/goals/${s.slug}/`))
3755
+ const ownerReceipt = /^\.opencode\/goals\/\.(?:owner-|launch-approvals)/.test(rel)
3756
+ if (rel.startsWith(".opencode/goals-archive/") || ownerReceipt || ownedRunPath || state && !isTerminal(state.status) && rel.startsWith(".opencode/goals/"))
3757
+ throw new Error("The goal contract and run state are owned by the owner and the host. Do not edit a live goal folder, owner consent records or archived history. Run state changes go through goal_progress, goal_wait, goal_block, goal_flag, goal_claim, goal_manage or goal_recover_divergence; a contract change goes through goal_propose_amendment for native owner acceptance.")
3758
+ // A goal-less manager session may propose a diff, but it is not a
3759
+ // loophole for editing another session's protected oracle.
3760
+ if (live.some((s) => this.contractOf(s)?.protect.some((g) => globMatch(rel, g))))
3761
+ throw new Error(`${rel} is protected by the goal contract (an oracle the worker may not change). If it is wrong, call goal_flag.`)
3762
+ }
3763
+ }),
3764
+ )
3765
+ }
3766
+
3767
+ private async registerRpc() {
3768
+ try {
3769
+ const registration: any = await (this.ctx.rpc as any).register(GoalRpc, {
3770
+ provenance: async (input: { sessionID?: string }) => this.provenance(input.sessionID),
3771
+ snapshot: async (input: { sessionID: string }) => {
3772
+ const state = this.runs.get(input.sessionID)
3773
+ const launches = this.launches.all(input.sessionID).map(launchView)
3774
+ return { view: state ? (this.view(state) ?? null) : null, launches }
3775
+ },
3776
+ list: async () => ({ goals: await this.inventory(), directory: this.root }),
3777
+ details: async (input: { key: string }) => ({ goal: (await this.withSessionMetadata(projectInventory(this.store, this.runs.values(), { detailsForKey: input.key, launches: this.launches.all() }).filter((g) => g.key === input.key)))[0] ?? null }),
3778
+ review: async (input: { sessionID: string; decisionId?: string }) => {
3779
+ try { return { ok: true, message: await this.reviewDecision(input.sessionID, input.decisionId) } }
3780
+ catch (error) { return { ok: false, message: String(error instanceof Error ? error.message : error), actionExecuted: false } }
3781
+ },
3782
+ manage: async (input: { sessionID: string; action: ManageAction; target: GoalTarget }) => this.manageGoal(input.sessionID, input.action, input.target),
3783
+ act: async (input: { sessionID: string; action: any; arg?: string; decisionId?: string }) => this.ownerAct(input.sessionID, input.action, input.arg, "dialog", input.decisionId),
3784
+ })
3785
+ this.rpc = registration
3786
+ this.registrations.push(registration)
3787
+ } catch {
3788
+ // RPC is optional: without it the server still runs goals; the TUI shows nothing.
3789
+ }
3790
+ }
3791
+
3792
+ // ───────────────────────────── worker tools (S7: native names, codemode off)
3793
+
3794
+ private async registerTools() {
3795
+ // T062: goal tools are registered codemode:false — they never exist in
3796
+ // Code Mode's catalog. A live run mis-routed goal_claim through `execute`
3797
+ // and got a misleading Unknown-tool error, so every description says so.
3798
+ const hint = " Called directly by name — never available inside Code Mode/execute."
3799
+ const tool = (name: string, description: string, input: Record<string, unknown>, execute: (args: any, context: any) => Promise<string | Record<string, unknown>>, extra: Record<string, unknown> = {}) => ({
3800
+ name,
3801
+ description: `${description}${hint}`,
3802
+ options: { namespace: "goal", codemode: false, ...extra },
3803
+ input: { type: "object", additionalProperties: false, ...input },
3804
+ execute: async (args: any, context: any) => {
3805
+ const out = await execute(args ?? {}, context)
3806
+ return typeof out === "string" ? { content: out } : { content: json(out), metadata: out }
3807
+ },
3808
+ })
3809
+ const forSession = (context: any) => this.runs.get(context.sessionID)
3810
+ const guard = (context: any, action: Parameters<typeof modelCan>[1]) => {
3811
+ const state = forSession(context)
3812
+ const why = modelCan(state, action)
3813
+ if (why) throw new Error(why)
3814
+ return state!
3815
+ }
3816
+
3817
+ const tools = [
3818
+ tool("manage", "Request a supported lifecycle operation on the EXACT selected project goal. Use goal_inventory for target key/runId/lock/sessionID. A current, explicit owner resume/continue message can authorize one resume of that same session/run/lock; the host reads and consumes the original message, never worker-supplied consent. Otherwise a native owner decision is prepared. Cannot waive criteria, approve evidence or execute arbitrary owner operations.", { properties: { action: { type: "string", enum: ["pause", "resume", "verify", "attach", "abort", "archive"] }, target: GoalRpc.methods.manage.input.properties.target }, required: ["action", "target"] }, async (args, context) => {
3819
+ // After reload this instance may have missed execution.started. A
3820
+ // model tool call proves the turn is still busy; resume must not
3821
+ // enqueue a competing kickoff alongside this owner-approved work.
3822
+ this.factsOf(context.sessionID).busy = true
3823
+ return this.manageGoal(context.sessionID, args.action, args.target, true)
3824
+ }),
3825
+ tool("inventory", "Read every goal contract in THIS project: drafts, live/stopped/complete runs and archives, with real session/agent/progress/priority and existing contract metadata. Goal contracts are not CLEO tasks; never infer a goal parent from a task ID. Unknown/missing metadata is reported honestly; this performs no lifecycle operation.", { properties: {} }, async () => ({ goals: await this.inventory() })),
3826
+ tool("review", "Re-present this session's pending engine decision in a native owner form after dismissal/commentary. Optional decisionId selects the exact review; this never approves, resumes or executes an operation. Expired reviews need a new review; changed target generations are refused.", { properties: { decisionId: { type: "string", minLength: 1, maxLength: 256 } } }, async (args, context) => this.reviewDecision(context.sessionID, args.decisionId ? String(args.decisionId) : undefined)),
3827
+ tool("resolve_amendment", "Resolve an exact staged amendment from the actual owner's latest Composer chat. The host fetches that user message and binds it once; tool-supplied text, quoted/conditional/stale/replayed consent cannot authorize anything. No sidebar/manual command or fabricated form reply. The host honors explicit owner continuation (resume/continue/get it done) after accepting this reviewed next lock even if the optional hint is omitted or false. A true continueGoal hint still requires that same actual owner directive; it never approves criteria.", {
3828
+ properties: { decisionId: { type: "string", minLength: 1, maxLength: 256 }, action: { type: "string", enum: ["accept", "reject", "comment"] }, continueGoal: { type: "boolean" } }, required: ["decisionId", "action"],
3829
+ }, async (args, context) => this.resolveAmendmentChat(context.sessionID, { decisionId: String(args.decisionId), action: args.action, continueGoal: args.continueGoal === true })),
3830
+ tool("propose_amendment", "Stage a validated replacement YAML contract as a reviewed diff for a selected live goal. Does NOT edit goal.yaml or grant consent. Exact owner accept/reject/comment is required; changed statements/checks reset proof and the goal remains stopped. Never call an owner accept operation yourself.", { properties: { slug: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", maxLength: 64 }, expectedLock: { type: "string", pattern: "^[a-f0-9]{64}$" }, yaml: { type: "string", minLength: 1, maxLength: 200000 }, rationale: { type: "string", minLength: 1, maxLength: 2000 } }, required: ["slug", "expectedLock", "yaml", "rationale"] }, async (args, context) => this.proposeAmendment(context.sessionID, { slug: String(args.slug), expectedLock: String(args.expectedLock), yaml: String(args.yaml), rationale: String(args.rationale) })),
3831
+ tool("recover_divergence", "When a live goal's disk goal.yaml differs from its locked contract (an edit made outside the engine), offer the owner a native review of the exact difference and last-writer provenance: Adopt disk version, Restore locked version or Leave unchanged. Callable from any live session of this project. No earlier proposal or approval counts. Inside an owner-set autonomy policy that includes recover, an in-policy difference is adopted directly, journalled and owner-revertible. Never edit goal.yaml yourself.", { properties: { slug: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", maxLength: 64 }, expectedLock: { type: "string", pattern: "^[a-f0-9]{64}$" } }, required: ["slug", "expectedLock"] }, async (args, context) => this.recoverDivergence(context.sessionID, { slug: String(args.slug), expectedLock: String(args.expectedLock) })),
3832
+ tool("prepare_launch", "Validate and stage an exact goal contract for native owner review and same/fresh-session launch. The host executes the owner's choice directly, including after reload or an interrupted question result. Preparation is not permission to start or approve yourself.", { properties: { slug: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", maxLength: 64 }, fresh: { type: "boolean" } }, required: ["slug"] }, async (args, context) => this.prepareLaunch(context.sessionID, String(args.slug), args.fresh === true)),
3833
+ tool("provenance", "Report the actual server PID, location, installed Git revision and loaded server/UI/skill artifact hashes for this goal session. Never substitute an unscoped shared-service or newest-cache lookup for this evidence.", { properties: {} }, async (_args, context) => ({ ...await this.provenance(context.sessionID) })),
3834
+ tool("status", "Show the active goal: status, criteria board, plan, budget, last verdict. Cheap; use it after a compaction or when unsure what remains.", { properties: {} }, async (_args, context) => {
3835
+ const state = forSession(context)
3836
+ if (!state) return "No goal is running in this session."
3837
+ return this.view(state) ?? { status: state.status }
3838
+ }),
3839
+ tool(
3840
+ "progress",
3841
+ "Record progress only on the active goal in this session. Check goal_status after interruption/reload; stopped or terminal goals return recorded:false with their exact status and no mutation. Do not retry a stopped goal or infer resume permission. Keep note and next within 400 characters each; plan progress is not host-proven criteria.",
3842
+ {
3843
+ properties: {
3844
+ step: { type: "string", description: "Plan step id, e.g. S2" },
3845
+ step_done: { type: "boolean", description: "true when the step is finished" },
3846
+ note: { type: "string", maxLength: 400, description: "What changed this turn (one line)" },
3847
+ next: { type: "string", maxLength: 400, description: "The next concrete action" },
3848
+ },
3849
+ required: ["note"],
3850
+ },
3851
+ async (args, context) =>
3852
+ this.serial(() => {
3853
+ const selected = forSession(context)
3854
+ const unavailable = modelCan(selected, "progress")
3855
+ if (unavailable) return { ok: false, recorded: false, status: selected?.status ?? "none", message: unavailable,
3856
+ nextAction: selected && !isTerminal(selected.status) ? "Owner may resume this exact goal; do not retry progress while stopped." : "Inspect goal_status; no progress was recorded.",
3857
+ ...(selected ? { slug: selected.slug, runId: selected.runId } : {}) }
3858
+ const state = selected!
3859
+ const contract = this.contractOf(state)
3860
+ if (args.step) {
3861
+ const id = String(args.step).toUpperCase()
3862
+ if (!contract?.plan.some((s) => s.id === id)) throw new Error(`unknown plan step ${id}`)
3863
+ const before = state.steps[id]
3864
+ state.steps[id] = args.step_done ? "done" : "active"
3865
+ if (args.step_done) {
3866
+ const nextStep = contract!.plan.find((s) => state.steps[s.id] === "pending" && s.depends_on.every((d) => state.steps[d] === "done"))
3867
+ if (nextStep) state.steps[nextStep.id] = "active"
3868
+ }
3869
+ if (before !== state.steps[id]) state.turnFacts.stepsChanged = true
3870
+ }
3871
+ state.progress = { note: String(args.note).slice(0, 400), ...(args.next ? { next: String(args.next).slice(0, 400) } : {}), at: Date.now(), turn: state.turn }
3872
+ state.turnFacts.goalCalls += 1
3873
+ // T108: a recorded progress note is host-visible work, so it counts
3874
+ // as progress for the stall detector.
3875
+ state.turnFacts.progressRecorded = true
3876
+ this.store.ledger(state.slug, { type: "progress", turn: state.turn, step: args.step, done: !!args.step_done, note: state.progress.note, next: state.progress.next })
3877
+ this.persist(state)
3878
+ return { ok: true, recorded: true, status: state.status, slug: state.slug, runId: state.runId, message: "Recorded. Plan progress does not approve completion criteria." }
3879
+ }),
3880
+ ),
3881
+ tool(
3882
+ "claim",
3883
+ "Claim the goal is complete. Give evidence per criterion (what you ran or inspected and what it showed). Then END YOUR TURN: the host runs every check itself and an independent verifier reviews the rest. Never claim to escape the loop.",
3884
+ {
3885
+ properties: {
3886
+ summary: { type: "string", maxLength: 2000 },
3887
+ evidence: { type: "object", description: "Map of criterion id → evidence note, e.g. {\"C1\": \"npm test: 84 passed\"}", additionalProperties: { type: "string", maxLength: 1000 } },
3888
+ },
3889
+ required: ["summary", "evidence"],
3890
+ },
3891
+ async (args, context) =>
3892
+ this.serial(() => {
3893
+ const state = guard(context, "claim")
3894
+ const contract = this.contractOf(state)!
3895
+ const evidence = Object.fromEntries(Object.entries(args.evidence ?? {}).map(([k, v]) => [k.toUpperCase(), String(v).slice(0, 1000)]))
3896
+ const missing = contract.criteria.filter((c) => c.essential && !evidence[c.id]).map((c) => c.id)
3897
+ if (missing.length) throw new Error(`Give evidence for every essential criterion; missing: ${missing.join(", ")}`)
3898
+ state.claim = { at: Date.now(), turn: state.turn, summary: String(args.summary).slice(0, 2000), evidence }
3899
+ for (const id of Object.keys(evidence)) if (state.criteria[id] && state.criteria[id]!.status !== "pass") state.criteria[id]!.status = "claimed"
3900
+ this.store.ledger(state.slug, { type: "claim", turn: state.turn, summary: state.claim.summary })
3901
+ this.persist(state)
3902
+ return "Claim recorded. End your turn now; the host will verify it and either complete the goal or return a verdict."
3903
+ }),
3904
+ ),
3905
+ tool(
3906
+ "block",
3907
+ "Report a blocker that only the owner can resolve (a missing credential, an access grant, a product decision). Use a short stable key so repeats are counted; after 3 consecutive reports the goal stops and asks the owner. Hard or slow work is not a blocker.",
3908
+ {
3909
+ properties: {
3910
+ key: { type: "string", maxLength: 80, description: "Stable key, e.g. missing-stripe-key" },
3911
+ reason: { type: "string", maxLength: 500 },
3912
+ needs: { type: "string", enum: ["decision", "credential", "access", "external"] },
3913
+ },
3914
+ required: ["key", "reason"],
3915
+ },
3916
+ async (args, context) =>
3917
+ this.serial(() => {
3918
+ const state = guard(context, "block")
3919
+ const key = String(args.key).toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 80)
3920
+ const same = state.blocker?.key === key && state.blocker.lastTurn >= state.turn - 1
3921
+ state.blocker = { key, count: same ? state.blocker!.count + (state.blocker!.lastTurn === state.turn ? 0 : 1) : 1, reason: String(args.reason).slice(0, 500), ...(args.needs ? { needs: String(args.needs) } : {}), lastTurn: state.turn }
3922
+ this.store.ledger(state.slug, { type: "block", key, count: state.blocker.count, reason: state.blocker.reason })
3923
+ this.persist(state)
3924
+ return state.blocker.count >= this.options.blockerRepeats
3925
+ ? "Blocker confirmed. The goal will stop and ask the owner when this turn ends."
3926
+ : `Blocker noted (${state.blocker.count}/${this.options.blockerRepeats}). Continue with any work that does not depend on it.`
3927
+ }),
3928
+ ),
3929
+ tool(
3930
+ "flag",
3931
+ "Flag a criterion that is contradictory, impossible or unsafe as written. The goal stops for the owner to review. Use this instead of gaming a check.",
3932
+ {
3933
+ properties: {
3934
+ criterion: { type: "string" },
3935
+ kind: { type: "string", enum: ["contradictory", "impossible", "unsafe"] },
3936
+ reason: { type: "string", maxLength: 800 },
3937
+ },
3938
+ required: ["kind", "reason"],
3939
+ },
3940
+ async (args, context) =>
3941
+ this.serial(() => {
3942
+ const state = guard(context, "flag")
3943
+ state.flags.push({ ...(args.criterion ? { criterion: String(args.criterion).toUpperCase() } : {}), kind: String(args.kind), reason: String(args.reason).slice(0, 800), at: Date.now() })
3944
+ setStatus(state, "needs_review", `${args.criterion ? `${String(args.criterion).toUpperCase()} ` : ""}flagged ${args.kind}: ${String(args.reason).slice(0, 160)}`)
3945
+ this.store.ledger(state.slug, { type: "flag", ...state.flags.at(-1) })
3946
+ this.noticeM(state.sessionID, M.needsReview(state.reason ?? "criterion failed repeatedly"))
3947
+ this.persist(state)
3948
+ return "Flag recorded; the goal is stopped for the owner's review. End your turn."
3949
+ }),
3950
+ ),
3951
+ tool(
3952
+ "wait",
3953
+ "Pause the loop without spending tokens while an external process runs (a build, a deploy, CI). The host resumes the goal after the given seconds.",
3954
+ {
3955
+ properties: {
3956
+ seconds: { type: "number", minimum: 10, maximum: 3000 },
3957
+ reason: { type: "string", maxLength: 300 },
3958
+ },
3959
+ required: ["seconds", "reason"],
3960
+ },
3961
+ async (args, context) =>
3962
+ this.serial(() => {
3963
+ const state = guard(context, "wait")
3964
+ const seconds = Math.max(10, Math.min(3000, Number(args.seconds) || 60))
3965
+ state.wait = { until: Date.now() + seconds * 1000, reason: String(args.reason).slice(0, 300) }
3966
+ state.activity = { kind: "waiting", since: Date.now(), until: state.wait.until, detail: state.wait.reason }
3967
+ this.store.ledger(state.slug, { type: "wait", seconds, reason: state.wait.reason })
3968
+ this.persist(state)
3969
+ this.noticeM(state.sessionID, M.goalWait(seconds, state.wait.reason))
3970
+ return `The goal will resume in ${seconds}s. End your turn now.`
3971
+ }),
3972
+ ),
3973
+ tool(
3974
+ "validate",
3975
+ "Validate a goal contract at .opencode/goals/<slug>/goal.yaml and report every problem. Use while writing a goal.",
3976
+ { properties: { slug: { type: "string", maxLength: 64 } }, required: ["slug"] },
3977
+ async (args) => {
3978
+ const read = this.store.readContract(String(args.slug))
3979
+ if (!read) return `No file at .opencode/goals/${args.slug}/goal.yaml`
3980
+ return `${formatIssues(read.issues)}${read.contract ? `\nlock ${read.lock.slice(0, 12)} · ${read.contract.criteria.length} criteria (${read.contract.criteria.filter((c) => isHostCheck(c.check)).length} host-checked), ${read.contract.invariants.length} invariants` : ""}`
3981
+ },
3982
+ ),
3983
+ tool(
3984
+ "start",
3985
+ `Start a validated goal only after genuine owner approval: a bound native choice, an explicit actual owner chat instruction for the exact previously displayed contract/session/mode, or the owner's /goal start. The host fetches and consumes chat itself once; expired form answers, worker text and changed targets cannot authorize it. With fresh: true the reviewed mode must create a NEW goal session, preserving this one.`,
3986
+ { properties: { slug: { type: "string", maxLength: 64 }, fresh: { type: "boolean", description: "Start clean: open a new session owned by the goal, prompt the kickoff there, leave this session untouched" } }, required: ["slug"] },
3987
+ async (args, context) => {
3988
+ this.factsOf(context.sessionID).busy = true
3989
+ const chat = await this.launchFromOwnerChat(context.sessionID, String(args.slug), args.fresh === true)
3990
+ if (chat !== undefined) return chat
3991
+ const approval = this.launchApproval(context.sessionID)
3992
+ if (!approval) {
3993
+ // T068: a refusal names what was observed so the agent can
3994
+ // self-correct instead of guessing between never-asked, wrong
3995
+ // label and expired. An expired approval leaves its record on
3996
+ // disk (any age); a wrong label is known from the last form
3997
+ // answer in memory.
3998
+ const seen = this.factsOf(context.sessionID).lastFormAnswer
3999
+ const diskRecord = this.readLaunchApprovals()[context.sessionID]
4000
+ const matched = (label?: string) => !!label && (label === LAUNCH_LABEL || label.startsWith(LAUNCH_LABEL))
4001
+ const detail = diskRecord && matched(diskRecord.label) && Date.now() - diskRecord.at > this.options.launchApprovalMs
4002
+ ? `the approval expired (a ${Math.round(this.options.launchApprovalMs / 60_000)}-minute window; restarts keep it until then)`
4003
+ : !seen || seen.labels.some(matched)
4004
+ ? "no launch approval was seen in this session"
4005
+ : `the owner's last answer was ${JSON.stringify(seen.labels[0] ?? "")}, which does not start with "${LAUNCH_LABEL}"`
4006
+ throw new Error(
4007
+ `Launching a goal needs the owner's approval: ${detail}. Use a fresh exact native review, or an explicit actual owner start instruction for the already displayed unchanged draft. No expired choice was replayed; restarting does not authorize it.`,
4008
+ )
4009
+ }
4010
+ this.factsOf(context.sessionID).launchApprovedAt = undefined
4011
+ if (!approval.slug || !approval.lock || approval.fresh !== false || approval.slug !== String(args.slug) || args.fresh === true)
4012
+ throw new Error("Legacy launch approval does not bind this goal/contract/mode. Use goal_prepare_launch for an exact native review; nothing started.")
4013
+ this.clearLaunchApproval(context.sessionID)
4014
+ return this.startGoal(context.sessionID, String(args.slug), "tool", { expectedLock: approval.lock, decisionId: `legacy:${approval.formID}` })
4015
+ },
4016
+ ),
4017
+ tool(
4018
+ "verdict",
4019
+ "Verifier only: record your verdict for every criterion you were asked to judge.",
4020
+ {
4021
+ properties: {
4022
+ verdicts: {
4023
+ type: "array",
4024
+ items: {
4025
+ type: "object",
4026
+ additionalProperties: false,
4027
+ properties: {
4028
+ id: { type: "string" },
4029
+ verdict: { type: "string", enum: ["proven", "not_proven", "contradicted"] },
4030
+ reason: { type: "string", maxLength: 600 },
4031
+ evidence: { type: "array", items: { type: "object", additionalProperties: false, properties: { path: { type: "string" }, quote: { type: "string", maxLength: 2000 } }, required: ["path", "quote"] } },
4032
+ },
4033
+ required: ["id", "verdict"],
4034
+ },
4035
+ },
4036
+ },
4037
+ required: ["verdicts"],
4038
+ },
4039
+ async (args, context) => {
4040
+ if (String(context.agent) !== this.options.verifierAgent && !this.scopedVerifierAgents.has(String(context.agent))) throw new Error("goal_verdict is reserved for the goal verifier.")
4041
+ const verdicts: VerifierVerdict[] = (args.verdicts ?? []).map((v: any) => ({ id: String(v.id).toUpperCase(), verdict: v.verdict, ...(v.reason ? { reason: String(v.reason) } : {}), evidence: Array.isArray(v.evidence) ? v.evidence.map((e: any) => ({ path: String(e.path), quote: String(e.quote) })) : [] }))
4042
+ this.verifierInbox.set(String(context.sessionID), verdicts)
4043
+ return "Verdict recorded. You are done; end your turn."
4044
+ },
4045
+ { permission: "goal_verdict" },
4046
+ ),
4047
+ ]
4048
+
4049
+ this.registrations.push(
4050
+ await this.ctx.tool.transform((editor: any) => {
4051
+ for (const t of tools) editor.add(t)
4052
+ }),
4053
+ )
4054
+ }
4055
+ }
4056
+
4057
+ // ───────────────────────────── helpers
4058
+
4059
+ /** Reduce a child session's messages to a storable, human-readable transcript. */
4060
+ /** Name why a verifier round stalled: Core reports "Permission denied:
4061
+ * external_directory" without the path, so pair it with the absolute paths
4062
+ * the child asked for outside the project and its granted read roots. */
4063
+ /** T107: parse the contract's verifier model/effort. Never throws — an unusable
4064
+ * value returns a `reason` so the caller falls back to the resolved default and
4065
+ * journals why. `#variant` on the model wins over a separate `effort`. */
4066
+ export function contractVerifierModel(v: { model?: string; effort?: string }): { ref?: { providerID: string; id: string; variant?: string }; label?: string; requested?: string; reason?: string } {
4067
+ const raw = (v.model ?? "").trim()
4068
+ const effort = (v.effort ?? "").trim()
4069
+ if (!raw) return effort ? { reason: `verification.effort "${effort}" was given without verification.model` } : {}
4070
+ const m = /^([\w][\w.-]*)\/([^#/\s]+)(?:#(\S+))?$/.exec(raw)
4071
+ if (!m) return { requested: raw, reason: `verification.model "${raw}" is not "providerID/modelID[#variant]"` }
4072
+ const variant = m[3] ?? (effort || undefined)
4073
+ return { ref: { providerID: m[1]!, id: m[2]!, ...(variant ? { variant } : {}) }, label: `${m[1]}/${m[2]}${variant ? `#${variant}` : ""}` }
4074
+ }
4075
+
4076
+ /** The parts of a context message. OpenCode carries them under `parts` in some
4077
+ * versions and under `content` (an array) in others; both readers here and the
4078
+ * transcript builder must see the same parts, or a denial or a late verdict is
4079
+ * silently missed. */
4080
+ export function messageParts(message: unknown): any[] {
4081
+ const m = message as { parts?: any[]; content?: any } | undefined
4082
+ return m?.parts ?? (Array.isArray(m?.content) ? m.content : [])
4083
+ }
4084
+
4085
+ /** A goal_verdict call captured in the transcript but recorded too late. */
4086
+ export function verdictsFromTranscript(messages: unknown[], ids: Set<string>): VerifierVerdict[] {
4087
+ for (const message of messages ?? []) {
4088
+ for (const part of messageParts(message)) {
4089
+ if (part?.type !== "tool" || String(part?.tool ?? part?.name ?? "") !== "goal_verdict") continue
4090
+ const raw = part.state?.input
4091
+ try {
4092
+ const input = typeof raw === "string" ? JSON.parse(raw) : raw
4093
+ const verdicts = Array.isArray(input?.verdicts) ? input.verdicts : []
4094
+ const usable = verdicts.filter((v: any) => v && ids.has(String(v.id)) && ["proven", "not_proven", "contradicted"].includes(String(v.verdict)))
4095
+ if (usable.length) return usable as VerifierVerdict[]
4096
+ } catch { /* an unparseable call is not a verdict */ }
4097
+ }
4098
+ }
4099
+ return []
4100
+ }
4101
+
4102
+ export function verifierCause(messages: unknown[], allowedRoots: readonly string[]): string | undefined {
4103
+ const canon = (p: string) => p.replace(/^\/private(?=\/(?:var|tmp)\/)/, "")
4104
+ const inside = (p: string) => allowedRoots.some((root) => { const rel = relative(canon(root), canon(p)); return rel === "" || !rel.startsWith("..") && !rel.startsWith("/") })
4105
+ const paths: string[] = []
4106
+ for (const message of messages ?? []) {
4107
+ for (const part of messageParts(message)) {
4108
+ if (part?.type !== "tool") continue
4109
+ // Only a tool RESULT that says the permission was rejected is a denial.
4110
+ // Reading a file whose text mentions external_directory is not. Read the
4111
+ // same fields the transcript builder stores (content/error/output) and
4112
+ // serialise them the same way — on newer OpenCode the denial is an object
4113
+ // in state.error, so String() gave "[object Object]" and the cause was
4114
+ // reported as unknown.
4115
+ const output = JSON.stringify(part.state?.content ?? part.state?.error ?? part.state?.output ?? "")
4116
+ if (!/permission\.rejected|Permission denied/i.test(output) || !/external_directory/i.test(output)) continue
4117
+ const raw = part.state?.input
4118
+ const asked = /"(?:path|filePath|directory|pattern)"\s*:\s*"([^"]+)"/.exec(typeof raw === "string" ? raw : JSON.stringify(raw ?? {}))?.[1]
4119
+ if (asked && !inside(asked) && !paths.includes(asked)) paths.push(asked)
4120
+ }
4121
+ }
4122
+ if (!paths.length) return undefined
4123
+ return `denied external_directory access to ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ` (+${paths.length - 5} more)` : ""}; add the folder to verification.read in the contract or cite evidence inside the project`
4124
+ }
4125
+
4126
+ function compactTranscript(messages: unknown[]): unknown[] {
4127
+ return messages.map((m) => {
4128
+ const message = m as { type?: string; role?: string; parts?: any[]; content?: any }
4129
+ const parts = messageParts(message)
4130
+ return {
4131
+ type: message.type,
4132
+ role: message.role,
4133
+ parts: parts.map((p) => ({
4134
+ type: p?.type,
4135
+ ...(p?.text !== undefined ? { text: String(p.text).slice(0, 4000) } : {}),
4136
+ ...(p?.tool !== undefined ? { tool: String(p.tool) } : p?.type === "tool" && p?.name !== undefined ? { tool: String(p.name) } : {}),
4137
+ ...(p?.state !== undefined ? typeof p.state === "object" && p.state
4138
+ // Keep what a reader needs to see why a round stalled: the call,
4139
+ // its status and a bounded slice of its output or error.
4140
+ ? { state: String(p.state.status ?? "unknown"), input: JSON.stringify(p.state.input ?? null).slice(0, 2000), output: JSON.stringify(p.state.content ?? p.state.error ?? p.state.output ?? null).slice(0, 2000) }
4141
+ : { state: String(p.state) } : {}),
4142
+ })),
4143
+ }
4144
+ })
4145
+ }
4146
+
4147
+ /**
4148
+ * T044: an exchange is empty when the child's turn ended without any
4149
+ * assistant text or tool call after the last user message — the silent
4150
+ * GLM/zai failure mode observed on the main host (four rounds, no parts,
4151
+ * no tools, no text). Empty exchanges are retried once and always recorded.
4152
+ */
4153
+ function exchangeEmpty(messages: unknown[]): boolean {
4154
+ let lastUser = messages.length
4155
+ for (let i = messages.length - 1; i >= 0; i--) {
4156
+ const m = messages[i] as { role?: string; type?: string }
4157
+ if (m?.role === "user" || m?.type === "user") {
4158
+ lastUser = i
4159
+ break
4160
+ }
4161
+ }
4162
+ return !messages.slice(lastUser + 1).some((raw) => {
4163
+ const m = raw as { role?: string; type?: string; parts?: any[]; content?: any }
4164
+ if (m?.role !== "assistant" && m?.type !== "assistant") return false
4165
+ const parts = m.parts ?? (Array.isArray(m.content) ? m.content : [])
4166
+ return parts.some((p) => {
4167
+ const text = typeof p?.text === "string" ? p.text.trim() : ""
4168
+ const toolish = p?.tool !== undefined || p?.call !== undefined || p?.toolCallId !== undefined
4169
+ return Boolean(text) || toolish
4170
+ })
4171
+ })
4172
+ }
4173
+
4174
+ /**
4175
+ * T016 fallback: when the verifier child ends without calling goal_verdict,
4176
+ * accept a fenced ```json block from its final assistant message as the
4177
+ * verdict. Same shape and normalization as the tool handler; recorded
4178
+ * distinctly as by:"verifier-fallback" so the audit trail never lies about
4179
+ * how the verdict arrived. Guards: only assistant messages (the prompt itself
4180
+ * demonstrates a fenced example), and at least one verdict id must belong to
4181
+ * the criteria this round asked about.
4182
+ */
4183
+ function parseFallbackVerdicts(messages: unknown[], ids: Set<string>): VerifierVerdict[] {
4184
+ const valid = new Set(["proven", "not_proven", "contradicted"])
4185
+ for (let i = messages.length - 1; i >= 0; i--) {
4186
+ const message = messages[i] as { type?: string; role?: string; parts?: any[]; content?: any }
4187
+ if (message?.type !== "assistant" && message?.role !== "assistant") continue
4188
+ const parts = message.parts ?? (Array.isArray(message.content) ? message.content : [])
4189
+ const body = parts.map((p) => String(p?.text ?? "")).join("\n")
4190
+ if (body.includes("independent completion verifier")) continue // our own prompt's example fence
4191
+ const fence = [...body.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]!)
4192
+ for (const block of fence.reverse()) {
4193
+ try {
4194
+ const parsed = JSON.parse(block)
4195
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.verdicts) ? parsed.verdicts : []
4196
+ const verdicts = list
4197
+ .filter((v: any) => v && typeof v.id === "string" && valid.has(v.verdict) && ids.has(String(v.id).toUpperCase()))
4198
+ .map((v: any) => ({
4199
+ id: String(v.id).toUpperCase(),
4200
+ verdict: v.verdict as VerifierVerdict["verdict"],
4201
+ ...(v.reason ? { reason: String(v.reason) } : {}),
4202
+ evidence: Array.isArray(v.evidence) ? v.evidence.filter((e: any) => e?.path && e?.quote).map((e: any) => ({ path: String(e.path), quote: String(e.quote) })) : [],
4203
+ }))
4204
+ if (verdicts.length) return verdicts
4205
+ } catch {
4206
+ // not json; try the next fence
4207
+ }
4208
+ }
4209
+ }
4210
+ return []
4211
+ }
4212
+
4213
+ /** One-line summary of a ledger event for the dashboard's Timeline section. */
4214
+ function timelineText(e: Record<string, unknown>): string {
4215
+ const str = (v: unknown, max = 70) => String(v ?? "").slice(0, max)
4216
+ switch (e.type) {
4217
+ case "start":
4218
+ return `run started via ${str(e.source, 20)}`
4219
+ case "admit":
4220
+ return `turn ${e.turn} admitted (${str(e.kind, 30)})`
4221
+ case "admit-failed":
4222
+ return `turn ${e.turn} admission failed: ${str(e.error)}`
4223
+ case "claim":
4224
+ return `completion claimed at turn ${e.turn}`
4225
+ case "verdict":
4226
+ return `verdict ${e.passed ? "passed" : "failed"}${Array.isArray(e.lines) && e.lines[0] ? ` — ${str(e.lines[0], 90)}` : ""}`
4227
+ case "complete":
4228
+ return "goal complete"
4229
+ case "paused":
4230
+ return `paused: ${str(e.reason, 60)}`
4231
+ case "resumed":
4232
+ return `resumed (${str(e.by, 40)})`
4233
+ case "waiting":
4234
+ return `waiting ${e.seconds ? `${e.seconds}s` : ""}: ${str(e.reason, 50)}`
4235
+ case "blocked":
4236
+ return `blocked: ${str(e.reason, 60)}`
4237
+ case "steer":
4238
+ return `owner steered: ${str(e.text, 60)}`
4239
+ case "attached":
4240
+ return `goal attached to another session by the owner (from ${str(e.from, 24)})`
4241
+ case "verifier-model":
4242
+ return `verifier model resolved: ${str(e.model, 40)}`
4243
+ case "verifier-empty":
4244
+ return `verifier round ${str(e.round, 4)} was empty — retrying`
4245
+ case "verifier-fallback":
4246
+ return `verifier answered in prose; ${str(e.count, 4)} verdict(s) parsed from it`
4247
+ default: {
4248
+ const extra = e.reason ?? e.error ?? e.note ?? e.text ?? ""
4249
+ return `${str(e.type, 24)}${extra ? `: ${str(extra, 60)}` : ""}`
4250
+ }
4251
+ }
4252
+ }
4253
+
4254
+ /** Build a user message for the context hook from the host's own Message class. */
4255
+ function makeUserMessage(messages: any[], text: string): any {
4256
+ const sample = messages.find((m) => m?.role === "user") ?? messages[0]
4257
+ const Ctor = sample?.constructor
4258
+ if (Ctor && typeof Ctor.user === "function") return Ctor.user(text)
4259
+ return { role: "user", content: [{ type: "text", text }] }
4260
+ }
4261
+
4262
+ function targetPaths(tool: string, input: any): string[] {
4263
+ if (!input || typeof input !== "object") return []
4264
+ if ((tool === "edit" || tool === "write") && typeof input.path === "string") return [input.path]
4265
+ if (tool === "patch" && typeof input.patchText === "string") return [...input.patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)].map((m) => m[1]!.trim())
4266
+ if ((tool === "shell" || tool === "bash" || tool === "execute") && typeof input.command === "string") return shellWriteTargets(input.command)
4267
+ return []
4268
+ }
4269
+
4270
+ /** T108: a shell command or script is another reachable write path into a live
4271
+ * goal folder. Reads must stay allowed, so only a write-shaped use of the path
4272
+ * counts: redirection, tee, in-place edit, move/copy/remove, a truncating
4273
+ * descriptor, or an inline interpreter that writes it. */
4274
+ export function shellWriteTargets(command: string): string[] {
4275
+ const goals = /(?:\.opencode\/goals\/|\/\.opencode\/goals\/)/g
4276
+ const found = new Set<string>()
4277
+ for (const match of command.matchAll(goals)) {
4278
+ const at = match.index! + match[0].length
4279
+ const rest = command.slice(at)
4280
+ const path = /\.opencode\/goals\/([^\s'";|&)>]+)/.exec(match[0] + rest)
4281
+ const relative = path ? `.opencode/goals/${path[1]}` : undefined
4282
+ if (!relative) continue
4283
+ const escaped = relative.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
4284
+ const writeish = [
4285
+ new RegExp(`>>?\\s*['\"]?${escaped}`), // > path, >> path
4286
+ new RegExp(`\\btee\\b[^|;&]*${escaped}`), // tee path
4287
+ new RegExp(`\\bsed\\b[^|;&]*-i[^|;&]*${escaped}`), // sed -i path
4288
+ new RegExp(`\\b(?:mv|cp|install|rm|truncate|touch|chmod|chown|ln|dd)\\b[^|;&]*${escaped}`), // move/remove/replace
4289
+ new RegExp(`[|>]\\s*['\"]?${escaped}`),
4290
+ new RegExp(`\\b(?:python3?|node|bun|deno|ruby|perl|php)\\b[\s\S]*${escaped}[\s\S]*?(?:\\.write\\(|open\\([^)]*['\"][wa]|writeFile|appendFile|createWriteStream|>>?)`),
4291
+ new RegExp(`\\b(?:python3?|node|bun|deno|ruby|perl|php)\\b[\s\S]*?(?:\\.write\\(|open\\([^)]*['\"][wa]|writeFile|appendFile|createWriteStream)[\s\S]*${escaped}`),
4292
+ // open('<path>', 'w') and friends put the write beside the path itself.
4293
+ // A bare open('<path>') is a READ; only a write call or an explicit
4294
+ // write mode counts (the sibling pattern below covers the mode).
4295
+ new RegExp(`(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream|truncate)\\(\\s*['\"]${escaped}['\"]`),
4296
+ new RegExp(`['\"]${escaped}['\"]\\s*,\\s*['\"][wa]`),
4297
+ new RegExp(`\\b(?:python3?|node|bun|deno|ruby|perl|php)\\b[\s\S]*?(?:writeFile|appendFile|createWriteStream|truncate|unlink|rename|copyFile)`),
4298
+ ].some((pattern) => pattern.test(command))
4299
+ if (writeish) found.add(relative)
4300
+ }
4301
+ return [...found]
4302
+ }
4303
+
4304
+ function globMatch(file: string, glob: string): boolean {
4305
+ let re = ""
4306
+ const g = glob.replace(/^\.\//, "")
4307
+ for (let i = 0; i < g.length; i++) {
4308
+ const c = g[i]!
4309
+ if (c === "*") {
4310
+ if (g[i + 1] === "*") {
4311
+ re += g[i + 2] === "/" ? "(?:.*/)?" : ".*"
4312
+ i += g[i + 2] === "/" ? 2 : 1
4313
+ } else re += "[^/]*"
4314
+ } else if (c === "?") re += "[^/]"
4315
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&")
4316
+ }
4317
+ return new RegExp(`^${re}$`).test(file)
4318
+ }
4319
+
4320
+ export type { Status }