@cleocode/goal 0.0.0-stage → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/cli/ocg.ts +149 -0
  5. package/dist/cli.js +8708 -0
  6. package/dist/tui.d.ts +2 -0
  7. package/dist/tui.js +1920 -0
  8. package/package.json +79 -4
  9. package/rpc.ts +2 -0
  10. package/server.ts +3 -0
  11. package/skills/write-goal/SKILL.md +211 -0
  12. package/skills/write-goal/assets/goal.template.yaml +38 -0
  13. package/skills/write-goal/references/examples.md +283 -0
  14. package/skills/write-goal/references/goal-schema.md +53 -0
  15. package/skills/write-goal/references/interview.md +36 -0
  16. package/src/cli/install.ts +125 -0
  17. package/src/cli/status.ts +116 -0
  18. package/src/contract/parse.ts +454 -0
  19. package/src/contract/render.ts +134 -0
  20. package/src/contract/types.ts +158 -0
  21. package/src/engine/amendment.ts +51 -0
  22. package/src/engine/amendments.ts +72 -0
  23. package/src/engine/cleo-bridge.ts +339 -0
  24. package/src/engine/cleo-link.ts +150 -0
  25. package/src/engine/continue-work.ts +131 -0
  26. package/src/engine/decisions.ts +44 -0
  27. package/src/engine/dogfood-record.ts +278 -0
  28. package/src/engine/handoff.ts +102 -0
  29. package/src/engine/interrupt-policy.ts +45 -0
  30. package/src/engine/inventory.ts +70 -0
  31. package/src/engine/launches.ts +82 -0
  32. package/src/engine/loop-stop.ts +50 -0
  33. package/src/engine/loop.ts +109 -0
  34. package/src/engine/next-work.ts +82 -0
  35. package/src/engine/owner-intent.ts +71 -0
  36. package/src/engine/owner-reviews.ts +178 -0
  37. package/src/engine/peer-announce.ts +67 -0
  38. package/src/engine/peer-transport.ts +78 -0
  39. package/src/engine/policy.ts +42 -0
  40. package/src/engine/registry.ts +119 -0
  41. package/src/engine/similarity.ts +65 -0
  42. package/src/engine/stale-decisions.ts +68 -0
  43. package/src/engine/state.ts +185 -0
  44. package/src/engine/store.ts +263 -0
  45. package/src/engine/tick-record.ts +129 -0
  46. package/src/engine/tick.ts +207 -0
  47. package/src/rpc.ts +182 -0
  48. package/src/server/app.ts +4320 -0
  49. package/src/server/help.ts +50 -0
  50. package/src/server/index.ts +23 -0
  51. package/src/server/intent.ts +22 -0
  52. package/src/server/messages.ts +228 -0
  53. package/src/summary/audit.ts +76 -0
  54. package/src/summary/build.ts +314 -0
  55. package/src/tui/dashboard.ts +322 -0
  56. package/src/tui/decision-form.ts +12 -0
  57. package/src/tui/format.ts +127 -0
  58. package/src/tui/index.tsx +718 -0
  59. package/src/tui/manager.ts +94 -0
  60. package/src/tui/navigation.ts +11 -0
  61. package/src/tui/pointer-select.ts +20 -0
  62. package/src/tui/tracer.ts +51 -0
  63. package/src/util/git.ts +102 -0
  64. package/src/util/ids.ts +20 -0
  65. package/src/util/shell.ts +42 -0
  66. package/src/verify/artifact.ts +95 -0
  67. package/src/verify/pipeline.ts +200 -0
  68. package/tui.tsx +2 -0
