@aryaminus/controlkeel-opencode 0.2.21 → 0.2.24

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,19 +11,41 @@ import { tool } from "@opencode-ai/plugin"
11
11
  * - routes plan submission and wait decisions through the CK CLI
12
12
  */
13
13
  export const ControlKeelGovernance: Plugin = async ({ project, client, $, directory }) => {
14
- const extractJsonCandidate = (output: string) => {
14
+ const extractJsonCandidates = (output: string) => {
15
15
  const trimmed = output.trim()
16
16
  if (!trimmed) {
17
- return null
17
+ return []
18
18
  }
19
19
 
20
20
  const lines = trimmed
21
21
  .split(/\r?\n/)
22
- .map((line) => line.trim())
23
- .filter((line) => line.length > 0)
22
+ .map((line) => line.trimEnd())
23
+ .filter((line) => line.trim().length > 0)
24
+
25
+ const candidates: string[] = []
26
+ const seen = new Set<string>()
27
+
28
+ const pushCandidate = (candidate: string) => {
29
+ const normalized = candidate.trim()
30
+ if (!normalized || seen.has(normalized)) {
31
+ return
32
+ }
33
+
34
+ seen.add(normalized)
35
+ candidates.push(normalized)
36
+ }
24
37
 
25
- const jsonLine = [...lines].reverse().find((line) => line.startsWith("{") || line.startsWith("["))
26
- return jsonLine ?? trimmed
38
+ pushCandidate(trimmed)
39
+
40
+ for (let i = 0; i < lines.length; i += 1) {
41
+ const line = lines[i].trimStart()
42
+ if (line.startsWith("{") || line.startsWith("[")) {
43
+ pushCandidate(line)
44
+ pushCandidate(lines.slice(i).join("\n"))
45
+ }
46
+ }
47
+
48
+ return candidates
27
49
  }
28
50
 
29
51
  const parseJson = (output: string) => {
@@ -35,17 +57,14 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
35
57
  try {
36
58
  return JSON.parse(trimmed)
37
59
  } catch (_error) {
38
- const candidate = extractJsonCandidate(trimmed)
39
-
40
- if (!candidate || candidate === trimmed) {
41
- throw new Error(`ControlKeel returned invalid JSON: ${output}`)
60
+ for (const candidate of extractJsonCandidates(trimmed)) {
61
+ try {
62
+ return JSON.parse(candidate)
63
+ } catch (_fallbackError) {
64
+ }
42
65
  }
43
66
 
44
- try {
45
- return JSON.parse(candidate)
46
- } catch (_fallbackError) {
47
- throw new Error(`ControlKeel returned invalid JSON: ${output}`)
48
- }
67
+ throw new Error(`ControlKeel returned invalid JSON: ${output}`)
49
68
  }
50
69
  }
51
70
 
@@ -161,6 +180,52 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
161
180
  }
162
181
  }
163
182
 
183
+ const resolveReviewScope = async () => {
184
+ const envTaskId = process.env.CONTROLKEEL_TASK_ID
185
+ const envSessionId = process.env.CONTROLKEEL_SESSION_ID
186
+
187
+ if (envTaskId || envSessionId) {
188
+ return {
189
+ taskId: envTaskId ?? null,
190
+ sessionId: envSessionId ?? null,
191
+ source: "env",
192
+ }
193
+ }
194
+
195
+ const contextEnv = process.env.LOGGER_LEVEL
196
+ ? process.env
197
+ : { ...process.env, LOGGER_LEVEL: "warning" }
198
+
199
+ const contextProc = Bun.spawn(["controlkeel", "context", "--json", "--project-root", directory], {
200
+ stdout: "pipe",
201
+ stderr: "pipe",
202
+ env: contextEnv,
203
+ })
204
+ const contextOut = await new Response(contextProc.stdout).text()
205
+ const contextErr = await new Response(contextProc.stderr).text()
206
+ const contextExit = await contextProc.exited
207
+
208
+ if (contextExit !== 0) {
209
+ throw new Error(
210
+ `controlkeel context --json failed with exit code ${contextExit}${contextErr.trim() ? `: ${contextErr.trim()}` : ""}`
211
+ )
212
+ }
213
+
214
+ const contextPayload = parseJson([contextOut, contextErr].filter(Boolean).join("\n"))
215
+ const contextTaskId = contextPayload?.current_task?.id
216
+ const contextSessionId = contextPayload?.session_id
217
+
218
+ if (!contextTaskId && !contextSessionId) {
219
+ throw new Error("ControlKeel context did not include a session_id or current_task.id")
220
+ }
221
+
222
+ return {
223
+ taskId: contextTaskId != null ? String(contextTaskId) : null,
224
+ sessionId: contextSessionId != null ? String(contextSessionId) : null,
225
+ source: "context",
226
+ }
227
+ }
228
+
164
229
  const submitPlan = async (
165
230
  body: string,
166
231
  submittedBy: string,
@@ -169,8 +234,7 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
169
234
  ) => {
170
235
  await ensurePlanSubmitSupport()
171
236
 
172
- const envTaskId = process.env.CONTROLKEEL_TASK_ID
173
- const envSessionId = process.env.CONTROLKEEL_SESSION_ID
237
+ const reviewScope = await resolveReviewScope()
174
238
  const waitTimeout = Number(waitTimeoutSeconds ?? process.env.CONTROLKEEL_REVIEW_WAIT_TIMEOUT ?? 30)
175
239
  const waitTimeoutSecondsSafe = Number.isFinite(waitTimeout) && waitTimeout > 0 ? waitTimeout : 30
176
240
 
@@ -181,10 +245,18 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
181
245
  try {
182
246
  const submitArgs = ["controlkeel", "review", "plan", "submit", "--body-file", tmpFile, "--submitted-by", submittedBy, "--json"]
183
247
  if (title) submitArgs.push("--title", title)
184
- if (envTaskId) submitArgs.push("--task-id", envTaskId)
185
- else if (envSessionId) submitArgs.push("--session-id", envSessionId)
248
+ if (reviewScope.taskId) submitArgs.push("--task-id", reviewScope.taskId)
249
+ else if (reviewScope.sessionId) submitArgs.push("--session-id", reviewScope.sessionId)
186
250
 
187
- const submitProc = Bun.spawn(submitArgs, { stdout: "pipe", stderr: "pipe" })
251
+ const submitEnv = process.env.LOGGER_LEVEL
252
+ ? process.env
253
+ : { ...process.env, LOGGER_LEVEL: "warning" }
254
+
255
+ const submitProc = Bun.spawn(submitArgs, {
256
+ stdout: "pipe",
257
+ stderr: "pipe",
258
+ env: submitEnv,
259
+ })
188
260
  const submitOut = await new Response(submitProc.stdout).text()
189
261
  const submitErr = await new Response(submitProc.stderr).text()
190
262
  const submitExit = await submitProc.exited
@@ -195,7 +267,7 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
195
267
  )
196
268
  }
197
269
 
198
- const submitPayload = parseJson(submitOut || submitErr)
270
+ const submitPayload = parseJson([submitOut, submitErr].filter(Boolean).join("\n"))
199
271
 
200
272
  if (typeof submitPayload?.error === "string" && submitPayload.error.includes("session_id")) {
201
273
  throw new Error(
@@ -208,7 +280,15 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
208
280
  throw new Error("ControlKeel did not return a review id")
209
281
  }
210
282
 
211
- const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], { stdout: "pipe", stderr: "pipe" })
283
+ const waitEnv = process.env.LOGGER_LEVEL
284
+ ? process.env
285
+ : { ...process.env, LOGGER_LEVEL: "warning" }
286
+
287
+ const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], {
288
+ stdout: "pipe",
289
+ stderr: "pipe",
290
+ env: waitEnv,
291
+ })
212
292
  const waitOut = await new Response(waitProc.stdout).text()
