@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.
- package/CHANGELOG.md +328 -0
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/cli/ocg.ts +149 -0
- package/dist/cli.js +8708 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +1920 -0
- package/package.json +79 -4
- package/rpc.ts +2 -0
- package/server.ts +3 -0
- package/skills/write-goal/SKILL.md +211 -0
- package/skills/write-goal/assets/goal.template.yaml +38 -0
- package/skills/write-goal/references/examples.md +283 -0
- package/skills/write-goal/references/goal-schema.md +53 -0
- package/skills/write-goal/references/interview.md +36 -0
- package/src/cli/install.ts +125 -0
- package/src/cli/status.ts +116 -0
- package/src/contract/parse.ts +454 -0
- package/src/contract/render.ts +134 -0
- package/src/contract/types.ts +158 -0
- package/src/engine/amendment.ts +51 -0
- package/src/engine/amendments.ts +72 -0
- package/src/engine/cleo-bridge.ts +339 -0
- package/src/engine/cleo-link.ts +150 -0
- package/src/engine/continue-work.ts +131 -0
- package/src/engine/decisions.ts +44 -0
- package/src/engine/dogfood-record.ts +278 -0
- package/src/engine/handoff.ts +102 -0
- package/src/engine/interrupt-policy.ts +45 -0
- package/src/engine/inventory.ts +70 -0
- package/src/engine/launches.ts +82 -0
- package/src/engine/loop-stop.ts +50 -0
- package/src/engine/loop.ts +109 -0
- package/src/engine/next-work.ts +82 -0
- package/src/engine/owner-intent.ts +71 -0
- package/src/engine/owner-reviews.ts +178 -0
- package/src/engine/peer-announce.ts +67 -0
- package/src/engine/peer-transport.ts +78 -0
- package/src/engine/policy.ts +42 -0
- package/src/engine/registry.ts +119 -0
- package/src/engine/similarity.ts +65 -0
- package/src/engine/stale-decisions.ts +68 -0
- package/src/engine/state.ts +185 -0
- package/src/engine/store.ts +263 -0
- package/src/engine/tick-record.ts +129 -0
- package/src/engine/tick.ts +207 -0
- package/src/rpc.ts +182 -0
- package/src/server/app.ts +4320 -0
- package/src/server/help.ts +50 -0
- package/src/server/index.ts +23 -0
- package/src/server/intent.ts +22 -0
- package/src/server/messages.ts +228 -0
- package/src/summary/audit.ts +76 -0
- package/src/summary/build.ts +314 -0
- package/src/tui/dashboard.ts +322 -0
- package/src/tui/decision-form.ts +12 -0
- package/src/tui/format.ts +127 -0
- package/src/tui/index.tsx +718 -0
- package/src/tui/manager.ts +94 -0
- package/src/tui/navigation.ts +11 -0
- package/src/tui/pointer-select.ts +20 -0
- package/src/tui/tracer.ts +51 -0
- package/src/util/git.ts +102 -0
- package/src/util/ids.ts +20 -0
- package/src/util/shell.ts +42 -0
- package/src/verify/artifact.ts +95 -0
- package/src/verify/pipeline.ts +200 -0
- package/tui.tsx +2 -0
|
@@ -0,0 +1,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
|
+
}
|