@aryaminus/controlkeel-opencode 0.1.27 → 0.1.29

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.
@@ -11,7 +11,7 @@ and validate them against the project's security, budget, and compliance policie
11
11
 
12
12
  ## Instructions
13
13
 
14
- 1. Use the `ck-validate` tool to run governance checks before providing feedback.
14
+ 1. Use `ck_context` first, then `ck_validate` before providing feedback.
15
15
  2. Report findings by severity: critical > high > medium > low.
16
16
  3. Never approve changes that have unresolved critical or high findings.
17
17
  4. Reference specific policy rules when flagging issues.
@@ -19,7 +19,10 @@ and validate them against the project's security, budget, and compliance policie
19
19
 
20
20
  ## Available MCP Tools
21
21
 
22
+ - `ck_context` — Load mission, findings, budget, and proof context
22
23
  - `ck_validate` — Run full governance validation
24
+ - `ck_finding` — Record a governed finding when you detect a missed issue
25
+ - `ck_review_submit` — Submit review material for human approval
26
+ - `ck_review_status` — Check review status before execution
23
27
  - `ck_budget` — Check remaining budget and spend history
24
- - `ck_findings` — List open findings for the current session
25
- - `ck_approve` — Approve a finding (requires operator confirmation)
28
+ - `ck_route` — Ask ControlKeel for the recommended specialist route
@@ -3,7 +3,7 @@ description: Run ControlKeel governance review on the current project
3
3
  agent: controlkeel-operator
4
4
  ---
5
5
 
6
- Review the current project for governance compliance. Run `ck-validate` to check
6
+ Review the current project for governance compliance. Run `ck_validate` to check
7
7
  for security findings, budget status, and proof readiness. Summarize the results
8
8
  and highlight any blockers that need attention before shipping.
9
9
 
@@ -6,7 +6,12 @@ Save the current plan to a markdown file, then submit it through ControlKeel.
6
6
 
7
7
  Recommended flow:
8
8
  1. Save the plan to `.opencode/review-plan.md`
9
- 2. Run `controlkeel review plan submit --body-file .opencode/review-plan.md --submitted-by opencode --json`
10
- 3. Read the returned `review.id` and `browser_url`
11
- 4. Wait with `controlkeel review plan wait --id <review_id> --json`
12
- 4. Do not execute until the review is approved
9
+ 2. Ensure `controlkeel version` reports `>= 0.1.26`
10
+ 3. Run `controlkeel review plan submit --body-file .opencode/review-plan.md --submitted-by opencode --task-id <task_id> --json` (or use `--session-id <session_id>`)
11
+ 4. Read the returned `review.id` and `browser_url`
12
+ 5. Wait with `controlkeel review plan wait --id <review_id> --timeout 30 --json`
13
+ 6. Do not execute until the review is approved
14
+
15
+ Fallback when the `submit_plan` tool is stale in a long-running OpenCode session:
16
+ - If the tool returns an error like `ControlKeel CLI [object Object] is too old`, run the CLI flow above directly.
17
+ - Restart OpenCode after plugin updates so `.opencode/plugins/controlkeel-governance.ts` is reloaded.
@@ -1,12 +1,13 @@
1
1
  {
2
- "mcpServers": {
2
+ "mcp": {
3
3
  "controlkeel": {
4
- "args": [
4
+ "command": [
5
+ "controlkeel",
5
6
  "mcp",
6
7
  "--project-root",
7
8
  "."
8
9
  ],
9
- "command": "controlkeel"
10
+ "type": "local"
10
11
  }
11
12
  }
12
13
  }
@@ -19,29 +19,185 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
19
19
  }
20
20
  }
21
21
 
