@cleocode/goal 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +319 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/cli/ocg.ts +149 -0
- package/dist/cli.js +8708 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +1920 -0
- package/package.json +79 -4
- package/rpc.ts +2 -0
- package/server.ts +3 -0
- package/skills/write-goal/SKILL.md +211 -0
- package/skills/write-goal/assets/goal.template.yaml +38 -0
- package/skills/write-goal/references/examples.md +283 -0
- package/skills/write-goal/references/goal-schema.md +53 -0
- package/skills/write-goal/references/interview.md +36 -0
- package/src/cli/install.ts +125 -0
- package/src/cli/status.ts +116 -0
- package/src/contract/parse.ts +454 -0
- package/src/contract/render.ts +134 -0
- package/src/contract/types.ts +158 -0
- package/src/engine/amendment.ts +51 -0
- package/src/engine/amendments.ts +72 -0
- package/src/engine/cleo-bridge.ts +339 -0
- package/src/engine/cleo-link.ts +150 -0
- package/src/engine/continue-work.ts +131 -0
- package/src/engine/decisions.ts +44 -0
- package/src/engine/dogfood-record.ts +278 -0
- package/src/engine/handoff.ts +102 -0
- package/src/engine/interrupt-policy.ts +45 -0
- package/src/engine/inventory.ts +70 -0
- package/src/engine/launches.ts +82 -0
- package/src/engine/loop-stop.ts +50 -0
- package/src/engine/loop.ts +109 -0
- package/src/engine/next-work.ts +82 -0
- package/src/engine/owner-intent.ts +71 -0
- package/src/engine/owner-reviews.ts +178 -0
- package/src/engine/peer-announce.ts +67 -0
- package/src/engine/peer-transport.ts +78 -0
- package/src/engine/policy.ts +42 -0
- package/src/engine/registry.ts +119 -0
- package/src/engine/similarity.ts +65 -0
- package/src/engine/stale-decisions.ts +68 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/store.ts +263 -0
- package/src/engine/tick-record.ts +129 -0
- package/src/engine/tick.ts +207 -0
- package/src/rpc.ts +182 -0
- package/src/server/app.ts +4320 -0
- package/src/server/help.ts +50 -0
- package/src/server/index.ts +23 -0
- package/src/server/intent.ts +22 -0
- package/src/server/messages.ts +228 -0
- package/src/summary/audit.ts +76 -0
- package/src/summary/build.ts +314 -0
- package/src/tui/dashboard.ts +322 -0
- package/src/tui/decision-form.ts +12 -0
- package/src/tui/format.ts +127 -0
- package/src/tui/index.tsx +718 -0
- package/src/tui/manager.ts +94 -0
- package/src/tui/navigation.ts +11 -0
- package/src/tui/pointer-select.ts +20 -0
- package/src/tui/tracer.ts +51 -0
- package/src/util/git.ts +102 -0
- package/src/util/ids.ts +20 -0
- package/src/util/shell.ts +42 -0
- package/src/verify/artifact.ts +95 -0
- package/src/verify/pipeline.ts +200 -0
- package/tui.tsx +2 -0
|
@@ -0,0 +1,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 }
|