opencode-goal-plugin 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -3
- package/CONTRIBUTING.md +8 -7
- package/README.md +54 -29
- package/SECURITY.md +29 -1
- package/demo/README.md +86 -0
- package/demo/opencode.json +11 -0
- package/demo/package.json +10 -0
- package/demo/src/math.js +3 -0
- package/demo/test/math.test.js +11 -0
- package/docs/providers.md +84 -0
- package/index.d.ts +49 -4
- package/package.json +7 -3
- package/scripts/behavior-benchmark.mjs +272 -0
- package/scripts/packed-host-contract.mjs +160 -0
- package/scripts/verify.mjs +4 -2
- package/src/completion-claim.js +127 -0
- package/src/goal-plugin.js +587 -136
- package/src/goal-tool-result.js +18 -0
- package/src/native-agent-config.js +69 -0
- package/src/opencode-session-api.js +100 -0
- package/src/persistence-lease.js +82 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Durable, guarded goal workflows for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"src",
|
|
17
17
|
"scripts",
|
|
18
18
|
"examples",
|
|
19
|
+
"demo",
|
|
20
|
+
"docs",
|
|
19
21
|
"index.d.ts",
|
|
20
22
|
"README.md",
|
|
21
23
|
"CHANGELOG.md",
|
|
@@ -28,6 +30,8 @@
|
|
|
28
30
|
"test": "node --test test/*.test.js",
|
|
29
31
|
"test:coverage": "node --test --experimental-test-coverage test/*.test.js",
|
|
30
32
|
"smoke": "node scripts/smoke-command-hook.mjs",
|
|
33
|
+
"smoke:packed-host": "node scripts/packed-host-contract.mjs",
|
|
34
|
+
"benchmark:behavior": "node scripts/behavior-benchmark.mjs",
|
|
31
35
|
"verify": "node scripts/verify.mjs",
|
|
32
36
|
"check": "node -c src/goal-plugin.js && npm test",
|
|
33
37
|
"pack:check": "npm pack --dry-run"
|
|
@@ -39,7 +43,7 @@
|
|
|
39
43
|
"goal"
|
|
40
44
|
],
|
|
41
45
|
"peerDependencies": {
|
|
42
|
-
"@opencode-ai/plugin": ">=1"
|
|
46
|
+
"@opencode-ai/plugin": ">=1.17.15 <2"
|
|
43
47
|
},
|
|
44
48
|
"peerDependenciesMeta": {
|
|
45
49
|
"@opencode-ai/plugin": {
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import { promises as fs } from "node:fs"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { GoalPlugin } from "../src/goal-plugin.js"
|
|
8
|
+
|
|
9
|
+
const startedAt = performance.now()
|
|
10
|
+
const temporaryDirectories = []
|
|
11
|
+
|
|
12
|
+
function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) {
|
|
13
|
+
return {
|
|
14
|
+
info: {
|
|
15
|
+
id,
|
|
16
|
+
role: "assistant",
|
|
17
|
+
sessionID,
|
|
18
|
+
tokens: { input: 20, output: 120, reasoning: 0 },
|
|
19
|
+
},
|
|
20
|
+
parts: [{ type: "text", text }],
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createHost(messageForSession = () => "Working with tools.") {
|
|
25
|
+
const prompts = []
|
|
26
|
+
const notices = []
|
|
27
|
+
return {
|
|
28
|
+
prompts,
|
|
29
|
+
notices,
|
|
30
|
+
client: {
|
|
31
|
+
app: { log: async () => {} },
|
|
32
|
+
session: {
|
|
33
|
+
messages: async ({ path }) => ({
|
|
34
|
+
data: [assistantMessage(path.id, messageForSession(path.id))],
|
|
35
|
+
}),
|
|
36
|
+
promptAsync: async (input) => {
|
|
37
|
+
prompts.push(input)
|
|
38
|
+
return {}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function promptCharacters(prompts) {
|
|
46
|
+
return prompts.reduce(
|
|
47
|
+
(total, prompt) => total + (prompt?.body?.parts || []).reduce(
|
|
48
|
+
(partTotal, part) => partTotal + (typeof part?.text === "string" ? part.text.length : 0),
|
|
49
|
+
0,
|
|
50
|
+
),
|
|
51
|
+
0,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function makeDirectory(label) {
|
|
56
|
+
const directory = await fs.mkdtemp(join(tmpdir(), `goal-benchmark-${label}-`))
|
|
57
|
+
temporaryDirectories.push(directory)
|
|
58
|
+
return directory
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function createHooks(host, options = {}) {
|
|
62
|
+
return GoalPlugin(
|
|
63
|
+
{ client: host.client, directory: await makeDirectory("workspace") },
|
|
64
|
+
{
|
|
65
|
+
persistState: false,
|
|
66
|
+
registerTools: false,
|
|
67
|
+
registerAgents: false,
|
|
68
|
+
minDelayMs: 1,
|
|
69
|
+
noProgressTokenThreshold: 1,
|
|
70
|
+
noProgressTurnsBeforePause: 10,
|
|
71
|
+
noToolCallTurnsBeforePause: 2,
|
|
72
|
+
...options,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function goalCommand(hooks, sessionID, argumentsText) {
|
|
78
|
+
const output = { parts: [] }
|
|
79
|
+
await hooks["command.execute.before"](
|
|
80
|
+
{ command: "goal", sessionID, arguments: argumentsText },
|
|
81
|
+
output,
|
|
82
|
+
)
|
|
83
|
+
return output.parts.map((part) => part.text || "").join("\n")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function idle(hooks, sessionID, id) {
|
|
87
|
+
await hooks.event({
|
|
88
|
+
event: {
|
|
89
|
+
id,
|
|
90
|
+
type: "session.status",
|
|
91
|
+
properties: { sessionID, status: { type: "idle" } },
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function scenario(name, points, run) {
|
|
97
|
+
const scenarioStartedAt = performance.now()
|
|
98
|
+
try {
|
|
99
|
+
const telemetry = await run()
|
|
100
|
+
return {
|
|
101
|
+
name,
|
|
102
|
+
passed: true,
|
|
103
|
+
points,
|
|
104
|
+
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
105
|
+
...telemetry,
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
passed: false,
|
|
111
|
+
points: 0,
|
|
112
|
+
possiblePoints: points,
|
|
113
|
+
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
114
|
+
error: error instanceof Error ? error.message : String(error),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const results = []
|
|
120
|
+
|
|
121
|
+
results.push(await scenario("verified-success", 20, async () => {
|
|
122
|
+
const sessionID = "benchmark-success"
|
|
123
|
+
const host = createHost(() => "Tests pass.\n[goal:evidence] npm test: 210/210\n[goal:complete]")
|
|
124
|
+
const hooks = await createHooks(host, {
|
|
125
|
+
auditor: async () => ({ approved: true, reason: "evidence independently accepted" }),
|
|
126
|
+
})
|
|
127
|
+
await goalCommand(hooks, sessionID, "ship a verified release")
|
|
128
|
+
await idle(hooks, sessionID, "success-idle")
|
|
129
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
130
|
+
assert.match(status, /State: achieved/)
|
|
131
|
+
await hooks.dispose()
|
|
132
|
+
return {
|
|
133
|
+
continuationPrompts: host.prompts.length,
|
|
134
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
135
|
+
status: "archived",
|
|
136
|
+
}
|
|
137
|
+
}))
|
|
138
|
+
|
|
139
|
+
results.push(await scenario("false-completion", 20, async () => {
|
|
140
|
+
const sessionID = "benchmark-false-completion"
|
|
141
|
+
const host = createHost(() => "Looks done.\n[goal:evidence] guessed from source\n[goal:complete]")
|
|
142
|
+
const hooks = await createHooks(host, {
|
|
143
|
+
auditor: async () => ({ approved: false, reason: "no executed verification" }),
|
|
144
|
+
})
|
|
145
|
+
await goalCommand(hooks, sessionID, "do not accept an unverified claim")
|
|
146
|
+
await idle(hooks, sessionID, "false-idle")
|
|
147
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
148
|
+
assert.match(status, /audit rejected/i)
|
|
149
|
+
assert.doesNotMatch(status, /No active goal/)
|
|
150
|
+
await hooks.dispose()
|
|
151
|
+
return {
|
|
152
|
+
continuationPrompts: host.prompts.length,
|
|
153
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
154
|
+
status: "rejected",
|
|
155
|
+
}
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
results.push(await scenario("loop-circuit-breaker", 15, async () => {
|
|
159
|
+
const sessionID = "benchmark-loop"
|
|
160
|
+
let turn = 0
|
|
161
|
+
const host = createHost(() => `Still discussing the work, turn ${turn++}.`)
|
|
162
|
+
const hooks = await createHooks(host)
|
|
163
|
+
await goalCommand(hooks, sessionID, "stop self-chat loops")
|
|
164
|
+
await idle(hooks, sessionID, "loop-1")
|
|
165
|
+
await idle(hooks, sessionID, "loop-2")
|
|
166
|
+
await idle(hooks, sessionID, "loop-3")
|
|
167
|
+
const status = await goalCommand(hooks, sessionID, "status")
|
|
168
|
+
assert.match(status, /no tool calls|self-chat loop/i)
|
|
169
|
+
assert.equal(host.prompts.length, 2)
|
|
170
|
+
await hooks.dispose()
|
|
171
|
+
return {
|
|
172
|
+
continuationPrompts: host.prompts.length,
|
|
173
|
+
continuationCharacters: promptCharacters(host.prompts),
|
|
174
|
+
status: "paused",
|
|
175
|
+
}
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
results.push(await scenario("human-interruption", 15, async () => {
|
|
179
|
+
const sessionID = "benchmark-interruption"
|
|
180
|
+
const host = createHost()
|
|
181
|
+
const hooks = await createHooks(host)
|
|
182
|
+
await goalCommand(hooks, sessionID, "respect explicit interruption")
|
|
183
|
+
await hooks.event({
|
|
184
|
+
event: {
|
|
185
|
+
type: "session.error",
|
|
186
|
+
properties: {
|
|
187
|
+
sessionID,
|
|
188
|
+
error: { name: "MessageAbortedError", message: "aborted by user" },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
await idle(hooks, sessionID, "interruption-idle")
|
|
193
|
+
assert.equal(host.prompts.length, 0)
|
|
194
|
+
assert.match(await goalCommand(hooks, sessionID, "status"), /abort|paused|stopped/i)
|
|
195
|
+
await hooks.dispose()
|
|
196
|
+
return { continuationPrompts: 0, status: "paused" }
|
|
197
|
+
}))
|
|
198
|
+
|
|
199
|
+
results.push(await scenario("compaction-continuity", 15, async () => {
|
|
200
|
+
const sessionID = "benchmark-compaction"
|
|
201
|
+
const host = createHost()
|
|
202
|
+
const hooks = await createHooks(host)
|
|
203
|
+
await goalCommand(hooks, sessionID, "preserve the objective across compaction")
|
|
204
|
+
const output = { context: [] }
|
|
205
|
+
await hooks["experimental.session.compacting"]({ sessionID }, output)
|
|
206
|
+
assert.equal(output.context.length, 1)
|
|
207
|
+
assert.match(output.context[0], /preserve the objective across compaction/)
|
|
208
|
+
assert.ok(output.context[0].length < 2_000, "compaction context exceeded token-efficient size cap")
|
|
209
|
+
await hooks.dispose()
|
|
210
|
+
return {
|
|
211
|
+
contextCharacters: output.context[0].length,
|
|
212
|
+
estimatedContextTokens: Math.ceil(output.context[0].length / 4),
|
|
213
|
+
status: "preserved",
|
|
214
|
+
}
|
|
215
|
+
}))
|
|
216
|
+
|
|
217
|
+
results.push(await scenario("restart-recovery", 15, async () => {
|
|
218
|
+
const sessionID = "benchmark-restart"
|
|
219
|
+
const directory = await makeDirectory("restart")
|
|
220
|
+
const stateFilePath = join(directory, "state.json")
|
|
221
|
+
const host = createHost()
|
|
222
|
+
const first = await GoalPlugin(
|
|
223
|
+
{ client: host.client, directory },
|
|
224
|
+
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
225
|
+
)
|
|
226
|
+
await goalCommand(first, sessionID, "recover safely after restart")
|
|
227
|
+
await first.dispose()
|
|
228
|
+
const second = await GoalPlugin(
|
|
229
|
+
{ client: host.client, directory },
|
|
230
|
+
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
231
|
+
)
|
|
232
|
+
const status = await goalCommand(second, sessionID, "status")
|
|
233
|
+
assert.match(status, /Recovered persisted goal state|recovered after restart/i)
|
|
234
|
+
await idle(second, sessionID, "restart-idle")
|
|
235
|
+
assert.equal(host.prompts.length, 0, "recovered goals must not resume without user consent")
|
|
236
|
+
const stateBytes = (await fs.stat(stateFilePath)).size
|
|
237
|
+
await second.dispose()
|
|
238
|
+
return { continuationPrompts: 0, persistedStateBytes: stateBytes, status: "recovered-paused" }
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
const score = results.reduce((total, result) => total + result.points, 0)
|
|
242
|
+
const possibleScore = 100
|
|
243
|
+
const continuationCharacters = results.reduce(
|
|
244
|
+
(total, result) => total + (result.continuationCharacters || 0),
|
|
245
|
+
0,
|
|
246
|
+
)
|
|
247
|
+
const report = {
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
benchmark: "opencode-goal-plugin-behavior",
|
|
250
|
+
score,
|
|
251
|
+
possibleScore,
|
|
252
|
+
passed: score === possibleScore,
|
|
253
|
+
durationMs: Number((performance.now() - startedAt).toFixed(2)),
|
|
254
|
+
efficiency: {
|
|
255
|
+
totalContinuationPrompts: results.reduce(
|
|
256
|
+
(total, result) => total + (result.continuationPrompts || 0),
|
|
257
|
+
0,
|
|
258
|
+
),
|
|
259
|
+
continuationCharacters,
|
|
260
|
+
estimatedContinuationTokens: Math.ceil(continuationCharacters / 4),
|
|
261
|
+
modelCalls: 0,
|
|
262
|
+
externalRequests: 0,
|
|
263
|
+
},
|
|
264
|
+
scenarios: results,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const directory of temporaryDirectories) {
|
|
268
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
272
|
+
if (!report.passed) process.exitCode = 1
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { pathToFileURL } from "node:url"
|
|
7
|
+
|
|
8
|
+
const repository = new URL("..", import.meta.url)
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-packed-host-"))
|
|
10
|
+
const packDirectory = join(root, "pack")
|
|
11
|
+
const projectDirectory = join(root, "host-project")
|
|
12
|
+
const cacheDirectory = join(root, "npm-cache")
|
|
13
|
+
const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory }
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await Promise.all([
|
|
17
|
+
mkdir(packDirectory, { recursive: true }),
|
|
18
|
+
mkdir(projectDirectory, { recursive: true }),
|
|
19
|
+
mkdir(cacheDirectory, { recursive: true }),
|
|
20
|
+
])
|
|
21
|
+
await writeFile(
|
|
22
|
+
join(projectDirectory, "package.json"),
|
|
23
|
+
JSON.stringify({ private: true, type: "module" }),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const packResult = JSON.parse(
|
|
27
|
+
execFileSync(
|
|
28
|
+
"npm",
|
|
29
|
+
["pack", "--json", "--pack-destination", packDirectory],
|
|
30
|
+
{ cwd: repository, encoding: "utf8", env: npmEnvironment },
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
assert.equal(packResult.length, 1)
|
|
34
|
+
const tarball = join(packDirectory, packResult[0].filename)
|
|
35
|
+
|
|
36
|
+
// Install only the artifact npm produced. The optional peer is omitted so this
|
|
37
|
+
// contract test is offline-safe and cannot mutate the user's OpenCode install.
|
|
38
|
+
execFileSync(
|
|
39
|
+
"npm",
|
|
40
|
+
[
|
|
41
|
+
"install",
|
|
42
|
+
"--ignore-scripts",
|
|
43
|
+
"--no-audit",
|
|
44
|
+
"--no-fund",
|
|
45
|
+
"--no-package-lock",
|
|
46
|
+
"--omit=peer",
|
|
47
|
+
"--offline",
|
|
48
|
+
"--cache",
|
|
49
|
+
cacheDirectory,
|
|
50
|
+
tarball,
|
|
51
|
+
],
|
|
52
|
+
{ cwd: projectDirectory, encoding: "utf8", env: npmEnvironment },
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const installedManifestPath = join(
|
|
56
|
+
projectDirectory,
|
|
57
|
+
"node_modules",
|
|
58
|
+
"opencode-goal-plugin",
|
|
59
|
+
"package.json",
|
|
60
|
+
)
|
|
61
|
+
const installedManifest = JSON.parse(await readFile(installedManifestPath, "utf8"))
|
|
62
|
+
const installedEntry = join(
|
|
63
|
+
projectDirectory,
|
|
64
|
+
"node_modules",
|
|
65
|
+
"opencode-goal-plugin",
|
|
66
|
+
installedManifest.main,
|
|
67
|
+
)
|
|
68
|
+
const installed = await import(pathToFileURL(installedEntry).href)
|
|
69
|
+
|
|
70
|
+
assert.equal(installed.default.id, "opencode-goal-plugin")
|
|
71
|
+
assert.equal(installed.default.server, installed.GoalPlugin)
|
|
72
|
+
|
|
73
|
+
const sessionID = "packed-host-contract"
|
|
74
|
+
const promptCalls = []
|
|
75
|
+
const client = {
|
|
76
|
+
app: { log: async () => {} },
|
|
77
|
+
session: {
|
|
78
|
+
messages: async ({ path }) => ({
|
|
79
|
+
data: [
|
|
80
|
+
{
|
|
81
|
+
info: {
|
|
82
|
+
id: "assistant-packed-contract",
|
|
83
|
+
role: "assistant",
|
|
84
|
+
sessionID: path.id,
|
|
85
|
+
tokens: { input: 1, output: 1, reasoning: 0 },
|
|
86
|
+
},
|
|
87
|
+
parts: [{ type: "text", text: "Work remains." }],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
promptAsync: async (input) => {
|
|
92
|
+
promptCalls.push(input)
|
|
93
|
+
return {}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
const hooks = await installed.GoalPlugin(
|
|
98
|
+
{ client, directory: projectDirectory },
|
|
99
|
+
{
|
|
100
|
+
persistState: false,
|
|
101
|
+
registerTools: false,
|
|
102
|
+
minDelayMs: 1,
|
|
103
|
+
noToolCallTurnsBeforePause: 10,
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for (const hook of [
|
|
108
|
+
"config",
|
|
109
|
+
"command.execute.before",
|
|
110
|
+
"event",
|
|
111
|
+
"experimental.chat.system.transform",
|
|
112
|
+
"experimental.session.compacting",
|
|
113
|
+
"experimental.compaction.autocontinue",
|
|
114
|
+
"dispose",
|
|
115
|
+
]) {
|
|
116
|
+
assert.equal(typeof hooks[hook], "function", `${hook} must be callable`)
|
|
117
|
+
}
|
|
118
|
+
const config = {}
|
|
119
|
+
await hooks.config(config)
|
|
120
|
+
assert.equal(config.agent.goal.mode, "primary")
|
|
121
|
+
assert.equal(config.agent["goal-verify"].tools.edit, false)
|
|
122
|
+
|
|
123
|
+
const output = { parts: [] }
|
|
124
|
+
await hooks["command.execute.before"](
|
|
125
|
+
{ command: "goal", sessionID, arguments: "verify the installed artifact --max-turns 1" },
|
|
126
|
+
output,
|
|
127
|
+
)
|
|
128
|
+
assert.match(output.parts[0]?.text, /New active goal/)
|
|
129
|
+
|
|
130
|
+
// Let the configured throttle window elapse before idle. This avoids leaving
|
|
131
|
+
// the contract dependent on the host's event-loop/timer shutdown behavior.
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
133
|
+
|
|
134
|
+
await hooks.event({
|
|
135
|
+
event: {
|
|
136
|
+
type: "session.status",
|
|
137
|
+
properties: { sessionID, status: { type: "idle" } },
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
assert.equal(promptCalls.length, 1)
|
|
142
|
+
// PluginInput currently supplies OpenCode's generated legacy client shape:
|
|
143
|
+
// session.promptAsync({ path, body }). The standalone adapter suite covers
|
|
144
|
+
// flattened v2 clients separately.
|
|
145
|
+
assert.deepEqual(promptCalls[0].path, { id: sessionID })
|
|
146
|
+
assert.equal(promptCalls[0].body.parts.length, 1)
|
|
147
|
+
assert.deepEqual(promptCalls[0].body.parts[0].metadata, {
|
|
148
|
+
"opencode-goal-plugin": { kind: "continuation" },
|
|
149
|
+
})
|
|
150
|
+
assert.equal(promptCalls[0].body.parts[0].synthetic, true)
|
|
151
|
+
|
|
152
|
+
await hooks.dispose()
|
|
153
|
+
await hooks.dispose()
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
`packed host contract passed (${installedManifest.name}@${installedManifest.version}; ${packResult[0].size} byte tarball)`,
|
|
157
|
+
)
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(root, { recursive: true, force: true })
|
|
160
|
+
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
import assert from "node:assert/strict"
|
|
8
8
|
|
|
9
9
|
const REQUIRED_HOOKS = [
|
|
10
|
+
"config",
|
|
10
11
|
"command.execute.before",
|
|
11
12
|
"event",
|
|
12
13
|
"experimental.chat.system.transform",
|
|
13
14
|
"experimental.compaction.autocontinue",
|
|
15
|
+
"experimental.session.compacting",
|
|
14
16
|
]
|
|
15
17
|
|
|
16
18
|
const results = []
|
|
@@ -68,10 +70,10 @@ const client = {
|
|
|
68
70
|
|
|
69
71
|
let hooks
|
|
70
72
|
|
|
71
|
-
await check(
|
|
73
|
+
await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} required hooks`, async () => {
|
|
72
74
|
// registerTools defaults to true but silently no-ops without the optional
|
|
73
75
|
// @opencode-ai/plugin peer dependency, so it is not asserted here — the
|
|
74
|
-
//
|
|
76
|
+
// These hooks are always present regardless of that peer dependency.
|
|
75
77
|
hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
|
|
76
78
|
for (const hookName of REQUIRED_HOOKS) {
|
|
77
79
|
assert.equal(
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const MAX_SUMMARY_LENGTH = 500
|
|
2
|
+
const MAX_CRITERIA = 20
|
|
3
|
+
const MAX_CHECKS = 20
|
|
4
|
+
const MAX_CHANGED_FILES = 100
|
|
5
|
+
const MAX_LIMITATIONS = 20
|
|
6
|
+
const MAX_CRITERION_LENGTH = 300
|
|
7
|
+
const MAX_ITEM_LENGTH = 500
|
|
8
|
+
const CHECK_RESULTS = new Set(["passed", "failed", "not-run"])
|
|
9
|
+
|
|
10
|
+
function cleanStringList(values, field) {
|
|
11
|
+
const cleaned = values.map((value) => (typeof value === "string" ? value.trim() : ""))
|
|
12
|
+
return cleaned.every((value) => value && value.length <= MAX_ITEM_LENGTH)
|
|
13
|
+
? { ok: true, values: cleaned }
|
|
14
|
+
: {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: `${field} entries must be non-empty strings of ${MAX_ITEM_LENGTH} characters or fewer`,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate an untrusted completion claim and render the concise evidence text
|
|
22
|
+
* consumed by completion auditors. Validation belongs at this tool boundary so
|
|
23
|
+
* malformed claims never enter goal state or verifier prompts.
|
|
24
|
+
*/
|
|
25
|
+
export function serializeCompletionClaim(raw = {}) {
|
|
26
|
+
const claim = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}
|
|
27
|
+
const summary = typeof claim.summary === "string" ? claim.summary.trim() : ""
|
|
28
|
+
if (!summary) return { ok: false, error: "summary must be a non-empty string" }
|
|
29
|
+
if (summary.length > MAX_SUMMARY_LENGTH) {
|
|
30
|
+
return { ok: false, error: `summary must be ${MAX_SUMMARY_LENGTH} characters or fewer` }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const criteria = claim.criteria === undefined ? [] : claim.criteria
|
|
34
|
+
const checks = claim.checks === undefined ? [] : claim.checks
|
|
35
|
+
const changedFiles = claim.changedFiles === undefined ? [] : claim.changedFiles
|
|
36
|
+
const knownLimitations = claim.knownLimitations === undefined ? [] : claim.knownLimitations
|
|
37
|
+
if (
|
|
38
|
+
!Array.isArray(criteria) ||
|
|
39
|
+
!Array.isArray(checks) ||
|
|
40
|
+
!Array.isArray(changedFiles) ||
|
|
41
|
+
!Array.isArray(knownLimitations)
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: "criteria, checks, changedFiles, and knownLimitations must be arrays when provided",
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
criteria.length > MAX_CRITERIA ||
|
|
50
|
+
checks.length > MAX_CHECKS ||
|
|
51
|
+
changedFiles.length > MAX_CHANGED_FILES ||
|
|
52
|
+
knownLimitations.length > MAX_LIMITATIONS
|
|
53
|
+
) {
|
|
54
|
+
return { ok: false, error: "completion claim exceeds item limits" }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cleanCriteria = []
|
|
58
|
+
for (const item of criteria) {
|
|
59
|
+
const criterion = typeof item?.criterion === "string" ? item.criterion.trim() : ""
|
|
60
|
+
const evidence = Array.isArray(item?.evidence)
|
|
61
|
+
? item.evidence.map((value) => (typeof value === "string" ? value.trim() : "")).filter(Boolean)
|
|
62
|
+
: []
|
|
63
|
+
if (!criterion || evidence.length === 0) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: "each criterion requires a non-empty criterion and at least one evidence item",
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
criterion.length > MAX_CRITERION_LENGTH ||
|
|
71
|
+
evidence.some((value) => value.length > MAX_ITEM_LENGTH)
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `criterion must be ${MAX_CRITERION_LENGTH} characters or fewer and evidence items ${MAX_ITEM_LENGTH} or fewer`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
cleanCriteria.push({ criterion, evidence })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cleanChecks = []
|
|
82
|
+
for (const item of checks) {
|
|
83
|
+
const result = typeof item?.result === "string" ? item.result.trim() : ""
|
|
84
|
+
if (!CHECK_RESULTS.has(result)) {
|
|
85
|
+
return { ok: false, error: "each check result must be passed, failed, or not-run" }
|
|
86
|
+
}
|
|
87
|
+
if (result === "failed") return { ok: false, error: "completion cannot include a failed check" }
|
|
88
|
+
const command = typeof item?.command === "string" ? item.command.trim() : ""
|
|
89
|
+
const explanation = typeof item?.explanation === "string" ? item.explanation.trim() : ""
|
|
90
|
+
const exitCode = item?.exitCode
|
|
91
|
+
if (exitCode !== undefined && (!Number.isInteger(exitCode) || exitCode < 0)) {
|
|
92
|
+
return { ok: false, error: "check exitCode must be a non-negative integer" }
|
|
93
|
+
}
|
|
94
|
+
if (!command && !explanation) {
|
|
95
|
+
return { ok: false, error: "each check requires a command or explanation" }
|
|
96
|
+
}
|
|
97
|
+
if (command.length > MAX_ITEM_LENGTH || explanation.length > MAX_ITEM_LENGTH) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `check command and explanation must be ${MAX_ITEM_LENGTH} characters or fewer`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cleanChecks.push({ command, result, exitCode, explanation })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const files = cleanStringList(changedFiles, "changedFiles")
|
|
107
|
+
if (!files.ok) return files
|
|
108
|
+
const limitations = cleanStringList(knownLimitations, "knownLimitations")
|
|
109
|
+
if (!limitations.ok) return limitations
|
|
110
|
+
|
|
111
|
+
const lines = [`Summary: ${summary}`]
|
|
112
|
+
cleanCriteria.forEach(({ criterion, evidence }) => {
|
|
113
|
+
lines.push(`Criterion: ${criterion} | Evidence: ${evidence.join("; ")}`)
|
|
114
|
+
})
|
|
115
|
+
cleanChecks.forEach(({ command, result, exitCode, explanation }) => {
|
|
116
|
+
const subject = command || "manual check"
|
|
117
|
+
const details = [exitCode === undefined ? "" : `exit ${exitCode}`, explanation]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join("; ")
|
|
120
|
+
lines.push(`Check: ${subject} | ${result}${details ? ` | ${details}` : ""}`)
|
|
121
|
+
})
|
|
122
|
+
if (files.values.length) lines.push(`Changed files: ${files.values.join(", ")}`)
|
|
123
|
+
if (limitations.values.length) {
|
|
124
|
+
lines.push(`Known limitations: ${limitations.values.join("; ")}`)
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, evidence: lines.join("\n") }
|
|
127
|
+
}
|