@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,454 @@
1
+ // Parses and validates goal.yaml. Every rule here is one the write-goal skill
2
+ // relies on to tell a loop-verifiable goal from a wish.
3
+ import { createHash } from "node:crypto"
4
+ import { parse as parseYamlPortable } from "yaml"
5
+ import {
6
+ AMEND_CLASSES,
7
+ CHECK_KINDS,
8
+ CLEO_GATES,
9
+ type AmendClass,
10
+ type CleoGate,
11
+ type CleoTaskLink,
12
+ type GoalAcRef,
13
+ HOST_KINDS,
14
+ PRIORITIES,
15
+ SCHEMA,
16
+ type Assumption,
17
+ type Autonomy,
18
+ type Budget,
19
+ type Check,
20
+ type Contract,
21
+ type Criterion,
22
+ type Issue,
23
+ type Priority,
24
+ type Step,
25
+ type VerificationMode,
26
+ } from "./types"
27
+
28
+ export type ParseResult = { contract?: Contract; issues: Issue[]; lock: string }
29
+
30
+ const VAGUE = [
31
+ "fast",
32
+ "faster",
33
+ "quick",
34
+ "quickly",
35
+ "robust",
36
+ "clean",
37
+ "cleaner",
38
+ "better",
39
+ "properly",
40
+ "proper",
41
+ "nice",
42
+ "good",
43
+ "efficient",
44
+ "scalable",
45
+ "intuitive",
46
+ "user-friendly",
47
+ "seamless",
48
+ "seamlessly",
49
+ "optimal",
50
+ "optimized",
51
+ "improved",
52
+ "reasonable",
53
+ "appropriate",
54
+ "correctly",
55
+ "works",
56
+ ]
57
+ const ACTIVITY = /^(keep|continue|improve|investigate|explore|work on|look into|try to|help|refactor as needed|clean up)\b/i
58
+ /**
59
+ * v0.5.1 - the three CLEO `satisfies:` AC-reference forms a `proves:` entry may
60
+ * take: `T123#AC2`, `T123#<uuid>` and `T123#AC2@<14-digit pin>`.
61
+ */
62
+ export const AC_REF = /^T\d+#(?:AC\d{1,4}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:@\d{14})?$/
63
+
64
+ export const lockOf = (text: string) => createHash("sha256").update(text).digest("hex")
65
+
66
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
67
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : typeof v === "number" ? String(v) : "")
68
+ const strings = (v: unknown): string[] => (Array.isArray(v) ? v.map(str).filter(Boolean) : typeof v === "string" && v.trim() ? [v.trim()] : [])
69
+
70
+ /** "90m", "3h", "1h30m", "45s", "2d" → ms. A bare number means minutes. */
71
+ export function parseDuration(value: unknown): number | undefined {
72
+ if (typeof value === "number") return value > 0 ? value * 60_000 : undefined
73
+ const text = str(value).toLowerCase().replace(/\s+/g, "")
74
+ if (!text) return undefined
75
+ const units: Record<string, number> = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1000 }
76
+ let total = 0
77
+ let matched = ""
78
+ for (const part of text.matchAll(/(\d+(?:\.\d+)?)([dhms])/g)) {
79
+ total += Number(part[1]) * units[part[2]!]!
80
+ matched += part[0]
81
+ }
82
+ return matched === text && total > 0 ? Math.round(total) : undefined
83
+ }
84
+
85
+ /** "3M", "400k", "1.5m", 250000 → tokens. */
86
+ export function parseCount(value: unknown): number | undefined {
87
+ if (typeof value === "number") return value > 0 ? Math.round(value) : undefined
88
+ const match = /^(\d+(?:\.\d+)?)\s*([kmb])?$/i.exec(str(value))
89
+ if (!match) return undefined
90
+ const scale = { k: 1e3, m: 1e6, b: 1e9 }[(match[2] ?? "").toLowerCase() as "k" | "m" | "b"] ?? 1
91
+ const n = Math.round(Number(match[1]) * scale)
92
+ return n > 0 ? n : undefined
93
+ }
94
+
95
+ function vagueWords(text: string): string[] {
96
+ if (/\d/.test(text)) return []
97
+ const lower = ` ${text.toLowerCase()} `
98
+ return VAGUE.filter((word) => new RegExp(`[^a-z-]${word.replace("-", "\\-")}[^a-z-]`).test(lower))
99
+ }
100
+
101
+ function parseCheck(raw: unknown, path: string, issues: Issue[]): Check | undefined {
102
+ if (!isRecord(raw)) {
103
+ issues.push({ level: "error", path, message: "check is required: say how this criterion is proven (a command, a file test, a search, or a verifier question)" })
104
+ return undefined
105
+ }
106
+ const kind = str(raw.kind)
107
+ if (!(CHECK_KINDS as readonly string[]).includes(kind)) {
108
+ issues.push({ level: "error", path: `${path}.kind`, message: `unknown check kind "${kind}"; use one of ${CHECK_KINDS.join(", ")}` })
109
+ return undefined
110
+ }
111
+ const need = (field: string, value: string) => {
112
+ if (!value) issues.push({ level: "error", path: `${path}.${field}`, message: `${kind} check needs ${field}` })
113
+ return value
114
+ }
115
+ switch (kind) {
116
+ case "command": {
117
+ const expect = isRecord(raw.expect) ? raw.expect : {}
118
+ // v0.8.0 D6: an unimplemented expectation key is an ERROR, never silently
119
+ // dropped. `stderr_contains` used to collapse a criterion to bare
120
+ // `exit: 0`, which passes while proving nothing.
121
+ const supported = ["exit", "stdout_contains", "stdout_regex"] as const
122
+ for (const key of Object.keys(expect))
123
+ if (!(supported as readonly string[]).includes(key))
124
+ issues.push({ level: "error", path: `${path}.expect.${key}`, message: `unknown expectation "${key}"; supported: ${supported.join(", ")}` })
125
+ const out: Check = {
126
+ kind,
127
+ run: need("run", str(raw.run)),
128
+ expect: {
129
+ ...(expect.exit !== undefined ? { exit: Number(expect.exit) } : {}),
130
+ ...(str(expect.stdout_contains) ? { stdout_contains: str(expect.stdout_contains) } : {}),
131
+ ...(str(expect.stdout_regex) ? { stdout_regex: str(expect.stdout_regex) } : {}),
132
+ },
133
+ ...(raw.timeout !== undefined ? { timeout: Number(raw.timeout) } : {}),
134
+ ...(raw.runs !== undefined ? { runs: Math.max(1, Math.min(10, Number(raw.runs) || 1)) } : {}),
135
+ ...(raw.live === true ? { live: true } : {}),
136
+ }
137
+ // T021: a newline in a command check is usually a YAML folded-scalar
138
+ // accident (>- keeps literal newlines before more-indented lines), which
139
+ // made run 0ffe5ac6e001 C3 unrunnable — the host shell executed the
140
+ // skill directory as a command. Intentional multi-line shell is legal,
141
+ // so warn, and always rehearse the exact stored command before claiming.
142
+ if (typeof out.run === "string" && out.run.includes("\n"))
143
+ issues.push({ level: "warning", path: `${path}.run`, message: "command check spans lines: YAML folded scalars (>-) keep literal newlines before more-indented lines, and the host shell will execute each line separately — keep the command on one line unless the newlines are intentional, and rehearse the stored command before claiming" })
144
+ if (out.expect && out.expect.exit === undefined && !out.expect.stdout_contains && !out.expect.stdout_regex) out.expect.exit = 0
145
+ if (out.expect?.stdout_regex) {
146
+ try {
147
+ new RegExp(out.expect.stdout_regex)
148
+ } catch {
149
+ issues.push({ level: "error", path: `${path}.expect.stdout_regex`, message: "not a valid regular expression" })
150
+ }
151
+ }
152
+ if (out.timeout !== undefined && !(out.timeout > 0)) issues.push({ level: "error", path: `${path}.timeout`, message: "timeout must be a positive number of seconds" })
153
+ return out
154
+ }
155
+ case "file":
156
+ return { kind, path: need("path", str(raw.path)), exists: raw.exists !== false }
157
+ case "contains": {
158
+ const text = str(raw.text)
159
+ const regex = str(raw.regex)
160
+ if (!text && !regex) issues.push({ level: "error", path, message: "contains check needs text or regex" })
161
+ return { kind, path: need("path", str(raw.path)), ...(text ? { text } : {}), ...(regex ? { regex } : {}) }
162
+ }
163
+ case "absent":
164
+ return { kind, pattern: need("pattern", str(raw.pattern)), ...(strings(raw.paths).length ? { paths: strings(raw.paths) } : {}) }
165
+ case "diff": {
166
+ const paths = strings(raw.paths)
167
+ if (!paths.length) issues.push({ level: "error", path: `${path}.paths`, message: "diff check needs at least one path" })
168
+ return { kind, paths }
169
+ }
170
+ default:
171
+ return { kind: kind as "verifier" | "human", ask: need("ask", str(raw.ask)) }
172
+ }
173
+ }
174
+
175
+ function parseCriteria(raw: unknown, path: string, prefix: "C" | "I", issues: Issue[]): Criterion[] {
176
+ if (!Array.isArray(raw)) return []
177
+ return raw.flatMap((item, index) => {
178
+ const at = `${path}[${index}]`
179
+ if (!isRecord(item)) {
180
+ issues.push({ level: "error", path: at, message: "must be a mapping with id, statement and check" })
181
+ return []
182
+ }
183
+ const id = str(item.id)
184
+ if (!new RegExp(`^${prefix}\\d+$`).test(id)) issues.push({ level: "error", path: `${at}.id`, message: `id must look like ${prefix}1, ${prefix}2, …` })
185
+ const statement = str(item.statement)
186
+ if (!statement) issues.push({ level: "error", path: `${at}.statement`, message: "statement is required" })
187
+ const vague = vagueWords(statement)
188
+ if (vague.length) issues.push({ level: "warning", path: `${at}.statement`, message: `vague without a number: ${vague.join(", ")} — state the threshold that proves it` })
189
+ const check = parseCheck(item.check, `${at}.check`, issues)
190
+ if (prefix === "I" && check && !(HOST_KINDS as readonly string[]).includes(check.kind))
191
+ issues.push({ level: "error", path: `${at}.check.kind`, message: "invariants must be host-checkable (command, file, contains, absent or diff)" })
192
+ if (!check) return []
193
+ // v0.5.1 - the precise per-criterion CLEO binding: any of the three
194
+ // satisfies forms (bare AC, uuid, or pinned AC).
195
+ const provesRaw = strings(item.proves)
196
+ for (const ref of provesRaw) if (!AC_REF.test(ref)) issues.push({ level: "error", path: `${at}.proves`, message: `AC reference must be one of T123#AC2, T123#<uuid> or T123#AC2@<14-digit pin> (got "${ref}")` })
197
+ const proves = provesRaw.filter((ref) => AC_REF.test(ref))
198
+ return [{ id, statement, essential: prefix === "I" ? true : item.essential !== false, check, ...(proves.length ? { proves: proves as GoalAcRef[] } : {}) }]
199
+ })
200
+ }
201
+
202
+ type YamlParse = (text: string) => unknown
203
+ /** Bun's built-in parser inside OpenCode (unchanged behaviour); the portable `yaml` package on plain Node (the npm bin). */
204
+ const bunYaml = (globalThis as { Bun?: { YAML?: { parse: YamlParse } } }).Bun?.YAML
205
+ const parseYaml: YamlParse = (text) => (bunYaml ? bunYaml.parse(text) : parseYamlPortable(text))
206
+
207
+ export function parseContract(text: string, options: { slug?: string } = {}): ParseResult {
208
+ const issues: Issue[] = []
209
+ const lock = lockOf(text)
210
+ let raw: unknown
211
+ try {
212
+ raw = parseYaml(text)
213
+ } catch (error) {
214
+ issues.push({ level: "error", path: "", message: `not valid YAML: ${error instanceof Error ? error.message : String(error)}${yamlHint(text)}` })
215
+ return { issues, lock }
216
+ }
217
+ if (!isRecord(raw)) {
218
+ issues.push({ level: "error", path: "", message: "goal.yaml must be a mapping" })
219
+ return { issues, lock }
220
+ }
221
+
222
+ const err = (path: string, message: string) => issues.push({ level: "error", path, message })
223
+ const warn = (path: string, message: string) => issues.push({ level: "warning", path, message })
224
+
225
+ if (raw.schema !== SCHEMA) err("schema", `schema must be "${SCHEMA}"`)
226
+ const id = str(raw.id)
227
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) err("id", "id must be a kebab-case slug (a-z, 0-9, hyphens)")
228
+ else if (options.slug && id !== options.slug) err("id", `id "${id}" must match its directory name "${options.slug}"`)
229
+ const title = str(raw.title)
230
+ if (!title) err("title", "title is required")
231
+ else if (title.length > 80) warn("title", "keep the title under 80 characters; it is shown in the sidebar")
232
+
233
+ const intent = isRecord(raw.intent) ? str(raw.intent.verbatim) : str(raw.intent)
234
+ if (!intent) err("intent.verbatim", "record the owner's own words verbatim; they stay the authoritative source")
235
+
236
+ const outcome = str(raw.outcome)
237
+ if (!outcome) err("outcome", "outcome is required: one end state that will be true when the goal is done")
238
+ else {
239
+ if (ACTIVITY.test(outcome)) warn("outcome", "reads as an activity; state the end state that becomes true instead")
240
+ const vague = vagueWords(outcome)
241
+ if (vague.length) warn("outcome", `vague without a number: ${vague.join(", ")}`)
242
+ }
243
+
244
+ const nonGoals = strings(raw.non_goals)
245
+ if (!nonGoals.length) err("non_goals", "list at least one non-goal so the loop does not fill the vacuum")
246
+
247
+ const criteria = parseCriteria(raw.criteria, "criteria", "C", issues)
248
+ if (!Array.isArray(raw.criteria) || !raw.criteria.length) err("criteria", "at least one criterion is required")
249
+ else if (!criteria.some((c) => c.essential)) err("criteria", "at least one criterion must be essential")
250
+ else if (!criteria.some((c) => c.essential && (HOST_KINDS as readonly string[]).includes(c.check.kind)))
251
+ warn("criteria", "no essential criterion is host-checkable; completion will rest on the verifier and the owner")
252
+ const invariants = parseCriteria(raw.invariants, "invariants", "I", issues)
253
+
254
+ const ids = new Set<string>()
255
+ for (const item of [...criteria, ...invariants]) {
256
+ if (ids.has(item.id)) err(item.id, `duplicate id ${item.id}`)
257
+ ids.add(item.id)
258
+ }
259
+
260
+ const assumptions: Assumption[] = (Array.isArray(raw.assumptions) ? raw.assumptions : []).flatMap((item: unknown, index: number) => {
261
+ if (!isRecord(item)) return []
262
+ const aid = str(item.id) || `A${index + 1}`
263
+ if (!/^A\d+$/.test(aid)) err(`assumptions[${index}].id`, "id must look like A1, A2, …")
264
+ const status = str(item.status) || "assumed"
265
+ if (!["assumed", "confirmed", "vetoed"].includes(status)) err(`assumptions[${index}].status`, "status must be assumed, confirmed or vetoed")
266
+ return [{ id: aid, text: str(item.text), ...(str(item.rationale) ? { rationale: str(item.rationale) } : {}), ...(typeof item.reversible === "boolean" ? { reversible: item.reversible } : {}), status: status as Assumption["status"] }]
267
+ })
268
+
269
+ const plan: Step[] = (Array.isArray(raw.plan) ? raw.plan : []).flatMap((item: unknown, index: number) => {
270
+ if (!isRecord(item)) return []
271
+ const sid = str(item.id)
272
+ if (!/^S\d+$/.test(sid)) err(`plan[${index}].id`, "id must look like S1, S2, …")
273
+ return [{ id: sid, title: str(item.title), proves: strings(item.proves), depends_on: strings(item.depends_on) }]
274
+ })
275
+ const stepIds = new Set(plan.map((s) => s.id))
276
+ for (const step of plan) {
277
+ for (const ref of step.proves) if (!criteria.some((c) => c.id === ref)) err(`plan.${step.id}.proves`, `unknown criterion ${ref}`)
278
+ for (const ref of step.depends_on) if (!stepIds.has(ref)) err(`plan.${step.id}.depends_on`, `unknown step ${ref}`)
279
+ if (!step.title) err(`plan.${step.id}.title`, "step title is required")
280
+ }
281
+ if (hasCycle(plan)) err("plan", "step dependencies form a cycle")
282
+
283
+ const budgetRaw = isRecord(raw.budget) ? raw.budget : {}
284
+ const budget: Budget = {}
285
+ if (budgetRaw.turns !== undefined) {
286
+ const turns = Number(budgetRaw.turns)
287
+ if (Number.isInteger(turns) && turns > 0) budget.turns = turns
288
+ else err("budget.turns", "turns must be a positive integer")
289
+ }
290
+ if (budgetRaw.wall !== undefined) {
291
+ const wallMs = parseDuration(budgetRaw.wall)
292
+ if (wallMs) budget.wallMs = wallMs
293
+ else err("budget.wall", 'wall must be a duration such as "90m" or "3h"')
294
+ }
295
+ if (budgetRaw.tokens !== undefined) {
296
+ const tokens = parseCount(budgetRaw.tokens)
297
+ if (tokens) budget.tokens = tokens
298
+ else err("budget.tokens", 'tokens must be a count such as 400k or "3M"')
299
+ }
300
+ if (budgetRaw.cost_usd !== undefined) {
301
+ const cost = Number(budgetRaw.cost_usd)
302
+ if (cost > 0) budget.cost_usd = cost
303
+ else err("budget.cost_usd", "cost_usd must be a positive number")
304
+ }
305
+
306
+ const autonomyRaw = isRecord(raw.autonomy) ? raw.autonomy : {}
307
+ const pick = <T extends string>(path: string, value: unknown, allowed: readonly T[], fallback: T): T => {
308
+ const v = str(value)
309
+ if (!v) return fallback
310
+ if ((allowed as readonly string[]).includes(v)) return v as T
311
+ err(path, `must be one of ${allowed.join(", ")}`)
312
+ return fallback
313
+ }
314
+ const autonomy: Autonomy = {
315
+ questions: pick("autonomy.questions", autonomyRaw.questions, ["defer", "allow"] as const, "defer"),
316
+ on_user_message: pick("autonomy.on_user_message", autonomyRaw.on_user_message, ["steer", "pause"] as const, "steer"),
317
+ on_interrupt: pick("autonomy.on_interrupt", autonomyRaw.on_interrupt, ["pause", "resume-on-message"] as const, "pause"),
318
+ amend: [],
319
+ on_stall: pick("autonomy.on_stall", autonomyRaw.on_stall, ["pause", "continue"] as const, "pause"),
320
+ ...(autonomyRaw.visit_new_projects === true ? { visit_new_projects: true } : {}),
321
+ ...(autonomyRaw.continue_past_budget === true ? { continue_past_budget: true } : {}),
322
+ }
323
+ if (autonomyRaw.amend !== undefined) {
324
+ if (!Array.isArray(autonomyRaw.amend)) err("autonomy.amend", `amend must be a list of ${AMEND_CLASSES.join(", ")}`)
325
+ else for (const value of autonomyRaw.amend) {
326
+ const v = str(value)
327
+ if (v && (AMEND_CLASSES as readonly string[]).includes(v)) { if (!autonomy.amend.includes(v as AmendClass)) autonomy.amend.push(v as AmendClass) }
328
+ else err("autonomy.amend", `unknown change class "${String(value)}"; allowed: ${AMEND_CLASSES.join(", ")}`)
329
+ }
330
+ }
331
+ const verificationRaw = isRecord(raw.verification) ? raw.verification : {}
332
+ const mode = pick<VerificationMode>("verification.mode", verificationRaw.mode, ["host", "host+verifier", "strict"] as const, "host+verifier")
333
+ const maxRejections = verificationRaw.max_rejections === undefined ? 3 : Number(verificationRaw.max_rejections)
334
+ if (!(Number.isInteger(maxRejections) && maxRejections >= 1 && maxRejections <= 10)) err("verification.max_rejections", "max_rejections must be an integer from 1 to 10")
335
+ const verifierRead = strings(verificationRaw.read)
336
+ if (verificationRaw.read !== undefined && !Array.isArray(verificationRaw.read)) err("verification.read", "read must be a list of folders the verifier may read")
337
+ for (const path of verifierRead) if (/[*?[\]{}]/.test(path)) err("verification.read", `"${path}" must be a plain folder or file path, not a glob`)
338
+ // T107: the contract may name the verifier child's model and effort. The
339
+ // strings are kept verbatim; the engine decides validity at run time and
340
+ // falls back with a recorded reason, so a bad value never blocks a launch.
341
+ const verifierModel = str(verificationRaw.model)
342
+ const verifierEffort = str(verificationRaw.effort)
343
+ // T100: an optional exact-run execution workspace (root-relative). A plain
344
+ // directory inside the project — no absolute path, no "..", no glob. The host
345
+ // additionally requires it to be a git work tree before the run starts.
346
+ const workspace = str(raw.workspace)
347
+ if (workspace && (workspace.startsWith("/") || workspace.split("/").includes("..") || /[*?[\]{}]/.test(workspace)))
348
+ err("workspace", `"${workspace}" must be a plain folder inside the project (no absolute path, ".." or glob)`)
349
+
350
+ const scopeRaw = isRecord(raw.scope) ? raw.scope : {}
351
+ const stopRaw = isRecord(raw.stop) ? raw.stop : {}
352
+ const executionRaw = isRecord(raw.execution) ? raw.execution : {}
353
+
354
+ if (issues.some((i) => i.level === "error")) return { issues, lock }
355
+ // T031: supersession pointer — "<slug>@<lock-prefix>", validated but only
356
+ // enforced at /goal start (existence, lock match, mid-flight admission).
357
+ const supersedes = str(raw.supersedes)
358
+ if (supersedes && !/^[a-z0-9][a-z0-9-]{0,63}@[0-9a-f]{8,64}$/.test(supersedes))
359
+ err("supersedes", "must look like \"<slug>@<lock-prefix>\" (the predecessor slug and the first bytes of its run lock)")
360
+ // T061: optional priority, low/medium/high; absent is valid (sorts last).
361
+ const rawPriority = str((raw as Record<string, unknown>).priority)
362
+ if (rawPriority && !PRIORITIES.includes(rawPriority as Priority))
363
+ err("priority", `priority must be one of low, medium, high (or omitted) — got "${rawPriority}"`)
364
+
365
+ // v0.5.0 — the optional CLEO linkage. `cleo.tasks` is a list whose entries are
366
+ // either a bare task id (link for reporting) or `{id, acs}` declaring which
367
+ // gate's evidence proves which AC alias. Absent ⇒ no CLEO behaviour change.
368
+ const cleoTasks: CleoTaskLink[] = []
369
+ if (raw.cleo !== undefined) {
370
+ if (!isRecord(raw.cleo)) err("cleo", "cleo must be a mapping with a tasks list")
371
+ else {
372
+ const tasksRaw = raw.cleo.tasks
373
+ if (tasksRaw !== undefined && !Array.isArray(tasksRaw)) err("cleo.tasks", "tasks must be a list of task ids")
374
+ const seen = new Set<string>()
375
+ for (const [index, entry] of (Array.isArray(tasksRaw) ? tasksRaw : []).entries()) {
376
+ const at = `cleo.tasks[${index}]`
377
+ const taskId = str(typeof entry === "string" ? entry : isRecord(entry) ? entry.id : "")
378
+ if (!/^[A-Z]{1,4}\d+$/.test(taskId)) { err(at, `task id must look like T123 (got "${taskId}")`); continue }
379
+ if (seen.has(taskId)) { err(at, `duplicate task ${taskId}`); continue }
380
+ seen.add(taskId)
381
+ const acsRaw = isRecord(entry) && isRecord((entry as Record<string, unknown>).acs) ? (entry as Record<string, unknown>).acs as Record<string, unknown> : undefined
382
+ const acs: Partial<Record<CleoGate, string[]>> = {}
383
+ if (acsRaw) {
384
+ // v0.5.1 - `acs` is legacy: the precise per-criterion `proves:` binding
385
+ // replaces it. Still read for compatibility, but warned as deprecated.
386
+ warn(`${at}.acs`, "cleo.tasks[].acs is deprecated - declare the binding on the criterion instead, e.g. proves: [\"T123#AC2\"]; the acs map is still read for compatibility")
387
+ for (const [gate, value] of Object.entries(acsRaw)) {
388
+ if (!(CLEO_GATES as readonly string[]).includes(gate)) { err(`${at}.acs.${gate}`, `unknown gate "${gate}"; allowed: ${CLEO_GATES.join(", ")}`); continue }
389
+ const aliases = strings(value).map((a) => a.toUpperCase())
390
+ if (!aliases.length) { err(`${at}.acs.${gate}`, "list at least one AC alias (AC1, AC2, …)"); continue }
391
+ for (const alias of aliases) if (!/^AC\d+$/.test(alias)) err(`${at}.acs.${gate}`, `AC alias must look like AC1 (got "${alias}")`)
392
+ acs[gate as CleoGate] = aliases
393
+ }
394
+ }
395
+ cleoTasks.push({ id: taskId, ...(Object.keys(acs).length ? { acs } : {}) })
396
+ }
397
+ }
398
+ }
399
+
400
+ const contract: Contract = {
401
+ schema: SCHEMA,
402
+ id,
403
+ title,
404
+ ...(rawPriority ? { priority: rawPriority as Priority } : {}),
405
+ ...(supersedes ? { supersedes } : {}),
406
+ intent: { verbatim: intent },
407
+ outcome,
408
+ ...(str(raw.why) ? { why: str(raw.why) } : {}),
409
+ scope: { in: strings(scopeRaw.in), out: strings(scopeRaw.out) },
410
+ non_goals: nonGoals,
411
+ constraints: strings(raw.constraints),
412
+ criteria,
413
+ invariants,
414
+ ...(workspace ? { workspace } : {}),
415
+ ...(cleoTasks.length ? { cleo: { tasks: cleoTasks } } : {}),
416
+ protect: strings(raw.protect),
417
+ assumptions,
418
+ plan,
419
+ budget,
420
+ stop: { ...(str(stopRaw.when) ? { when: str(stopRaw.when) } : {}), escalate_when: strings(stopRaw.escalate_when) },
421
+ autonomy,
422
+ verification: { mode, max_rejections: maxRejections, read: verifierRead, ...(verifierModel ? { model: verifierModel } : {}), ...(verifierEffort ? { effort: verifierEffort } : {}) },
423
+ execution: str(executionRaw.agent) ? { agent: str(executionRaw.agent) } : {},
424
+ meta: isRecord(raw.meta) ? raw.meta : {},
425
+ }
426
+ return { contract, issues, lock }
427
+ }
428
+
429
+ function hasCycle(plan: Step[]): boolean {
430
+ const deps = new Map(plan.map((s) => [s.id, s.depends_on]))
431
+ const state = new Map<string, 1 | 2>()
432
+ const visit = (id: string): boolean => {
433
+ if (state.get(id) === 2) return false
434
+ if (state.get(id) === 1) return true
435
+ state.set(id, 1)
436
+ for (const next of deps.get(id) ?? []) if (deps.has(next) && visit(next)) return true
437
+ state.set(id, 2)
438
+ return false
439
+ }
440
+ return plan.some((s) => visit(s.id))
441
+ }
442
+
443
+ /** Bun's YAML errors carry no position; point at the likeliest culprit. */
444
+ function yamlHint(text: string): string {
445
+ const lines = text.split("\n")
446
+ for (let i = 0; i < lines.length; i++) {
447
+ const value = /^\s*(?:- )?[A-Za-z_][\w-]*:\s+([^'"\[{|>#].*)$/.exec(lines[i]!)?.[1]
448
+ if (value && /:\s/.test(value)) return ` (line ${i + 1} has an unquoted value containing ": " — wrap the value in single quotes)`
449
+ }
450
+ return ""
451
+ }
452
+
453
+ export const formatIssues = (issues: Issue[]) =>
454
+ issues.length ? issues.map((i) => `${i.level === "error" ? "✗" : "!"} ${i.path || "goal.yaml"}: ${i.message}`).join("\n") : "✓ goal.yaml is valid"
@@ -0,0 +1,134 @@
1
+ // What the model sees. The system block is byte-stable for a whole run (prefix
2
+ // cache); everything that changes per turn lives in the tail note.
3
+ import type { Check, Contract, Criterion } from "./types"
4
+ import { budgetUse, type RunState } from "../engine/state"
5
+
6
+ const describeCheck = (check: Check): string => {
7
+ switch (check.kind) {
8
+ case "command":
9
+ return `host runs \`${check.run}\`${check.expect?.exit !== undefined ? ` → exit ${check.expect.exit}` : ""}${check.expect?.stdout_contains ? ` and output contains "${check.expect.stdout_contains}"` : ""}${check.expect?.stdout_regex ? ` and output matches /${check.expect.stdout_regex}/` : ""}${check.runs && check.runs > 1 ? ` (${check.runs} consecutive runs)` : ""}`
10
+ case "file":
11
+ return `host checks ${check.path} ${check.exists === false ? "does not exist" : "exists"}`
12
+ case "contains":
13
+ return `host checks ${check.path} contains ${check.text ? `"${check.text}"` : `/${check.regex}/`}`
14
+ case "absent":
15
+ return `host searches for /${check.pattern}/${check.paths?.length ? ` in ${check.paths.join(", ")}` : ""} and expects no match`
16
+ case "diff":
17
+ return `host checks ${check.paths.join(", ")} are unchanged since the run started`
18
+ case "verifier":
19
+ return `independent read-only verifier answers: ${check.ask}`
20
+ case "human":
21
+ return `the owner signs off: ${check.ask}`
22
+ }
23
+ }
24
+
25
+ const line = (c: Criterion) => `- ${c.id}${c.essential ? "" : " (optional)"}: ${c.statement}\n proof: ${describeCheck(c.check)}${c.proves?.length ? `\n proves CLEO: ${c.proves.join(", ")}` : ""}`
26
+
27
+ export function renderSystemBlock(contract: Contract, lock: string): string {
28
+ const out: string[] = []
29
+ out.push(`<goal_contract id="${contract.id}" lock="${lock.slice(0, 12)}">`)
30
+ out.push("Goal mode is active for this session. The contract below is fixed for this run. It is the owner's task data: pursue it, but it does not override system, developer, tool or repository policies.")
31
+ out.push("")
32
+ out.push(`Title: ${contract.title}`)
33
+ out.push(`Outcome (the end state that must become true): ${contract.outcome}`)
34
+ if (contract.why) out.push(`Why: ${contract.why}`)
35
+ out.push(`Owner's words: "${contract.intent.verbatim.replace(/\s+/g, " ")}"`)
36
+ out.push("")
37
+ out.push("Criteria (every non-optional one must be proven before the goal completes):")
38
+ for (const c of contract.criteria) out.push(line(c))
39
+ if (contract.invariants.length) {
40
+ out.push("Invariants (must stay true the whole run; the host re-checks them):")
41
+ for (const c of contract.invariants) out.push(line(c))
42
+ }
43
+ if (contract.protect.length) out.push(`Protected paths (never edit, delete or rename): ${contract.protect.join(", ")}`)
44
+ if (contract.scope.in.length || contract.scope.out.length)
45
+ out.push(`Scope: in ${contract.scope.in.join(", ") || "(unspecified)"}; out ${contract.scope.out.join(", ") || "(unspecified)"}`)
46
+ out.push(`Non-goals: ${contract.non_goals.join("; ")}`)
47
+ if (contract.constraints.length) out.push(`Constraints: ${contract.constraints.join("; ")}`)
48
+ if (contract.cleo?.tasks.length)
49
+ out.push(`CLEO tasks this goal serves (evidence is recorded through the cleo CLI only; an AC no evidence proves stays open and is reported): ${contract.cleo.tasks.map((t) => (t.acs ? `${t.id} (${Object.entries(t.acs).map(([g, acs]) => `${g}=${(acs ?? []).join("/")}`).join(", ")})` : t.id)).join("; ")}`)
50
+ // v0.5.2 - a multi-task goal's run commits must cite every linked task id, or a
51
+ // later `cleo goal close` cannot bind the ones no commit names.
52
+ const linked = contract.cleo?.tasks.map((t) => t.id) ?? []
53
+ if (linked.length > 1)
54
+ out.push(`Commit citation (REQUIRED, ${linked.length} linked tasks): because this work lands on main with no PR or task branch, every RUN COMMIT message must cite ALL of these ids — ${linked.join(", ")} — e.g. \`feat(${linked.join(",")}): …\` or a trailer listing them. A commit that cites only some of them leaves the rest open with a "neither cites" blocker when the tasks are closed through the bridge.`)
55
+ if (contract.plan.length) {
56
+ out.push("Plan:")
57
+ for (const s of contract.plan)
58
+ out.push(`- ${s.id}: ${s.title}${s.proves.length ? ` (proves ${s.proves.join(", ")})` : ""}${s.depends_on.length ? ` (after ${s.depends_on.join(", ")})` : ""}`)
59
+ }
60
+ const assumed = contract.assumptions.filter((a) => a.status !== "vetoed")
61
+ if (assumed.length) out.push(`Assumptions (binding until the owner vetoes them): ${assumed.map((a) => `${a.id} ${a.text}`).join("; ")}`)
62
+ if (contract.stop.when) out.push(`Done when: ${contract.stop.when}`)
63
+ if (contract.stop.escalate_when.length) out.push(`Stop and call goal_block when: ${contract.stop.escalate_when.join("; ")}`)
64
+ out.push(`Contract file: .opencode/goals/${contract.id}/goal.yaml`)
65
+ out.push("</goal_contract>")
66
+ out.push("")
67
+ out.push("<goal_protocol>")
68
+ out.push("- Work from current evidence. Inspect files and run commands; earlier conversation helps you find things but proves nothing.")
69
+ out.push("- Keep the full outcome. Never narrow success to what is easy, what already exists, or what makes a check pass.")
70
+ out.push("- Every turn, take concrete actions with tools. A turn that only talks or restates status counts as no progress.")
71
+ out.push("- Record progress with goal_progress: the step you are on, one line on what changed, and the next action.")
72
+ out.push("- When you believe every criterion holds, call goal_claim with evidence per criterion, then end your turn. The host runs the checks itself and an independent verifier reviews the rest; you never grade your own work.")
73
+ out.push("- If a criterion is contradictory, impossible or unsafe as written, call goal_flag. Do not edit tests or checks to make them pass.")
74
+ out.push("- If you are blocked on something only the owner can provide, call goal_block with a short stable key. Hard, slow or uncertain work is not blocked.")
75
+ out.push("- To wait on an external process, call goal_wait with the seconds and the reason instead of polling in a loop.")
76
+ out.push("- Never edit the contract or anything under .opencode/goals/, and never touch protected paths.")
77
+ if (contract.autonomy.questions === "defer")
78
+ out.push("- Do not ask the owner questions during the run. Make the most reasonable reversible choice and note it in goal_progress as an assumption, or call goal_block if it truly needs an owner decision.")
79
+ out.push("</goal_protocol>")
80
+ return out.join("\n")
81
+ }
82
+
83
+ const pct = (used: number, limit: number) => `${Math.round((used / limit) * 100)}%`
84
+ const fmtMs = (ms: number) => {
85
+ const m = Math.round(ms / 60000)
86
+ return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`
87
+ }
88
+ export const fmtTokens = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n))
89
+
90
+ export function boardLine(state: RunState, contract: Contract): string {
91
+ return [...contract.criteria, ...contract.invariants]
92
+ .map((c) => {
93
+ const s = state.criteria[c.id]
94
+ const mark = s?.status === "pass" ? "✓" : s?.status === "fail" ? "✗" : s?.status === "claimed" ? "◐" : "·"
95
+ return `${mark} ${c.id}${s?.by ? ` (${s.by})` : ""}`
96
+ })
97
+ .join(" ")
98
+ }
99
+
100
+ export type TailKind = "kickoff" | "continue" | "verdict" | "recovery" | "wrapup" | "resume"
101
+
102
+ export function renderTailNote(state: RunState, contract: Contract, kind: TailKind, now = Date.now()): string {
103
+ const out: string[] = []
104
+ const use = budgetUse(state, contract, now)
105
+ const budget = use.parts.length
106
+ ? use.parts.map((p) => `${p.name} ${p.name === "wall" ? `${fmtMs(p.used)}/${fmtMs(p.limit)}` : p.name === "tokens" ? `${fmtTokens(p.used)}/${fmtTokens(p.limit)}` : p.name === "cost" ? `$${p.used.toFixed(2)}/$${p.limit}` : `${p.used}/${p.limit}`}`).join(" · ")
107
+ : "no budget set"
108
+ out.push(`<goal_status turn="${state.turn}" run="${state.runId}">`)
109
+ const header: Record<TailKind, string> = {
110
+ kickoff: `Start the goal "${contract.title}". Read the contract above, inspect the current state of the repository, then begin with the first unfinished plan step. Take concrete actions now.`,
111
+ continue: "Continue the goal from its current state. Choose the next concrete action toward the outcome and take it with tools.",
112
+ verdict: "The host checked your completion claim and it did not pass. Fix what the verdict below names, then claim again with fresh evidence.",
113
+ recovery: "Your recent turns did not change anything the host can see. Do not restate status. Write down the 1-3 next concrete actions for yourself, then execute the first one with tools now. If you are truly blocked, call goal_block.",
114
+ wrapup: "The goal's budget is used up. Do not start new substantive work. Record where things stand with goal_progress (what is done, what remains, the exact next step), then end your turn.",
115
+ resume: "The owner resumed this goal. Re-read the contract and the current repository state, then continue from the first unfinished step.",
116
+ }
117
+ out.push(header[kind])
118
+ out.push(`Budget: ${budget}`)
119
+ if (use.ratio >= 0.8 && use.ratio < 1 && kind !== "wrapup") out.push(`Budget warning: ${pct(use.ratio, 1)} used. Finish the current step and move toward a claim or a clean handoff.`)
120
+ out.push(`Criteria: ${boardLine(state, contract)}`)
121
+ if (contract.plan.length) out.push(`Plan: ${contract.plan.map((s) => `${s.id} ${state.steps[s.id] ?? "pending"}`).join(" · ")}`)
122
+ if (state.progress) out.push(`Last progress (turn ${state.progress.turn}): ${state.progress.note}${state.progress.next ? ` → next: ${state.progress.next}` : ""}`)
123
+ if (state.verdict && !state.verdict.passed && (kind === "verdict" || state.verdict.turn >= state.turn - 1)) {
124
+ out.push(`HOST VERDICT (turn ${state.verdict.turn}):`)
125
+ for (const l of state.verdict.lines) out.push(` ${l}`)
126
+ }
127
+ if (state.blocker && state.blocker.count > 0) out.push(`Reported blocker "${state.blocker.key}" ×${state.blocker.count}: ${state.blocker.reason}. Look for independent work that does not depend on it before reporting it again.`)
128
+ if (state.compacted) out.push(`Context was compacted. Before acting, re-read .opencode/goals/${contract.id}/goal.yaml and the recent entries of .opencode/goals/${contract.id}/ledger.jsonl; do not redo finished work.`)
129
+ out.push("</goal_status>")
130
+ return out.join("\n")
131
+ }
132
+
133
+ export const triggerText = (state: RunState, contract: Contract, kind: TailKind) =>
134
+ kind === "kickoff" ? `◎ goal started: ${contract.title}` : kind === "wrapup" ? `◎ goal budget reached — wrap up (turn ${state.turn})` : `↻ goal turn ${state.turn}${kind === "verdict" ? " — host verdict" : kind === "recovery" ? " — recovery" : kind === "resume" ? " — resumed" : ""}`