opencode-codeops 2.2.1 → 2.3.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 +38 -0
- package/README.md +53 -0
- package/_shared/discovery.md +92 -0
- package/_shared/zero-ambiguity-gate.md +4 -0
- package/bin/lib/discovery-rules.d.mts +228 -0
- package/bin/lib/discovery-rules.mjs +1213 -0
- package/bin/lib/discovery-server.d.mts +62 -0
- package/bin/lib/discovery-server.mjs +755 -0
- package/bin/lib/discovery-store.d.mts +224 -0
- package/bin/lib/discovery-store.mjs +1189 -0
- package/bin/lib/discovery-tools.d.mts +66 -0
- package/bin/lib/discovery-tools.mjs +540 -0
- package/discovery-ui/dist/assets/app.js +107 -0
- package/discovery-ui/dist/index.html +17 -0
- package/package.json +14 -2
- package/plugin/index.ts +45 -4
- package/schemas/codeops-config.schema.json +8 -0
- package/scripts/__pycache__/codeops_plan.cpython-314.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-314.pyc +0 -0
- package/scripts/build-discovery-ui.mjs +59 -0
- package/skills/analyze-project/SKILL.md +19 -0
- package/skills/make-plan/SKILL.md +7 -1
- package/skills/preflight/SKILL.md +4 -0
- package/skills/preflight/report-format.md +4 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the CodeOps Discovery plugin integration.
|
|
3
|
+
*
|
|
4
|
+
* The module is plain JavaScript so `node --test` can exercise it directly;
|
|
5
|
+
* these declarations give the TypeScript plugin entry point typed access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DiscoveryRuntime } from "./discovery-server.mjs"
|
|
9
|
+
|
|
10
|
+
/** The project's Discovery settings. */
|
|
11
|
+
export interface DiscoverySettings {
|
|
12
|
+
enabled: boolean
|
|
13
|
+
chatMaxQuestions: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The dependencies of one controller. */
|
|
17
|
+
export interface DiscoveryControllerDeps {
|
|
18
|
+
directory: string
|
|
19
|
+
dataDir: string
|
|
20
|
+
assetsDir: string
|
|
21
|
+
version: string
|
|
22
|
+
openBrowser?: (url: string) => boolean
|
|
23
|
+
branchOf?: (directory: string) => string | null
|
|
24
|
+
warn?: (message: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The result of a tool call. */
|
|
28
|
+
export interface DiscoveryToolResult {
|
|
29
|
+
ok: boolean
|
|
30
|
+
reason?: string
|
|
31
|
+
message?: string
|
|
32
|
+
[key: string]: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The per-project Discovery controller. */
|
|
36
|
+
export interface DiscoveryController {
|
|
37
|
+
/** Re-read the setting and enable or disable Discovery. */
|
|
38
|
+
sync(): Promise<void>
|
|
39
|
+
isEnabled(): boolean
|
|
40
|
+
onSessionDeleted(sessionID: string): Promise<void>
|
|
41
|
+
/** Unregister, reload the tool list, and close the listener when idle. */
|
|
42
|
+
dispose(): Promise<void>
|
|
43
|
+
/** The runtime, or null while Discovery has never been enabled. */
|
|
44
|
+
readonly runtime: DiscoveryRuntime | null
|
|
45
|
+
/** Publish a plan round. */
|
|
46
|
+
publish(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
|
|
47
|
+
/** Read submitted answers. */
|
|
48
|
+
answers(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
|
|
49
|
+
/** Record or retire questions. */
|
|
50
|
+
resolve(input: unknown, sessionID: string): Promise<DiscoveryToolResult>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the project's Discovery settings. */
|
|
54
|
+
export declare function readDiscoveryConfig(directory: string): DiscoverySettings
|
|
55
|
+
|
|
56
|
+
/** Create the per-project controller; never throws. */
|
|
57
|
+
export declare function createDiscoveryController(
|
|
58
|
+
ctx: unknown,
|
|
59
|
+
deps: DiscoveryControllerDeps
|
|
60
|
+
): DiscoveryController
|
|
61
|
+
|
|
62
|
+
/** Register the three agent tools; never throws. */
|
|
63
|
+
export declare function registerDiscoveryTools(
|
|
64
|
+
ctx: unknown,
|
|
65
|
+
controller: DiscoveryController
|
|
66
|
+
): Promise<boolean>
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery plugin integration.
|
|
3
|
+
*
|
|
4
|
+
* Connects Discovery to OpenCode: the opt-in check, the controller lifecycle,
|
|
5
|
+
* the three agent tools, waking sessions, delivery, orphaning, and opening the
|
|
6
|
+
* browser. The plugin entry point wires this module behind feature detection.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is defensive: a missing host API, an unreadable config, or a
|
|
9
|
+
* failing store call degrades to a documented result object. No handler throws
|
|
10
|
+
* into an agent turn.
|
|
11
|
+
*
|
|
12
|
+
* @module discovery-tools
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from "node:child_process"
|
|
16
|
+
import { readFileSync } from "node:fs"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
|
|
19
|
+
import { DiscoveryPublishSchema, PLAN_KINDS } from "./discovery-rules.mjs"
|
|
20
|
+
import { createDiscoveryStore } from "./discovery-store.mjs"
|
|
21
|
+
import { getDiscoveryRuntime } from "./discovery-server.mjs"
|
|
22
|
+
|
|
23
|
+
/** The default question limit when the config names none. */
|
|
24
|
+
const DEFAULT_CHAT_MAX_QUESTIONS = 3
|
|
25
|
+
|
|
26
|
+
/** The name of the publish tool. */
|
|
27
|
+
const PUBLISH_TOOL = "codeops_discovery_publish"
|
|
28
|
+
|
|
29
|
+
/** The name of the answers tool. */
|
|
30
|
+
const ANSWERS_TOOL = "codeops_discovery_answers"
|
|
31
|
+
|
|
32
|
+
/** The name of the resolve tool. */
|
|
33
|
+
const RESOLVE_TOOL = "codeops_discovery_resolve"
|
|
34
|
+
|
|
35
|
+
/** The short tool descriptions shown to the agent. */
|
|
36
|
+
const DESCRIPTIONS = {
|
|
37
|
+
[PUBLISH_TOOL]:
|
|
38
|
+
"Publish a round of discovery questions and open the browser page. " +
|
|
39
|
+
"Read _shared/discovery.md before using it.",
|
|
40
|
+
[ANSWERS_TOOL]:
|
|
41
|
+
"Read the answers the user submitted for a published plan. " +
|
|
42
|
+
"Record them with codeops_discovery_resolve afterwards. " +
|
|
43
|
+
"Read _shared/discovery.md before using it.",
|
|
44
|
+
[RESOLVE_TOOL]:
|
|
45
|
+
"Record submitted answers as resolved (with their register ids) or retire questions. " +
|
|
46
|
+
"Read _shared/discovery.md before using it.",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The answers tool input schema. */
|
|
50
|
+
const ANSWERS_INPUT = {
|
|
51
|
+
type: "object",
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
required: ["artifact", "kind"],
|
|
54
|
+
properties: {
|
|
55
|
+
artifact: { type: "string", minLength: 1, maxLength: 300 },
|
|
56
|
+
kind: { type: "string", enum: [...PLAN_KINDS] },
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The resolve tool input schema. */
|
|
61
|
+
const RESOLVE_INPUT = {
|
|
62
|
+
type: "object",
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
required: ["artifact", "kind"],
|
|
65
|
+
properties: {
|
|
66
|
+
artifact: { type: "string", minLength: 1, maxLength: 300 },
|
|
67
|
+
kind: { type: "string", enum: [...PLAN_KINDS] },
|
|
68
|
+
recorded: {
|
|
69
|
+
type: "array",
|
|
70
|
+
items: {
|
|
71
|
+
type: "object",
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
required: ["key", "as"],
|
|
74
|
+
properties: {
|
|
75
|
+
key: { type: "string", minLength: 1 },
|
|
76
|
+
as: { type: "string", minLength: 1 },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
retire: { type: "array", items: { type: "string", minLength: 1 } },
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A short failure result. */
|
|
85
|
+
function failure(reason, message, extra = {}) {
|
|
86
|
+
return { ok: false, reason, message, ...extra }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Open a URL with the platform's default browser without a shell. */
|
|
90
|
+
function defaultOpenBrowser(url) {
|
|
91
|
+
try {
|
|
92
|
+
const [command, args] =
|
|
93
|
+
process.platform === "darwin"
|
|
94
|
+
? ["open", [url]]
|
|
95
|
+
: process.platform === "win32"
|
|
96
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
97
|
+
: ["xdg-open", [url]]
|
|
98
|
+
const child = execFile(command, args, { detached: true, stdio: "ignore" })
|
|
99
|
+
child.on("error", () => {})
|
|
100
|
+
child.unref()
|
|
101
|
+
return true
|
|
102
|
+
} catch {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read the project's Discovery settings.
|
|
109
|
+
*
|
|
110
|
+
* An unreadable file, a missing `discovery` object, or a missing/non-boolean
|
|
111
|
+
* `enabled` means disabled. An invalid `chatMaxQuestions` falls back to 3
|
|
112
|
+
* without disabling Discovery.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} directory - The project directory.
|
|
115
|
+
* @returns {{ enabled: boolean, chatMaxQuestions: number }} The settings.
|
|
116
|
+
*/
|
|
117
|
+
export function readDiscoveryConfig(directory) {
|
|
118
|
+
let parsed
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(readFileSync(join(directory, "codeops", "codeops.json"), "utf8"))
|
|
121
|
+
} catch {
|
|
122
|
+
return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
|
|
123
|
+
}
|
|
124
|
+
const discovery = parsed?.discovery
|
|
125
|
+
if (discovery === null || typeof discovery !== "object" || Array.isArray(discovery)) {
|
|
126
|
+
return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
|
|
127
|
+
}
|
|
128
|
+
if (typeof discovery.enabled !== "boolean" || discovery.enabled !== true) {
|
|
129
|
+
return { enabled: false, chatMaxQuestions: DEFAULT_CHAT_MAX_QUESTIONS }
|
|
130
|
+
}
|
|
131
|
+
const limit =
|
|
132
|
+
Number.isInteger(discovery.chatMaxQuestions) && discovery.chatMaxQuestions >= 0
|
|
133
|
+
? discovery.chatMaxQuestions
|
|
134
|
+
: DEFAULT_CHAT_MAX_QUESTIONS
|
|
135
|
+
return { enabled: true, chatMaxQuestions: limit }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create the per-instance Discovery controller.
|
|
140
|
+
*
|
|
141
|
+
* The store and the process-wide runtime are created on the first **enabled**
|
|
142
|
+
* `sync()` and never while the project is disabled. Every method contains its
|
|
143
|
+
* own failures; the controller never throws to its caller.
|
|
144
|
+
*
|
|
145
|
+
* @param {unknown} ctx - The plugin context; only `tool`, `session`, and
|
|
146
|
+
* `location` are read.
|
|
147
|
+
* @param {{ directory: string, dataDir: string, assetsDir: string, version: string,
|
|
148
|
+
* openBrowser?: (url: string) => boolean,
|
|
149
|
+
* branchOf?: (directory: string) => string | null,
|
|
150
|
+
* warn?: (message: string) => void }} deps - Controller dependencies.
|
|
151
|
+
* @returns {object} The controller.
|
|
152
|
+
*/
|
|
153
|
+
export function createDiscoveryController(ctx, deps) {
|
|
154
|
+
const openBrowser = typeof deps.openBrowser === "function" ? deps.openBrowser : defaultOpenBrowser
|
|
155
|
+
let runtime = null
|
|
156
|
+
let projectSlug = null
|
|
157
|
+
let unregister = null
|
|
158
|
+
let enabled = false
|
|
159
|
+
let disposed = false
|
|
160
|
+
let warnedAboutWake = false
|
|
161
|
+
|
|
162
|
+
/** Reload the tool list without letting a host failure escape. */
|
|
163
|
+
async function reloadTools() {
|
|
164
|
+
try {
|
|
165
|
+
await ctx?.tool?.reload?.()
|
|
166
|
+
} catch {
|
|
167
|
+
// The next request re-reads the transform.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The absolute page URL of a plan, or null. */
|
|
172
|
+
function pageUrl(planSlug) {
|
|
173
|
+
return projectSlug ? runtime?.planUrl(projectSlug, planSlug) ?? null : null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Find the registered project. */
|
|
177
|
+
async function project() {
|
|
178
|
+
if (!runtime) return null
|
|
179
|
+
return runtime.store.findProjectByDirectory(deps.directory)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Build the wake text for one plan's claimed submissions. */
|
|
183
|
+
async function wakeText(record, claimed) {
|
|
184
|
+
const projectRecord = await runtime.store.getProject(projectSlug)
|
|
185
|
+
const name = projectRecord?.name ?? "project"
|
|
186
|
+
const groups = new Set(claimed.flatMap((submission) => submission.groups))
|
|
187
|
+
const titles = record.groups.filter((group) => groups.has(group.key)).map((group) => group.title)
|
|
188
|
+
const count = claimed.reduce((total, submission) => total + submission.questions.length, 0)
|
|
189
|
+
const noun = count === 1 ? "answer" : "answers"
|
|
190
|
+
return `Discovery: ${count} ${noun} submitted for ${name} › ${record.title} (${titles.join(", ")}). Read them with ${ANSWERS_TOOL}.`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Wake the plan's owner with one message covering every claimed submission.
|
|
195
|
+
*
|
|
196
|
+
* Returns `woken` only after the prompt resolves and the claims are recorded.
|
|
197
|
+
* A missing session API, an unreachable owner, a live foreign claim, or a
|
|
198
|
+
* rejected prompt all leave the work pending.
|
|
199
|
+
*/
|
|
200
|
+
async function deliver(planSlug) {
|
|
201
|
+
if (!runtime || !projectSlug || disposed) return "pending"
|
|
202
|
+
try {
|
|
203
|
+
const record = await runtime.store.getPlan(projectSlug, planSlug)
|
|
204
|
+
if (!record || !record.owner?.sessionID) return "pending"
|
|
205
|
+
const sessionID = record.owner.sessionID
|
|
206
|
+
if (typeof ctx?.session?.get !== "function" || typeof ctx?.session?.prompt !== "function") {
|
|
207
|
+
return "pending"
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await ctx.session.get({ sessionID })
|
|
211
|
+
} catch {
|
|
212
|
+
return "pending"
|
|
213
|
+
}
|
|
214
|
+
const pending = await runtime.store.pendingDeliveries(projectSlug)
|
|
215
|
+
const entry = pending.find((item) => item.planSlug === planSlug)
|
|
216
|
+
if (!entry) return "pending"
|
|
217
|
+
const claimed = []
|
|
218
|
+
try {
|
|
219
|
+
for (const submission of entry.submissions) {
|
|
220
|
+
if (await runtime.store.claimDelivery(projectSlug, planSlug, submission.id, sessionID)) {
|
|
221
|
+
claimed.push(submission)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (claimed.length === 0) return "pending"
|
|
225
|
+
const text = await wakeText(record, claimed)
|
|
226
|
+
await ctx.session.prompt({ sessionID, text, delivery: "queue" })
|
|
227
|
+
} catch {
|
|
228
|
+
for (const submission of claimed) {
|
|
229
|
+
try {
|
|
230
|
+
await runtime.store.releaseDelivery(projectSlug, planSlug, submission.id)
|
|
231
|
+
} catch {
|
|
232
|
+
// A refused file stays claimed until its pid is gone.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return "pending"
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await runtime.store.markDelivered(
|
|
239
|
+
projectSlug,
|
|
240
|
+
planSlug,
|
|
241
|
+
claimed.map((submission) => submission.id),
|
|
242
|
+
sessionID,
|
|
243
|
+
)
|
|
244
|
+
for (const submission of claimed) {
|
|
245
|
+
runtime.notify(projectSlug, planSlug, "delivered", { submissionId: submission.id })
|
|
246
|
+
}
|
|
247
|
+
return "woken"
|
|
248
|
+
} catch {
|
|
249
|
+
return "pending"
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
return "pending"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Deliver every plan that has pending work. */
|
|
257
|
+
async function deliverPending() {
|
|
258
|
+
if (!runtime || !projectSlug) return
|
|
259
|
+
try {
|
|
260
|
+
const pending = await runtime.store.pendingDeliveries(projectSlug)
|
|
261
|
+
for (const entry of pending) await deliver(entry.planSlug)
|
|
262
|
+
} catch {
|
|
263
|
+
// Delivery is best effort at every point.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Turn Discovery on for this project. */
|
|
268
|
+
async function enable() {
|
|
269
|
+
try {
|
|
270
|
+
if (!runtime) {
|
|
271
|
+
const store = createDiscoveryStore({ dataDir: deps.dataDir })
|
|
272
|
+
runtime = getDiscoveryRuntime({
|
|
273
|
+
store,
|
|
274
|
+
assetsDir: deps.assetsDir,
|
|
275
|
+
version: deps.version,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
const branch =
|
|
279
|
+
typeof deps.branchOf === "function" ? deps.branchOf(deps.directory) : null
|
|
280
|
+
const record = await runtime.store.registerProject({
|
|
281
|
+
directory: deps.directory,
|
|
282
|
+
branch,
|
|
283
|
+
repository: {
|
|
284
|
+
id: String(ctx?.location?.project?.id ?? deps.directory),
|
|
285
|
+
canonical: String(ctx?.location?.project?.canonical ?? deps.directory),
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
projectSlug = record.slug
|
|
289
|
+
await runtime.ensureListening()
|
|
290
|
+
unregister = runtime.registerProject(deps.directory, record.slug, deliver)
|
|
291
|
+
enabled = true
|
|
292
|
+
if (
|
|
293
|
+
!warnedAboutWake &&
|
|
294
|
+
(typeof ctx?.session?.get !== "function" || typeof ctx?.session?.prompt !== "function")
|
|
295
|
+
) {
|
|
296
|
+
warnedAboutWake = true
|
|
297
|
+
if (typeof deps.warn === "function") {
|
|
298
|
+
deps.warn("Discovery cannot wake sessions in this OpenCode build.")
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
await deliverPending()
|
|
302
|
+
await reloadTools()
|
|
303
|
+
} catch {
|
|
304
|
+
enabled = false
|
|
305
|
+
unregister = null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Turn Discovery off for this project without blocking the hook. */
|
|
310
|
+
async function disable() {
|
|
311
|
+
if (unregister) {
|
|
312
|
+
try {
|
|
313
|
+
unregister()
|
|
314
|
+
} catch {
|
|
315
|
+
// The registration may already be gone.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
unregister = null
|
|
319
|
+
enabled = false
|
|
320
|
+
projectSlug = null
|
|
321
|
+
if (runtime) {
|
|
322
|
+
try {
|
|
323
|
+
void runtime.closeIfIdle()
|
|
324
|
+
} catch {
|
|
325
|
+
// Nothing is listening.
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
await reloadTools()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
get runtime() {
|
|
333
|
+
return runtime
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
isEnabled() {
|
|
337
|
+
return enabled
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
async sync() {
|
|
341
|
+
if (disposed) return
|
|
342
|
+
const config = readDiscoveryConfig(deps.directory)
|
|
343
|
+
if (config.enabled === enabled) return
|
|
344
|
+
if (config.enabled) await enable()
|
|
345
|
+
else await disable()
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async onSessionDeleted(sessionID) {
|
|
349
|
+
if (!runtime || !projectSlug) return
|
|
350
|
+
try {
|
|
351
|
+
await runtime.store.markOrphaned(projectSlug, sessionID)
|
|
352
|
+
} catch {
|
|
353
|
+
// Orphaning is best effort; a later publish can still take over.
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async dispose() {
|
|
358
|
+
disposed = true
|
|
359
|
+
if (unregister) {
|
|
360
|
+
try {
|
|
361
|
+
unregister()
|
|
362
|
+
} catch {
|
|
363
|
+
// Already unregistered.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
unregister = null
|
|
367
|
+
enabled = false
|
|
368
|
+
await reloadTools()
|
|
369
|
+
if (runtime) await runtime.closeIfIdle()
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
/** Publish a plan round on behalf of the agent. */
|
|
373
|
+
async publish(input, sessionID) {
|
|
374
|
+
try {
|
|
375
|
+
if (!enabled || !runtime) {
|
|
376
|
+
return failure("disabled", "Discovery is disabled for this project.")
|
|
377
|
+
}
|
|
378
|
+
const listening = await runtime.ensureListening()
|
|
379
|
+
if (!listening) {
|
|
380
|
+
return failure("unavailable", "Discovery is unavailable on this machine; ask in chat.")
|
|
381
|
+
}
|
|
382
|
+
const projectRecord = await project()
|
|
383
|
+
if (!projectRecord) return failure("not-found", "This project is not registered.")
|
|
384
|
+
const result = await runtime.store.publish(projectRecord.slug, input, sessionID)
|
|
385
|
+
if (!result.ok) {
|
|
386
|
+
if (result.reason === "invalid") {
|
|
387
|
+
return failure("invalid", "The publish payload is invalid.", {
|
|
388
|
+
errors: result.errors,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
return failure("owned", "This plan is owned by another session.", {
|
|
392
|
+
owner: result.owner,
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
const url = pageUrl(result.plan.slug)
|
|
396
|
+
const opened = !runtime.hasViewer(projectRecord.slug, result.plan.slug) && Boolean(openBrowser(url))
|
|
397
|
+
runtime.notify(projectRecord.slug, result.plan.slug, "changed", {
|
|
398
|
+
revision: result.plan.revision,
|
|
399
|
+
})
|
|
400
|
+
await deliver(result.plan.slug)
|
|
401
|
+
return {
|
|
402
|
+
ok: true,
|
|
403
|
+
url,
|
|
404
|
+
plan: {
|
|
405
|
+
slug: result.plan.slug,
|
|
406
|
+
title: result.plan.title,
|
|
407
|
+
kind: result.plan.kind,
|
|
408
|
+
artifact: result.plan.artifact,
|
|
409
|
+
revision: result.plan.revision,
|
|
410
|
+
},
|
|
411
|
+
revision: result.plan.revision,
|
|
412
|
+
opened,
|
|
413
|
+
fallbackPort: listening.fallback ? Number(new URL(listening.url).port) : null,
|
|
414
|
+
warnings: result.warnings,
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
return failure("error", "Discovery could not publish this plan.")
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
/** Read the submitted answers for a plan. */
|
|
422
|
+
async answers(input, sessionID) {
|
|
423
|
+
try {
|
|
424
|
+
if (!enabled || !runtime) {
|
|
425
|
+
return failure("disabled", "Discovery is disabled for this project.")
|
|
426
|
+
}
|
|
427
|
+
const projectRecord = await project()
|
|
428
|
+
if (!projectRecord) return failure("not-found", "This project is not registered.")
|
|
429
|
+
const plan = await runtime.store.findPlan(projectRecord.slug, input.kind, input.artifact)
|
|
430
|
+
if (!plan) return failure("not-found", "No published plan matches that artifact.")
|
|
431
|
+
const result = await runtime.store.readAnswers(projectRecord.slug, plan.slug, sessionID)
|
|
432
|
+
if (!result.ok) {
|
|
433
|
+
if (result.reason === "not-found") {
|
|
434
|
+
return failure("not-found", "The plan no longer exists.")
|
|
435
|
+
}
|
|
436
|
+
return failure("owned", "This plan is owned by another session.", {
|
|
437
|
+
owner: result.owner,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
runtime.notify(projectRecord.slug, plan.slug, "changed", {
|
|
441
|
+
revision: result.plan.revision,
|
|
442
|
+
})
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
plan: {
|
|
446
|
+
slug: result.plan.slug,
|
|
447
|
+
title: result.plan.title,
|
|
448
|
+
revision: result.plan.revision,
|
|
449
|
+
},
|
|
450
|
+
submissions: result.submissions,
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
return failure("error", "Discovery could not read the answers.")
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
/** Record or retire questions for a plan. */
|
|
458
|
+
async resolve(input, sessionID) {
|
|
459
|
+
try {
|
|
460
|
+
if (!enabled || !runtime) {
|
|
461
|
+
return failure("disabled", "Discovery is disabled for this project.")
|
|
462
|
+
}
|
|
463
|
+
const projectRecord = await project()
|
|
464
|
+
if (!projectRecord) return failure("not-found", "This project is not registered.")
|
|
465
|
+
const plan = await runtime.store.findPlan(projectRecord.slug, input.kind, input.artifact)
|
|
466
|
+
if (!plan) return failure("not-found", "No published plan matches that artifact.")
|
|
467
|
+
const result = await runtime.store.resolve(projectRecord.slug, plan.slug, sessionID, {
|
|
468
|
+
recorded: input.recorded,
|
|
469
|
+
retire: input.retire,
|
|
470
|
+
})
|
|
471
|
+
if (!result.ok) {
|
|
472
|
+
if (result.reason === "not-found") {
|
|
473
|
+
return failure("not-found", "The plan no longer exists.")
|
|
474
|
+
}
|
|
475
|
+
if (result.reason === "owned") {
|
|
476
|
+
return failure("owned", "This plan is owned by another session.", {
|
|
477
|
+
owner: result.owner,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
return failure("invalid", "The resolve input is invalid.", {
|
|
481
|
+
...(result.errors === undefined ? {} : { errors: result.errors }),
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
runtime.notify(projectRecord.slug, plan.slug, "changed", {
|
|
485
|
+
revision: plan.revision,
|
|
486
|
+
})
|
|
487
|
+
return { ok: true, recorded: result.recorded, retired: result.retired }
|
|
488
|
+
} catch {
|
|
489
|
+
return failure("error", "Discovery could not record the answers.")
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Register the three Discovery tools on a plugin context.
|
|
497
|
+
*
|
|
498
|
+
* The transform adds the tools only while the controller is enabled, so a
|
|
499
|
+
* disabled project behaves exactly as before. Registration never throws and
|
|
500
|
+
* returns a boolean, like the progress tool.
|
|
501
|
+
*
|
|
502
|
+
* @param {unknown} ctx - The plugin context.
|
|
503
|
+
* @param {object} controller - The Discovery controller.
|
|
504
|
+
* @returns {Promise<boolean>} True when the transform completed.
|
|
505
|
+
*/
|
|
506
|
+
export async function registerDiscoveryTools(ctx, controller) {
|
|
507
|
+
if (typeof ctx?.tool?.transform !== "function") return false
|
|
508
|
+
try {
|
|
509
|
+
await ctx.tool.transform((tool) => {
|
|
510
|
+
if (!controller.isEnabled()) return
|
|
511
|
+
tool.add({
|
|
512
|
+
name: PUBLISH_TOOL,
|
|
513
|
+
description: DESCRIPTIONS[PUBLISH_TOOL],
|
|
514
|
+
input: DiscoveryPublishSchema,
|
|
515
|
+
async execute(input, toolContext) {
|
|
516
|
+
return { output: await controller.publish(input, toolContext?.sessionID ?? "") }
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
tool.add({
|
|
520
|
+
name: ANSWERS_TOOL,
|
|
521
|
+
description: DESCRIPTIONS[ANSWERS_TOOL],
|
|
522
|
+
input: ANSWERS_INPUT,
|
|
523
|
+
async execute(input, toolContext) {
|
|
524
|
+
return { output: await controller.answers(input, toolContext?.sessionID ?? "") }
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
tool.add({
|
|
528
|
+
name: RESOLVE_TOOL,
|
|
529
|
+
description: DESCRIPTIONS[RESOLVE_TOOL],
|
|
530
|
+
input: RESOLVE_INPUT,
|
|
531
|
+
async execute(input, toolContext) {
|
|
532
|
+
return { output: await controller.resolve(input, toolContext?.sessionID ?? "") }
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
return true
|
|
537
|
+
} catch {
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
}
|