22
- const submitPlan = async (body: string, submittedBy: string) => {
23
- const result = await $`controlkeel review plan submit --stdin --submitted-by ${submittedBy} --json`
24
- .text(body)
25
- const submitPayload = parseJson(result)
26
- const reviewId = submitPayload?.review?.id
27
- if (!reviewId) {
28
- throw new Error("ControlKeel did not return a review id")
22
+ const toText = async (output: unknown) => {
23
+ if (typeof output === "string") {
24
+ return output
29
25
  }
30
- const waitPayload = parseJson(await $`controlkeel review plan wait --id ${reviewId} --json`)
26
+
27
+ if (output instanceof Uint8Array) {
28
+ return new TextDecoder().decode(output)
29
+ }
30
+
31
+ if (output instanceof ArrayBuffer) {
32
+ return new TextDecoder().decode(new Uint8Array(output))
33
+ }
34
+
35
+ if (output == null) {
36
+ return ""
37
+ }
38
+
39
+ if (typeof output === "object") {
40
+ if (typeof (output as { text?: unknown }).text === "function") {
41
+ try {
42
+ const direct = await (output as { text: () => Promise<string> }).text()
43
+ if (typeof direct === "string") {
44
+ return direct
45
+ }
46
+ } catch (_error) {
47
+ }
48
+ }
49
+
50
+ const stdout = (output as { stdout?: unknown }).stdout
51
+ if (typeof stdout === "string") {
52
+ return stdout
53
+ }
54
+
55
+ if (stdout instanceof Uint8Array) {
56
+ return new TextDecoder().decode(stdout)
57
+ }
58
+
59
+ if (stdout instanceof ArrayBuffer) {
60
+ return new TextDecoder().decode(new Uint8Array(stdout))
61
+ }
62
+
63
+ if (stdout && typeof (stdout as { text?: unknown }).text === "function") {
64
+ try {
65
+ const streamed = await (stdout as { text: () => Promise<string> }).text()
66
+ if (typeof streamed === "string") {
67
+ return streamed
68
+ }
69
+ } catch (_error) {
70
+ }
71
+ }
72
+ }
73
+
74
+ return String(output)
75
+ }
76
+
77
+ const parseVersion = (output: string) => {
78
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/)
79
+ if (!match) {
80
+ return null
81
+ }
82
+
31
83
  return {
32
- reviewId,
33
- submitPayload,
34
- waitPayload,
35
- browserUrl: submitPayload?.browser_url,
36
- status: waitPayload?.review?.status,
37
- feedbackNotes: waitPayload?.review?.feedback_notes ?? null,
84
+ major: Number(match[1]),
85
+ minor: Number(match[2]),
86
+ patch: Number(match[3]),
87
+ }
88
+ }
89
+
90
+ const versionAtLeast = (
91
+ current: { major: number; minor: number; patch: number },
92
+ required: { major: number; minor: number; patch: number }
93
+ ) => {
94
+ if (current.major !== required.major) {
95
+ return current.major > required.major
96
+ }
97
+
98
+ if (current.minor !== required.minor) {
99
+ return current.minor > required.minor
100
+ }
101
+
102
+ return current.patch >= required.patch
103
+ }
104
+
105
+ const ensurePlanSubmitSupport = async () => {
106
+ let versionOutput = ""
107
+
108
+ try {
109
+ const versionProc = Bun.spawn(["controlkeel", "version"], {
110
+ stdout: "pipe",
111
+ stderr: "pipe",
112
+ })
113
+ versionOutput = await new Response(versionProc.stdout).text()
114
+ const versionExit = await versionProc.exited
115
+ if (versionExit !== 0) {
116
+ throw new Error(`controlkeel version exited with code ${versionExit}`)
117
+ }
118
+ } catch (error) {
119
+ throw new Error(
120
+ "Failed to run `controlkeel version`. Install ControlKeel >= 0.1.26 and ensure `controlkeel` is on PATH."
121
+ )
122
+ }
123
+
124
+ const parsed = parseVersion(versionOutput)
125
+ const required = { major: 0, minor: 1, patch: 26 }
126
+
127
+ if (!parsed || !versionAtLeast(parsed, required)) {
128
+ throw new Error(
129
+ `ControlKeel CLI ${versionOutput.trim() || "unknown"} is too old for plan-review submit. Install >= 0.1.26.`
130
+ )
131
+ }
132
+ }
133
+
134
+ const submitPlan = async (
135
+ body: string,
136
+ submittedBy: string,
137
+ title?: string,
138
+ waitTimeoutSeconds?: number
139
+ ) => {
140
+ await ensurePlanSubmitSupport()
141
+
142
+ const envTaskId = process.env.CONTROLKEEL_TASK_ID
143
+ const envSessionId = process.env.CONTROLKEEL_SESSION_ID
144
+ const waitTimeout = Number(waitTimeoutSeconds ?? process.env.CONTROLKEEL_REVIEW_WAIT_TIMEOUT ?? 30)
145
+ const waitTimeoutSecondsSafe = Number.isFinite(waitTimeout) && waitTimeout > 0 ? waitTimeout : 30
146
+
147
+ // Write body to temp file to avoid stdin piping issues
148
+ const tmpFile = `${directory}/.opencode/review-plan-${Date.now()}.md`
149
+ await Bun.write(tmpFile, body)
150
+
151
+ try {
152
+ const submitArgs = ["controlkeel", "review", "plan", "submit", "--body-file", tmpFile, "--submitted-by", submittedBy, "--json"]
153
+ if (title) submitArgs.push("--title", title)
154
+ if (envTaskId) submitArgs.push("--task-id", envTaskId)
155
+ else if (envSessionId) submitArgs.push("--session-id", envSessionId)
156
+
157
+ const submitProc = Bun.spawn(submitArgs, { stdout: "pipe", stderr: "pipe" })
158
+ const submitOut = await new Response(submitProc.stdout).text()
159
+ await submitProc.exited
160
+
161
+ const submitPayload = parseJson(submitOut)
162
+
163
+ if (typeof submitPayload?.error === "string" && submitPayload.error.includes("session_id")) {
164
+ throw new Error(
165
+ "ControlKeel plan submission requires review context. Set CONTROLKEEL_TASK_ID (preferred) or CONTROLKEEL_SESSION_ID, or pass --task-id/--session-id manually."
166
+ )
167
+ }
168
+
169
+ const reviewId = submitPayload?.review?.id
170
+ if (!reviewId) {
171
+ throw new Error("ControlKeel did not return a review id")
172
+ }
173
+
174
+ const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], { stdout: "pipe", stderr: "pipe" })
175
+ const waitOut = await new Response(waitProc.stdout).text()
176
+ await waitProc.exited
177
+
178
+ const waitPayload = parseJson(waitOut)
179
+ return {
180
+ reviewId,
181
+ submitPayload,
182
+ waitPayload,
183
+ browserUrl: submitPayload?.browser_url,
184
+ status: waitPayload?.review?.status,
185
+ feedbackNotes: waitPayload?.review?.feedback_notes ?? null,
186
+ }
187
+ } finally {
188
+ // Clean up temp file
189
+ try { await Bun.file(tmpFile).unlink?.() ?? (await $`rm -f ${tmpFile}`.quiet()) } catch {}
38
190
  }
