@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,178 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"
3
+ import { dirname } from "node:path"
4
+
5
+ export type ReviewRequest = {
6
+ id: string
7
+ sessionID: string
8
+ slug: string
9
+ lock: string
10
+ title: string
11
+ question: string
12
+ createdAt: number
13
+ expiresAt: number
14
+ /** Target-bound lifecycle decisions have no clock expiry; legacy records
15
+ * and sealed launch approvals remain timed by default. */
16
+ expiryPolicy?: "time" | "target"
17
+ issuerPID: number
18
+ status: "pending" | "interrupted" | "executing" | "succeeded" | "rejected" | "commented" | "cancelled" | "expired" | "failed"
19
+ formID?: string
20
+ fieldKey?: string
21
+ choiceValues?: Record<string, string>
22
+ result?: string
23
+ comment?: string
24
+ /** Authenticated owner-message identity, when this is a direct owner command. */
25
+ ownerMessageID?: string
26
+ }
27
+ type ReviewData<T> = { version: 1; requests: Record<string, T> }
28
+ export type ReviewChoice = { value: string; label: string; description: string }
29
+
30
+ /** Clock expiry is reserved for explicitly timed/legacy reviews. */
31
+ export function reviewExpired(request: Pick<ReviewRequest, "expiresAt" | "expiryPolicy">, now = Date.now()): boolean {
32
+ return request.expiryPolicy !== "target" && request.expiresAt < now
33
+ }
34
+
35
+ /** Interpret only the values actually offered in the bound field. Internal
36
+ * aliases typed as custom commentary are not selectable owner consent. */
37
+ export function selectedReviewChoice(request: ReviewRequest, choices: readonly ReviewChoice[], answer: Record<string, unknown>): ReviewChoice | undefined {
38
+ const value = request.fieldKey ? answer[request.fieldKey] : undefined
39
+ return typeof value === "string" ? choices.find((c) => request.choiceValues?.[c.value] === value) : undefined
40
+ }
41
+
42
+ export const processAlive = (pid: number): boolean => {
43
+ try { process.kill(pid, 0); return true } catch (error: any) { return error?.code === "EPERM" }
44
+ }
45
+
46
+ /** The host-owned sidecar is outside individual runs, so authoring sessions
47
+ * can recover approvals before any run exists. A short cross-process lock
48
+ * makes reservation one-use even when two servers share a project folder. */
49
+ export class OwnerReviewStore<T extends ReviewRequest> {
50
+ constructor(readonly file: string, readonly choices: readonly ReviewChoice[]) {}
51
+ protected reviewChoices(_request: T): readonly ReviewChoice[] { return this.choices }
52
+
53
+ private read(): ReviewData<T> {
54
+ if (!existsSync(this.file)) return { version: 1, requests: {} }
55
+ const data = JSON.parse(readFileSync(this.file, "utf8"))
56
+ if (data?.version !== 1 || !data.requests || Array.isArray(data.requests)) throw new Error("Owner decision store is invalid; refusing consent")
57
+ return data
58
+ }
59
+ protected mutate<R>(fn: (data: ReviewData<T>) => R): R {
60
+ mkdirSync(dirname(this.file), { recursive: true })
61
+ const lock = `${this.file}.lock`
62
+ // Do not steal an active lock. An abandoned lock can be recovered only
63
+ // after its recorded process has exited, never merely because it is old.
64
+ if (existsSync(lock)) {
65
+ const pid = Number(readFileSync(lock, "utf8"))
66
+ if (Number.isInteger(pid) && pid > 0 && !processAlive(pid)) unlinkSync(lock)
67
+ }
68
+ const fd = openSync(lock, "wx", 0o600)
69
+ try {
70
+ writeFileSync(fd, String(process.pid))
71
+ const data = this.read()
72
+ const result = fn(data)
73
+ const tmp = `${this.file}.${randomUUID()}.tmp`
74
+ const output = openSync(tmp, "wx", 0o600)
75
+ try { writeFileSync(output, `${JSON.stringify(data, null, 2)}\n`); fsyncSync(output) } finally { closeSync(output) }
76
+ renameSync(tmp, this.file)
77
+ return result
78
+ } finally { closeSync(fd); unlinkSync(lock) }
79
+ }
80
+ all(sessionID?: string): T[] {
81
+ return Object.values(this.read().requests).filter((p) => !sessionID || p.sessionID === sessionID)
82
+ }
83
+ get(id: string): T | undefined { return this.read().requests[id] }
84
+ /** Host-only source binding, never tool-supplied approval text. Failed
85
+ * attempts spend the source too; cross-process duplicates cannot rebind. */
86
+ bindOwnerMessage(id: string, ownerMessageID: string): T | undefined {
87
+ return this.mutate((data) => {
88
+ const p = data.requests[id]
89
+ if (!p || !["pending", "interrupted"].includes(p.status) || p.ownerMessageID || reviewExpired(p)
90
+ || p.issuerPID !== process.pid && processAlive(p.issuerPID)
91
+ || Object.values(data.requests).some((r) => r.ownerMessageID === ownerMessageID)) return undefined
92
+ p.ownerMessageID = ownerMessageID
93
+ p.status = "pending"
94
+ return p
95
+ })
96
+ }
97
+ create(input: Omit<T, "id" | "status">, options: { keepOthers?: boolean } = {}): T {
98
+ return this.mutate((data) => {
99
+ if (!options.keepOthers) for (const p of Object.values(data.requests))
100
+ if (p.sessionID === input.sessionID && ["pending", "interrupted"].includes(p.status)) p.status = "cancelled"
101
+ const p = { ...input, id: randomUUID(), status: "pending" } as T
102
+ // The nonce is in the displayed question and must match the actual
103
+ // question field. A matching label in an unrelated form is not enough.
104
+ p.question += `\nDecision: ${p.id}`
105
+ data.requests[p.id] = p
106
+ return p
107
+ })
108
+ }
109
+ bind(form: { id: string; sessionID: string; fields?: Array<{ key?: string; title?: string; description?: string; options?: Array<{ value: string; label: string }> }> }): T | undefined {
110
+ const matches = (p: T) => {
111
+ const choices = this.reviewChoices(p)
112
+ return p.sessionID === form.sessionID && p.status === "pending" && !p.formID && !reviewExpired(p) && form.fields?.length === 1 && form.fields.some((f) => !!f.key && (f.title === p.question || f.description === p.question) && f.options?.length === choices.length && choices.every((c) => f.options?.some((o) => o.label === c.label && (o.value === c.value || o.value === c.label))))
113
+ }
114
+ // Most forms have nothing to do with this store. Do not fsync an empty
115
+ // sidecar on every unrelated form; revalidate under the lock if matched.
116
+ if (!this.all(form.sessionID).some(matches)) return undefined
117
+ return this.mutate((data) => {
118
+ const p = Object.values(data.requests).find(matches)
119
+ if (!p) return undefined
120
+ if (p.issuerPID !== process.pid && processAlive(p.issuerPID)) return undefined
121
+ p.formID = form.id
122
+ const field = form.fields?.find((f) => f.title === p.question || f.description === p.question)
123
+ p.fieldKey = field?.key
124
+ p.choiceValues = Object.fromEntries(this.reviewChoices(p).map((c) => [c.value, field!.options!.find((o) => o.label === c.label)!.value]))
125
+ return p
126
+ })
127
+ }
128
+ byForm(sessionID: string, formID: string): T | undefined {
129
+ return this.all(sessionID).find((p) => p.formID === formID)
130
+ }
131
+ update(id: string, change: Partial<Pick<ReviewRequest, "status" | "result" | "comment">>): T {
132
+ return this.mutate((data) => {
133
+ const p = data.requests[id]
134
+ if (!p) throw new Error("Unknown owner decision")
135
+ Object.assign(p, change)
136
+ return p
137
+ })
138
+ }
139
+ reopenForm(id: string): T | undefined {
140
+ return this.mutate((data) => {
141
+ const p = data.requests[id]
142
+ if (!p || !["pending", "interrupted"].includes(p.status)) return undefined
143
+ p.status = "pending"
144
+ delete p.formID
145
+ delete p.fieldKey
146
+ delete p.choiceValues
147
+ return p
148
+ })
149
+ }
150
+ interruptForm(id: string): T | undefined {
151
+ return this.mutate((data) => {
152
+ const p = data.requests[id]
153
+ if (!p || p.status !== "pending") return undefined
154
+ p.status = "interrupted"
155
+ p.result = "The form was dismissed or interrupted by the host; no operation was authorized."
156
+ delete p.formID
157
+ delete p.fieldKey
158
+ delete p.choiceValues
159
+ return p
160
+ })
161
+ }
162
+ reserve(id: string): T | undefined {
163
+ return this.mutate((data) => {
164
+ const p = data.requests[id]
165
+ if (!p || p.status !== "pending") return undefined
166
+ // Different processes can create separate proposals for the same real
167
+ // message. Enforce consumption under THIS cross-process lock, not only
168
+ // with an earlier all() read in the caller.
169
+ if (p.ownerMessageID && Object.values(data.requests).some((other) => other.id !== id
170
+ && other.ownerMessageID === p.ownerMessageID && ["executing", "succeeded", "failed"].includes(other.status))) return undefined
171
+ if (reviewExpired(p)) { p.status = "expired"; return undefined }
172
+ if (p.issuerPID !== process.pid && processAlive(p.issuerPID)) return undefined
173
+ p.status = "executing"
174
+ p.issuerPID = process.pid
175
+ return p
176
+ })
177
+ }
178
+ }
@@ -0,0 +1,67 @@
1
+ // v0.8.0 — when the loop may tell peers it is available.
2
+ //
3
+ // Owner policy: announce on an idle transition, always, but never nag. The two
4
+ // brakes are mechanical:
5
+ // 1. one outstanding announcement at a time — while a peer has not answered,
6
+ // another announcement is spam, not availability;
7
+ // 2. a cooldown floor after the last announcement — a rapid sequence of idle
8
+ // transitions (complete → resume → complete) is one idle period, not five.
9
+ //
10
+ // Pure. The transport (AgentMBX) lives in peer-transport.ts and is injected, so
11
+ // the unit suite never touches the network.
12
+
13
+ export type AnnounceState = {
14
+ /** When this run last announced. */
15
+ lastAt?: number
16
+ /** The announcement still waiting for an answer, if any. */
17
+ outstanding?: { id: string; at: number }
18
+ }
19
+
20
+ export type AnnounceDecision =
21
+ | { due: true }
22
+ | { due: false; reason: "outstanding" | "cooldown" }
23
+
24
+ export function announceDue(state: AnnounceState, now: number, cooldownMs: number): AnnounceDecision {
25
+ if (state.outstanding) return { due: false, reason: "outstanding" }
26
+ if (state.lastAt !== undefined && now - state.lastAt < cooldownMs) return { due: false, reason: "cooldown" }
27
+ return { due: true }
28
+ }
29
+
30
+ /** What a peer reads. Short, factual, no task invention and no secrets. */
31
+ export function announcementText(input: { project: string; slug: string; taskId?: string }): string {
32
+ const task = input.taskId ? ` (CLEO ${input.taskId})` : ""
33
+ return `Available for work. Project: ${input.project}. Last goal: ${input.slug}${task}. Reply with a task id or a one-line work request; I will treat it as a candidate, not as consent.`
34
+ }
35
+
36
+ export type PeerReply = {
37
+ id: string
38
+ from: string
39
+ text: string
40
+ at: number
41
+ project?: string
42
+ }
43
+
44
+ /** The injectable transport. The policy above never imports this. */
45
+ export type PeerChannel = {
46
+ announce(input: { text: string; sessionID?: string }): Promise<{ id: string } | undefined>
47
+ replies(input: { since: number; sessionID?: string }): Promise<PeerReply[]>
48
+ }
49
+
50
+ /**
51
+ * A reply becomes CANDIDATE work, never an instruction. A task id in the text
52
+ * is recorded so the next-work decision can offer it; the body is kept verbatim
53
+ * and labelled as a peer claim.
54
+ */
55
+ export function candidateFromReply(reply: PeerReply, currentProject: string) {
56
+ const task = /\bT\d{1,7}\b/.exec(reply.text)?.[0]
57
+ const firstLine = reply.text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "(empty reply)"
58
+ return {
59
+ source: "peer" as const,
60
+ project: reply.project ?? currentProject,
61
+ ...(task ? { taskId: task } : {}),
62
+ title: firstLine.slice(0, 200),
63
+ from: reply.from,
64
+ messageId: reply.id,
65
+ at: reply.at,
66
+ }
67
+ }
@@ -0,0 +1,78 @@
1
+ // v0.8.0 — the AgentMBX transport behind the pure announcement policy.
2
+ //
3
+ // One process boundary, one place that can fail. Every failure is returned as
4
+ // "no id"/"no replies" and journalled by the caller; it is never invented. The
5
+ // channel is only constructed when an identity is configured, so a project with
6
+ // no mailbox does not shell out to a command whose side effects nobody asked for.
7
+
8
+ import type { PeerChannel, PeerReply } from "./peer-announce"
9
+
10
+ export type PeerTransportOptions = {
11
+ bin: string
12
+ identity: string
13
+ cli: string
14
+ timeoutMs?: number
15
+ }
16
+
17
+ const asText = (v: unknown) => (typeof v === "string" ? v : "")
18
+
19
+ /** The id from `{"id": "..."}`, a plain id line, or `id: ...`; undefined if absent. */
20
+ export function parseMessageId(stdout: string): string | undefined {
21
+ const text = stdout.trim()
22
+ if (!text) return undefined
23
+ try {
24
+ const parsed = JSON.parse(text)
25
+ const id = asText(parsed?.id ?? parsed?.data?.id)
26
+ if (id) return id
27
+ } catch { /* not JSON */ }
28
+ return /(?:^|\s)(?:id|messageId)[:=]\s*"?([A-Za-z0-9_-]{6,})"?/i.exec(text)?.[1]
29
+ ?? /^([A-Za-z0-9_-]{20,})$/.exec(text)?.[1]
30
+ }
31
+
32
+ export function createPeerChannel(options: PeerTransportOptions): PeerChannel {
33
+ const timeoutMs = options.timeoutMs ?? 10_000
34
+ const run = async (args: string[]): Promise<{ stdout: string; exit: number }> =>
35
+ new Promise((resolve) => {
36
+ const child = Bun.spawn([options.bin, ...args], { stdout: "pipe", stderr: "ignore" })
37
+ const timer = setTimeout(() => child.kill(), timeoutMs)
38
+ Promise.all([new Response(child.stdout).text(), child.exited])
39
+ .then(([stdout, exit]) => resolve({ stdout, exit: Number(exit) }))
40
+ .catch(() => resolve({ stdout: "", exit: 1 }))
41
+ .finally(() => clearTimeout(timer))
42
+ })
43
+
44
+ const sessionArgs = (sessionID?: string) =>
45
+ sessionID ? ["--cli", options.cli, "--session", sessionID] : []
46
+
47
+ return {
48
+ async announce(input) {
49
+ const result = await run([
50
+ "send", "--as", options.identity, "--to", "*",
51
+ "--subject", "Available for work", "--kind", "message",
52
+ "-m", input.text, ...sessionArgs(input.sessionID),
53
+ ])
54
+ if (result.exit !== 0) return undefined
55
+ const id = parseMessageId(result.stdout)
56
+ return id ? { id } : undefined
57
+ },
58
+ async replies(input) {
59
+ const result = await run([
60
+ "inbox", "--as", options.identity, "--all", "--json",
61
+ ...sessionArgs(input.sessionID),
62
+ ])
63
+ if (result.exit !== 0) return []
64
+ let parsed: unknown
65
+ try { parsed = JSON.parse(result.stdout) } catch { return [] }
66
+ const rows = Array.isArray(parsed) ? parsed : Array.isArray((parsed as any)?.messages) ? (parsed as any).messages : []
67
+ return rows.flatMap((m: any): PeerReply[] => {
68
+ const id = asText(m?.id)
69
+ const from = asText(m?.from)
70
+ const at = Date.parse(asText(m?.ts))
71
+ if (!id || !from || from === options.identity || Number.isNaN(at) || at <= input.since) return []
72
+ // The list endpoint has no body; the subject is the first line of what
73
+ // the peer said. A fuller read is the next step, never an invention.
74
+ return [{ id, from, at, text: asText(m?.subject) || "(no subject)" }]
75
+ })
76
+ },
77
+ }
78
+ }
@@ -0,0 +1,42 @@
1
+ // Owner-set autonomy policy. The OWNER lists change classes in the launched
2
+ // contract (autonomy.amend); the agent may apply a change itself only when
3
+ // every difference falls inside those classes. Criteria statements, checks
4
+ // (other than their timeout), roles, protect, scope, verification, stop
5
+ // rules and the policy itself are never agent-applicable.
6
+ import type { AmendClass, Contract, Criterion } from "../contract/types"
7
+ import { canonical } from "./amendment"
8
+
9
+ export type ChangeClassification = { classes: AmendClass[]; blocked: string[] }
10
+
11
+ const withoutTimeout = (c: Criterion) => ({ ...c, check: { ...c.check, timeout: undefined } })
12
+
13
+ export function classifyChange(prior: Contract, next: Contract): ChangeClassification {
14
+ const classes = new Set<AmendClass>()
15
+ const blocked: string[] = []
16
+ for (const key of ["criteria", "invariants"] as const) {
17
+ const a = prior[key], b = next[key]
18
+ if (a.length !== b.length || a.some((c, i) => c.id !== b[i]!.id)) { blocked.push(`${key} added, removed or reordered`); continue }
19
+ a.forEach((c, i) => {
20
+ const d = b[i]!
21
+ if (canonical(c) === canonical(d)) return
22
+ if (canonical(withoutTimeout(c)) === canonical(withoutTimeout(d))) classes.add("timeout")
23
+ else blocked.push(`${c.id} statement, check or role changed`)
24
+ })
25
+ }
26
+ if (canonical(prior.budget) !== canonical(next.budget)) classes.add("budget")
27
+ if (canonical(prior.plan) !== canonical(next.plan) || canonical(prior.assumptions) !== canonical(next.assumptions)) classes.add("plan")
28
+ if (canonical(prior.autonomy) !== canonical(next.autonomy)) blocked.push("the autonomy policy itself changed (owner only)")
29
+ const handled = new Set<string>(["criteria", "invariants", "budget", "plan", "assumptions", "autonomy"])
30
+ for (const key of new Set([...Object.keys(prior), ...Object.keys(next)]))
31
+ if (!handled.has(key) && canonical((prior as any)[key]) !== canonical((next as any)[key])) blocked.push(`${key} changed`)
32
+ return { classes: [...classes], blocked }
33
+ }
34
+
35
+ /** Whether `policy` lets the agent apply this change (or recovery) itself. */
36
+ export function policyAllows(policy: readonly AmendClass[], change: ChangeClassification, recovery = false): { ok: boolean; reasons: string[] } {
37
+ const reasons = [...change.blocked]
38
+ if (recovery && !policy.includes("recover")) reasons.push("divergence recovery is not in the policy")
39
+ for (const c of change.classes) if (!policy.includes(c)) reasons.push(`${c} changes are not in the policy`)
40
+ if (!change.classes.length && !change.blocked.length) reasons.push("no change")
41
+ return { ok: policy.length > 0 && reasons.length === 0, reasons }
42
+ }
@@ -0,0 +1,119 @@
1
+ // T026 — the cross-project goal registry. A machine-level DERIVED index
2
+ // (council 20261003T162655Z-800c52ca): the per-project .opencode/goals/
3
+ // folders stay the source of truth; this file is a rebuildable cache that
4
+ // answers "which goals exist where, in what state" without scanning every
5
+ // project. Atomic writes (tmp + rename); OCGOAL_REGISTRY overrides the
6
+ // location so hosts and tests never pollute the real index.
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, statSync } from "node:fs"
8
+ import { homedir, tmpdir } from "node:os"
9
+ import { dirname, join } from "node:path"
10
+
11
+ export type RegistryEntry = { title: string; status: string; runId?: string; lock?: string; archived?: boolean; priority?: "low" | "medium" | "high"; updatedAt: number }
12
+ export type RegistryData = { version: 1; projects: Record<string, { goals: Record<string, RegistryEntry>; updatedAt: number }> }
13
+
14
+ /** T061: priority then recency — high > medium > low > absent, ties by updatedAt descending (missing updatedAt = 0). */
15
+ export const byPriorityThenRecency = <T extends { priority?: RegistryEntry["priority"]; updatedAt?: number }>(a: T, b: T): number => {
16
+ const wa = a.priority === "high" ? 3 : a.priority === "medium" ? 2 : a.priority === "low" ? 1 : 0
17
+ const wb = b.priority === "high" ? 3 : b.priority === "medium" ? 2 : b.priority === "low" ? 1 : 0
18
+ return wb - wa || (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
19
+ }
20
+
21
+ export const DEFAULT_REGISTRY_PATH = join(homedir(), ".local", "share", "opencode", "goal-registry.json")
22
+
23
+ export class Registry {
24
+ constructor(readonly path = process.env.OCGOAL_REGISTRY ?? DEFAULT_REGISTRY_PATH) {}
25
+
26
+ read(): RegistryData {
27
+ try {
28
+ const value = JSON.parse(readFileSync(this.path, "utf8"))
29
+ if (value?.version === 1 && value.projects && typeof value.projects === "object") return value as RegistryData
30
+ } catch {
31
+ // missing or corrupt: the index is derived, so start empty
32
+ }
33
+ return { version: 1, projects: {} }
34
+ }
35
+
36
+ private write(data: RegistryData) {
37
+ mkdirSync(dirname(this.path), { recursive: true })
38
+ const tmp = join(tmpdir(), `goal-registry-${process.pid}-${Date.now()}.json`)
39
+ writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`)
40
+ renameSync(tmp, this.path)
41
+ }
42
+
43
+ /** Upsert one goal; skips the write when nothing changed (persist is hot). */
44
+ update(projectRoot: string, slug: string, entry: RegistryEntry): boolean {
45
+ const data = this.read()
46
+ const project = data.projects[projectRoot] ?? { goals: {}, updatedAt: 0 }
47
+ const current = project.goals[slug]
48
+ if (
49
+ current &&
50
+ current.title === entry.title &&
51
+ current.status === entry.status &&
52
+ current.runId === entry.runId &&
53
+ current.lock === entry.lock &&
54
+ current.archived === entry.archived &&
55
+ current.priority === entry.priority
56
+ )
57
+ return false
58
+ project.goals[slug] = entry
59
+ project.updatedAt = entry.updatedAt
60
+ data.projects[projectRoot] = project
61
+ this.write(data)
62
+ return true
63
+ }
64
+
65
+ remove(projectRoot: string, slug: string): boolean {
66
+ const data = this.read()
67
+ const project = data.projects[projectRoot]
68
+ if (!project?.goals[slug]) return false
69
+ delete project.goals[slug]
70
+ project.updatedAt = Date.now()
71
+ if (Object.keys(project.goals).length === 0) delete data.projects[projectRoot]
72
+ this.write(data)
73
+ return true
74
+ }
75
+
76
+ /** All goals across all known projects: [{project, slug, ...entry}], priority then recency. */
77
+ list(): Array<{ project: string; slug: string } & RegistryEntry> {
78
+ const out: Array<{ project: string; slug: string } & RegistryEntry> = []
79
+ for (const [project, p] of Object.entries(this.read().projects))
80
+ for (const [slug, entry] of Object.entries(p.goals)) out.push({ project, slug, ...entry })
81
+ return out.sort(byPriorityThenRecency)
82
+ }
83
+
84
+ /**
85
+ * Rebuild by scanning project roots; returns true when the rebuild changed
86
+ * the index (the parity property tests rely on: update-driven and
87
+ * scan-driven content must be identical for the same on-disk truth).
88
+ */
89
+ rebuild(roots: string[]): boolean {
90
+ const data: RegistryData = { version: 1, projects: {} }
91
+ for (const root of roots) {
92
+ const goalsDir = join(root, ".opencode", "goals")
93
+ if (!existsSync(goalsDir)) continue
94
+ const project = { goals: {} as Record<string, RegistryEntry>, updatedAt: 0 }
95
+ for (const dirent of readdirSync(goalsDir, { withFileTypes: true })) {
96
+ if (!dirent.isDirectory()) continue
97
+ const slug = dirent.name
98
+ const runPath = join(goalsDir, slug, "run.json")
99
+ let entry: RegistryEntry | undefined
100
+ try {
101
+ const run = JSON.parse(readFileSync(runPath, "utf8"))
102
+ if (run?.version !== 1) continue
103
+ entry = { title: run.title ?? slug, status: run.status ?? "draft", ...(run.runId ? { runId: run.runId } : {}), ...(run.lock ? { lock: run.lock } : {}), ...(run.priority ? { priority: run.priority } : {}), updatedAt: run.updatedAt ?? statSync(runPath).mtimeMs }
104
+ } catch {
105
+ // a goal folder without a readable run.json: still a draft goal
106
+ const contract = join(goalsDir, slug, "goal.yaml")
107
+ if (!existsSync(contract)) continue
108
+ entry = { title: slug, status: "draft", updatedAt: statSync(contract).mtimeMs }
109
+ }
110
+ project.goals[slug] = entry
111
+ project.updatedAt = Math.max(project.updatedAt, entry.updatedAt)
112
+ }
113
+ if (Object.keys(project.goals).length) data.projects[root] = project
114
+ }
115
+ const before = JSON.stringify(this.read())
116
+ this.write(data)
117
+ return JSON.stringify(this.read()) !== before
118
+ }
119
+ }
@@ -0,0 +1,65 @@
1
+ // T033 — admission-time existence and similarity probes (council
2
+ // 20261003T162655Z-800c52ca): before a new slug is formed, /goal new checks
3
+ // whether this intent already exists (exact slug or title) and surfaces
4
+ // similar goals ranked, so duplicate intent is caught at the door. Pure and
5
+ // unit-tested; the registry supplies candidates across live AND archived
6
+ // goals (existence must survive archiving).
7
+
8
+ const STOP = new Set([
9
+ "the", "a", "an", "to", "for", "of", "and", "or", "in", "on", "at", "is", "are", "be", "it", "its",
10
+ "this", "that", "with", "as", "by", "from", "you", "we", "our", "your", "make", "get", "set", "up",
11
+ ])
12
+
13
+ export function tokenize(text: string): string[] {
14
+ return text
15
+ .toLowerCase()
16
+ .split(/[^a-z0-9]+/)
17
+ .filter((t) => t.length > 2 && !STOP.has(t))
18
+ }
19
+
20
+ /** Jaccard overlap of token sets — cheap, order-free, good enough at goal scale. */
21
+ export function similarity(a: string, b: string): number {
22
+ const A = new Set(tokenize(a))
23
+ const B = new Set(tokenize(b))
24
+ if (!A.size || !B.size) return 0
25
+ let shared = 0
26
+ for (const t of A) if (B.has(t)) shared += 1
27
+ return shared / (A.size + B.size - shared)
28
+ }
29
+
30
+ export type Candidate = { slug: string; title: string; status?: string; archived?: boolean; project?: string }
31
+
32
+ export type Match = Candidate & { score: number; matched: string[] }
33
+
34
+ /** Exact existence: same slug, or a title that is the query verbatim (case-insensitive). */
35
+ export function exactExists(query: string, slugHint: string, candidates: Candidate[]): Candidate | undefined {
36
+ const q = query.trim().toLowerCase()
37
+ return candidates.find((c) => c.slug === slugHint.trim().toLowerCase() || c.title.trim().toLowerCase() === q)
38
+ }
39
+
40
+ /** Ranked similar goals above the threshold, best first, capped. */
41
+ export function similarGoals(query: string, candidates: Candidate[], threshold = 0.34, limit = 5): Match[] {
42
+ const tokens = new Set(tokenize(query))
43
+ return candidates
44
+ .map((c) => {
45
+ const hay = `${c.slug} ${c.title}`
46
+ const score = similarity(query, hay)
47
+ const matched = [...tokens].filter((t) => tokenize(hay).includes(t))
48
+ return { ...c, score, matched }
49
+ })
50
+ .filter((m) => m.score >= threshold)
51
+ .sort((a, b) => b.score - a.score)
52
+ .slice(0, limit)
53
+ }
54
+
55
+ /** The note /goal new prepends when the probe finds anything worth surfacing. */
56
+ export function admissionNote(query: string, slugHint: string, candidates: Candidate[]): string | undefined {
57
+ const exact = exactExists(query, slugHint, candidates)
58
+ const similar = similarGoals(query, candidates).filter((m) => !exact || m.slug !== exact.slug)
59
+ if (!exact && !similar.length) return undefined
60
+ const lines = ["[goal admission probe]"]
61
+ if (exact) lines.push(`EXISTS already: ${exact.slug} — "${exact.title}" (${exact.status ?? "draft"}${exact.archived ? ", archived" : ""}). Do not duplicate it: continue, resume or supersede it instead.`)
62
+ for (const m of similar) lines.push(`similar (${m.score.toFixed(2)}): ${m.slug} — "${m.title}" (${m.status ?? "draft"}${m.archived ? ", archived" : ""}) — consider superseding it with supersedes: ${m.slug}@<lock> if this replaces it.`)
63
+ lines.push("Surface these to the owner as options before forming a new slug: start fresh · continue/resume the existing goal · supersede it.")
64
+ return lines.join("\n")
65
+ }
@@ -0,0 +1,68 @@
1
+ // v0.8.0 — a decision that can never legitimately be answered must never be
2
+ // shown again.
3
+ //
4
+ // Three ways a stored owner question goes stale without an answer:
5
+ // - the target generation moved on (lock/run/session changed) or the goal was
6
+ // superseded — the old question is about bytes that no longer exist;
7
+ // - the review's own clock expired (legacy/timed reviews only);
8
+ // - it was already answered or otherwise settled.
9
+ //
10
+ // A dismissed review (`interrupted`) is NOT stale: it is unanswered and may be
11
+ // re-presented by an explicit reload or goal_review. Pure on purpose.
12
+
13
+ import { reviewExpired } from "./owner-reviews"
14
+
15
+ export type DecisionLike = {
16
+ status: string
17
+ expiresAt: number
18
+ expiryPolicy?: "time" | "target"
19
+ }
20
+
21
+ export type TargetLike = {
22
+ slug: string
23
+ runId?: string
24
+ sessionID?: string
25
+ lock: string
26
+ status?: string
27
+ }
28
+
29
+ export type Staleness =
30
+ | { stale: false }
31
+ | { stale: true; code: "answered" | "expired" | "superseded" | "target-changed" | "missing"; reason: string }
32
+
33
+ const OPEN = ["pending", "interrupted"]
34
+
35
+ export function decisionStaleness(request: DecisionLike, current: TargetLike | undefined, now = Date.now()): Staleness {
36
+ if (!OPEN.includes(request.status))
37
+ return { stale: true, code: "answered", reason: `the decision is already ${request.status}; old choices cannot be replayed` }
38
+ if (reviewExpired(request, now))
39
+ return { stale: true, code: "expired", reason: "the review expired; a new exact review is required" }
40
+ if (!current)
41
+ return { stale: true, code: "missing", reason: "the decision's target no longer exists" }
42
+ if (current.status === "superseded")
43
+ return { stale: true, code: "superseded", reason: "the goal was superseded by a successor" }
44
+ return { stale: false }
45
+ }
46
+
47
+ /** Whether the target tuple the review was bound to still names the same run. */
48
+ export function sameTarget(
49
+ bound: { slug: string; runId?: string; sessionID?: string; lock: string },
50
+ current: TargetLike | undefined,
51
+ ): boolean {
52
+ return !!current && current.slug === bound.slug && current.lock === bound.lock
53
+ && current.runId === bound.runId && current.sessionID === bound.sessionID
54
+ }
55
+
56
+ /** The full check the engine uses before re-presenting: staleness + tuple. */
57
+ export function staleDecision(
58
+ request: DecisionLike,
59
+ bound: { slug: string; runId?: string; sessionID?: string; lock: string },
60
+ current: TargetLike | undefined,
61
+ now = Date.now(),
62
+ ): Staleness {
63
+ const base = decisionStaleness(request, current, now)
64
+ if (base.stale) return base
65
+ if (!sameTarget(bound, current))
66
+ return { stale: true, code: "target-changed", reason: "the goal generation or owning session changed; the old review is invalid" }
67
+ return { stale: false }
68
+ }