workermill 0.1.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.
@@ -0,0 +1,875 @@
1
+ import {
2
+ CostTracker,
3
+ PermissionManager,
4
+ createModel,
5
+ createToolDefinitions,
6
+ getPersonaEmoji,
7
+ getProviderForPersona,
8
+ printError,
9
+ printToolCall,
10
+ printToolResult,
11
+ wmCoordinatorLog,
12
+ wmLog,
13
+ wmLogPrefix
14
+ } from "./chunk-3KIFXIBC.js";
15
+ import {
16
+ __dirname
17
+ } from "./chunk-2NTK7H4W.js";
18
+
19
+ // src/orchestrator.js
20
+ import chalk from "chalk";
21
+ import ora from "ora";
22
+ import { streamText, generateObject, generateText, stepCountIs } from "ai";
23
+ import { z } from "zod";
24
+
25
+ // src/personas.js
26
+ import fs from "fs";
27
+ import path from "path";
28
+ import os from "os";
29
+ function parsePersonaFile(content) {
30
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
31
+ if (!match)
32
+ return null;
33
+ const frontmatter = match[1];
34
+ const body = match[2].trim();
35
+ const meta = {};
36
+ for (const line of frontmatter.split("\n")) {
37
+ const kvMatch = line.match(/^(\w+):\s*(.+)$/);
38
+ if (kvMatch) {
39
+ const key = kvMatch[1];
40
+ let value = kvMatch[2].trim();
41
+ if (value.startsWith("[") && value.endsWith("]")) {
42
+ value = value.slice(1, -1).split(",").map((s) => s.trim());
43
+ }
44
+ meta[key] = value;
45
+ }
46
+ }
47
+ if (!meta.name || !meta.slug)
48
+ return null;
49
+ return {
50
+ name: meta.name,
51
+ slug: meta.slug,
52
+ description: meta.description || "",
53
+ tools: meta.tools || ["bash", "read_file", "write_file", "edit_file", "patch", "glob", "grep", "ls", "fetch", "sub_agent"],
54
+ provider: meta.provider,
55
+ model: meta.model,
56
+ systemPrompt: body
57
+ };
58
+ }
59
+ function loadPersona(slug) {
60
+ const locations = [
61
+ path.join(process.cwd(), ".workermill", "personas", `${slug}.md`),
62
+ path.join(os.homedir(), ".workermill", "personas", `${slug}.md`),
63
+ path.join(import.meta.dirname || __dirname, "../../packages/engine/src/personas", `${slug}.md`),
64
+ // Also try relative to the repo root
65
+ path.join(process.cwd(), "packages/engine/src/personas", `${slug}.md`)
66
+ ];
67
+ for (const loc of locations) {
68
+ try {
69
+ if (fs.existsSync(loc)) {
70
+ const content = fs.readFileSync(loc, "utf-8");
71
+ const persona = parsePersonaFile(content);
72
+ if (persona)
73
+ return persona;
74
+ }
75
+ } catch {
76
+ continue;
77
+ }
78
+ }
79
+ return {
80
+ name: slug.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
81
+ slug,
82
+ description: `${slug} specialist`,
83
+ tools: ["bash", "read_file", "write_file", "edit_file", "patch", "glob", "grep", "ls", "fetch", "sub_agent"],
84
+ systemPrompt: `You are a senior ${slug.replace(/_/g, " ")}. You write clean, production-ready code following best practices. Focus on your area of expertise and coordinate with other experts when needed.`
85
+ };
86
+ }
87
+
88
+ // src/orchestrator.js
89
+ async function classifyComplexity(config, userInput) {
90
+ const { provider, model: modelName, apiKey, host } = getProviderForPersona(config);
91
+ if (apiKey) {
92
+ const envMap = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" };
93
+ const envVar = envMap[provider];
94
+ if (envVar && !process.env[envVar])
95
+ process.env[envVar] = apiKey;
96
+ }
97
+ const model = createModel(provider, modelName, host);
98
+ try {
99
+ const result = await generateObject({
100
+ model,
101
+ schema: z.object({
102
+ complexity: z.enum(["single", "multi"]),
103
+ reason: z.string()
104
+ }),
105
+ prompt: `Analyze this coding task. If it involves multiple distinct concerns that would benefit from different specialist personas (e.g., database + backend + frontend + devops), classify as "multi". If it's a focused task that one developer could handle, classify as "single". Just classify \u2014 do not break down into stories.
106
+
107
+ Task:
108
+ ${userInput}`
109
+ });
110
+ return {
111
+ isMulti: result.object.complexity === "multi",
112
+ reason: result.object.reason
113
+ };
114
+ } catch (err) {
115
+ try {
116
+ const textResult = await generateText({
117
+ model,
118
+ prompt: `Is this task "single" (one developer) or "multi" (needs multiple specialists)? Respond with just "single" or "multi" and a brief reason.
119
+
120
+ Task: ${userInput}`
121
+ });
122
+ const isMulti = /\bmulti\b/i.test(textResult.text);
123
+ return { isMulti, reason: textResult.text.slice(0, 200) };
124
+ } catch {
125
+ }
126
+ return { isMulti: false, reason: `Classification failed: ${err instanceof Error ? err.message : String(err)}` };
127
+ }
128
+ }
129
+ function topologicalSort(stories) {
130
+ const idMap = new Map(stories.map((s) => [s.id, s]));
131
+ const visited = /* @__PURE__ */ new Set();
132
+ const result = [];
133
+ const visiting = /* @__PURE__ */ new Set();
134
+ function visit(id) {
135
+ if (visited.has(id))
136
+ return;
137
+ if (visiting.has(id)) {
138
+ console.log(chalk.yellow(` \u26A0 Circular dependency at ${id}, using input order`));
139
+ return;
140
+ }
141
+ visiting.add(id);
142
+ const story = idMap.get(id);
143
+ if (story?.dependsOn) {
144
+ for (const dep of story.dependsOn) {
145
+ if (idMap.has(dep))
146
+ visit(dep);
147
+ }
148
+ }
149
+ visiting.delete(id);
150
+ visited.add(id);
151
+ if (story)
152
+ result.push(story);
153
+ }
154
+ for (const story of stories) {
155
+ visit(story.id);
156
+ }
157
+ return result;
158
+ }
159
+ async function planStories(config, userTask, workingDir, sandboxed = true) {
160
+ const planner = loadPersona("planner");
161
+ const { provider: pProvider, model: pModel, host: pHost } = getProviderForPersona(config, "planner");
162
+ if (pProvider) {
163
+ const pApiKey = config.providers[pProvider]?.apiKey;
164
+ if (pApiKey) {
165
+ const envMap = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" };
166
+ const envVar = envMap[pProvider];
167
+ if (envVar && !process.env[envVar]) {
168
+ const key = pApiKey.startsWith("{env:") ? process.env[pApiKey.slice(5, -1)] : pApiKey;
169
+ if (key)
170
+ process.env[envVar] = key;
171
+ }
172
+ }
173
+ }
174
+ const plannerModel = createModel(pProvider, pModel, pHost);
175
+ const plannerTools = createToolDefinitions(workingDir, plannerModel, sandboxed);
176
+ const readOnlyTools = {};
177
+ if (planner) {
178
+ for (const toolName of planner.tools) {
179
+ if (plannerTools[toolName]) {
180
+ readOnlyTools[toolName] = plannerTools[toolName];
181
+ }
182
+ }
183
+ }
184
+ const plannerPrompt = `You are an expert implementation planner. Analyze this task and create a high-quality implementation plan.
185
+
186
+ ## Task
187
+ ${userTask}
188
+
189
+ ## Working directory
190
+ ${workingDir}
191
+
192
+ ## Instructions
193
+ 1. Use your tools to explore the working directory and understand what exists. Stay within the working directory.
194
+ 2. Design a plan that breaks the task into focused stories, each assigned to a specialist persona.
195
+ 3. Each story should be a meaningful unit of work \u2014 not too granular, not too broad.
196
+ 4. Quality criteria:
197
+ - Every story has a clear, specific description
198
+ - Stories are ordered correctly \u2014 dependencies satisfied before dependents
199
+ - Each story is scoped for ONE persona
200
+ - Descriptions include enough detail for the persona to execute without ambiguity
201
+
202
+ ## Output format
203
+ Return ONLY a JSON code block with this structure:
204
+ \`\`\`json
205
+ {
206
+ "stories": [
207
+ {
208
+ "id": "short-kebab-case-id",
209
+ "title": "Brief title",
210
+ "persona": "persona_name",
211
+ "description": "Detailed description: what to create/modify, which files, what approach, what to watch out for",
212
+ "dependsOn": ["id-of-dependency"]
213
+ }
214
+ ]
215
+ }
216
+ \`\`\`
217
+
218
+ Available personas: architect, backend_developer, frontend_developer, fullstack_developer, devops_engineer, qa_engineer, security_engineer, database_engineer, mobile_developer, data_engineer, ml_engineer`;
219
+ wmLog("planner", `Starting planning agent using ${pModel}`);
220
+ wmLog("planner", "Reading repository structure...");
221
+ const planStream = streamText({
222
+ model: plannerModel,
223
+ system: planner?.systemPrompt || "You are an implementation planner.",
224
+ prompt: plannerPrompt,
225
+ tools: readOnlyTools,
226
+ stopWhen: stepCountIs(100),
227
+ abortSignal: AbortSignal.timeout(3 * 60 * 1e3)
228
+ });
229
+ let planText = "";
230
+ const planPrefix = wmLogPrefix("planner");
231
+ let planNeedsPrefix = true;
232
+ let inJsonBlock = false;
233
+ for await (const chunk of planStream.textStream) {
234
+ if (chunk) {
235
+ planText += chunk;
236
+ if (chunk.includes("```json")) {
237
+ inJsonBlock = true;
238
+ continue;
239
+ }
240
+ if (chunk.includes("```") && inJsonBlock) {
241
+ inJsonBlock = false;
242
+ continue;
243
+ }
244
+ if (inJsonBlock)
245
+ continue;
246
+ if (planNeedsPrefix) {
247
+ process.stdout.write(planPrefix);
248
+ planNeedsPrefix = false;
249
+ }
250
+ process.stdout.write(chalk.white(chunk));
251
+ if (chunk.endsWith("\n"))
252
+ planNeedsPrefix = true;
253
+ }
254
+ }
255
+ if (!planNeedsPrefix)
256
+ process.stdout.write("\n");
257
+ const finalText = await planStream.text;
258
+ if (finalText && finalText.length > planText.length) {
259
+ planText = finalText;
260
+ }
261
+ let stories = parseStoriesFromText(planText);
262
+ if (stories.length === 0) {
263
+ console.log(chalk.yellow(" \u26A0 Planner didn't produce structured stories, falling back to single story"));
264
+ stories = [{
265
+ id: "implement",
266
+ title: userTask.slice(0, 60),
267
+ persona: "fullstack_developer",
268
+ description: userTask
269
+ }];
270
+ }
271
+ return stories;
272
+ }
273
+ function parseStoriesFromText(text) {
274
+ const codeBlocks = [...text.matchAll(/```(?:json)?\s*\n?([\s\S]*?)```/g)];
275
+ for (const match of codeBlocks) {
276
+ const stories2 = tryParseStories(match[1].trim());
277
+ if (stories2)
278
+ return stories2;
279
+ }
280
+ const storiesIdx = text.indexOf('"stories"');
281
+ if (storiesIdx !== -1) {
282
+ let braceStart = text.lastIndexOf("{", storiesIdx);
283
+ if (braceStart !== -1) {
284
+ const json = extractBalancedJSON(text, braceStart);
285
+ if (json) {
286
+ const stories2 = tryParseStories(json);
287
+ if (stories2)
288
+ return stories2;
289
+ }
290
+ }
291
+ }
292
+ const arrayStart = text.indexOf("[");
293
+ if (arrayStart !== -1 && text.indexOf('"persona"') !== -1) {
294
+ const json = extractBalancedJSON(text, arrayStart);
295
+ if (json) {
296
+ const stories2 = tryParseStories(json);
297
+ if (stories2)
298
+ return stories2;
299
+ }
300
+ }
301
+ const stories = tryParseStories(text.trim());
302
+ if (stories)
303
+ return stories;
304
+ const preview = text.slice(0, 500);
305
+ console.log(chalk.dim(` (planner output preview: ${preview}${text.length > 500 ? "..." : ""})`));
306
+ return [];
307
+ }
308
+ function tryParseStories(text) {
309
+ try {
310
+ const parsed = JSON.parse(text);
311
+ if (Array.isArray(parsed)) {
312
+ if (parsed.length > 0 && parsed[0].persona)
313
+ return parsed;
314
+ }
315
+ if (parsed && Array.isArray(parsed.stories)) {
316
+ if (parsed.stories.length > 0)
317
+ return parsed.stories;
318
+ }
319
+ } catch {
320
+ }
321
+ return null;
322
+ }
323
+ function extractBalancedJSON(text, start) {
324
+ const open = text[start];
325
+ const close = open === "{" ? "}" : open === "[" ? "]" : null;
326
+ if (!close)
327
+ return null;
328
+ let depth = 0;
329
+ let inString = false;
330
+ let escape = false;
331
+ for (let i = start; i < text.length; i++) {
332
+ const ch = text[i];
333
+ if (escape) {
334
+ escape = false;
335
+ continue;
336
+ }
337
+ if (ch === "\\") {
338
+ escape = true;
339
+ continue;
340
+ }
341
+ if (ch === '"') {
342
+ inString = !inString;
343
+ continue;
344
+ }
345
+ if (inString)
346
+ continue;
347
+ if (ch === open)
348
+ depth++;
349
+ if (ch === close) {
350
+ depth--;
351
+ if (depth === 0) {
352
+ return text.slice(start, i + 1);
353
+ }
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+ function extractScore(text) {
359
+ const markerMatch = text.match(/::review_score::(\d+)/);
360
+ if (markerMatch)
361
+ return parseInt(markerMatch[1], 10);
362
+ const scorePatterns = [
363
+ /\bscore[:\s]+(\d+)\s*\/\s*100/i,
364
+ /\b(\d+)\s*\/\s*100/,
365
+ /\bscore[:\s]+(\d+)/i,
366
+ /\brating[:\s]+(\d+)/i
367
+ ];
368
+ for (const pattern of scorePatterns) {
369
+ const match = text.match(pattern);
370
+ if (match) {
371
+ const n = parseInt(match[1], 10);
372
+ if (n >= 0 && n <= 100)
373
+ return n;
374
+ }
375
+ }
376
+ if (/\bapprove/i.test(text))
377
+ return 85;
378
+ if (/\brevis/i.test(text))
379
+ return 60;
380
+ return 75;
381
+ }
382
+ async function runOrchestration(config, userTask, trustAll, sandboxed = true, agentRl) {
383
+ const costTracker = new CostTracker();
384
+ const context = {
385
+ filesCreated: [],
386
+ filesModified: [],
387
+ decisions: [],
388
+ learnings: []
389
+ };
390
+ const permissions = new PermissionManager(trustAll);
391
+ if (agentRl)
392
+ permissions.setReadline(agentRl);
393
+ const workingDir = process.cwd();
394
+ const plannerStories = await planStories(config, userTask, workingDir, sandboxed);
395
+ wmLog("planner", `Plan generated: ${plannerStories.length} stories`);
396
+ plannerStories.forEach((s, i) => {
397
+ const emoji = getPersonaEmoji(s.persona);
398
+ wmLog("planner", `Step ${i + 1}: [${s.persona}] ${s.title}${s.dependsOn?.length ? ` (after: ${s.dependsOn.join(", ")})` : ""}`);
399
+ });
400
+ wmLog("planner", `Plan validated: ${plannerStories.length} stories. Task queued for execution.`);
401
+ console.log();
402
+ if (config.review?.useCritic) {
403
+ const critic = loadPersona("critic");
404
+ if (critic) {
405
+ const { provider: cProvider, model: cModel, host: cHost } = getProviderForPersona(config, "critic");
406
+ const criticModel = createModel(cProvider, cModel, cHost);
407
+ const criticTools = createToolDefinitions(workingDir, criticModel, sandboxed);
408
+ const criticReadOnly = {};
409
+ for (const name of critic.tools) {
410
+ if (criticTools[name]) {
411
+ criticReadOnly[name] = criticTools[name];
412
+ }
413
+ }
414
+ const criticSpinner = ora({ stream: process.stdout, text: chalk.white("Critic reviewing plan..."), prefixText: " " }).start();
415
+ const criticStream = streamText({
416
+ model: criticModel,
417
+ system: critic.systemPrompt,
418
+ prompt: `Review this implementation plan. Score it 0-100 using ::review_score::N marker.
419
+
420
+ Stories:
421
+ ${plannerStories.map((s) => `- ${s.id}: ${s.title} (${s.persona}) \u2014 ${s.description}`).join("\n")}`,
422
+ tools: criticReadOnly,
423
+ stopWhen: stepCountIs(100),
424
+ abortSignal: AbortSignal.timeout(3 * 60 * 1e3)
425
+ });
426
+ for await (const _chunk of criticStream.textStream) {
427
+ }
428
+ const criticText = await criticStream.text;
429
+ criticSpinner.stop();
430
+ const score = extractScore(criticText);
431
+ wmLog("critic", `::review_score::${score}`);
432
+ wmLog("critic", score >= 80 ? "Plan approved" : "Plan needs revision");
433
+ console.log();
434
+ }
435
+ }
436
+ const sorted = topologicalSort(plannerStories);
437
+ if (!trustAll) {
438
+ let answer = "n";
439
+ try {
440
+ answer = await permissions.askUser(chalk.dim(" Execute this plan? (y/n): "));
441
+ } catch {
442
+ }
443
+ if (answer.trim().toLowerCase() !== "y" && answer.trim().toLowerCase() !== "yes") {
444
+ console.log(chalk.dim(" Plan cancelled.\n"));
445
+ return;
446
+ }
447
+ console.log();
448
+ }
449
+ for (let i = 0; i < sorted.length; i++) {
450
+ const story = sorted[i];
451
+ const persona = loadPersona(story.persona);
452
+ if (!persona) {
453
+ printError(`Unknown persona: ${story.persona}`);
454
+ continue;
455
+ }
456
+ const { provider, model: modelName, apiKey, host } = getProviderForPersona(config, persona.provider || story.persona);
457
+ if (apiKey) {
458
+ const envMap = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" };
459
+ const envVar = envMap[provider];
460
+ if (envVar && !process.env[envVar])
461
+ process.env[envVar] = apiKey;
462
+ }
463
+ wmCoordinatorLog(`Task claimed by orchestrator`);
464
+ wmLog(story.persona, `Starting ${story.title}`);
465
+ wmLog(story.persona, `Executing story with AIClient (model: ${modelName})...`);
466
+ const spinner = ora({
467
+ stream: process.stdout,
468
+ text: "",
469
+ prefixText: "",
470
+ spinner: "dots"
471
+ }).start();
472
+ const model = createModel(provider, modelName, host);
473
+ const allTools = createToolDefinitions(workingDir, model, sandboxed);
474
+ const personaTools = {};
475
+ let lastToolCall = "";
476
+ for (const toolName of persona.tools) {
477
+ const toolDef = allTools[toolName];
478
+ if (toolDef) {
479
+ personaTools[toolName] = {
480
+ ...toolDef,
481
+ execute: async (input) => {
482
+ const allowed = await permissions.checkPermission(toolName, input);
483
+ if (!allowed)
484
+ return "Tool execution denied by user.";
485
+ const callKey = `${toolName}:${JSON.stringify(input)}`;
486
+ const isDuplicate = callKey === lastToolCall;
487
+ lastToolCall = callKey;
488
+ if (!isDuplicate) {
489
+ spinner.stop();
490
+ wmLog(story.persona, `Tool: ${toolName}`);
491
+ }
492
+ const result = await toolDef.execute(input);
493
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
494
+ if (!isDuplicate) {
495
+ printToolResult(toolName, resultStr);
496
+ }
497
+ spinner.start();
498
+ return result;
499
+ }
500
+ };
501
+ }
502
+ }
503
+ let revisionFeedback = "";
504
+ for (let revision = 0; revision <= 2; revision++) {
505
+ const contextParts = [];
506
+ if (context.filesCreated.length > 0) {
507
+ contextParts.push(`Files created: ${context.filesCreated.join(", ")}`);
508
+ }
509
+ if (context.filesModified.length > 0) {
510
+ contextParts.push(`Files modified: ${context.filesModified.join(", ")}`);
511
+ }
512
+ if (context.decisions.length > 0) {
513
+ contextParts.push(`Decisions: ${context.decisions.join("; ")}`);
514
+ }
515
+ if (context.learnings.length > 0) {
516
+ contextParts.push(`Learnings: ${context.learnings.join("; ")}`);
517
+ }
518
+ const contextBlock = contextParts.length > 0 ? `
519
+
520
+ ## Context from prior experts
521
+ ${contextParts.join("\n")}` : "";
522
+ const systemPrompt = `${persona.systemPrompt}${contextBlock}
523
+
524
+ Working directory: ${workingDir}
525
+
526
+ Your task: ${story.description}
527
+
528
+ ## Critical rules
529
+ - NEVER start long-running processes (dev servers, watch modes, npm start, npm run dev, nodemon, tsc --watch, webpack serve, etc.). These block execution indefinitely.
530
+ - NEVER run interactive commands that wait for user input.
531
+ - Only run commands that complete and exit: npm install, npm test, npx tsc --noEmit, etc.
532
+ - If you need to verify a server works, check that the code compiles or run a quick test \u2014 do NOT start the actual server.
533
+
534
+ When you make a decision that affects other parts of the system, include ::decision:: markers in your output.
535
+ When you learn something useful, include ::learning:: markers.
536
+ When you create a file, include ::file_created::path markers.
537
+ When you modify a file, include ::file_modified::path markers.${revisionFeedback ? `
538
+
539
+ ## Revision requested
540
+ ${revisionFeedback}` : ""}`;
541
+ try {
542
+ const stream = streamText({
543
+ model,
544
+ system: systemPrompt,
545
+ prompt: story.description,
546
+ tools: personaTools,
547
+ stopWhen: stepCountIs(100),
548
+ abortSignal: AbortSignal.timeout(10 * 60 * 1e3)
549
+ });
550
+ let allText = "";
551
+ const storyPrefix = wmLogPrefix(story.persona);
552
+ let needsPrefix = true;
553
+ for await (const chunk of stream.textStream) {
554
+ if (chunk) {
555
+ allText += chunk;
556
+ if (chunk.includes("::decision::") || chunk.includes("::learning::") || chunk.includes("::file_created::") || chunk.includes("::file_modified::"))
557
+ continue;
558
+ spinner.stop();
559
+ if (needsPrefix) {
560
+ process.stdout.write(storyPrefix);
561
+ needsPrefix = false;
562
+ }
563
+ process.stdout.write(chalk.white(chunk));
564
+ if (chunk.endsWith("\n")) {
565
+ needsPrefix = true;
566
+ }
567
+ }
568
+ }
569
+ if (!needsPrefix) {
570
+ process.stdout.write("\n");
571
+ }
572
+ const finalStreamText = await stream.text;
573
+ const text = finalStreamText && finalStreamText.length > allText.length ? finalStreamText : allText;
574
+ const usage = await stream.totalUsage;
575
+ spinner.stop();
576
+ const decisionMatches = text.match(/::decision::(.*?)(?=::\w+::|$)/gs);
577
+ if (decisionMatches) {
578
+ for (const m of decisionMatches) {
579
+ const decision = m.replace("::decision::", "").trim();
580
+ context.decisions.push(decision);
581
+ wmLog(story.persona, decision);
582
+ }
583
+ }
584
+ const learningMatches = text.match(/::learning::(.*?)(?=::\w+::|$)/gs);
585
+ if (learningMatches) {
586
+ for (const m of learningMatches) {
587
+ context.learnings.push(m.replace("::learning::", "").trim());
588
+ }
589
+ }
590
+ const fileCreatedMatches = text.match(/::file_created::(.*?)(?=::\w+::|$)/gs);
591
+ if (fileCreatedMatches) {
592
+ for (const m of fileCreatedMatches) {
593
+ context.filesCreated.push(m.replace("::file_created::", "").trim());
594
+ }
595
+ }
596
+ const fileModifiedMatches = text.match(/::file_modified::(.*?)(?=::\w+::|$)/gs);
597
+ if (fileModifiedMatches) {
598
+ for (const m of fileModifiedMatches) {
599
+ context.filesModified.push(m.replace("::file_modified::", "").trim());
600
+ }
601
+ }
602
+ const inTokens = usage?.inputTokens || 0;
603
+ const outTokens = usage?.outputTokens || 0;
604
+ costTracker.addUsage(persona.name, provider, modelName, inTokens, outTokens);
605
+ wmLog(story.persona, `${story.title} \u2014 completed!`);
606
+ console.log();
607
+ break;
608
+ } catch (err) {
609
+ spinner.stop();
610
+ printError(`Story ${i + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
611
+ break;
612
+ }
613
+ }
614
+ }
615
+ const maxRevisions = config.review?.maxRevisions ?? 2;
616
+ const autoRevise = config.review?.autoRevise ?? false;
617
+ const approvalThreshold = config.review?.approvalThreshold ?? 80;
618
+ const reviewer = loadPersona("reviewer");
619
+ if (reviewer) {
620
+ const { provider: revProvider, model: revModel, host: revHost } = getProviderForPersona(config, reviewer.provider || "reviewer");
621
+ const revApiKey = config.providers[revProvider]?.apiKey;
622
+ if (revApiKey) {
623
+ const envMap = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" };
624
+ const envVar = envMap[revProvider];
625
+ const key = revApiKey.startsWith("{env:") ? process.env[revApiKey.slice(5, -1)] : revApiKey;
626
+ if (envVar && key && !process.env[envVar])
627
+ process.env[envVar] = key;
628
+ }
629
+ const reviewModel = createModel(revProvider, revModel, revHost);
630
+ const reviewTools = createToolDefinitions(workingDir, reviewModel, sandboxed);
631
+ const reviewerTools = {};
632
+ for (const toolName of reviewer.tools) {
633
+ if (reviewTools[toolName]) {
634
+ reviewerTools[toolName] = reviewTools[toolName];
635
+ }
636
+ }
637
+ for (let reviewRound = 0; reviewRound <= maxRevisions; reviewRound++) {
638
+ const isRevision = reviewRound > 0;
639
+ wmCoordinatorLog(isRevision ? `Starting Tech Lead review (revision ${reviewRound}/${maxRevisions})...` : "Starting Tech Lead review...");
640
+ wmLog("tech_lead", "Starting agent execution");
641
+ const reviewSpinner = ora({
642
+ stream: process.stdout,
643
+ text: chalk.white(isRevision ? "Reviewer \u2014 Re-checking after revisions" : "Reviewer \u2014 Checking code quality"),
644
+ prefixText: " "
645
+ }).start();
646
+ try {
647
+ const reviewPrompt = `Review the changes made by the following experts:
648
+
649
+ ${sorted.map((s, idx) => `${idx + 1}. ${s.persona}: ${s.title} \u2014 ${s.description}`).join("\n")}
650
+
651
+ Files created: ${context.filesCreated.join(", ") || "none"}
652
+ Files modified: ${context.filesModified.join(", ") || "none"}
653
+
654
+ Use the read_file, glob, and grep tools to examine the actual changes. Look for:
655
+ - Bugs or logic errors
656
+ - Missing error handling
657
+ - Security issues
658
+ - Code that doesn't follow project conventions
659
+ - Missing tests
660
+
661
+ Provide a review with a quality score (0-100) using ::review_score:: marker and a verdict using ::review_verdict::approved or ::review_verdict::needs_revision.
662
+ If there are issues, be specific about which files and what needs to change.`;
663
+ const reviewStream = streamText({
664
+ model: reviewModel,
665
+ system: reviewer.systemPrompt,
666
+ prompt: reviewPrompt,
667
+ tools: reviewerTools,
668
+ stopWhen: stepCountIs(100),
669
+ abortSignal: AbortSignal.timeout(5 * 60 * 1e3)
670
+ });
671
+ let allReviewText = "";
672
+ const revPrefix = wmLogPrefix("tech_lead");
673
+ let revNeedsPrefix = true;
674
+ for await (const chunk of reviewStream.textStream) {
675
+ if (chunk) {
676
+ allReviewText += chunk;
677
+ if (chunk.includes("::review_score::") || chunk.includes("::review_verdict::"))
678
+ continue;
679
+ reviewSpinner.stop();
680
+ if (revNeedsPrefix) {
681
+ process.stdout.write(revPrefix);
682
+ revNeedsPrefix = false;
683
+ }
684
+ process.stdout.write(chalk.white(chunk));
685
+ if (chunk.endsWith("\n"))
686
+ revNeedsPrefix = true;
687
+ }
688
+ }
689
+ if (!revNeedsPrefix)
690
+ process.stdout.write("\n");
691
+ const finalReviewText = await reviewStream.text;
692
+ const reviewText = finalReviewText && finalReviewText.length > allReviewText.length ? finalReviewText : allReviewText;
693
+ const reviewUsage = await reviewStream.totalUsage;
694
+ reviewSpinner.stop();
695
+ const score = extractScore(reviewText);
696
+ const approved = score >= approvalThreshold;
697
+ wmLog("tech_lead", `::code_quality_score::${score}`);
698
+ wmLog("tech_lead", `::review_decision::${approved ? "approved" : "needs_revision"}`);
699
+ wmCoordinatorLog(approved ? `Review approved (score: ${score}/100)` : `Review needs revision (score: ${score}/100)`);
700
+ console.log();
701
+ costTracker.addUsage(`Reviewer (round ${reviewRound + 1})`, revProvider, revModel, reviewUsage?.inputTokens || 0, reviewUsage?.outputTokens || 0);
702
+ if (approved)
703
+ break;
704
+ if (reviewRound >= maxRevisions) {
705
+ console.log(chalk.yellow(` \u26A0 Max review revisions (${maxRevisions}) reached`));
706
+ break;
707
+ }
708
+ let shouldRevise = autoRevise;
709
+ if (!autoRevise) {
710
+ try {
711
+ const answer = await permissions.askUser(chalk.dim(" Revise and re-review? ") + chalk.white(`(y/n, ${maxRevisions - reviewRound} attempt${maxRevisions - reviewRound > 1 ? "s" : ""} left): `));
712
+ shouldRevise = answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
713
+ } catch {
714
+ shouldRevise = false;
715
+ }
716
+ } else {
717
+ console.log(chalk.dim(` Auto-revising (${maxRevisions - reviewRound} attempt${maxRevisions - reviewRound > 1 ? "s" : ""} left)...`));
718
+ }
719
+ if (!shouldRevise) {
720
+ console.log(chalk.dim(" Skipping revision, proceeding to commit."));
721
+ break;
722
+ }
723
+ console.log(chalk.bold("\n \u2500\u2500\u2500 Revision Pass \u2500\u2500\u2500\n"));
724
+ for (let i = 0; i < sorted.length; i++) {
725
+ const story = sorted[i];
726
+ const storyPersona = loadPersona(story.persona);
727
+ if (!storyPersona)
728
+ continue;
729
+ const { provider: sProvider, model: sModel, host: sHost } = getProviderForPersona(config, storyPersona.provider || story.persona);
730
+ if (sProvider) {
731
+ const sApiKey = config.providers[sProvider]?.apiKey;
732
+ if (sApiKey) {
733
+ const envMap = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY" };
734
+ const envVar = envMap[sProvider];
735
+ if (envVar && !process.env[envVar]) {
736
+ const key = sApiKey.startsWith("{env:") ? process.env[sApiKey.slice(5, -1)] : sApiKey;
737
+ if (key)
738
+ process.env[envVar] = key;
739
+ }
740
+ }
741
+ }
742
+ const revSpinner = ora({
743
+ stream: process.stdout,
744
+ text: chalk.white(`Revising ${i + 1}/${sorted.length} \u2014 ${storyPersona.name} \u2014 ${story.title}`),
745
+ prefixText: " "
746
+ }).start();
747
+ const storyModel = createModel(sProvider, sModel, sHost);
748
+ const storyAllTools = createToolDefinitions(workingDir, storyModel, sandboxed);
749
+ const storyTools = {};
750
+ for (const toolName of storyPersona.tools) {
751
+ const toolDef = storyAllTools[toolName];
752
+ if (toolDef) {
753
+ storyTools[toolName] = {
754
+ ...toolDef,
755
+ execute: async (input) => {
756
+ const allowed = await permissions.checkPermission(toolName, input);
757
+ if (!allowed)
758
+ return "Tool execution denied by user.";
759
+ revSpinner.stop();
760
+ printToolCall(toolName, input);
761
+ const result = await toolDef.execute(input);
762
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
763
+ printToolResult(toolName, resultStr);
764
+ revSpinner.start();
765
+ return result;
766
+ }
767
+ };
768
+ }
769
+ }
770
+ const revisionSystemPrompt = `${storyPersona.systemPrompt}
771
+
772
+ Working directory: ${workingDir}
773
+
774
+ ## Critical rules
775
+ - NEVER start long-running processes (dev servers, watch modes, npm start, npm run dev, nodemon, tsc --watch, etc.)
776
+ - NEVER run interactive commands that wait for user input
777
+ - Only run commands that complete and exit
778
+
779
+ ## Reviewer feedback \u2014 fix these issues:
780
+ ${reviewText}
781
+
782
+ Your task: Address the reviewer's feedback for "${story.title}". Fix the specific issues mentioned. Do not rewrite code that wasn't flagged.`;
783
+ try {
784
+ const revStream = streamText({
785
+ model: storyModel,
786
+ system: revisionSystemPrompt,
787
+ prompt: `Fix the reviewer's issues for: ${story.title}
788
+
789
+ ${story.description}`,
790
+ tools: storyTools,
791
+ stopWhen: stepCountIs(100),
792
+ abortSignal: AbortSignal.timeout(5 * 60 * 1e3)
793
+ });
794
+ for await (const _chunk of revStream.textStream) {
795
+ }
796
+ const revUsage = await revStream.totalUsage;
797
+ revSpinner.stop();
798
+ costTracker.addUsage(`${storyPersona.name} (revision)`, sProvider, sModel, revUsage?.inputTokens || 0, revUsage?.outputTokens || 0);
799
+ wmLog(story.persona, `${story.title} \u2014 revision complete!`);
800
+ } catch (err) {
801
+ revSpinner.stop();
802
+ console.log(chalk.yellow(` \u26A0 Revision failed for story ${i + 1}: ${err instanceof Error ? err.message : String(err)}`));
803
+ }
804
+ }
805
+ console.log();
806
+ } catch (err) {
807
+ reviewSpinner.stop();
808
+ console.log(chalk.yellow(` \u26A0 Review skipped: ${err instanceof Error ? err.message : String(err)}`));
809
+ console.log();
810
+ break;
811
+ }
812
+ }
813
+ }
814
+ try {
815
+ const { execSync } = await import("child_process");
816
+ try {
817
+ execSync("git rev-parse --git-dir", { cwd: workingDir, encoding: "utf-8", stdio: "pipe" });
818
+ } catch {
819
+ wmCoordinatorLog("Initializing git repository...");
820
+ execSync("git init", { cwd: workingDir, encoding: "utf-8", stdio: "pipe" });
821
+ const fs2 = await import("fs");
822
+ const gitignorePath = `${workingDir}/.gitignore`;
823
+ if (!fs2.existsSync(gitignorePath)) {
824
+ fs2.writeFileSync(gitignorePath, "node_modules/\ndist/\n.env\n.workermill/\n*.log\n", "utf-8");
825
+ }
826
+ wmCoordinatorLog("Git repo initialized");
827
+ }
828
+ const diff = execSync("git diff --stat", { cwd: workingDir, encoding: "utf-8", stdio: "pipe" }).trim();
829
+ const untracked = execSync("git ls-files --others --exclude-standard", { cwd: workingDir, encoding: "utf-8", stdio: "pipe" }).trim();
830
+ const hasChanges = diff || untracked;
831
+ if (hasChanges) {
832
+ console.log(chalk.bold(" \u2500\u2500\u2500 Changes \u2500\u2500\u2500"));
833
+ if (diff) {
834
+ console.log(chalk.dim(" " + diff.split("\n").join("\n ")));
835
+ }
836
+ if (untracked) {
837
+ const untrackedFiles = untracked.split("\n");
838
+ console.log(chalk.dim(" New files:"));
839
+ for (const f of untrackedFiles) {
840
+ console.log(chalk.dim(` + ${f}`));
841
+ }
842
+ }
843
+ console.log();
844
+ if (!trustAll) {
845
+ const answer = await permissions.askUser(chalk.dim(" Commit these changes? (y/n): "));
846
+ if (answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes") {
847
+ const filesToStage = [...context.filesCreated, ...context.filesModified].filter(Boolean);
848
+ if (filesToStage.length > 0) {
849
+ for (const f of filesToStage) {
850
+ try {
851
+ execSync(`git add "${f}"`, { cwd: workingDir, stdio: "pipe" });
852
+ } catch {
853
+ }
854
+ }
855
+ } else {
856
+ execSync("git add -u", { cwd: workingDir, stdio: "pipe" });
857
+ }
858
+ const storyTitles = sorted.map((s) => s.title).join(", ");
859
+ const msg = `feat: ${storyTitles}`.slice(0, 72);
860
+ execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: workingDir, stdio: "pipe" });
861
+ console.log(chalk.green(" \u2713 Changes committed"));
862
+ }
863
+ }
864
+ }
865
+ } catch (err) {
866
+ }
867
+ console.log(chalk.bold(" \u2500\u2500\u2500 Session Complete \u2500\u2500\u2500"));
868
+ console.log();
869
+ console.log(chalk.dim(" " + costTracker.getSummary().split("\n").join("\n ")));
870
+ console.log();
871
+ }
872
+ export {
873
+ classifyComplexity,
874
+ runOrchestration
875
+ };