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.
@@ -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}\n\nGenerate exactly ${count} new tasks.\n${context}`
2385
+ ? `${plannerPrompt}${outputEnforcement}`
2137
2386
  : "");
2138
2387
 
2139
2388
  if (agentPool?.launchEphemeralThread && promptText) {