@@ -0,0 +1,94 @@
1
+ import type { GoalSummary, GoalTarget, LaunchView, ManageAction } from "../rpc"
2
+ import { priorityWeight } from "../contract/types"
3
+ import type { Tone } from "./format"
4
+
5
+ const terminal = new Set(["complete", "failed", "aborted", "superseded"])
6
+ export const goalKey = (goal: GoalSummary) => goal.key ?? `${goal.archived ? "archive" : "live"}:${goal.slug}:${goal.runId ?? "draft"}`
7
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
8
+ const columns = (text: string) => Bun.stringWidth(text, { ambiguousIsNarrow: false })
9
+
10
+ /** Column-fit, not UTF-16-length-fit. Don't split combining characters or
11
+ * emoji sequences; full labels remain available in the manager details. */
12
+ export function fitColumns(text: string, width: number): string {
13
+ const flat = text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim()
14
+ const limit = Math.max(1, Math.floor(width))
15
+ if (columns(flat) <= limit) return flat
16
+ const suffix = columns("…") <= limit ? "…" : "."
17
+ let out = ""
18
+ for (const { segment } of graphemes.segment(flat)) {
19
+ if (columns(out + segment + suffix) > limit) break
20
+ out += segment
21
+ }
22
+ return out + suffix
23
+ }
24
+
25
+ export const targetOf = (goal: GoalSummary): GoalTarget => ({ key: goalKey(goal), lock: goal.lock ?? "", ...(goal.runId ? { runId: goal.runId } : {}), ...(goal.sessionID ? { sessionID: goal.sessionID } : {}) })
26
+
27
+ /** Admission is visible progress, not a fabricated running/proven state. */
28
+ export function launchStatusText(launch?: LaunchView, current?: Pick<GoalSummary, "slug" | "sessionID" | "lock" | "runId" | "status"> | null): string | undefined {
29
+ if (!launch || launch.status === "succeeded") return undefined
30
+ if (current?.runId && current.sessionID === launch.sessionID && current.slug === launch.slug && current.lock === launch.lock && !terminal.has(current.status))
31
+ return `Goal ${current.slug} · ${current.status} · last launch attempt ${launch.status}; existing run unchanged`
32
+ if (launch.status === "executing") return `Starting ${launch.slug} · ${launch.activity?.detail ?? "owner-approved admission"} · no run yet`
33
+ if (launch.status === "pending" || launch.status === "interrupted") return `Launch review ${launch.slug} · pending in Composer · nothing started`
34
+ return `Launch ${launch.slug} · ${launch.status} · ${launch.result ?? "nothing started; inspect the review"}`
35
+ }
36
+ export type ManagerAction = { label: string; description: string; action: ManageAction | "jump" | "back" }
37
+ export function managerActions(goal: GoalSummary, actorSessionID: string, actorHasGoal: boolean): ManagerAction[] {
38
+ const rows: ManagerAction[] = []
39
+ if (goal.sessionID && goal.sessionState === "present") rows.push({ label: "Open goal session", description: "Deliberately jump after viewing these details", action: "jump" })
40
+ if (goal.valid !== false && !goal.archived && goal.runId) {
41
+ if (["running", "waiting", "verifying"].includes(goal.status)) rows.push({ label: "Pause selected goal", description: "Stop only this goal's loop", action: "pause" })
42
+ if (["paused", "blocked", "needs_review"].includes(goal.status)) rows.push({ label: "Resume selected goal", description: "Continue this stopped run", action: "resume" })
43
+ if (!terminal.has(goal.status) && goal.status !== "verifying") rows.push({ label: "Verify selected goal", description: "Run its declared host checks and verifier", action: "verify" })
44
+ if (goal.attachable && !actorHasGoal && goal.sessionID !== actorSessionID) rows.push({ label: "Attach selected goal here", description: "Move its ownership; preserve history and progress", action: "attach" })
45
+ if (!terminal.has(goal.status)) rows.push({ label: "Abort selected goal", description: "Requires a native confirmation; preserves history", action: "abort" })
46
+ else rows.push({ label: "Archive selected goal", description: "Requires a native confirmation; preserves history", action: "archive" })
47
+ }
48
+ rows.push({ label: "Back to all goals", description: "Collapse details without changing sessions", action: "back" })
49
+ return rows
50
+ }
51
+
52
+ export function wrapColumns(text: string, width: number): string[] {
53
+ const limit = Math.max(2, Math.floor(width))
54
+ const result: string[] = []
55
+ for (const line of text.split("\n")) {
56
+ let out = ""
57
+ for (const { segment } of graphemes.segment(line)) {
58
+ if (out && columns(out + segment) > limit) { result.push(out); out = "" }
59
+ out += segment
60
+ }
61
+ result.push(out)
62
+ }
63
+ return result
64
+ }
65
+
66
+ /** Current first, then the declared priority/recency order. Never sort or
67
+ * mutate the server's inventory in place, or manufacture a task hierarchy. */
68
+ export function orderedGoals(goals: readonly GoalSummary[], sessionID: string): GoalSummary[] {
69
+ const here = (g: GoalSummary) => !g.archived ? g.sessionID === sessionID ? 2 : g.handoff?.fromSessionID === sessionID || !g.runId && g.launch?.sessionID === sessionID ? 1 : 0 : 0
70
+ return [...goals].sort((a, b) => here(b) - here(a) || priorityWeight(b.priority) - priorityWeight(a.priority) || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
71
+ }
72
+
73
+ export function sidebarCards(goals: readonly GoalSummary[], sessionID: string, width = 40, maxLines = 12) {
74
+ const active = orderedGoals(goals.filter((g) => !g.archived && !g.terminal && !terminal.has(g.status)), sessionID)
75
+ // Header + (one gap, two selectable lines, progress, optional handoff
76
+ // slug/session pointer) per card; reserve for the largest card shape.
77
+ // reserve a final SELECTABLE manager pointer whenever anything overflows.
78
+ const capacity = Math.max(1, Math.floor((maxLines - 2) / 5))
79
+ const visible = active.slice(0, capacity)
80
+ return {
81
+ total: active.length, hidden: Math.max(0, active.length - visible.length),
82
+ cards: visible.map((goal) => {
83
+ const here = goal.sessionID === sessionID
84
+ const where = !goal.sessionID ? goal.launch?.sessionID === sessionID ? "author here" : "draft" : here ? "here" : "elsewhere"
85
+ const tone: Tone = goal.status === "blocked" ? "error" : ["running", "waiting", "verifying"].includes(goal.status) ? "success" : "warning"
86
+ const steps = goal.steps ?? []
87
+ const plan = steps.length ? `${steps.filter((s) => s.status === "done").length}/${steps.length} plan · ` : ""
88
+ const handoffPointer = goal.handoff?.fromSessionID === sessionID && goal.sessionID
89
+ ? `${fitColumns(goal.slug, Math.max(1, width - 17))} · ses…${goal.sessionID.slice(-8)}` : undefined
90
+ return { key: goalKey(goal), goal, tone, title: fitColumns(goal.title, Math.max(1, width - 2)), description: fitColumns(`${goal.status}${goal.launch?.status === "executing" ? " · starting" : goal.launch?.status === "expired" ? " · review expired" : ""} · ${where}`, Math.max(1, width - 2)), progress: fitColumns(`${goal.handoff?.fromSessionID === sessionID ? "from here · " : ""}${plan}${goal.proven}/${goal.total} proven · ${goal.agent ?? "agent unknown"}`, width), handoffPointer: handoffPointer ? fitColumns(handoffPointer, width) : undefined }
91
+ }),
92
+ pointer: fitColumns(active.length > visible.length ? `+${active.length - visible.length} ${active.length - visible.length === 1 ? "goal" : "goals"} — F9 manager` : "F9 goals manager", width),
93
+ }
94
+ }
@@ -0,0 +1,11 @@
1
+ import type { Plugin } from "@opencode/plugin/tui"
2
+
3
+ /** Explicit navigation only. Selection/expansion must never call this. V2
4
+ * opens without focus, then focuses by SESSION id; no invented tab IDs. */
5
+ export function openGoalSession(ui: Plugin.Context["ui"], sessionID: string): "tab" | "route" {
6
+ try {
7
+ if (ui.tabs?.enabled() && ui.tabs.open(sessionID) && ui.tabs.focus(sessionID)) return "tab"
8
+ } catch { /* Disabled/unavailable tabs use the verified router fallback. */ }
9
+ ui.router.navigate({ type: "session", sessionID })
10
+ return "route"
11
+ }
@@ -0,0 +1,20 @@
1
+ import type { MouseEvent, SelectRenderable } from "@opentui/core"
2
+
3
+ /** OpenTUI 0.5.14 Select is keyboard-only. Add pointer activation for our
4
+ * plain one/two-line rows, retaining its real selection and keyboard path.
5
+ * Its visible slice is centered on the selected index; derive that slice
6
+ * from public geometry rather than accessing private scrollOffset. */
7
+ export function pointerSelect(control: SelectRenderable, event: MouseEvent, rowHeight: 1 | 2): void {
8
+ if (event.button !== 0 || event.isDragging) return
9
+ const row = Math.floor((event.y - control.y) / rowHeight)
10
+ const visible = Math.max(1, Math.floor(control.height / rowHeight))
11
+ if (row < 0 || row >= visible || event.x < control.x || event.x >= control.x + control.width) return
12
+ const first = Math.max(0, Math.min(control.getSelectedIndex() - Math.floor(visible / 2), control.options.length - visible))
13
+ const index = first + row
14
+ if (index >= control.options.length) return
15
+ event.stopPropagation()
16
+ event.preventDefault()
17
+ control.focus()
18
+ control.setSelectedIndex(index)
19
+ control.selectCurrent()
20
+ }
@@ -0,0 +1,51 @@
1
+ // T053 — the live activity tracer. A pure formatter that renders what the
2
+ // engine is doing RIGHT NOW with elapsed time and countdowns — verifying,
3
+ // the verifier child running, goal_wait countdowns, turn cooldowns — for
4
+ // the composer-top slot and the panel. Fed by the view's activity field
5
+ // (updated on every persist); zero model cost. Snapshot-tested so the
6
+ // wording cannot drift silently.
7
+ import type { GoalView } from "../rpc"
8
+ import { fit, fmtDuration, type Line, type Tone } from "./format"
9
+
10
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
11
+
12
+ /** Countdowns keep their seconds — "2m 10s", not "2m" — while the clock ticks. */
13
+ export const fmtCountdown = (ms: number) => {
14
+ const s = Math.max(0, Math.ceil(ms / 1000))
15
+ if (s < 60) return `${s}s`
16
+ const m = Math.floor(s / 60)
17
+ return `${m}m ${String(s % 60).padStart(2, "0")}s`
18
+ }
19
+
20
+ export type TracerTone = Tone
21
+
22
+ /** The animated frame for a running activity (ticks once per second via the TUI clock). */
23
+ export const spinFrame = (now: number) => SPINNER[Math.floor(now / 120) % SPINNER.length]!
24
+
25
+ export function tracerLines(view: GoalView, now: number, width = 40): Line[] {
26
+ const a = view.activity
27
+ // a tracer only shows while the engine is actively working — never a stale
28
+ // line on stopped or terminal statuses
29
+ if (!["running", "waiting", "verifying"].includes(view.status)) return []
30
+ if (!a) {
31
+ // active statuses without a recorded activity still deserve a pulse
32
+ return [{ text: fit(`◎ goal ${view.status} · turn ${view.turn}`, width), tone: "muted" }]
33
+ }
34
+ const elapsed = Math.max(0, Math.floor((now - a.since) / 1000))
35
+ const elapsedText = `${fmtDuration(elapsed * 1000)}`
36
+ const left = a.until ? Math.max(0, a.until - now) : undefined
37
+ const leftText = left !== undefined ? fmtCountdown(left) : undefined
38
+ switch (a.kind) {
39
+ case "verifier-child":
40
+ return [{ text: fit(`${spinFrame(now)} verifier child running — ${elapsedText}${a.detail ? ` · ${a.detail}` : ""}`, width), tone: "info" }]
41
+ case "verifying":
42
+ return [{ text: fit(`${spinFrame(now)} verifying the claim — ${elapsedText}${a.detail ? ` · ${a.detail}` : " (host checks, then the verifier child)"}`, width), tone: "info" }]
43
+ case "waiting":
44
+ return [{ text: fit(`◷ waiting — ${leftText ?? elapsedText}${left !== undefined ? " left" : ""}${a.detail ? ` · ${a.detail}` : ""}`, width), tone: "info" }]
45
+ case "cooldown":
46
+ return [{ text: fit(`· cooldown — next turn in ${leftText ?? "…"}`, width), tone: "muted" }]
47
+ case "turn":
48
+ default:
49
+ return [{ text: fit(`${spinFrame(now)} turn ${view.turn} in progress — ${elapsedText}${a.detail ? ` · ${a.detail}` : ""}`, width), tone: "success" }]
50
+ }
51
+ }
@@ -0,0 +1,102 @@
1
+ // Worktree fingerprints: progress is measured by authoritative state change,
2
+ // not by how much the model talked.
3
+ import { spawnSync } from "node:child_process"
4
+ import { createHash } from "node:crypto"
5
+ import { statSync } from "node:fs"
6
+
7
+ const GOAL_STATE = ":(exclude).opencode/goals"
8
+
9
+ function git(cwd: string, args: string[]): { ok: boolean; out: string } {
10
+ // node:child_process so the bin also runs on plain Node (Bun implements it too).
11
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 })
12
+ return { ok: result.status === 0, out: result.stdout ?? "" }
13
+ }
14
+
15
+ export const isRepo = (cwd: string) => git(cwd, ["rev-parse", "--is-inside-work-tree"]).out.trim() === "true"
16
+
17
+ /** T100: the working-tree root for `cwd` (the linked worktree's own root when
18
+ * cwd is inside one). Used to require a bound workspace to BE a work-tree root
19
+ * rather than any subdirectory of the project. */
20
+ export const topLevel = (cwd: string): string | undefined => {
21
+ const r = git(cwd, ["rev-parse", "--show-toplevel"])
22
+ return r.ok ? r.out.trim() : undefined
23
+ }
24
+
25
+ export const headCommit = (cwd: string): string | undefined => {
26
+ const r = git(cwd, ["rev-parse", "HEAD"])
27
+ return r.ok ? r.out.trim() : undefined
28
+ }
29
+
30
+ /** T116: linked worktrees live under the project but their commits are invisible
31
+ * to the root worktree's diff, so the last goal run recorded changed:false and
32
+ * tripped the stall auto-pause three times. `git worktree list --porcelain`
33
+ * names every worktree's HEAD and branch; a commit in any of them changes it. */
34
+ export function worktreeSignature(cwd: string): string {
35
+ return git(cwd, ["worktree", "list", "--porcelain"])
36
+ .out.split("\n")
37
+ .map((line) => line.trim())
38
+ .filter(Boolean)
39
+ .join("\n")
40
+ }
41
+
42
+ /** Hash of HEAD + staged/unstaged diff + untracked file list + linked-worktree HEADs, excluding goal state. */
43
+ export function fingerprint(cwd: string): string | undefined {
44
+ if (!isRepo(cwd)) return undefined
45
+ const head = git(cwd, ["rev-parse", "HEAD"]).out
46
+ const diff = git(cwd, ["diff", "HEAD", "--no-color", "--", ".", GOAL_STATE]).out
47
+ const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ".", GOAL_STATE]).out
48
+ const contents = untracked
49
+ .split("\n")
50
+ .filter(Boolean)
51
+ .slice(0, 500)
52
+ .map((file) => {
53
+ try {
54
+ const stat = statSync(`${cwd}/${file}`)
55
+ return `${file}:${stat.size}:${Math.trunc(stat.mtimeMs)}`
56
+ } catch {
57
+ return file
58
+ }
59
+ })
60
+ return createHash("sha256").update(head).update(diff).update(contents.join("\n")).update(worktreeSignature(cwd)).digest("hex").slice(0, 20)
61
+ }
62
+
63
+ /** T109: a bounded, host-computed git history digest. The verifier child has only
64
+ * read/glob/grep (no shell), so a criterion that asks about commit history was
65
+ * unanswerable and burned the round budget. The host supplies this text; the
66
+ * verifier judges from it and from the copy the host saves under evidence/. */
67
+ export function gitDigest(cwd: string, limit = 20): string {
68
+ if (!isRepo(cwd)) return "(not a git repository)"
69
+ const head = git(cwd, ["rev-parse", "HEAD"]).out.trim()
70
+ const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim()
71
+ const log = git(cwd, ["log", "-n", String(limit), "--no-color", "--pretty=format:%h %ad %s", "--date=short"]).out.trim()
72
+ const trees = worktreeSignature(cwd)
73
+ return [`HEAD ${head}`, `branch ${branch}`, log, trees ? `linked worktrees:\n${trees}` : ""].filter(Boolean).join("\n")
74
+ }
75
+
76
+ /** Files changed relative to `base` (committed, staged, unstaged and untracked). */
77
+ export function changedSince(cwd: string, base: string | undefined): string[] {
78
+ if (!isRepo(cwd)) return []
79
+ const files = new Set<string>()
80
+ if (base) for (const f of git(cwd, ["diff", "--name-only", base, "--", ".", GOAL_STATE]).out.split("\n")) if (f) files.add(f)
81
+ for (const f of git(cwd, ["diff", "--name-only", "HEAD", "--", ".", GOAL_STATE]).out.split("\n")) if (f) files.add(f)
82
+ for (const f of git(cwd, ["ls-files", "--others", "--exclude-standard", "--", ".", GOAL_STATE]).out.split("\n")) if (f) files.add(f)
83
+ return [...files].sort()
84
+ }
85
+
86
+ /** Minimal glob → RegExp (supports **, *, ?). */
87
+ export function globToRegExp(glob: string): RegExp {
88
+ let re = ""
89
+ for (let i = 0; i < glob.length; i++) {
90
+ const c = glob[i]!
91
+ if (c === "*") {
92
+ if (glob[i + 1] === "*") {
93
+ re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"
94
+ i += glob[i + 2] === "/" ? 2 : 1
95
+ } else re += "[^/]*"
96
+ } else if (c === "?") re += "[^/]"
97
+ else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&")
98
+ }
99
+ return new RegExp(`^${re}$`)
100
+ }
101
+
102
+ export const matchesAny = (file: string, globs: string[]) => globs.some((g) => globToRegExp(g.replace(/^\.\//, "")).test(file))
@@ -0,0 +1,20 @@
1
+ // Message ids in OpenCode 2's native ascending format (12 hex chars of
2
+ // timestamp*4096+counter, then 14 base62 chars), so continuation prompts sort
3
+ // correctly in session history. Mirrors @opencode/schema identifier.ts.
4
+ const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
5
+ let lastTimestamp = 0
6
+ let counter = 0
7
+
8
+ export function ascendingId(prefix: string, timestamp = Date.now()): string {
9
+ if (timestamp !== lastTimestamp) {
10
+ lastTimestamp = timestamp
11
+ counter = 0
12
+ }
13
+ counter++
14
+ const value = BigInt(timestamp) * 0x1000n + BigInt(counter)
15
+ const time = Array.from({ length: 6 }, (_, i) => Number((value >> BigInt(40 - 8 * i)) & 0xffn).toString(16).padStart(2, "0")).join("")
16
+ const bytes = crypto.getRandomValues(new Uint8Array(14))
17
+ return `${prefix}_${time}${Array.from(bytes, (b) => CHARS[b % 62]).join("")}`
18
+ }
19
+
20
+ export const runId = () => ascendingId("run").slice(4, 16)
@@ -0,0 +1,42 @@
1
+ // Runs owner-declared check commands in the owner's login shell, so PATH and
2
+ // toolchains match their terminal rather than the background service.
3
+ import { spawn } from "node:child_process"
4
+
5
+ export type ShellResult = { exit: number; stdout: string; stderr: string; timedOut: boolean; ms: number }
6
+
7
+ const LIMIT = 64 * 1024
8
+
9
+ /** Keep head and tail of oversized output so a runaway log never floods the verdict. */
10
+ const clip = (out: string) =>
11
+ out.length > LIMIT ? `${out.slice(0, LIMIT / 4)}\n…[truncated ${out.length - LIMIT / 2} chars]…\n${out.slice(-LIMIT / 4)}` : out
12
+
13
+ export async function runShell(command: string, cwd: string, timeoutSeconds = 300, extraEnv: Record<string, string> = {}): Promise<ShellResult> {
14
+ const shell = process.env.SHELL && process.env.SHELL.startsWith("/") ? process.env.SHELL : "/bin/sh"
15
+ const started = Date.now()
16
+ // node:child_process so the bin also runs on plain Node (Bun implements it too).
17
+ const child = spawn(shell, ["-lc", command], { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...extraEnv, CI: process.env.CI ?? "1" } })
18
+ let stdout = ""
19
+ let stderr = ""
20
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
21
+ if (stdout.length < LIMIT * 2) stdout += chunk
22
+ })
23
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
24
+ if (stderr.length < LIMIT * 2) stderr += chunk
25
+ })
26
+ let timedOut = false
27
+ const timer = setTimeout(() => {
28
+ timedOut = true
29
+ child.kill("SIGKILL")
30
+ }, Math.max(1, timeoutSeconds) * 1000)
31
+ const exit = await new Promise<number>((resolve) => {
32
+ child.on("error", () => resolve(127))
33
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)))
34
+ })
35
+ clearTimeout(timer)
36
+ return { exit: timedOut ? 124 : exit, stdout: clip(stdout), stderr: clip(stderr), timedOut, ms: Date.now() - started }
37
+ }
38
+
39
+ export const tail = (text: string, lines = 12, chars = 1200) => {
40
+ const t = text.trimEnd().split("\n").slice(-lines).join("\n")
41
+ return t.length > chars ? `…${t.slice(-chars)}` : t
42
+ }
@@ -0,0 +1,95 @@
1
+ // Runtime provenance belongs to the plugin instance that is executing the
2
+ // goal, not whichever shared service an unscoped `opencode api` happens to
3
+ // find. Pin the loaded bytes once at setup; registry metadata is re-read
4
+ // through that instance's own location-scoped API.
5
+ import { createHash } from "node:crypto"
6
+ import { lstatSync, readFileSync, readdirSync } from "node:fs"
7
+ import { join } from "node:path"
8
+
9
+ export type ArtifactHashes = Record<string, string>
10
+ export type ArtifactSource = { type: string; target?: string; version?: string; outdated?: boolean; path?: string }
11
+ export type RuntimeProvenance = {
12
+ schema: "opencode-goal/provenance/v1"
13
+ serverPID: number
14
+ openCodeVersion: string
15
+ location: string
16
+ packageRoot: string
17
+ packageVersion: string
18
+ capturedAt: number
19
+ observedAt: number
20
+ source: ArtifactSource
21
+ status: string
22
+ features: { server: boolean; tui: boolean; rpc: boolean }
23
+ artifacts: ArtifactHashes
24
+ sessionID?: string
25
+ runId?: string
26
+ lock?: string
27
+ }
28
+
29
+ const entrypoints = ["package.json", "server.ts", "tui.tsx", "rpc.ts"]
30
+ const directories = ["src", "skills", "dist"]
31
+ export const isArtifactPath = (path: string) => entrypoints.includes(path) || directories.some((dir) => path.startsWith(`${dir}/`))
32
+ export const hashArtifact = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex")
33
+
34
+ export function captureArtifactHashes(root: string): ArtifactHashes {
35
+ const files = [...entrypoints]
36
+ const walk = (relative: string) => {
37
+ for (const item of readdirSync(join(root, relative), { withFileTypes: true })) {
38
+ const path = `${relative}/${item.name}`
39
+ if (item.isSymbolicLink()) throw new Error(`Artifact symlink is not a loaded-byte proof: ${path}`)
40
+ if (item.isDirectory()) walk(path)
41
+ else if (item.isFile()) files.push(path)
42
+ }
43
+ }
44
+ for (const dir of directories) walk(dir)
45
+ return Object.fromEntries(files.sort().map((path) => {
46
+ if (!lstatSync(join(root, path)).isFile()) throw new Error(`Missing artifact file: ${path}`)
47
+ return [path, hashArtifact(readFileSync(join(root, path)))]
48
+ }))
49
+ }
50
+
51
+ /** Return all discrepancies, never let a matching semantic label substitute
52
+ * for a Git revision or the actual bytes. Absent outdated means no update is
53
+ * reported by V2; an absent revision is still an error. */
54
+ export function artifactDiscrepancies(actual: RuntimeProvenance, expected: {
55
+ revision: string
56
+ artifacts: ArtifactHashes
57
+ location?: string
58
+ serverPID?: number
59
+ sessionID?: string
60
+ runId?: string
61
+ lock?: string
62
+ minVersion?: string
63
+ }): string[] {
64
+ const errors: string[] = []
65
+ if (actual.schema !== "opencode-goal/provenance/v1") errors.push("missing runtime provenance schema")
66
+ if (!/^[a-f0-9]{40}$/.test(expected.revision)) errors.push("expected revision must be a full immutable Git SHA")
67
+ if (actual.source.type !== "package") errors.push("the running plugin is not a package install")
68
+ if (actual.source.version !== expected.revision) errors.push(`running revision ${actual.source.version ?? "unknown"} differs from ${expected.revision}`)
69
+ if (actual.source.outdated === true) errors.push("the running plugin reports outdated=true")
70
+ if (actual.status !== "active" || !actual.features?.server || !actual.features?.tui || !actual.features?.rpc) errors.push("the serving plugin is not active with server/TUI/RPC features")
71
+ for (const key of ["location", "serverPID", "sessionID", "runId", "lock"] as const) {
72
+ if (expected[key] !== undefined && actual[key] !== expected[key]) errors.push(`runtime ${key} does not match the goal/check caller`)
73
+ }
74
+ const required = ["package.json", "server.ts", "tui.tsx", "rpc.ts", "dist/tui.js", "src/server/app.ts", "src/tui/index.tsx", "src/tui/dashboard.ts", "skills/write-goal/SKILL.md"]
75
+ for (const path of required) if (!expected.artifacts[path]) errors.push(`release manifest omits ${path}`)
76
+ const paths = new Set([...Object.keys(expected.artifacts), ...Object.keys(actual.artifacts)])
77
+ for (const path of paths) {
78
+ if (!isArtifactPath(path) || !/^[a-f0-9]{64}$/.test(expected.artifacts[path] ?? "") || actual.artifacts[path] !== expected.artifacts[path])
79
+ errors.push(`artifact mismatch: ${path}`)
80
+ }
81
+ if (expected.minVersion && !versionAtLeast(actual.packageVersion, expected.minVersion)) errors.push(`package ${actual.packageVersion} is below ${expected.minVersion}`)
82
+ return errors
83
+ }
84
+
85
+ export function versionAtLeast(actual: string, minimum: string): boolean {
86
+ const parse = (s: string) => /^(\d+)\.(\d+)\.(\d+)(-[\w.-]+)?(?:\+[\w.-]+)?$/.exec(s)
87
+ const a = parse(actual), b = parse(minimum)
88
+ if (!a || !b) return false
89
+ for (let i = 1; i <= 3; i++) {
90
+ if (Number(a[i]) !== Number(b[i])) return Number(a[i]) > Number(b[i])
91
+ }
92
+ // A prerelease is below its corresponding stable minimum. This checker
93
+ // deliberately does not rank arbitrary prerelease identifiers.
94
+ return !a[4] || a[4] === b[4]
95
+ }
@@ -0,0 +1,200 @@
1
+ // Completion is decided here, never by the worker. Order: contract integrity →
2
+ // host-run checks → independent verifier (with quotes the host re-reads) →
3
+ // owner sign-off for human criteria.
4
+ import { existsSync, readFileSync } from "node:fs"
5
+ import { isAbsolute, join, normalize, relative } from "node:path"
6
+ import type { Check, Contract, Criterion } from "../contract/types"
7
+ import { isHostCheck } from "../contract/types"
8
+ import { lockOf } from "../contract/parse"
9
+ import type { RunState } from "../engine/state"
10
+ import { changedSince, isRepo, matchesAny } from "../util/git"
11
+ import { runShell, tail, type ShellResult } from "../util/shell"
12
+
13
+ export type CheckResult = { id: string; pass: boolean; by: "host" | "verifier" | "verifier-fallback" | "human"; detail: string; raw?: unknown }
14
+
15
+ export type VerifierVerdict = { id: string; verdict: "proven" | "not_proven" | "contradicted"; reason?: string; evidence: Array<{ path: string; quote: string }> }
16
+ export type VerifierRun = (input: { contract: Contract; state: RunState; criteria: Criterion[]; host: CheckResult[] }) => Promise<{ verdicts: VerifierVerdict[]; by?: "verifier" | "verifier-fallback"; error?: string }>
17
+
18
+ export type VerifyOutcome = {
19
+ passed: boolean
20
+ needsHuman: string[]
21
+ results: CheckResult[]
22
+ integrity: string[]
23
+ lines: string[]
24
+ }
25
+
26
+ const inside = (root: string, path: string) => {
27
+ const full = normalize(isAbsolute(path) ? path : join(root, path))
28
+ const rel = relative(root, full)
29
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? full : rel === "" ? full : undefined
30
+ }
31
+
32
+ export async function runHostCheck(check: Check, root: string, base: string | undefined, shell = runShell): Promise<{ pass: boolean; detail: string; raw?: unknown }> {
33
+ switch (check.kind) {
34
+ case "command": {
35
+ const runs: ShellResult[] = []
36
+ for (let i = 0; i < (check.runs ?? 1); i++) {
37
+ const result = await shell(check.run, root, check.timeout ?? 300)
38
+ runs.push(result)
39
+ const why = failure(check, result)
40
+ if (why) return { pass: false, detail: `run ${i + 1}/${check.runs ?? 1}: ${why}\n${tail(result.stdout + (result.stderr ? `\n[stderr]\n${result.stderr}` : ""))}`, raw: runs }
41
+ }
42
+ const last = runs.at(-1)!
43
+ return { pass: true, detail: `exit ${last.exit} in ${(last.ms / 1000).toFixed(1)}s${(check.runs ?? 1) > 1 ? ` ×${check.runs}` : ""}`, raw: runs }
44
+ }
45
+ case "file": {
46
+ const path = inside(root, check.path)
47
+ if (!path) return { pass: false, detail: `${check.path} is outside the project` }
48
+ const exists = existsSync(path)
49
+ const want = check.exists !== false
50
+ return { pass: exists === want, detail: `${check.path} ${exists ? "exists" : "does not exist"}` }
51
+ }
52
+ case "contains": {
53
+ const path = inside(root, check.path)
54
+ if (!path || !existsSync(path)) return { pass: false, detail: `${check.path} not found` }
55
+ const text = readFileSync(path, "utf8")
56
+ const ok = check.text ? text.includes(check.text) : new RegExp(check.regex!, "m").test(text)
57
+ return { pass: ok, detail: ok ? `${check.path} contains the expected ${check.text ? "text" : "pattern"}` : `${check.path} lacks ${check.text ? `"${check.text}"` : `/${check.regex}/`}` }
58
+ }
59
+ case "absent": {
60
+ const paths = check.paths?.length ? check.paths : ["."]
61
+ const quoted = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
62
+ const cmd = isRepo(root)
63
+ ? `git grep -n -I -E -e ${quoted(check.pattern)} -- ${paths.map(quoted).join(" ")} ':(exclude).opencode/goals'`
64
+ : `grep -rnIE --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=goals -e ${quoted(check.pattern)} ${paths.map(quoted).join(" ")}`
65
+ const result = await shell(cmd, root, 120)
66
+ const matches = result.stdout.trim().split("\n").filter(Boolean)
67
+ if (result.exit > 1) return { pass: false, detail: `search failed (exit ${result.exit}): ${tail(result.stderr, 3)}` }
68
+ return { pass: matches.length === 0, detail: matches.length ? `${matches.length} match(es) remain, e.g.\n${matches.slice(0, 5).join("\n")}` : "no matches" }
69
+ }
70
+ case "diff": {
71
+ const changed = changedSince(root, base).filter((f) => matchesAny(f, check.paths))
72
+ return { pass: changed.length === 0, detail: changed.length ? `changed since start: ${changed.slice(0, 10).join(", ")}` : "unchanged since start" }
73
+ }
74
+ default:
75
+ return { pass: false, detail: `${check.kind} is not a host check` }
76
+ }
77
+ }
78
+
79
+ function failure(check: Extract<Check, { kind: "command" }>, result: ShellResult): string | undefined {
80
+ if (result.timedOut) return `timed out after ${check.timeout ?? 300}s`
81
+ const e = check.expect ?? { exit: 0 }
82
+ if (e.exit !== undefined && result.exit !== e.exit) return `exit ${result.exit}, expected ${e.exit}`
83
+ if (e.stdout_contains && !result.stdout.includes(e.stdout_contains)) return `output lacks "${e.stdout_contains}"`
84
+ if (e.stdout_regex && !new RegExp(e.stdout_regex, "m").test(result.stdout)) return `output does not match /${e.stdout_regex}/`
85
+ return undefined
86
+ }
87
+
88
+ const collapse = (s: string) => s.replace(/\s+/g, " ").trim()
89
+
90
+ /** T108: JUnit/XML bytes carry entity escapes (`&#10;`, `&quot;`, `&lt;`). A
91
+ * verifier that quotes the *decoded* line it read (a newline, a quote mark) did
92
+ * not match the raw XML, so a correct round was rejected as "quoted text not
93
+ * found" — the live C5 failure. Decode both sides before comparing. */
94
+ export function decodeEntities(s: string): string {
95
+ return s
96
+ .replace(/&#x([0-9a-fA-F]+);/g, (m, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch { return m } })
97
+ .replace(/&#(\d+);/g, (m, d) => { try { return String.fromCodePoint(Number(d)) } catch { return m } })
98
+ .replace(/&lt;/g, "<")
99
+ .replace(/&gt;/g, ">")
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&apos;/g, "'")
102
+ .replace(/&amp;/g, "&")
103
+ }
104
+
105
+ /** The host re-reads every quote the verifier cites; an unverifiable quote is no
106
+ * evidence. A quote may match the raw bytes or their entity-decoded equivalent,
107
+ * so both the raw junit and a plain-text renderer of the same line are accepted. */
108
+ export function checkQuotes(root: string, verdict: VerifierVerdict): { ok: boolean; why?: string } {
109
+ if (verdict.verdict !== "proven") return { ok: false, why: verdict.reason ?? verdict.verdict }
110
+ if (!verdict.evidence.length) return { ok: false, why: "verifier cited no evidence" }
111
+ for (const item of verdict.evidence) {
112
+ const path = inside(root, item.path)
113
+ if (!path || !existsSync(path)) return { ok: false, why: `cited file ${item.path} does not exist` }
114
+ if (!item.quote || collapse(item.quote).length < 8) return { ok: false, why: `quote from ${item.path} is too short to check` }
115
+ const raw = readFileSync(path, "utf8")
116
+ const hit = (hay: string, needle: string) => collapse(hay).includes(collapse(needle))
117
+ if (!hit(raw, item.quote) && !hit(decodeEntities(raw), decodeEntities(item.quote)))
118
+ return { ok: false, why: `quoted text not found in ${item.path} (raw or entity-decoded)` }
119
+ }
120
+ return { ok: true }
121
+ }
122
+
123
+ export async function verifyClaim(contract: Contract, state: RunState, deps: { root: string; contractText: string; verifier?: VerifierRun; shell?: typeof runShell; onProgress?: (activity: RunState["activity"]) => void }): Promise<VerifyOutcome> {
124
+ const { root } = deps
125
+ const integrity: string[] = []
126
+ if (lockOf(deps.contractText) !== state.lock)
127
+ integrity.push(
128
+ "goal.yaml changed since the run started: the contract is sha256-locked for this run. The owner re-locks an amended contract with /goal amend confirm (audit-trailed, generation-bound) or aborts and starts fresh; a worker must never edit .opencode/goals/.",
129
+ )
130
+ if (contract.protect.length) {
131
+ const touched = changedSince(root, state.base.commit).filter((f) => matchesAny(f, contract.protect))
132
+ if (touched.length) integrity.push(`protected paths were modified: ${touched.slice(0, 10).join(", ")}. Restore them; do not change the oracle to make it pass.`)
133
+ }
134
+
135
+ const results: CheckResult[] = []
136
+ // T057: an owner-approved criterion is final against HOST re-checks too —
137
+ // a stale check (the live C15 needle case) must never stomp an explicit
138
+ // sign-off. The owner outranks every automated re-verification.
139
+ const ownerFinal = (id: string) => state.criteria[id]?.status === "pass" && state.criteria[id]?.by === "human"
140
+ const checkable = [...contract.criteria, ...contract.invariants].filter((c) => isHostCheck(c.check))
141
+ for (const [index, c] of checkable.entries()) {
142
+ if (ownerFinal(c.id)) {
143
+ results.push({ id: c.id, pass: true, by: "human", detail: "approved by the owner (final)" })
144
+ continue
145
+ }
146
+ // T063: the tracer shows live per-criterion progress through the checks
147
+ state.activity = { kind: "verifying", since: state.activity?.since ?? Date.now(), detail: `check ${c.id} · ${index + 1}/${checkable.length}` }
148
+ deps.onProgress?.(state.activity)
149
+ const r = await runHostCheck(c.check, root, state.base.commit, deps.shell)
150
+ results.push({ id: c.id, pass: r.pass, by: "host", detail: r.detail, raw: r.raw })
151
+ }
152
+
153
+ const semantic = contract.criteria.filter((c) => c.check.kind === "verifier")
154
+ const strictExtra = contract.verification.mode === "strict" ? contract.criteria.filter((c) => isHostCheck(c.check) && results.find((r) => r.id === c.id)?.pass) : []
155
+ // T051: an owner-approved criterion (pass by:human) is final — the owner
156
+ // outranks the verifier, so re-verification must respect it permanently
157
+ // instead of re-running a child that can stomp an explicit sign-off.
158
+ const toVerify = (contract.verification.mode === "host" ? [] : [...semantic, ...strictExtra]).filter((c) => !ownerFinal(c.id))
159
+ for (const c of [...semantic, ...strictExtra]) {
160
+ if (ownerFinal(c.id) && !results.some((r) => r.id === c.id))
161
+ results.push({ id: c.id, pass: true, by: "human", detail: "approved by the owner (final)" })
162
+ }
163
+ if (toVerify.length) {
164
+ if (!deps.verifier) {
165
+ for (const c of toVerify) results.push({ id: c.id, pass: false, by: "verifier", detail: "verifier unavailable; failing closed" })
166
+ } else {
167
+ const run = await deps.verifier({ contract, state, criteria: toVerify, host: results }).catch((error) => ({ verdicts: [] as VerifierVerdict[], by: "verifier" as const, error: String(error) }))
168
+ for (const c of toVerify) {
169
+ const v = run.verdicts.find((x) => x.id === c.id)
170
+ const checked = v ? checkQuotes(root, v) : { ok: false, why: run.error ? `verifier failed: ${run.error}` : "verifier returned no verdict" }
171
+ const prior = results.findIndex((r) => r.id === c.id)
172
+ const entry: CheckResult = { id: c.id, pass: checked.ok, by: run.by === "verifier-fallback" ? "verifier-fallback" : "verifier", detail: checked.ok ? `proven with ${v!.evidence.length} quote(s)${run.by === "verifier-fallback" ? " (parsed fallback verdict)" : ""}` : checked.why ?? "not proven", raw: v }
173
+ if (prior >= 0) {
174
+ // strict mode: the verifier must agree with the passing host check
175
+ if (!checked.ok) results[prior] = { ...entry, detail: `host passed but verifier disagrees: ${entry.detail}` }
176
+ } else results.push(entry)
177
+ }
178
+ }
179
+ }
180
+
181
+ const needsHuman = contract.criteria.filter((c) => c.check.kind === "human" && !(state.criteria[c.id]?.status === "pass" && state.criteria[c.id]?.by === "human")).map((c) => c.id)
182
+ for (const c of contract.criteria.filter((c) => c.check.kind === "human")) {
183
+ const s = state.criteria[c.id]
184
+ results.push({ id: c.id, pass: s?.status === "pass" && s.by === "human", by: "human", detail: s?.status === "pass" ? "approved by the owner" : "awaiting owner sign-off" })
185
+ }
186
+
187
+ const required = new Set([...contract.criteria.filter((c) => c.essential).map((c) => c.id), ...contract.invariants.map((c) => c.id)])
188
+ const failed = results.filter((r) => required.has(r.id) && !r.pass && r.by !== "human")
189
+ const passed = integrity.length === 0 && failed.length === 0 && needsHuman.filter((id) => required.has(id)).length === 0
190
+
191
+ const lines: string[] = []
192
+ for (const i of integrity) lines.push(`INTEGRITY: ${i}`)
193
+ for (const r of results) {
194
+ if (r.pass) continue
195
+ const c = [...contract.criteria, ...contract.invariants].find((x) => x.id === r.id)
196
+ lines.push(`${r.id} ${required.has(r.id) ? "FAILED" : "not met (optional)"} [${r.by}] ${c?.statement ?? ""}\n ${r.detail.replace(/\n/g, "\n ")}`)
197
+ }
198
+ if (!lines.length) lines.push("All required criteria passed.")
199
+ return { passed, needsHuman, results, integrity, lines }
200
+ }
package/tui.tsx ADDED
@@ -0,0 +1,2 @@
1
+ // Root TUI entry (see server.ts).
2
+ export { default } from "./dist/tui.js"