@cleocode/goal 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +319 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/cli/ocg.ts +149 -0
  5. package/dist/cli.js +8708 -0
  6. package/dist/tui.d.ts +2 -0
  7. package/dist/tui.js +1920 -0
  8. package/package.json +79 -4
  9. package/rpc.ts +2 -0
  10. package/server.ts +3 -0
  11. package/skills/write-goal/SKILL.md +211 -0
  12. package/skills/write-goal/assets/goal.template.yaml +38 -0
  13. package/skills/write-goal/references/examples.md +283 -0
  14. package/skills/write-goal/references/goal-schema.md +53 -0
  15. package/skills/write-goal/references/interview.md +36 -0
  16. package/src/cli/install.ts +125 -0
  17. package/src/cli/status.ts +116 -0
  18. package/src/contract/parse.ts +454 -0
  19. package/src/contract/render.ts +134 -0
  20. package/src/contract/types.ts +158 -0
  21. package/src/engine/amendment.ts +51 -0
  22. package/src/engine/amendments.ts +72 -0
  23. package/src/engine/cleo-bridge.ts +339 -0
  24. package/src/engine/cleo-link.ts +150 -0
  25. package/src/engine/continue-work.ts +131 -0
  26. package/src/engine/decisions.ts +44 -0
  27. package/src/engine/dogfood-record.ts +278 -0
  28. package/src/engine/handoff.ts +102 -0
  29. package/src/engine/interrupt-policy.ts +45 -0
  30. package/src/engine/inventory.ts +70 -0
  31. package/src/engine/launches.ts +82 -0
  32. package/src/engine/loop-stop.ts +50 -0
  33. package/src/engine/loop.ts +109 -0
  34. package/src/engine/next-work.ts +82 -0
  35. package/src/engine/owner-intent.ts +71 -0
  36. package/src/engine/owner-reviews.ts +178 -0
  37. package/src/engine/peer-announce.ts +67 -0
  38. package/src/engine/peer-transport.ts +78 -0
  39. package/src/engine/policy.ts +42 -0
  40. package/src/engine/registry.ts +119 -0
  41. package/src/engine/similarity.ts +65 -0
  42. package/src/engine/stale-decisions.ts +68 -0
  43. package/src/engine/state.ts +185 -0
  44. package/src/engine/store.ts +263 -0
  45. package/src/engine/tick-record.ts +129 -0
  46. package/src/engine/tick.ts +207 -0
  47. package/src/rpc.ts +182 -0
  48. package/src/server/app.ts +4320 -0
  49. package/src/server/help.ts +50 -0
  50. package/src/server/index.ts +23 -0
  51. package/src/server/intent.ts +22 -0
  52. package/src/server/messages.ts +228 -0
  53. package/src/summary/audit.ts +76 -0
  54. package/src/summary/build.ts +314 -0
  55. package/src/tui/dashboard.ts +322 -0
  56. package/src/tui/decision-form.ts +12 -0
  57. package/src/tui/format.ts +127 -0
  58. package/src/tui/index.tsx +718 -0
  59. package/src/tui/manager.ts +94 -0
  60. package/src/tui/navigation.ts +11 -0
  61. package/src/tui/pointer-select.ts +20 -0
  62. package/src/tui/tracer.ts +51 -0
  63. package/src/util/git.ts +102 -0
  64. package/src/util/ids.ts +20 -0
  65. package/src/util/shell.ts +42 -0
  66. package/src/verify/artifact.ts +95 -0
  67. package/src/verify/pipeline.ts +200 -0
  68. package/tui.tsx +2 -0
