bosun 0.35.2 → 0.35.3
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/README.md +14 -1
- package/agent-hooks.mjs +7 -1
- package/agent-pool.mjs +16 -0
- package/agent-prompts.mjs +190 -4
- package/agent-sdk.mjs +6 -1
- package/agent-work-analyzer.mjs +48 -9
- package/autofix.mjs +32 -18
- package/bosun.schema.json +1 -1
- package/kanban-adapter.mjs +62 -12
- package/monitor.mjs +25 -6
- package/opencode-shell.mjs +881 -0
- package/package.json +5 -2
- package/primary-agent.mjs +43 -0
- package/setup.mjs +33 -4
- package/task-executor.mjs +43 -14
- package/ui/app.js +10 -7
- package/ui/components/chat-view.js +31 -9
- package/ui/components/session-list.js +20 -4
- package/ui/modules/router.js +2 -0
- package/ui/tabs/agents.js +66 -8
- package/ui-server.mjs +142 -5
- package/workflow-engine.mjs +664 -10
- package/workflow-nodes.mjs +250 -1
- package/workflow-templates/github.mjs +389 -71
- package/workflow-templates/planning.mjs +31 -11
- package/workflow-templates.mjs +3 -0
package/workflow-nodes.mjs
CHANGED
|
@@ -2106,6 +2106,245 @@ registerNodeType("agent.select_profile", {
|
|
|
2106
2106
|
},
|
|
2107
2107
|
});
|
|
2108
2108
|
|
|
2109
|
+
function parsePlannerJsonFromText(value) {
|
|
2110
|
+
const text = normalizeLineEndings(String(value || ""))
|
|
2111
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
2112
|
+
// Strip common agent prefixes: "Agent: ", "Assistant: ", etc.
|
|
2113
|
+
.replace(/^\s*(?:Agent|Assistant|Planner|Output)\s*:\s*/i, "")
|
|
2114
|
+
.trim();
|
|
2115
|
+
if (!text) return null;
|
|
2116
|
+
|
|
2117
|
+
const candidates = [];
|
|
2118
|
+
// Match fenced blocks (```json ... ``` or ``` ... ```)
|
|
2119
|
+
const fenceRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
2120
|
+
let match;
|
|
2121
|
+
while ((match = fenceRegex.exec(text)) !== null) {
|
|
2122
|
+
const body = String(match[1] || "").trim();
|
|
2123
|
+
if (body) candidates.push(body);
|
|
2124
|
+
}
|
|
2125
|
+
// Also try stripped text without fences as raw JSON
|
|
2126
|
+
const strippedText = text.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "").trim();
|
|
2127
|
+
if (strippedText && !candidates.includes(strippedText)) {
|
|
2128
|
+
candidates.push(strippedText);
|
|
2129
|
+
}
|
|
2130
|
+
candidates.push(text);
|
|
2131
|
+
|
|
2132
|
+
for (const candidate of candidates) {
|
|
2133
|
+
try {
|
|
2134
|
+
const parsed = JSON.parse(candidate);
|
|
2135
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
2136
|
+
} catch {
|
|
2137
|
+
// Try extracting a balanced object from prose-wrapped output.
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const start = candidate.indexOf("{");
|
|
2141
|
+
if (start < 0) continue;
|
|
2142
|
+
let depth = 0;
|
|
2143
|
+
let inString = false;
|
|
2144
|
+
let escaped = false;
|
|
2145
|
+
for (let i = start; i < candidate.length; i += 1) {
|
|
2146
|
+
const ch = candidate[i];
|
|
2147
|
+
if (inString) {
|
|
2148
|
+
if (escaped) {
|
|
2149
|
+
escaped = false;
|
|
2150
|
+
} else if (ch === "\\") {
|
|
2151
|
+
escaped = true;
|
|
2152
|
+
} else if (ch === "\"") {
|
|
2153
|
+
inString = false;
|
|
2154
|
+
}
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
if (ch === "\"") {
|
|
2158
|
+
inString = true;
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
if (ch === "{") depth += 1;
|
|
2162
|
+
if (ch === "}") {
|
|
2163
|
+
depth -= 1;
|
|
2164
|
+
if (depth === 0) {
|
|
2165
|
+
const jsonSlice = candidate.slice(start, i + 1);
|
|
2166
|
+
try {
|
|
2167
|
+
const parsed = JSON.parse(jsonSlice);
|
|
2168
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
2169
|
+
} catch {
|
|
2170
|
+
// Keep scanning.
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
return null;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function normalizePlannerTaskForCreation(task, index) {
|
|
2181
|
+
if (!task || typeof task !== "object") return null;
|
|
2182
|
+
const title = String(task.title || "").trim();
|
|
2183
|
+
if (!title) return null;
|
|
2184
|
+
|
|
2185
|
+
const lines = [];
|
|
2186
|
+
const description = String(task.description || "").trim();
|
|
2187
|
+
if (description) lines.push(description);
|
|
2188
|
+
|
|
2189
|
+
const appendList = (heading, values) => {
|
|
2190
|
+
if (!Array.isArray(values) || values.length === 0) return;
|
|
2191
|
+
const items = values
|
|
2192
|
+
.map((item) => String(item || "").trim())
|
|
2193
|
+
.filter(Boolean);
|
|
2194
|
+
if (!items.length) return;
|
|
2195
|
+
lines.push("", `## ${heading}`);
|
|
2196
|
+
for (const item of items) lines.push(`- ${item}`);
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
appendList("Implementation Steps", task.implementation_steps);
|
|
2200
|
+
appendList("Acceptance Criteria", task.acceptance_criteria);
|
|
2201
|
+
appendList("Verification", task.verification);
|
|
2202
|
+
|
|
2203
|
+
const baseBranch = String(task.base_branch || "").trim();
|
|
2204
|
+
if (baseBranch) {
|
|
2205
|
+
lines.push("", `Base branch: \`${baseBranch}\``);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
return {
|
|
2209
|
+
title,
|
|
2210
|
+
description: lines.join("\n").trim(),
|
|
2211
|
+
index,
|
|
2212
|
+
baseBranch: baseBranch || null,
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function extractPlannerTasksFromWorkflowOutput(output, maxTasks = 5) {
|
|
2217
|
+
const parsed = parsePlannerJsonFromText(output);
|
|
2218
|
+
if (!parsed || !Array.isArray(parsed.tasks)) return [];
|
|
2219
|
+
|
|
2220
|
+
const max = Number.isFinite(Number(maxTasks))
|
|
2221
|
+
? Math.max(1, Math.min(100, Math.trunc(Number(maxTasks))))
|
|
2222
|
+
: 5;
|
|
2223
|
+
const dedup = new Set();
|
|
2224
|
+
const tasks = [];
|
|
2225
|
+
for (let i = 0; i < parsed.tasks.length && tasks.length < max; i += 1) {
|
|
2226
|
+
const normalized = normalizePlannerTaskForCreation(parsed.tasks[i], i);
|
|
2227
|
+
if (!normalized) continue;
|
|
2228
|
+
const key = normalized.title.toLowerCase();
|
|
2229
|
+
if (dedup.has(key)) continue;
|
|
2230
|
+
dedup.add(key);
|
|
2231
|
+
tasks.push(normalized);
|
|
2232
|
+
}
|
|
2233
|
+
return tasks;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
registerNodeType("action.materialize_planner_tasks", {
|
|
2237
|
+
describe: () => "Parse planner JSON output and create backlog tasks in Kanban",
|
|
2238
|
+
schema: {
|
|
2239
|
+
type: "object",
|
|
2240
|
+
properties: {
|
|
2241
|
+
plannerNodeId: { type: "string", default: "run-planner", description: "Node ID that produced planner output" },
|
|
2242
|
+
maxTasks: { type: "number", default: 5, description: "Maximum number of tasks to materialize" },
|
|
2243
|
+
status: { type: "string", default: "todo", description: "Status for created tasks" },
|
|
2244
|
+
dedup: { type: "boolean", default: true, description: "Skip titles already in backlog" },
|
|
2245
|
+
failOnZero: { type: "boolean", default: true, description: "Fail node when zero tasks are created" },
|
|
2246
|
+
minCreated: { type: "number", default: 1, description: "Minimum created tasks required for success" },
|
|
2247
|
+
projectId: { type: "string", description: "Optional explicit project ID for list/create operations" },
|
|
2248
|
+
},
|
|
2249
|
+
},
|
|
2250
|
+
async execute(node, ctx, engine) {
|
|
2251
|
+
const plannerNodeId = String(ctx.resolve(node.config?.plannerNodeId || "run-planner")).trim() || "run-planner";
|
|
2252
|
+
const plannerOutput = ctx.getNodeOutput(plannerNodeId) || {};
|
|
2253
|
+
const outputText = String(plannerOutput?.output || "").trim();
|
|
2254
|
+
const maxTasks = Number(ctx.resolve(node.config?.maxTasks || ctx.data?.taskCount || 5)) || 5;
|
|
2255
|
+
const failOnZero = node.config?.failOnZero !== false;
|
|
2256
|
+
const minCreated = Number(ctx.resolve(node.config?.minCreated || 1)) || 1;
|
|
2257
|
+
const dedupEnabled = node.config?.dedup !== false;
|
|
2258
|
+
const status = String(ctx.resolve(node.config?.status || "todo")).trim() || "todo";
|
|
2259
|
+
const projectId = String(ctx.resolve(node.config?.projectId || "")).trim();
|
|
2260
|
+
|
|
2261
|
+
const parsedTasks = extractPlannerTasksFromWorkflowOutput(outputText, maxTasks);
|
|
2262
|
+
if (!parsedTasks.length) {
|
|
2263
|
+
// Log diagnostic info to help debug planner output format issues
|
|
2264
|
+
const outputPreview = outputText.length > 200
|
|
2265
|
+
? `${outputText.slice(0, 200)}…`
|
|
2266
|
+
: outputText || "(empty)";
|
|
2267
|
+
const message = `Planner output from "${plannerNodeId}" did not include parseable tasks. ` +
|
|
2268
|
+
`Output length: ${outputText.length} chars. Preview: ${outputPreview}`;
|
|
2269
|
+
ctx.log(node.id, message, failOnZero ? "error" : "warn");
|
|
2270
|
+
if (failOnZero) throw new Error(message);
|
|
2271
|
+
return {
|
|
2272
|
+
success: false,
|
|
2273
|
+
parsedCount: 0,
|
|
2274
|
+
createdCount: 0,
|
|
2275
|
+
skippedCount: 0,
|
|
2276
|
+
reason: "no_parseable_tasks",
|
|
2277
|
+
outputPreview,
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
const kanban = engine.services?.kanban;
|
|
2282
|
+
if (!kanban?.createTask) {
|
|
2283
|
+
throw new Error("Kanban adapter not available for planner materialization");
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const existingTitleSet = new Set();
|
|
2287
|
+
if (dedupEnabled && kanban?.listTasks && projectId) {
|
|
2288
|
+
try {
|
|
2289
|
+
const existing = await kanban.listTasks(projectId, {});
|
|
2290
|
+
const rows = Array.isArray(existing) ? existing : [];
|
|
2291
|
+
for (const row of rows) {
|
|
2292
|
+
const title = String(row?.title || "").trim().toLowerCase();
|
|
2293
|
+
if (title) existingTitleSet.add(title);
|
|
2294
|
+
}
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
ctx.log(node.id, `Could not prefetch tasks for dedup: ${err.message}`, "warn");
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
const created = [];
|
|
2301
|
+
const skipped = [];
|
|
2302
|
+
for (const task of parsedTasks) {
|
|
2303
|
+
const key = task.title.toLowerCase();
|
|
2304
|
+
if (dedupEnabled && existingTitleSet.has(key)) {
|
|
2305
|
+
skipped.push({ title: task.title, reason: "duplicate_title" });
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
const payload = {
|
|
2310
|
+
title: task.title,
|
|
2311
|
+
description: task.description,
|
|
2312
|
+
status,
|
|
2313
|
+
};
|
|
2314
|
+
if (projectId) payload.projectId = projectId;
|
|
2315
|
+
const createdTask = await kanban.createTask(payload);
|
|
2316
|
+
created.push({
|
|
2317
|
+
id: createdTask?.id || null,
|
|
2318
|
+
title: task.title,
|
|
2319
|
+
});
|
|
2320
|
+
existingTitleSet.add(key);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const createdCount = created.length;
|
|
2324
|
+
const skippedCount = skipped.length;
|
|
2325
|
+
ctx.log(
|
|
2326
|
+
node.id,
|
|
2327
|
+
`Planner materialization parsed=${parsedTasks.length} created=${createdCount} skipped=${skippedCount}`,
|
|
2328
|
+
);
|
|
2329
|
+
|
|
2330
|
+
if (failOnZero && createdCount < Math.max(1, minCreated)) {
|
|
2331
|
+
throw new Error(
|
|
2332
|
+
`Planner materialization created ${createdCount} tasks (required: ${Math.max(1, minCreated)})`,
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
return {
|
|
2337
|
+
success: createdCount >= Math.max(1, minCreated),
|
|
2338
|
+
parsedCount: parsedTasks.length,
|
|
2339
|
+
createdCount,
|
|
2340
|
+
skippedCount,
|
|
2341
|
+
created,
|
|
2342
|
+
skipped,
|
|
2343
|
+
tasks: parsedTasks,
|
|
2344
|
+
};
|
|
2345
|
+
},
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2109
2348
|
registerNodeType("agent.run_planner", {
|
|
2110
2349
|
describe: () => "Run the task planner agent to generate new backlog tasks",
|
|
2111
2350
|
schema: {
|
|
@@ -2131,9 +2370,19 @@ registerNodeType("agent.run_planner", {
|
|
|
2131
2370
|
// This delegates to the existing planner prompt flow
|
|
2132
2371
|
const agentPool = engine.services?.agentPool;
|
|
2133
2372
|
const plannerPrompt = engine.services?.prompts?.planner;
|
|
2373
|
+
// Enforce strict output instructions to ensure the downstream materialize node
|
|
2374
|
+
// can parse the planner output. The planner prompt already defines the contract,
|
|
2375
|
+
// but we reinforce it here to prevent agents from wrapping output in prose.
|
|
2376
|
+
const outputEnforcement =
|
|
2377
|
+
`\n\n## CRITICAL OUTPUT REQUIREMENT\n` +
|
|
2378
|
+
`Generate exactly ${count} new tasks.\n` +
|
|
2379
|
+
(context ? `${context}\n\n` : "\n") +
|
|
2380
|
+
`Your response MUST be a single fenced JSON block with shape { "tasks": [...] }.\n` +
|
|
2381
|
+
`Do NOT include any text, commentary, or prose outside the JSON block.\n` +
|
|
2382
|
+
`The downstream system will parse your output as JSON — any extra text will cause task creation to fail.`;
|
|
2134
2383
|
const promptText = explicitPrompt ||
|
|
2135
2384
|
(plannerPrompt
|
|
2136
|
-
? `${plannerPrompt}
|
|
2385
|
+
? `${plannerPrompt}${outputEnforcement}`
|
|
2137
2386
|
: "");
|
|
2138
2387
|
|
|
2139
2388
|
if (agentPool?.launchEphemeralThread && promptText) {
|