39
191
  }
40
192
 
41
193
  return {
42
- "shell.env": async (_input, output) => {
194
+ "shell.env": async (input, output) => {
43
195
  output.env.CONTROLKEEL_PROJECT_ROOT = directory
44
196
  output.env.CONTROLKEEL_AGENT_ID = "opencode"
197
+
198
+ if (input.sessionID) {
199
+ output.env.CONTROLKEEL_THREAD_ID = input.sessionID
200
+ }
45
201
  },
46
202
 
47
203
  config: async (config) => {
@@ -74,9 +230,15 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
74
230
  args: {
75
231
  plan: tool.schema.string().describe("Markdown plan body to submit for review."),
76
232
  title: tool.schema.string().optional(),
233
+ wait_timeout_seconds: tool.schema.number().int().positive().optional(),
77
234
  },
78
235
  async execute(args) {
79
- const result = await submitPlan(args.plan, "opencode")
236
+ const result = await submitPlan(
237
+ args.plan,
238
+ "opencode",
239
+ args.title,
240
+ args.wait_timeout_seconds
241
+ )
80
242
  return JSON.stringify(result, null, 2)
81
243
  },
82
244
  }),
package/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+
1
3
  /**
2
4
  * Published OpenCode package entrypoint for ControlKeel.
3
5
  *
@@ -13,29 +15,177 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
13
15
  }
14
16
  }
15
17
 
16
- const submitPlan = async (body, submittedBy) => {
17
- const result = await $`controlkeel review plan submit --stdin --submitted-by ${submittedBy} --json`
18
- .text(body)
19
- const submitPayload = parseJson(result)
20
- const reviewId = submitPayload?.review?.id
21
- if (!reviewId) {
22
- throw new Error("ControlKeel did not return a review id")
18
+ const toText = async (output) => {
19
+ if (typeof output === "string") {
20
+ return output
21
+ }
22
+
23
+ if (output instanceof Uint8Array) {
24
+ return new TextDecoder().decode(output)
25
+ }
26
+
27
+ if (output instanceof ArrayBuffer) {
28
+ return new TextDecoder().decode(new Uint8Array(output))
29
+ }
30
+
31
+ if (output == null) {
32
+ return ""
33
+ }
34
+
35
+ if (typeof output === "object") {
36
+ if (typeof output.text === "function") {
37
+ try {
38
+ const direct = await output.text()
39
+ if (typeof direct === "string") {
40
+ return direct
41
+ }
42
+ } catch (_error) {
43
+ }
44
+ }
45
+
46
+ const stdout = output.stdout
47
+ if (typeof stdout === "string") {
48
+ return stdout
49
+ }
50
+
51
+ if (stdout instanceof Uint8Array) {
52
+ return new TextDecoder().decode(stdout)
53
+ }
54
+
55
+ if (stdout instanceof ArrayBuffer) {
56
+ return new TextDecoder().decode(new Uint8Array(stdout))
57
+ }
58
+
59
+ if (stdout && typeof stdout.text === "function") {
60
+ try {
61
+ const streamed = await stdout.text()
62
+ if (typeof streamed === "string") {
63
+ return streamed
64
+ }
65
+ } catch (_error) {
66
+ }
67
+ }
68
+ }
69
+
70
+ return String(output)
71
+ }
72
+
73
+ const parseVersion = (output) => {
74
+ const match = output.match(/(\d+)\.(\d+)\.(\d+)/)
75
+ if (!match) {
76
+ return null
23
77
  }
24
- const waitPayload = parseJson(await $`controlkeel review plan wait --id ${reviewId} --json`)
78
+
25
79
  return {
26
- reviewId,
27
- submitPayload,
28
- waitPayload,
29
- browserUrl: submitPayload?.browser_url,
30
- status: waitPayload?.review?.status,
31
- feedbackNotes: waitPayload?.review?.feedback_notes ?? null,
80
+ major: Number(match[1]),
81
+ minor: Number(match[2]),
82
+ patch: Number(match[3]),
83
+ }
84
+ }
85
+
86
+ const versionAtLeast = (current, required) => {
87
+ if (current.major !== required.major) {
88
+ return current.major > required.major
89
+ }
90
+
91
+ if (current.minor !== required.minor) {
92
+ return current.minor > required.minor
93
+ }
94
+
95
+ return current.patch >= required.patch
96
+ }
97
+
98
+ const ensurePlanSubmitSupport = async () => {
99
+ let versionOutput = ""
100
+
101
+ try {
102
+ const versionProc = Bun.spawn(["controlkeel", "version"], {
103
+ stdout: "pipe",
104
+ stderr: "pipe",
105
+ })
106
+ versionOutput = await new Response(versionProc.stdout).text()
107
+ const versionExit = await versionProc.exited
108
+ if (versionExit !== 0) {
109
+ throw new Error(`controlkeel version exited with code ${versionExit}`)
110
+ }
111
+ } catch (_error) {
112
+ throw new Error(
113
+ "Failed to run `controlkeel version`. Install ControlKeel >= 0.1.26 and ensure `controlkeel` is on PATH."
114
+ )
115
+ }
116
+
117
+ const parsed = parseVersion(versionOutput)
118
+ const required = { major: 0, minor: 1, patch: 26 }
119
+
120
+ if (!parsed || !versionAtLeast(parsed, required)) {
121
+ throw new Error(
122
+ `ControlKeel CLI ${versionOutput.trim() || "unknown"} is too old for plan-review submit. Install >= 0.1.26.`
123
+ )
124
+ }
125
+ }
126
+
127
+ const submitPlan = async (body, submittedBy, title, waitTimeoutSeconds) => {
128
+ await ensurePlanSubmitSupport()
129
+
130
+ const envTaskId = process.env.CONTROLKEEL_TASK_ID
131
+ const envSessionId = process.env.CONTROLKEEL_SESSION_ID
132
+ const waitTimeout = Number(waitTimeoutSeconds ?? process.env.CONTROLKEEL_REVIEW_WAIT_TIMEOUT ?? 30)
133
+ const waitTimeoutSecondsSafe = Number.isFinite(waitTimeout) && waitTimeout > 0 ? waitTimeout : 30
134
+
135
+ // Write body to temp file to avoid stdin piping issues
136
+ const tmpFile = `${directory}/.opencode/review-plan-${Date.now()}.md`
137
+ await Bun.write(tmpFile, body)
138
+
139
+ try {
140
+ const submitArgs = ["controlkeel", "review", "plan", "submit", "--body-file", tmpFile, "--submitted-by", submittedBy, "--json"]
141
+ if (title) submitArgs.push("--title", title)
142
+ if (envTaskId) submitArgs.push("--task-id", envTaskId)
143
+ else if (envSessionId) submitArgs.push("--session-id", envSessionId)
144
+
145
+ const submitProc = Bun.spawn(submitArgs, { stdout: "pipe", stderr: "pipe" })
146
+ const submitOut = await new Response(submitProc.stdout).text()
147
+ await submitProc.exited
148
+
149
+ const submitPayload = parseJson(submitOut)
150
+
151
+ if (typeof submitPayload?.error === "string" && submitPayload.error.includes("session_id")) {
152
+ throw new Error(
153
+ "ControlKeel plan submission requires review context. Set CONTROLKEEL_TASK_ID (preferred) or CONTROLKEEL_SESSION_ID, or pass --task-id/--session-id manually."
154
+ )
155
+ }
156
+
157
+ const reviewId = submitPayload?.review?.id
158
+ if (!reviewId) {
159
+ throw new Error("ControlKeel did not return a review id")
160
+ }
161
+
162
+ const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], { stdout: "pipe", stderr: "pipe" })
163
+ const waitOut = await new Response(waitProc.stdout).text()
164
+ await waitProc.exited
165
+
166
+ const waitPayload = parseJson(waitOut)
167
+ return {
168
+ reviewId,
169
+ submitPayload,
170
+ waitPayload,
171
+ browserUrl: submitPayload?.browser_url,
172
+ status: waitPayload?.review?.status,
173
+ feedbackNotes: waitPayload?.review?.feedback_notes ?? null,
174
+ }
175
+ } finally {
176
+ // Clean up temp file
177
+ try { await Bun.file(tmpFile).unlink?.() ?? (await $`rm -f ${tmpFile}`.quiet()) } catch {}
32
178
  }
33
179
  }
34
180
 
35
181
  return {
36
- "shell.env": async (_input, output) => {
182
+ "shell.env": async (input, output) => {
37
183
  output.env.CONTROLKEEL_PROJECT_ROOT = directory
38
184
  output.env.CONTROLKEEL_AGENT_ID = "opencode"
185
+
186
+ if (input.sessionID) {
187
+ output.env.CONTROLKEEL_THREAD_ID = input.sessionID
188
+ }
39
189
  },
40
190
 
41
191
  config: async (config) => {
@@ -55,37 +205,32 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
55
205
  }
56
206
  },
57
207
 
58
- "tool.execute": async (input, output) => {
59
- if (input.toolID !== "submit_plan") {
60
- return
61
- }
62
-
63
- const planBody = typeof input.args?.plan === "string" ? input.args.plan : ""
64
- const review = await submitPlan(planBody, "opencode")
65
- output.metadata = {
66
- reviewId: review.reviewId,
67
- browserUrl: review.browserUrl,
68
- status: review.status,
69
- feedbackNotes: review.feedbackNotes,
70
- }
71
- output.result = JSON.stringify(output.metadata, null, 2)
208
+ "experimental.chat.system.transform": async (_input, output) => {
209
+ output.system.push(
210
+ "Use submit_plan when you are ready for human review. Do not proceed with implementation until ControlKeel approves the plan."
211
+ )
72
212
  },
73
213
 
74
- tools: async () => ({
75
- submit_plan: {
76
- description: "Submit the current plan to ControlKeel and wait for browser review approval.",
77
- parameters: {
78
- type: "object",
79
- properties: {
80
- plan: {
81
- type: "string",
82
- description: "Markdown plan body to submit to ControlKeel review.",
83
- },
84
- },
85
- required: ["plan"],
214
+ tool: {
215
+ "submit_plan": tool({
216
+ description:
217
+ "Submit a plan to ControlKeel for browser review. The tool waits for approval before execution continues.",
218
+ args: {
219
+ plan: tool.schema.string().describe("Markdown plan body to submit for review."),
220
+ title: tool.schema.string().optional(),
221
+ wait_timeout_seconds: tool.schema.number().int().positive().optional(),
86
222
  },
87
- },
88
- }),
223
+ async execute(args) {
224
+ const result = await submitPlan(
225
+ args.plan,
226
+ "opencode",
227
+ args.title,
228
+ args.wait_timeout_seconds
229
+ )
230
+ return JSON.stringify(result, null, 2)
231
+ },
232
+ }),
233
+ },
89
234
  }
90
235
  }
91
236
 
package/package.json CHANGED
@@ -2,6 +2,9 @@
2
2
  "bugs": {
3
3
  "url": "https://github.com/aryaminus/controlkeel/issues"
4
4
  },
5
+ "dependencies": {
6
+ "@opencode-ai/plugin": "1.3.13"
7
+ },
5
8
  "description": "ControlKeel OpenCode adapter bundle",
6
9
  "exports": {
7
10
  ".": "./index.js",
@@ -32,5 +35,5 @@
32
35
  "url": "git+https://github.com/aryaminus/controlkeel.git"
33
36
  },
34
37
  "type": "module",
35
- "version": "0.1.27"
38
+ "version": "0.1.29"
36
39
  }