213
293
  const waitErr = await new Response(waitProc.stderr).text()
214
294
  const waitExit = await waitProc.exited
@@ -219,7 +299,7 @@ export const ControlKeelGovernance: Plugin = async ({ project, client, $, direct
219
299
  )
220
300
  }
221
301
 
222
- const waitPayload = parseJson(waitOut || waitErr)
302
+ const waitPayload = parseJson([waitOut, waitErr].filter(Boolean).join("\n"))
223
303
  return {
224
304
  reviewId,
225
305
  submitPayload,
package/index.js CHANGED
@@ -7,19 +7,41 @@ import { tool } from "@opencode-ai/plugin"
7
7
  * but ships as plain JavaScript for npm-based installs.
8
8
  */
9
9
  export const ControlKeelGovernance = async ({ $, directory }) => {
10
- const extractJsonCandidate = (output) => {
10
+ const extractJsonCandidates = (output) => {
11
11
  const trimmed = output.trim()
12
12
  if (!trimmed) {
13
- return null
13
+ return []
14
14
  }
15
15
 
16
16
  const lines = trimmed
17
17
  .split(/\r?\n/)
18
- .map((line) => line.trim())
19
- .filter((line) => line.length > 0)
18
+ .map((line) => line.trimEnd())
19
+ .filter((line) => line.trim().length > 0)
20
+
21
+ const candidates = []
22
+ const seen = new Set()
23
+
24
+ const pushCandidate = (candidate) => {
25
+ const normalized = candidate.trim()
26
+ if (!normalized || seen.has(normalized)) {
27
+ return
28
+ }
29
+
30
+ seen.add(normalized)
31
+ candidates.push(normalized)
32
+ }
20
33
 
21
- const jsonLine = [...lines].reverse().find((line) => line.startsWith("{") || line.startsWith("["))
22
- return jsonLine ?? trimmed
34
+ pushCandidate(trimmed)
35
+
36
+ for (let i = 0; i < lines.length; i += 1) {
37
+ const line = lines[i].trimStart()
38
+ if (line.startsWith("{") || line.startsWith("[")) {
39
+ pushCandidate(line)
40
+ pushCandidate(lines.slice(i).join("\n"))
41
+ }
42
+ }
43
+
44
+ return candidates
23
45
  }
24
46
 
25
47
  const parseJson = (output) => {
@@ -31,17 +53,14 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
31
53
  try {
32
54
  return JSON.parse(trimmed)
33
55
  } catch (_error) {
34
- const candidate = extractJsonCandidate(trimmed)
35
-
36
- if (!candidate || candidate === trimmed) {
37
- throw new Error(`ControlKeel returned invalid JSON: ${output}`)
56
+ for (const candidate of extractJsonCandidates(trimmed)) {
57
+ try {
58
+ return JSON.parse(candidate)
59
+ } catch (_fallbackError) {
60
+ }
38
61
  }
39
62
 
40
- try {
41
- return JSON.parse(candidate)
42
- } catch (_fallbackError) {
43
- throw new Error(`ControlKeel returned invalid JSON: ${output}`)
44
- }
63
+ throw new Error(`ControlKeel returned invalid JSON: ${output}`)
45
64
  }
46
65
  }
47
66
 
@@ -154,11 +173,56 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
154
173
  }
155
174
  }
156
175
 
176
+ const resolveReviewScope = async () => {
177
+ const envTaskId = process.env.CONTROLKEEL_TASK_ID
178
+ const envSessionId = process.env.CONTROLKEEL_SESSION_ID
179
+
180
+ if (envTaskId || envSessionId) {
181
+ return {
182
+ taskId: envTaskId ?? null,
183
+ sessionId: envSessionId ?? null,
184
+ source: "env",
185
+ }
186
+ }
187
+
188
+ const contextEnv = process.env.LOGGER_LEVEL
189
+ ? process.env
190
+ : { ...process.env, LOGGER_LEVEL: "warning" }
191
+
192
+ const contextProc = Bun.spawn(["controlkeel", "context", "--json", "--project-root", directory], {
193
+ stdout: "pipe",
194
+ stderr: "pipe",
195
+ env: contextEnv,
196
+ })
197
+ const contextOut = await new Response(contextProc.stdout).text()
198
+ const contextErr = await new Response(contextProc.stderr).text()
199
+ const contextExit = await contextProc.exited
200
+
201
+ if (contextExit !== 0) {
202
+ throw new Error(
203
+ `controlkeel context --json failed with exit code ${contextExit}${contextErr.trim() ? `: ${contextErr.trim()}` : ""}`
204
+ )
205
+ }
206
+
207
+ const contextPayload = parseJson([contextOut, contextErr].filter(Boolean).join("\n"))
208
+ const contextTaskId = contextPayload?.current_task?.id
209
+ const contextSessionId = contextPayload?.session_id
210
+
211
+ if (!contextTaskId && !contextSessionId) {
212
+ throw new Error("ControlKeel context did not include a session_id or current_task.id")
213
+ }
214
+
215
+ return {
216
+ taskId: contextTaskId != null ? String(contextTaskId) : null,
217
+ sessionId: contextSessionId != null ? String(contextSessionId) : null,
218
+ source: "context",
219
+ }
220
+ }
221
+
157
222
  const submitPlan = async (body, submittedBy, title, waitTimeoutSeconds) => {
158
223
  await ensurePlanSubmitSupport()
159
224
 
160
- const envTaskId = process.env.CONTROLKEEL_TASK_ID
161
- const envSessionId = process.env.CONTROLKEEL_SESSION_ID
225
+ const reviewScope = await resolveReviewScope()
162
226
  const waitTimeout = Number(waitTimeoutSeconds ?? process.env.CONTROLKEEL_REVIEW_WAIT_TIMEOUT ?? 30)
163
227
  const waitTimeoutSecondsSafe = Number.isFinite(waitTimeout) && waitTimeout > 0 ? waitTimeout : 30
164
228
 
@@ -169,10 +233,18 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
169
233
  try {
170
234
  const submitArgs = ["controlkeel", "review", "plan", "submit", "--body-file", tmpFile, "--submitted-by", submittedBy, "--json"]
171
235
  if (title) submitArgs.push("--title", title)
172
- if (envTaskId) submitArgs.push("--task-id", envTaskId)
173
- else if (envSessionId) submitArgs.push("--session-id", envSessionId)
236
+ if (reviewScope.taskId) submitArgs.push("--task-id", reviewScope.taskId)
237
+ else if (reviewScope.sessionId) submitArgs.push("--session-id", reviewScope.sessionId)
174
238
 
175
- const submitProc = Bun.spawn(submitArgs, { stdout: "pipe", stderr: "pipe" })
239
+ const submitEnv = process.env.LOGGER_LEVEL
240
+ ? process.env
241
+ : { ...process.env, LOGGER_LEVEL: "warning" }
242
+
243
+ const submitProc = Bun.spawn(submitArgs, {
244
+ stdout: "pipe",
245
+ stderr: "pipe",
246
+ env: submitEnv,
247
+ })
176
248
  const submitOut = await new Response(submitProc.stdout).text()
177
249
  const submitErr = await new Response(submitProc.stderr).text()
178
250
  const submitExit = await submitProc.exited
@@ -183,7 +255,7 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
183
255
  )
184
256
  }
185
257
 
186
- const submitPayload = parseJson(submitOut || submitErr)
258
+ const submitPayload = parseJson([submitOut, submitErr].filter(Boolean).join("\n"))
187
259
 
188
260
  if (typeof submitPayload?.error === "string" && submitPayload.error.includes("session_id")) {
189
261
  throw new Error(
@@ -196,7 +268,15 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
196
268
  throw new Error("ControlKeel did not return a review id")
197
269
  }
198
270
 
199
- const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], { stdout: "pipe", stderr: "pipe" })
271
+ const waitEnv = process.env.LOGGER_LEVEL
272
+ ? process.env
273
+ : { ...process.env, LOGGER_LEVEL: "warning" }
274
+
275
+ const waitProc = Bun.spawn(["controlkeel", "review", "plan", "wait", "--id", String(reviewId), "--timeout", String(waitTimeoutSecondsSafe), "--json"], {
276
+ stdout: "pipe",
277
+ stderr: "pipe",
278
+ env: waitEnv,
279
+ })
200
280
  const waitOut = await new Response(waitProc.stdout).text()
201
281
  const waitErr = await new Response(waitProc.stderr).text()
202
282
  const waitExit = await waitProc.exited
@@ -207,7 +287,7 @@ export const ControlKeelGovernance = async ({ $, directory }) => {
207
287
  )
208
288
  }
209
289
 
210
- const waitPayload = parseJson(waitOut || waitErr)
290
+ const waitPayload = parseJson([waitOut, waitErr].filter(Boolean).join("\n"))
211
291
  return {
212
292
  reviewId,
213
293
  submitPayload,
package/package.json CHANGED
@@ -35,5 +35,5 @@
35
35
  "url": "git+https://github.com/aryaminus/controlkeel.git"
36
36
  },
37
37
  "type": "module",
38
- "version": "0.2.21"
38
+ "version": "0.2.24"
39
39
  }