opencode-goal-plugin 0.4.7 → 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.
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // Installation verification for opencode-goal-plugin.
3
+ // Checks the plugin can be loaded and wired up correctly without ever
4
+ // invoking a model — every check below uses the same mock-client approach
5
+ // as scripts/smoke-command-hook.mjs.
6
+
7
+ import assert from "node:assert/strict"
8
+
9
+ const REQUIRED_HOOKS = [
10
+ "config",
11
+ "command.execute.before",
12
+ "event",
13
+ "experimental.chat.system.transform",
14
+ "experimental.compaction.autocontinue",
15
+ "experimental.session.compacting",
16
+ ]
17
+
18
+ const results = []
19
+
20
+ function check(name, fn) {
21
+ return Promise.resolve()
22
+ .then(fn)
23
+ .then(() => {
24
+ results.push({ name, ok: true })
25
+ console.log(` ✅ ${name}`)
26
+ })
27
+ .catch((error) => {
28
+ results.push({ name, ok: false, error })
29
+ console.log(` ❌ ${name}`)
30
+ console.log(` ${error.message}`)
31
+ })
32
+ }
33
+
34
+ console.log("opencode-goal-plugin installation verification\n")
35
+
36
+ await check("Node.js >= 18", () => {
37
+ const major = Number(process.versions.node.split(".")[0])
38
+ assert.ok(major >= 18, `Node ${process.versions.node} is below the required >=18`)
39
+ })
40
+
41
+ let pluginModule
42
+ let GoalPlugin
43
+
44
+ await check("plugin module resolves and exposes expected shape", async () => {
45
+ pluginModule = await import("opencode-goal-plugin")
46
+ GoalPlugin = pluginModule.GoalPlugin
47
+ assert.equal(pluginModule.default.id, "opencode-goal-plugin")
48
+ assert.equal(typeof pluginModule.default.server, "function")
49
+ assert.equal(typeof GoalPlugin, "function")
50
+ })
51
+
52
+ const sessionID = `verify-${process.pid}`
53
+ const promptCalls = []
54
+ const logCalls = []
55
+
56
+ const client = {
57
+ app: {
58
+ log: async (input) => {
59
+ logCalls.push(input)
60
+ },
61
+ },
62
+ session: {
63
+ messages: async () => ({ data: [] }),
64
+ promptAsync: async (input) => {
65
+ promptCalls.push(input)
66
+ return {}
67
+ },
68
+ },
69
+ }
70
+
71
+ let hooks
72
+
73
+ await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} required hooks`, async () => {
74
+ // registerTools defaults to true but silently no-ops without the optional
75
+ // @opencode-ai/plugin peer dependency, so it is not asserted here — the
76
+ // These hooks are always present regardless of that peer dependency.
77
+ hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
78
+ for (const hookName of REQUIRED_HOOKS) {
79
+ assert.equal(
80
+ typeof hooks[hookName],
81
+ "function",
82
+ `missing or non-function hook: ${hookName}`,
83
+ )
84
+ }
85
+ })
86
+
87
+ async function runGoalCommand(args) {
88
+ const output = { parts: [] }
89
+ await hooks["command.execute.before"](
90
+ { command: "goal", sessionID, arguments: args },
91
+ output,
92
+ )
93
+ assert.equal(output.parts.length, 1)
94
+ assert.equal(output.parts[0].type, "text")
95
+ return output.parts[0].text
96
+ }
97
+
98
+ await check("/goal status works", async () => {
99
+ const text = await runGoalCommand("status")
100
+ assert.match(text, /No active goal/)
101
+ })
102
+
103
+ await check("/goal set works", async () => {
104
+ const text = await runGoalCommand("verify the installation --max-turns 1")
105
+ assert.match(text, /New active goal: verify the installation/)
106
+ const statusText = await runGoalCommand("status")
107
+ assert.match(statusText, /Active goal: verify the installation/)
108
+ })
109
+
110
+ await check("no model calls were made during verification", () => {
111
+ assert.equal(promptCalls.length, 0, "expected zero promptAsync calls")
112
+ })
113
+
114
+ // Clean up the goal created above so this script has no side effects.
115
+ await runGoalCommand("clear")
116
+
117
+ console.log()
118
+
119
+ const failed = results.filter((r) => !r.ok)
120
+ if (failed.length > 0) {
121
+ console.log(`${failed.length}/${results.length} checks failed.`)
122
+ process.exit(1)
123
+ }
124
+
125
+ console.log(`All ${results.length} checks passed. opencode-goal-plugin is installed correctly.`)
@@ -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
+ }