@@ -0,0 +1,207 @@
1
+ // v0.7.0 - the `ocg tick` entry point: RECORD the turn, DECIDE, then act.
2
+ //
3
+ // CLEO's Stop dispatcher spawns `ocg tick` with the harness's Stop JSON on stdin
4
+ // and reads JSON ONLY from stdout:
5
+ // {"decision":"block","reason":"<continuation>"} keep the agent working
6
+ // {"final":false} hold the session open
7
+ // anything else (including non-JSON, or nothing) no live goal
8
+ // Logs go to stderr. It runs only where `.opencode/goals/` exists.
9
+ //
10
+ // v0.6.0 made the tick decide; it was read-only, so nothing advanced the counters
11
+ // the stall, recovery, wrap-up and claim decisions read - a goal could never be
12
+ // driven to an end outside OpenCode. v0.7.0 records the turn first (through OCG's
13
+ // own store, in the adapter's own shape) and RUNS the verification of a claim, so
14
+ // a host-only goal can actually reach `complete`. Everything it records is what a
15
+ // Stop-time observer can know or recover from the payload - never a guess, and a
16
+ // missing payload, transcript or store is a stated degradation, not an error.
17
+
18
+ import { existsSync } from "node:fs"
19
+ import { join } from "node:path"
20
+ import type { Contract } from "../contract/types"
21
+ import { renderTailNote } from "../contract/render"
22
+ import { budgetUse, setStatus, TERMINAL as TERMINAL_STATUSES, type RunState } from "./state"
23
+ import { continuationTail, decide, type LoopLimits, type LoopSignals } from "./loop"
24
+ import { recordTurn } from "./tick-record"
25
+ import type { VerifyOutcome } from "../verify/pipeline"
26
+
27
+ /** The plugin's own defaults, so a tick agrees with the adapter unless overridden. */
28
+ export const TICK_LIMITS: LoopLimits = { blockerRepeats: 3, stallTurns: 3, recoveryAt: 2, backstopTurns: 200 }
29
+ export const TICK_COOLDOWN_MS = 1_500
30
+ /** CLEO caps a forwarded reason at 4000 characters. */
31
+ export const REASON_CAP = 4_000
32
+
33
+ /** The store surface the tick writes through - OCG's own store, never a hand edit. */
34
+ export type TickStore = {
35
+ slugs: () => string[]
36
+ readRun: (slug: string) => RunState | undefined
37
+ writeRun: (state: RunState) => void
38
+ readContract: (slug: string) => { contract?: Contract; text: string } | undefined
39
+ ledger: (slug: string, event: Record<string, unknown> & { type: string }) => void
40
+ evidence: (slug: string, runId: string, name: string, data: unknown) => string
41
+ }
42
+
43
+ export type TickDeps = {
44
+ root: string
45
+ store: TickStore
46
+ /** The repository fingerprint now (git), or undefined when it cannot be taken. */
47
+ fingerprint?: () => string | undefined
48
+ /** Run the claim verification. Absent means the tick cannot finish a goal. */
49
+ verify?: (state: RunState, contract: Contract, contractText: string) => Promise<VerifyOutcome>
50
+ /** The raw harness Stop payload, when the harness passed one. */
51
+ payload?: string
52
+ readTranscript?: (path: string) => string | undefined
53
+ limits?: LoopLimits
54
+ cooldownMs?: number
55
+ now?: number
56
+ }
57
+
58
+ export type TickResult = {
59
+ /** Exactly what goes to stdout (empty when no goal is live). */
60
+ stdout: string
61
+ /** Lines for stderr. */
62
+ log: string[]
63
+ }
64
+
65
+ const BLOCK = (reason: string) => JSON.stringify({ decision: "block", reason })
66
+ const HOLD = JSON.stringify({ final: false })
67
+ const why = (error: unknown): string => (error instanceof Error ? error.message : String(error))
68
+
69
+ /** The newest non-terminal run in the project, by `updatedAt`.
70
+ *
71
+ * An absent or unreadable store is a stated degradation, never an exception: a
72
+ * crash looks exactly like "no live goal" to the dispatcher (empty stdout), which
73
+ * would silently drop the run. If a record cannot be read we cannot rule out a
74
+ * live goal, so the caller HOLDS rather than printing nothing. */
75
+ function liveRun(store: TickStore, log: string[]): { live?: RunState; unreadable: boolean } {
76
+ let slugs: string[]
77
+ try {
78
+ slugs = store.slugs()
79
+ } catch (error) {
80
+ log.push(`could not read the run store (${why(error)}); cannot rule out a live goal`)
81
+ return { unreadable: true }
82
+ }
83
+ let live: RunState | undefined
84
+ let unreadable = false
85
+ for (const slug of slugs) {
86
+ let state: RunState | undefined
87
+ try {
88
+ state = store.readRun(slug)
89
+ } catch (error) {
90
+ log.push(`could not read the run record for ${slug} (${why(error)}); cannot rule it out`)
91
+ unreadable = true
92
+ continue
93
+ }
94
+ if (!state?.slug || TERMINAL_STATUSES.includes(state.status)) continue
95
+ if (!live || (state.updatedAt ?? 0) > (live.updatedAt ?? 0)) live = state
96
+ }
97
+ return { live, unreadable }
98
+ }
99
+
100
+ export async function tick(deps: TickDeps): Promise<TickResult> {
101
+ const log: string[] = []
102
+ const now = deps.now ?? Date.now()
103
+ const limits = deps.limits ?? TICK_LIMITS
104
+ // Outside a goal project there is nothing to tick at all - print nothing.
105
+ if (!existsSync(join(deps.root, ".opencode", "goals"))) {
106
+ log.push("no .opencode/goals directory; nothing to tick")
107
+ return { stdout: "", log }
108
+ }
109
+ const { live, unreadable } = liveRun(deps.store, log)
110
+ if (!live && unreadable) {
111
+ log.push("a run record could not be read; cannot rule out a live goal - holding")
112
+ return { stdout: HOLD, log }
113
+ }
114
+ if (!live) {
115
+ log.push("no live goal here; nothing to tick")
116
+ return { stdout: "", log }
117
+ }
118
+ let read: { contract?: Contract; text: string } | undefined
119
+ try {
120
+ read = deps.store.readContract(live.slug)
121
+ } catch (error) {
122
+ log.push(`could not read the contract for ${live.slug} (${why(error)}); holding`)
123
+ return { stdout: HOLD, log }
124
+ }
125
+ if (!read?.contract) {
126
+ log.push(`goal ${live.slug} has no readable contract; holding`)
127
+ return { stdout: HOLD, log }
128
+ }
129
+ const contract = read.contract
130
+
131
+ // ── 1. RECORD the turn, through OCG's own store, in the adapter's own shape.
132
+ const record = recordTurn(live, contract, { fingerprint: deps.fingerprint ?? (() => undefined), ...(deps.payload ? { payload: deps.payload } : {}), ...(deps.readTranscript ? { readTranscript: deps.readTranscript } : {}), now })
133
+ deps.store.ledger(live.slug, {
134
+ type: "turn",
135
+ turn: live.turn,
136
+ kind: "tick",
137
+ tools: live.turnFacts.tools,
138
+ activity: live.turnFacts.tools > 0,
139
+ changed: record.changed,
140
+ progress: record.progress,
141
+ noProgress: live.counters.noProgress,
142
+ })
143
+ deps.store.writeRun(live)
144
+ log.push(`recorded turn ${record.turn} for ${live.slug} (${record.progress ? "progress" : "no progress"}${record.claimHonoured ? ", claim honoured" : ""})`)
145
+ for (const gap of record.couldNotSee) log.push(`could not see ${gap}`)
146
+
147
+ // ── 2. DECIDE, with the same pure function the OpenCode adapter uses.
148
+ const signals: LoopSignals = {
149
+ turn: "succeeded",
150
+ ...(live.pending ? { pending: live.pending.kind } : {}),
151
+ changed: record.changed,
152
+ progressRecorded: record.progress,
153
+ stepsChanged: false,
154
+ activeStalled: false,
155
+ stallReason: `stalled: no change the host could see for ${live.counters.noProgress} turns`,
156
+ onStall: contract.autonomy.on_stall,
157
+ budgetRatio: budgetUse(live, contract).ratio,
158
+ backstop: false,
159
+ cooldownMs: deps.cooldownMs ?? TICK_COOLDOWN_MS,
160
+ now,
161
+ }
162
+ const action = decide(live, signals, limits)
163
+
164
+ // ── 3. ACT.
165
+ switch (action.kind) {
166
+ case "verify": {
167
+ if (!deps.verify) {
168
+ log.push("a claim is pending but this tick cannot verify; holding")
169
+ return { stdout: HOLD, log }
170
+ }
171
+ live.claim = undefined
172
+ setStatus(live, "verifying", "tick verification")
173
+ deps.store.ledger(live.slug, { type: "verify-start", source: "claim", turn: live.turn })
174
+ deps.store.writeRun(live)
175
+ const outcome = await deps.verify(live, contract, read.text)
176
+ for (const r of outcome.results) live.criteria[r.id] = { status: r.pass ? "pass" : "fail", by: r.by, at: now, detail: r.detail.slice(0, 600), rejections: live.criteria[r.id]?.rejections ?? 0 }
177
+ live.verdict = { passed: outcome.passed, lines: outcome.lines.slice(0, 20), at: now, turn: live.turn }
178
+ deps.store.evidence(live.slug, live.runId, `verify-tick-${live.turn}`, outcome)
179
+ if (outcome.passed) {
180
+ setStatus(live, "complete", "all required criteria verified")
181
+ deps.store.ledger(live.slug, { type: "complete", turn: live.turn })
182
+ deps.store.writeRun(live)
183
+ log.push(`goal ${live.slug} complete: ${outcome.lines[0] ?? "all required criteria verified"}`)
184
+ return { stdout: "", log } // no live goal remains; the harness session may end
185
+ }
186
+ // Not proven: keep the run going and hand the agent the verdict.
187
+ setStatus(live, "running")
188
+ deps.store.writeRun(live)
189
+ log.push(`goal ${live.slug} did not pass verification: ${outcome.lines.slice(0, 2).join(" ").slice(0, 200)}`)
190
+ return { stdout: BLOCK(renderTailNote(live, contract, "verdict", now).slice(0, REASON_CAP)), log }
191
+ }
192
+ case "admit":
193
+ case "schedule": {
194
+ const tail = continuationTail(action)!
195
+ deps.store.writeRun(live)
196
+ const reason = renderTailNote(live, contract, tail, now).slice(0, REASON_CAP)
197
+ log.push(`goal ${live.slug}: decision ${action.kind}; continuing with the ${tail} continuation (${reason.length} chars)`)
198
+ return { stdout: BLOCK(reason), log }
199
+ }
200
+ default: {
201
+ if (action.kind === "stop") setStatus(live, action.status, action.reason)
202
+ deps.store.writeRun(live)
203
+ log.push(`goal ${live.slug}: decision ${action.kind}${action.kind === "stop" ? ` (${action.code})` : ""}; holding the session`)
204
+ return { stdout: HOLD, log }
205
+ }
206
+ }
207
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,182 @@
1
+ // Shared server↔TUI contract. A plain object (Rpc.define is an identity
2
+ // function), so neither side needs @opencode/plugin at runtime.
3
+ const anyObject = { type: "object", additionalProperties: true } as const
4
+ import type { Handoff } from "./engine/state"
5
+ import type { LaunchView } from "./engine/launches"
6
+ export type { LaunchView } from "./engine/launches"
7
+ // V2 RPC's schema adapter rejects regex patterns. Length is declared here;
8
+ // the host validates hex syntax AND exact target identity before execution.
9
+ const targetObject = { type: "object", properties: { key: { type: "string", minLength: 1, maxLength: 256 }, lock: { type: "string", minLength: 64, maxLength: 64 }, runId: { type: "string", minLength: 1, maxLength: 128 }, sessionID: { type: "string", minLength: 1, maxLength: 256 } }, required: ["key", "lock"], additionalProperties: false } as const
10
+
11
+ export const GoalRpc = {
12
+ id: "opencode-goal",
13
+ methods: {
14
+ provenance: {
15
+ input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 } }, additionalProperties: false },
16
+ output: anyObject,
17
+ },
18
+ snapshot: {
19
+ input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 } }, required: ["sessionID"], additionalProperties: false },
20
+ output: anyObject,
21
+ },
22
+ list: {
23
+ input: { type: "object", properties: {}, additionalProperties: false },
24
+ output: anyObject,
25
+ },
26
+ details: {
27
+ input: { type: "object", properties: { key: { type: "string", minLength: 1, maxLength: 256 } }, required: ["key"], additionalProperties: false },
28
+ output: anyObject,
29
+ },
30
+ review: {
31
+ input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 }, decisionId: { type: "string", minLength: 1, maxLength: 256 } }, required: ["sessionID"], additionalProperties: false },
32
+ output: anyObject,
33
+ },
34
+ manage: {
35
+ input: { type: "object", properties: { sessionID: { type: "string", minLength: 1, maxLength: 256 }, action: { type: "string", enum: ["pause", "resume", "verify", "attach", "abort", "archive"] }, target: targetObject }, required: ["sessionID", "action", "target"], additionalProperties: false },
36
+ output: anyObject,
37
+ },
38
+ act: {
39
+ input: {
40
+ type: "object",
41
+ properties: {
42
+ sessionID: { type: "string", minLength: 1, maxLength: 256 },
43
+ action: { type: "string", enum: ["pause", "resume", "abort", "verify", "approve", "reject", "amend", "archive", "attach", "start", "start-next", "decompose"] },
44
+ arg: { type: "string", maxLength: 2000 },
45
+ decisionId: { type: "string", minLength: 1, maxLength: 256 },
46
+ },
47
+ required: ["sessionID", "action"],
48
+ additionalProperties: false,
49
+ },
50
+ output: anyObject,
51
+ },
52
+ },
53
+ events: {
54
+ handoff: { schema: { type: "object", properties: { fromSessionID: { type: "string" }, toSessionID: { type: "string" }, slug: { type: "string" }, runId: { type: "string" }, fallback: { type: "string" } }, required: ["fromSessionID", "toSessionID", "slug", "runId"], additionalProperties: false } },
55
+ updated: { schema: { type: "object", properties: { sessionID: { type: "string" }, view: anyObject }, required: ["sessionID"], additionalProperties: true } },
56
+ notice: { schema: { type: "object", properties: { sessionID: { type: "string" }, level: { type: "string" }, text: { type: "string" }, attention: { type: "string" } }, required: ["text"], additionalProperties: true } },
57
+ summary: { schema: { type: "object", properties: { sessionID: { type: "string" }, slug: { type: "string" }, status: { type: "string" }, headline: { type: "string" }, title: { type: "string" }, digest: { type: "string" }, text: { type: "string" } }, required: ["sessionID", "text"], additionalProperties: true } },
58
+ decision: {
59
+ schema: {
60
+ type: "object",
61
+ properties: {
62
+ sessionID: { type: "string" },
63
+ slug: { type: "string" },
64
+ kind: { type: "string", enum: ["needs_review", "paused-after-verdict", "blocked", "budget_limited", "amend-proposed", "supersede-ack", "complete", "start-picker", "owner-operation"] },
65
+ title: { type: "string" },
66
+ message: { type: "string" },
67
+ question: { type: "string" },
68
+ expiresAt: { type: "number" },
69
+ choices: { type: "array", items: { type: "object", properties: { label: { type: "string" }, act: { type: "string" }, arg: { type: "string" }, run: { type: "string" } }, required: ["label"], additionalProperties: true } },
70
+ },
71
+ required: ["sessionID", "kind", "message", "choices"],
72
+ additionalProperties: true,
73
+ },
74
+ },
75
+ // T075: whichever surface answered a decision (native form or TUI
76
+ // dialog) — live TUIs close their open dialog on it (suppression).
77
+ "decision.resolved": {
78
+ schema: {
79
+ type: "object",
80
+ properties: {
81
+ sessionID: { type: "string" },
82
+ kind: { type: "string" },
83
+ decisionId: { type: "string" },
84
+ via: { type: "string", enum: ["form", "dialog", "form-guidance", "amendment-invalidated"] },
85
+ },
86
+ required: ["sessionID", "kind", "via"],
87
+ additionalProperties: true,
88
+ },
89
+ },
90
+ "decision.failed": {
91
+ schema: { type: "object", properties: { sessionID: { type: "string" }, decisionId: { type: "string" }, message: { type: "string" } }, required: ["sessionID", "decisionId", "message"], additionalProperties: false },
92
+ },
93
+ },
94
+ } as const
95
+
96
+ export type CriterionView = {
97
+ id: string
98
+ statement: string
99
+ essential: boolean
100
+ invariant: boolean
101
+ kind: string
102
+ status: "unknown" | "pass" | "fail" | "claimed"
103
+ by?: string
104
+ detail?: string
105
+ at?: number
106
+ }
107
+
108
+ export type TimelineEntry = { t: number; kind: string; text: string }
109
+
110
+ export type GoalView = {
111
+ slug: string
112
+ title: string
113
+ outcome: string
114
+ status: string
115
+ reason?: string
116
+ runId: string
117
+ /** Actual run identity for correlating admission attempts, not version labels. */
118
+ sessionID?: string
119
+ lock?: string
120
+ /** T106: the tuple goal_manage accepts, so a caller never loses a round to
121
+ * discovery after a generation change — key, runId, sessionID and lock. */
122
+ target?: GoalTarget
123
+ turn: number
124
+ timeline: TimelineEntry[]
125
+ activeMs: number
126
+ activeSince: number | null
127
+ usage: { tokens: number; cost: number }
128
+ budget: Array<{ name: string; used: number; limit: number }>
129
+ budgetRatio: number
130
+ criteria: CriterionView[]
131
+ steps: Array<{ id: string; title: string; status: string }>
132
+ progress?: { note: string; next?: string; at: number; turn: number }
133
+ verdict?: { passed: boolean; lines: string[]; at: number; turn: number }
134
+ blocker?: { key: string; count: number; reason: string; needs?: string }
135
+ wait?: { until: number; reason: string }
136
+ awaitingUser: boolean
137
+ /** T052: persistent owner-action-required state — stays until the status resolves. */
138
+ actionRequired?: string
139
+ /** T053: what the engine is doing now — the live activity tracer's feed. */
140
+ activity?: { kind: "turn" | "verifying" | "verifier-child" | "cooldown" | "waiting"; since: number; until?: number; detail?: string }
141
+ amendments: number
142
+ flags: number
143
+ updatedAt: number
144
+ }
145
+
146
+ export type GoalSummary = {
147
+ slug: string
148
+ title: string
149
+ status: string
150
+ priority?: "low" | "medium" | "high"
151
+ sessionID?: string
152
+ proven: number
153
+ total: number
154
+ terminal?: boolean
155
+ attachable?: boolean
156
+ /** Distinguishes live and archived generations even if their slugs match. */
157
+ key?: string
158
+ archived?: boolean
159
+ valid?: boolean
160
+ runId?: string
161
+ lock?: string
162
+ updatedAt?: number
163
+ outcome?: string
164
+ progress?: RunProgress
165
+ turn?: number
166
+ steps?: Array<{ id: string; title: string; status: string }>
167
+ /** Raw existing contract metadata: never inferred from CLEO task IDs. */
168
+ meta?: Record<string, unknown>
169
+ supersedes?: string
170
+ configuredAgent?: string
171
+ agent?: string
172
+ sessionTitle?: string
173
+ sessionState?: "present" | "elsewhere" | "missing" | "unknown" | "unassigned"
174
+ issues?: string[]
175
+ details?: { criteria: CriterionView[]; constraints: string[]; nonGoals: string[] }
176
+ handoff?: Handoff
177
+ /** Latest exact launch review/admission receipt, distinct from run status. */
178
+ launch?: LaunchView
179
+ }
180
+ export type GoalTarget = { key: string; lock: string; runId?: string; sessionID?: string }
181
+ export type ManageAction = "pause" | "resume" | "verify" | "attach" | "abort" | "archive"
182
+ type RunProgress = { note: string; next?: string; at: number; turn: number }