baro-ai 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.
- package/bin/baro-tui +0 -0
- package/dist/chunk-3RG5ZIWI.js +10 -0
- package/dist/cli.js +744 -0
- package/dist/core/executor.js +222 -0
- package/package.json +37 -0
- package/scripts/postinstall.js +94 -0
package/bin/baro-tui
ADDED
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
__require
|
|
10
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
__require
|
|
4
|
+
} from "./chunk-3RG5ZIWI.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.tsx
|
|
7
|
+
import { render, Box as Box6 } from "ink";
|
|
8
|
+
|
|
9
|
+
// src/App.tsx
|
|
10
|
+
import { useState as useState3 } from "react";
|
|
11
|
+
import { useApp as useApp2, useInput as useInput4 } from "ink";
|
|
12
|
+
|
|
13
|
+
// src/screens/ApiKeyScreen.tsx
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Box, Text, useInput } from "ink";
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
19
|
+
function ApiKeyScreen({ onComplete, onQuit }) {
|
|
20
|
+
const [input, setInput] = useState("");
|
|
21
|
+
const [error, setError] = useState("");
|
|
22
|
+
useInput((ch, key) => {
|
|
23
|
+
if (key.escape) return onQuit();
|
|
24
|
+
if (key.return) {
|
|
25
|
+
if (!input.trim()) {
|
|
26
|
+
setError("API key is required");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
saveKey(input.trim());
|
|
30
|
+
onComplete();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.backspace || key.delete) {
|
|
34
|
+
setInput((v) => v.slice(0, -1));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (ch && !key.ctrl) {
|
|
38
|
+
setInput((v) => v + ch);
|
|
39
|
+
setError("");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const masked = input.length <= 8 ? input : input.slice(0, 4) + "\u2022".repeat(Math.min(input.length - 8, 30)) + input.slice(-4);
|
|
43
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
44
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "\u25B8 baro" }),
|
|
45
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Autonomous parallel coding" }),
|
|
46
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1 }),
|
|
47
|
+
/* @__PURE__ */ jsx(Text, { children: "Paste your API key to get started:" }),
|
|
48
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
49
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u276F " }),
|
|
50
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: masked || /* @__PURE__ */ jsx(Text, { dimColor: true, children: "sk-..." }) }),
|
|
51
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u2588" })
|
|
52
|
+
] }),
|
|
53
|
+
error ? /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "red", children: error }) }) : null,
|
|
54
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Enter to save \xB7 Esc to quit \xB7 Saved to ~/.baro/.env" }) })
|
|
55
|
+
] });
|
|
56
|
+
}
|
|
57
|
+
function saveKey(key) {
|
|
58
|
+
const home = process.env.HOME ?? "";
|
|
59
|
+
const dir = path.join(home, ".baro");
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
const envPath = path.join(dir, ".env");
|
|
62
|
+
const isAnthropic = key.startsWith("sk-ant-");
|
|
63
|
+
const varName = isAnthropic ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
64
|
+
fs.writeFileSync(envPath, `${varName}=${key}
|
|
65
|
+
`, { mode: 384 });
|
|
66
|
+
process.env[varName] = key;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/screens/PlanScreen.tsx
|
|
70
|
+
import { useState as useState2, useCallback } from "react";
|
|
71
|
+
import { Box as Box2, Text as Text2, useInput as useInput2 } from "ink";
|
|
72
|
+
import Spinner from "ink-spinner";
|
|
73
|
+
|
|
74
|
+
// src/core/planner.ts
|
|
75
|
+
import { z } from "zod";
|
|
76
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
77
|
+
|
|
78
|
+
// src/core/stream.ts
|
|
79
|
+
var API_URL = "https://api.openai.com/v1/responses";
|
|
80
|
+
var MAX_TOOL_ROUNDS = 15;
|
|
81
|
+
async function streamCompletion(opts) {
|
|
82
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
83
|
+
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
|
|
84
|
+
let input = [
|
|
85
|
+
...opts.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
86
|
+
{ role: "user", content: opts.task }
|
|
87
|
+
];
|
|
88
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
89
|
+
const body = { model: opts.model, input, stream: true };
|
|
90
|
+
if (opts.reasoning) body.reasoning = opts.reasoning;
|
|
91
|
+
if (opts.jsonSchema) {
|
|
92
|
+
body.text = {
|
|
93
|
+
format: { type: "json_schema", name: "prd_output", schema: opts.jsonSchema, strict: true }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (opts.tools?.length) {
|
|
97
|
+
body.tools = opts.tools.map((t) => ({
|
|
98
|
+
type: "function",
|
|
99
|
+
name: t.name,
|
|
100
|
+
description: t.description,
|
|
101
|
+
parameters: t.parameters
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
const response = await fetch(API_URL, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
107
|
+
body: JSON.stringify(body)
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const errText = await response.text();
|
|
111
|
+
throw new Error(`OpenAI API error ${response.status}: ${errText}`);
|
|
112
|
+
}
|
|
113
|
+
if (!response.body) throw new Error("No response body");
|
|
114
|
+
const { textOutput, toolCalls, responseId } = await parseSSE(response.body, opts);
|
|
115
|
+
if (toolCalls.length === 0) {
|
|
116
|
+
return textOutput;
|
|
117
|
+
}
|
|
118
|
+
const toolResults = [];
|
|
119
|
+
for (const tc of toolCalls) {
|
|
120
|
+
const toolDef = opts.tools?.find((t) => t.name === tc.name);
|
|
121
|
+
if (!toolDef) continue;
|
|
122
|
+
opts.onToolCall?.(tc.name, tc.args);
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(tc.args);
|
|
126
|
+
result = await toolDef.invoke(parsed);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
result = `Error: ${err.message}`;
|
|
129
|
+
}
|
|
130
|
+
toolResults.push({
|
|
131
|
+
type: "function_call_output",
|
|
132
|
+
call_id: tc.callId,
|
|
133
|
+
output: typeof result === "string" ? result : JSON.stringify(result)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
input = toolResults;
|
|
137
|
+
}
|
|
138
|
+
throw new Error("Too many tool calling rounds");
|
|
139
|
+
}
|
|
140
|
+
async function parseSSE(body, opts) {
|
|
141
|
+
let textOutput = "";
|
|
142
|
+
const toolCalls = [];
|
|
143
|
+
const toolCallArgs = /* @__PURE__ */ new Map();
|
|
144
|
+
let responseId = "";
|
|
145
|
+
const reader = body.getReader();
|
|
146
|
+
const decoder = new TextDecoder();
|
|
147
|
+
let buffer = "";
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done) break;
|
|
151
|
+
buffer += decoder.decode(value, { stream: true });
|
|
152
|
+
const lines = buffer.split("\n");
|
|
153
|
+
buffer = lines.pop() ?? "";
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (!line.startsWith("data: ")) continue;
|
|
156
|
+
const data = line.slice(6).trim();
|
|
157
|
+
if (data === "[DONE]") continue;
|
|
158
|
+
try {
|
|
159
|
+
const ev = JSON.parse(data);
|
|
160
|
+
if (ev.type === "response.created") {
|
|
161
|
+
responseId = ev.response?.id ?? "";
|
|
162
|
+
} else if (ev.type === "response.output_text.delta") {
|
|
163
|
+
textOutput += ev.delta ?? "";
|
|
164
|
+
opts.onToken(ev.delta ?? "");
|
|
165
|
+
} else if (ev.type === "response.reasoning.delta") {
|
|
166
|
+
opts.onThinking?.(ev.delta ?? "");
|
|
167
|
+
} else if (ev.type === "response.output_item.added") {
|
|
168
|
+
if (ev.item?.type === "function_call") {
|
|
169
|
+
toolCallArgs.set(ev.output_index, {
|
|
170
|
+
callId: ev.item.call_id ?? "",
|
|
171
|
+
name: ev.item.name ?? "",
|
|
172
|
+
args: ""
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
} else if (ev.type === "response.function_call_arguments.delta") {
|
|
176
|
+
const tc = toolCallArgs.get(ev.output_index);
|
|
177
|
+
if (tc) tc.args += ev.delta ?? "";
|
|
178
|
+
} else if (ev.type === "response.output_item.done") {
|
|
179
|
+
if (ev.item?.type === "function_call") {
|
|
180
|
+
const tc = toolCallArgs.get(ev.output_index);
|
|
181
|
+
if (tc) {
|
|
182
|
+
toolCalls.push({ callId: tc.callId, name: tc.name, args: tc.args });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { textOutput, toolCalls, responseId };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/core/tools.ts
|
|
194
|
+
import * as fs2 from "fs";
|
|
195
|
+
import * as path2 from "path";
|
|
196
|
+
import { execSync } from "child_process";
|
|
197
|
+
var IGNORE = /* @__PURE__ */ new Set([
|
|
198
|
+
"node_modules",
|
|
199
|
+
".git",
|
|
200
|
+
"dist",
|
|
201
|
+
"build",
|
|
202
|
+
".next",
|
|
203
|
+
".nuxt",
|
|
204
|
+
"coverage",
|
|
205
|
+
".cache",
|
|
206
|
+
"__pycache__",
|
|
207
|
+
"target",
|
|
208
|
+
".output"
|
|
209
|
+
]);
|
|
210
|
+
var MAX_FILE_SIZE = 15e3;
|
|
211
|
+
function createCodebaseTools(cwd) {
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
name: "list_files",
|
|
215
|
+
description: "List files and directories. Use path='' for project root. Returns file names with types. Ignores node_modules, .git, etc.",
|
|
216
|
+
parameters: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
path: { type: "string", description: "Relative path from project root. Empty for root." },
|
|
220
|
+
recursive: { type: "boolean", description: "List all files recursively (max 200). Default false." }
|
|
221
|
+
},
|
|
222
|
+
required: ["path"],
|
|
223
|
+
additionalProperties: false
|
|
224
|
+
},
|
|
225
|
+
async invoke(args) {
|
|
226
|
+
const target = safePath(cwd, args.path || ".");
|
|
227
|
+
if (!target || !fs2.existsSync(target)) return `Directory not found: ${args.path}`;
|
|
228
|
+
if (!fs2.statSync(target).isDirectory()) return `Not a directory: ${args.path}`;
|
|
229
|
+
const results = [];
|
|
230
|
+
function walk(dir, prefix, depth) {
|
|
231
|
+
if (results.length >= 200 || depth > 4) return;
|
|
232
|
+
for (const entry of fs2.readdirSync(dir, { withFileTypes: true })) {
|
|
233
|
+
if (IGNORE.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
234
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
results.push(rel + "/");
|
|
237
|
+
if (args.recursive) walk(path2.join(dir, entry.name), rel, depth + 1);
|
|
238
|
+
} else {
|
|
239
|
+
results.push(rel);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
walk(target, "", 0);
|
|
244
|
+
return results.join("\n") || "(empty directory)";
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "read_file",
|
|
249
|
+
description: "Read file contents. Large files truncated to ~15000 chars. Use to understand code structure, configs, etc.",
|
|
250
|
+
parameters: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
path: { type: "string", description: "Relative path to file (e.g. 'src/index.ts')" }
|
|
254
|
+
},
|
|
255
|
+
required: ["path"],
|
|
256
|
+
additionalProperties: false
|
|
257
|
+
},
|
|
258
|
+
async invoke(args) {
|
|
259
|
+
const target = safePath(cwd, args.path);
|
|
260
|
+
if (!target || !fs2.existsSync(target)) return `File not found: ${args.path}`;
|
|
261
|
+
if (fs2.statSync(target).isDirectory()) return `${args.path} is a directory. Use list_files.`;
|
|
262
|
+
if (fs2.statSync(target).size > 5e5) return `File too large (${(fs2.statSync(target).size / 1024).toFixed(0)}KB)`;
|
|
263
|
+
let content = fs2.readFileSync(target, "utf-8");
|
|
264
|
+
if (content.length > MAX_FILE_SIZE) {
|
|
265
|
+
content = content.slice(0, MAX_FILE_SIZE) + "\n... (truncated)";
|
|
266
|
+
}
|
|
267
|
+
return content;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "grep",
|
|
272
|
+
description: "Search for a text pattern across project files. Returns matching lines with file paths. Ignores node_modules, .git, etc.",
|
|
273
|
+
parameters: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
pattern: { type: "string", description: "Text to search for (case-insensitive)" },
|
|
277
|
+
path: { type: "string", description: "Directory to search in. Default: entire project." },
|
|
278
|
+
file_pattern: { type: "string", description: "File glob (e.g. '*.ts'). Default: all." }
|
|
279
|
+
},
|
|
280
|
+
required: ["pattern"],
|
|
281
|
+
additionalProperties: false
|
|
282
|
+
},
|
|
283
|
+
async invoke(args) {
|
|
284
|
+
const searchDir = safePath(cwd, args.path || ".");
|
|
285
|
+
if (!searchDir || !fs2.existsSync(searchDir)) return `Directory not found: ${args.path}`;
|
|
286
|
+
try {
|
|
287
|
+
const excludes = Array.from(IGNORE).map((d) => `--exclude-dir=${d}`).join(" ");
|
|
288
|
+
const include = args.file_pattern ? `--include='${args.file_pattern}'` : "";
|
|
289
|
+
const cmd = `grep -rn -i ${excludes} ${include} --max-count=50 -- ${JSON.stringify(args.pattern)} ${JSON.stringify(searchDir)} 2>/dev/null || true`;
|
|
290
|
+
const output = execSync(cmd, { encoding: "utf-8", maxBuffer: 1024 * 1024 });
|
|
291
|
+
const lines = output.split("\n").filter(Boolean).map(
|
|
292
|
+
(line) => line.startsWith(cwd) ? line.slice(cwd.length + 1) : line
|
|
293
|
+
);
|
|
294
|
+
return lines.slice(0, 50).join("\n") || "No matches found.";
|
|
295
|
+
} catch {
|
|
296
|
+
return "No matches found.";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: "file_tree",
|
|
302
|
+
description: "Get a condensed tree view of the project structure up to 3 levels deep. Good starting point to understand the codebase.",
|
|
303
|
+
parameters: {
|
|
304
|
+
type: "object",
|
|
305
|
+
properties: {},
|
|
306
|
+
additionalProperties: false
|
|
307
|
+
},
|
|
308
|
+
async invoke() {
|
|
309
|
+
const lines = [path2.basename(cwd) + "/"];
|
|
310
|
+
function walk(dir, prefix, depth) {
|
|
311
|
+
if (lines.length >= 150 || depth > 3) return;
|
|
312
|
+
let entries;
|
|
313
|
+
try {
|
|
314
|
+
entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
315
|
+
} catch {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
entries.sort((a, b) => {
|
|
319
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
320
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
321
|
+
return a.name.localeCompare(b.name);
|
|
322
|
+
});
|
|
323
|
+
for (let i = 0; i < entries.length; i++) {
|
|
324
|
+
if (IGNORE.has(entries[i].name) || entries[i].name.startsWith(".")) continue;
|
|
325
|
+
const isLast = i === entries.length - 1;
|
|
326
|
+
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
327
|
+
const childPrefix = isLast ? " " : "\u2502 ";
|
|
328
|
+
if (entries[i].isDirectory()) {
|
|
329
|
+
lines.push(`${prefix}${connector}${entries[i].name}/`);
|
|
330
|
+
walk(path2.join(dir, entries[i].name), prefix + childPrefix, depth + 1);
|
|
331
|
+
} else {
|
|
332
|
+
lines.push(`${prefix}${connector}${entries[i].name}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
walk(cwd, "", 0);
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
function safePath(cwd, filePath) {
|
|
343
|
+
const resolved = path2.resolve(cwd, filePath);
|
|
344
|
+
if (!resolved.startsWith(path2.resolve(cwd))) return null;
|
|
345
|
+
return resolved;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/core/planner.ts
|
|
349
|
+
var StorySchema = z.object({
|
|
350
|
+
id: z.string().describe("Short ID like S1, S2, S3"),
|
|
351
|
+
priority: z.number().describe("Priority level: lower = earlier"),
|
|
352
|
+
title: z.string().describe("Short title for the story"),
|
|
353
|
+
description: z.string().describe("What needs to be implemented"),
|
|
354
|
+
dependsOn: z.array(z.string()).describe("IDs of stories this depends on"),
|
|
355
|
+
retries: z.number().describe("Retry attempts if story fails (usually 2)"),
|
|
356
|
+
acceptance: z.array(z.string()).describe("Testable acceptance criteria"),
|
|
357
|
+
tests: z.array(z.string()).describe("Test commands (e.g. ['npm test'])")
|
|
358
|
+
});
|
|
359
|
+
var PrdSchema = z.object({
|
|
360
|
+
project: z.string().describe("Short project name"),
|
|
361
|
+
branchName: z.string().describe("Git branch name (kebab-case)"),
|
|
362
|
+
description: z.string().describe("One-line project description"),
|
|
363
|
+
userStories: z.array(StorySchema).describe("User stories forming a DAG")
|
|
364
|
+
});
|
|
365
|
+
var SYSTEM_PROMPT = `You are an expert software architect. Break down project goals into concrete user stories that form a dependency DAG.
|
|
366
|
+
|
|
367
|
+
IMPORTANT: Before generating a plan, USE YOUR TOOLS to explore the existing codebase:
|
|
368
|
+
1. Call file_tree to see the project structure
|
|
369
|
+
2. Call read_file on key files (package.json, README, entry points, configs)
|
|
370
|
+
3. Call grep to find relevant patterns
|
|
371
|
+
4. THEN generate a plan that fits the existing code
|
|
372
|
+
|
|
373
|
+
Rules:
|
|
374
|
+
- Each story: single focused unit of work for one AI agent
|
|
375
|
+
- Use dependsOn for dependencies; same-priority stories with no deps run IN PARALLEL
|
|
376
|
+
- Keep stories small (15-60 min of work)
|
|
377
|
+
- Include testable acceptance criteria and test commands
|
|
378
|
+
- No circular dependencies
|
|
379
|
+
- Start with foundational stories, build up
|
|
380
|
+
- retries: 2-3 for most stories
|
|
381
|
+
- IDs: S1, S2, S3...
|
|
382
|
+
- branchName: kebab-case
|
|
383
|
+
- Build on existing code, don't recreate what exists`;
|
|
384
|
+
function fixSchemaForOpenAI(schema) {
|
|
385
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
386
|
+
if (schema.type === "object" && schema.properties) {
|
|
387
|
+
schema.additionalProperties = false;
|
|
388
|
+
}
|
|
389
|
+
if (schema.properties) {
|
|
390
|
+
for (const key of Object.keys(schema.properties)) {
|
|
391
|
+
fixSchemaForOpenAI(schema.properties[key]);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (schema.items) fixSchemaForOpenAI(schema.items);
|
|
395
|
+
delete schema.$schema;
|
|
396
|
+
return schema;
|
|
397
|
+
}
|
|
398
|
+
var Planner = class {
|
|
399
|
+
messages;
|
|
400
|
+
model;
|
|
401
|
+
onToken;
|
|
402
|
+
onToolCall;
|
|
403
|
+
tools;
|
|
404
|
+
constructor(options = {}) {
|
|
405
|
+
this.model = options.model ?? "gpt-5.4";
|
|
406
|
+
this.onToken = options.onToken;
|
|
407
|
+
this.onToolCall = options.onToolCall;
|
|
408
|
+
this.tools = options.cwd ? createCodebaseTools(options.cwd) : [];
|
|
409
|
+
this.messages = [{ role: "system", content: SYSTEM_PROMPT }];
|
|
410
|
+
}
|
|
411
|
+
async send(userMessage) {
|
|
412
|
+
const raw = zodToJsonSchema(PrdSchema, "prd");
|
|
413
|
+
const schema = fixSchemaForOpenAI(
|
|
414
|
+
raw.definitions?.prd ?? raw
|
|
415
|
+
);
|
|
416
|
+
const fullText = await streamCompletion({
|
|
417
|
+
model: this.model,
|
|
418
|
+
messages: this.messages,
|
|
419
|
+
task: userMessage,
|
|
420
|
+
jsonSchema: schema,
|
|
421
|
+
reasoning: { effort: "high" },
|
|
422
|
+
tools: this.tools.length > 0 ? this.tools : void 0,
|
|
423
|
+
onToken: this.onToken ?? (() => {
|
|
424
|
+
}),
|
|
425
|
+
onToolCall: this.onToolCall
|
|
426
|
+
});
|
|
427
|
+
let prd;
|
|
428
|
+
try {
|
|
429
|
+
prd = JSON.parse(fullText);
|
|
430
|
+
} catch {
|
|
431
|
+
throw new Error("Failed to parse plan output as JSON");
|
|
432
|
+
}
|
|
433
|
+
this.messages.push(
|
|
434
|
+
{ role: "user", content: userMessage },
|
|
435
|
+
{ role: "assistant", content: fullText }
|
|
436
|
+
);
|
|
437
|
+
return {
|
|
438
|
+
...prd,
|
|
439
|
+
userStories: prd.userStories.map((s) => ({
|
|
440
|
+
...s,
|
|
441
|
+
passes: false,
|
|
442
|
+
completedAt: null,
|
|
443
|
+
durationSecs: null
|
|
444
|
+
}))
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/screens/PlanScreen.tsx
|
|
450
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
451
|
+
function PlanScreen({ onPlanReady, onQuit }) {
|
|
452
|
+
const [input, setInput] = useState2("");
|
|
453
|
+
const [loading, setLoading] = useState2(false);
|
|
454
|
+
const [tokenCount, setTokenCount] = useState2(0);
|
|
455
|
+
const [toolCalls, setToolCalls] = useState2([]);
|
|
456
|
+
const [error, setError] = useState2("");
|
|
457
|
+
const [planner] = useState2(() => new Planner({
|
|
458
|
+
cwd: process.cwd(),
|
|
459
|
+
onToken: () => setTokenCount((c) => c + 1),
|
|
460
|
+
onToolCall: (name, args) => {
|
|
461
|
+
let label = name;
|
|
462
|
+
if (name === "read_file") label = `Reading ${args?.path ?? "..."}`;
|
|
463
|
+
else if (name === "grep") label = `Searching for "${args?.pattern ?? "..."}"`;
|
|
464
|
+
else if (name === "list_files") label = `Listing ${args?.path || "root"}`;
|
|
465
|
+
else if (name === "file_tree") label = "Scanning project structure";
|
|
466
|
+
setToolCalls((prev) => [...prev.slice(-8), label]);
|
|
467
|
+
}
|
|
468
|
+
}));
|
|
469
|
+
const submit = useCallback(async () => {
|
|
470
|
+
const goal = input.trim();
|
|
471
|
+
if (!goal) return;
|
|
472
|
+
setLoading(true);
|
|
473
|
+
setTokenCount(0);
|
|
474
|
+
setToolCalls([]);
|
|
475
|
+
setError("");
|
|
476
|
+
try {
|
|
477
|
+
const prd = await planner.send(goal);
|
|
478
|
+
onPlanReady(prd);
|
|
479
|
+
} catch (err) {
|
|
480
|
+
setError(err.message ?? String(err));
|
|
481
|
+
setLoading(false);
|
|
482
|
+
}
|
|
483
|
+
}, [input, planner, onPlanReady]);
|
|
484
|
+
useInput2((ch, key) => {
|
|
485
|
+
if (key.escape) return onQuit();
|
|
486
|
+
if (loading) return;
|
|
487
|
+
if (key.return) {
|
|
488
|
+
submit();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (key.backspace || key.delete) {
|
|
492
|
+
setInput((v) => v.slice(0, -1));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (ch && !key.ctrl) {
|
|
496
|
+
setInput((v) => v + ch);
|
|
497
|
+
setError("");
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
501
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "\u25B8 baro plan" }),
|
|
502
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Describe what you want to build" }),
|
|
503
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1 }),
|
|
504
|
+
loading ? /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
505
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
506
|
+
/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: /* @__PURE__ */ jsx2(Spinner, { type: "dots" }) }),
|
|
507
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
508
|
+
" ",
|
|
509
|
+
toolCalls.length > 0 && tokenCount === 0 ? "Exploring codebase..." : "Generating plan...",
|
|
510
|
+
" "
|
|
511
|
+
] }),
|
|
512
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
513
|
+
"(",
|
|
514
|
+
tokenCount,
|
|
515
|
+
" tokens)"
|
|
516
|
+
] })
|
|
517
|
+
] }),
|
|
518
|
+
toolCalls.map((tc, i) => /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
519
|
+
" \u2699 ",
|
|
520
|
+
tc
|
|
521
|
+
] }, i))
|
|
522
|
+
] }) : /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
523
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u276F " }),
|
|
524
|
+
/* @__PURE__ */ jsx2(Text2, { children: input }),
|
|
525
|
+
/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "\u2588" })
|
|
526
|
+
] }),
|
|
527
|
+
error ? /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "red", children: error }) }) : null,
|
|
528
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Enter to generate \xB7 Esc to quit" }) })
|
|
529
|
+
] });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/screens/ReviewScreen.tsx
|
|
533
|
+
import { Box as Box3, Text as Text3, useInput as useInput3 } from "ink";
|
|
534
|
+
import * as fs3 from "fs";
|
|
535
|
+
import * as path3 from "path";
|
|
536
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
537
|
+
function ReviewScreen({ prd, onAccept, onRefine, onQuit }) {
|
|
538
|
+
useInput3((ch, key) => {
|
|
539
|
+
if (key.escape) return onQuit();
|
|
540
|
+
if (key.return || ch === "y") {
|
|
541
|
+
const prdPath = path3.join(process.cwd(), "prd.json");
|
|
542
|
+
fs3.writeFileSync(prdPath, JSON.stringify(prd, null, 2) + "\n");
|
|
543
|
+
onAccept();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (ch === "r") return onRefine();
|
|
547
|
+
if (ch === "q") return onQuit();
|
|
548
|
+
});
|
|
549
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
550
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: "\u25B8 baro plan" }),
|
|
551
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1 }),
|
|
552
|
+
/* @__PURE__ */ jsxs3(Box3, { children: [
|
|
553
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, color: "white", children: prd.project }),
|
|
554
|
+
/* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
|
|
555
|
+
" (",
|
|
556
|
+
prd.branchName,
|
|
557
|
+
")"
|
|
558
|
+
] })
|
|
559
|
+
] }),
|
|
560
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: prd.description }),
|
|
561
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1 }),
|
|
562
|
+
prd.userStories.map((story) => {
|
|
563
|
+
const deps = story.dependsOn.length > 0 ? ` \u2190 ${story.dependsOn.join(", ")}` : "";
|
|
564
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginBottom: 1, children: [
|
|
565
|
+
/* @__PURE__ */ jsxs3(Box3, { children: [
|
|
566
|
+
/* @__PURE__ */ jsx3(Text3, { color: "yellow", children: story.id }),
|
|
567
|
+
/* @__PURE__ */ jsxs3(Text3, { children: [
|
|
568
|
+
" ",
|
|
569
|
+
story.title
|
|
570
|
+
] }),
|
|
571
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: deps })
|
|
572
|
+
] }),
|
|
573
|
+
story.acceptance.map((ac, i) => /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
|
|
574
|
+
" \u2713 ",
|
|
575
|
+
ac
|
|
576
|
+
] }, i))
|
|
577
|
+
] }, story.id);
|
|
578
|
+
}),
|
|
579
|
+
/* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
|
|
580
|
+
prd.userStories.length,
|
|
581
|
+
" stories \xB7 ",
|
|
582
|
+
new Set(prd.userStories.map((s) => s.priority)).size,
|
|
583
|
+
" priority levels"
|
|
584
|
+
] }),
|
|
585
|
+
/* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { children: [
|
|
586
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, color: "green", children: "Enter" }),
|
|
587
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " accept \xB7 " }),
|
|
588
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "r" }),
|
|
589
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " refine \xB7 " }),
|
|
590
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, children: "q" }),
|
|
591
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " quit" })
|
|
592
|
+
] }) })
|
|
593
|
+
] });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/screens/ExecuteScreen.tsx
|
|
597
|
+
import { useEffect } from "react";
|
|
598
|
+
import { Box as Box4, Text as Text4, useApp } from "ink";
|
|
599
|
+
import { spawn, execSync as execSync2 } from "child_process";
|
|
600
|
+
import * as fs4 from "fs";
|
|
601
|
+
import * as path4 from "path";
|
|
602
|
+
import { fileURLToPath } from "url";
|
|
603
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
604
|
+
function findBinary(name, searchFrom) {
|
|
605
|
+
const pkgBin = path4.resolve(searchFrom, "..", "bin", name);
|
|
606
|
+
if (existsExec(pkgBin)) return pkgBin;
|
|
607
|
+
let dir = searchFrom;
|
|
608
|
+
for (let i = 0; i < 10; i++) {
|
|
609
|
+
const cargoTarget = path4.join(dir, "target", "release", name);
|
|
610
|
+
if (existsExec(cargoTarget)) return cargoTarget;
|
|
611
|
+
const parent = path4.dirname(dir);
|
|
612
|
+
if (parent === dir) break;
|
|
613
|
+
dir = parent;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const which = execSync2(`which ${name}`, { encoding: "utf-8" }).trim();
|
|
617
|
+
if (which) return which;
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
function existsExec(p) {
|
|
623
|
+
try {
|
|
624
|
+
fs4.accessSync(p, fs4.constants.X_OK);
|
|
625
|
+
return true;
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function ExecuteScreen({ prd, onDone }) {
|
|
631
|
+
const app = useApp();
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
setTimeout(() => {
|
|
634
|
+
app.exit();
|
|
635
|
+
const thisDir = path4.dirname(fileURLToPath(import.meta.url));
|
|
636
|
+
const executorPath = path4.join(thisDir, "core", "executor.js");
|
|
637
|
+
const tuiBinary = findBinary("baro-tui", thisDir);
|
|
638
|
+
if (!tuiBinary) {
|
|
639
|
+
console.error("\nError: baro-tui binary not found.");
|
|
640
|
+
console.error("Build it: cd <baro-repo> && cargo build --release");
|
|
641
|
+
console.error("Or install: npm install -g @baro-ai/cli\n");
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
const executor = spawn("node", [executorPath], {
|
|
645
|
+
cwd: process.cwd(),
|
|
646
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
647
|
+
env: { ...process.env }
|
|
648
|
+
});
|
|
649
|
+
const tui = spawn(tuiBinary, [], {
|
|
650
|
+
cwd: process.cwd(),
|
|
651
|
+
stdio: [executor.stdout, "inherit", "inherit"]
|
|
652
|
+
});
|
|
653
|
+
tui.on("error", (err) => {
|
|
654
|
+
console.error("\nFailed to start baro-tui:", err.message);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
});
|
|
657
|
+
tui.on("close", () => process.exit(0));
|
|
658
|
+
}, 100);
|
|
659
|
+
}, [app]);
|
|
660
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
661
|
+
/* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "\u25B8 baro" }),
|
|
662
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Starting execution dashboard..." })
|
|
663
|
+
] });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/App.tsx
|
|
667
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
668
|
+
function App() {
|
|
669
|
+
const app = useApp2();
|
|
670
|
+
const [screen, setScreen] = useState3(() => {
|
|
671
|
+
if (process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
|
672
|
+
return "plan";
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const fs5 = __require("fs");
|
|
676
|
+
const home = process.env.HOME ?? "";
|
|
677
|
+
const envPath = `${home}/.baro/.env`;
|
|
678
|
+
if (fs5.existsSync(envPath)) {
|
|
679
|
+
const content = fs5.readFileSync(envPath, "utf-8");
|
|
680
|
+
for (const line of content.split("\n")) {
|
|
681
|
+
const [key, val] = line.split("=");
|
|
682
|
+
if (key && val) process.env[key.trim()] = val.trim();
|
|
683
|
+
}
|
|
684
|
+
if (process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY) {
|
|
685
|
+
return "plan";
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} catch {
|
|
689
|
+
}
|
|
690
|
+
return "apikey";
|
|
691
|
+
});
|
|
692
|
+
const [prd, setPrd] = useState3(null);
|
|
693
|
+
useInput4((input, key) => {
|
|
694
|
+
if (key.escape && screen === "execute") {
|
|
695
|
+
app.exit();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
if (screen === "apikey") {
|
|
699
|
+
return /* @__PURE__ */ jsx5(
|
|
700
|
+
ApiKeyScreen,
|
|
701
|
+
{
|
|
702
|
+
onComplete: () => setScreen("plan"),
|
|
703
|
+
onQuit: () => app.exit()
|
|
704
|
+
}
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (screen === "plan") {
|
|
708
|
+
return /* @__PURE__ */ jsx5(
|
|
709
|
+
PlanScreen,
|
|
710
|
+
{
|
|
711
|
+
onPlanReady: (plan) => {
|
|
712
|
+
setPrd(plan);
|
|
713
|
+
setScreen("review");
|
|
714
|
+
},
|
|
715
|
+
onQuit: () => app.exit()
|
|
716
|
+
}
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (screen === "review") {
|
|
720
|
+
return /* @__PURE__ */ jsx5(
|
|
721
|
+
ReviewScreen,
|
|
722
|
+
{
|
|
723
|
+
prd,
|
|
724
|
+
onAccept: () => setScreen("execute"),
|
|
725
|
+
onRefine: () => setScreen("plan"),
|
|
726
|
+
onQuit: () => app.exit()
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
return /* @__PURE__ */ jsx5(
|
|
731
|
+
ExecuteScreen,
|
|
732
|
+
{
|
|
733
|
+
prd,
|
|
734
|
+
onDone: () => app.exit()
|
|
735
|
+
}
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// src/cli.tsx
|
|
740
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
741
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
742
|
+
render(
|
|
743
|
+
/* @__PURE__ */ jsx6(Box6, { width: "100%", flexDirection: "column", alignItems: "flex-start", children: /* @__PURE__ */ jsx6(App, {}) })
|
|
744
|
+
);
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "../chunk-3RG5ZIWI.js";
|
|
3
|
+
|
|
4
|
+
// src/core/executor.ts
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
|
|
8
|
+
// src/core/cli-task.ts
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
var CliTask = class {
|
|
11
|
+
id;
|
|
12
|
+
opts;
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
this.id = opts.id;
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
}
|
|
17
|
+
execute() {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
let stdout = "";
|
|
21
|
+
let stderr = "";
|
|
22
|
+
const proc = spawn(this.opts.command, this.opts.args, {
|
|
23
|
+
cwd: this.opts.cwd,
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
env: { ...process.env }
|
|
26
|
+
});
|
|
27
|
+
proc.stdout.on("data", (chunk) => {
|
|
28
|
+
const text = chunk.toString();
|
|
29
|
+
stdout += text;
|
|
30
|
+
if (this.opts.onStdout) {
|
|
31
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
32
|
+
this.opts.onStdout(line);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
proc.stderr.on("data", (chunk) => {
|
|
37
|
+
const text = chunk.toString();
|
|
38
|
+
stderr += text;
|
|
39
|
+
if (this.opts.onStderr) {
|
|
40
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
41
|
+
this.opts.onStderr(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
proc.on("error", (err) => {
|
|
46
|
+
reject(new Error(`Failed to spawn ${this.opts.command}: ${err.message}`));
|
|
47
|
+
});
|
|
48
|
+
proc.on("close", (code) => {
|
|
49
|
+
const result = {
|
|
50
|
+
stdout,
|
|
51
|
+
stderr,
|
|
52
|
+
exitCode: code ?? 1,
|
|
53
|
+
durationMs: Date.now() - start
|
|
54
|
+
};
|
|
55
|
+
if (code === 0) resolve(result);
|
|
56
|
+
else {
|
|
57
|
+
const err = new Error(`${this.opts.command} exited with code ${code}`);
|
|
58
|
+
err.result = result;
|
|
59
|
+
reject(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/core/dag.ts
|
|
67
|
+
function buildDag(stories) {
|
|
68
|
+
const incomplete = stories.filter((s) => !s.passes);
|
|
69
|
+
const completedIds = new Set(stories.filter((s) => s.passes).map((s) => s.id));
|
|
70
|
+
const storyMap = new Map(incomplete.map((s) => [s.id, s]));
|
|
71
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
72
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
73
|
+
for (const s of incomplete) {
|
|
74
|
+
const activeDeps = s.dependsOn.filter(
|
|
75
|
+
(depId) => storyMap.has(depId) && !completedIds.has(depId)
|
|
76
|
+
);
|
|
77
|
+
inDegree.set(s.id, activeDeps.length);
|
|
78
|
+
for (const dep of activeDeps) {
|
|
79
|
+
if (!dependents.has(dep)) dependents.set(dep, []);
|
|
80
|
+
dependents.get(dep).push(s.id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const levels = [];
|
|
84
|
+
let queue = incomplete.filter((s) => (inDegree.get(s.id) ?? 0) === 0);
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
queue.sort((a, b) => a.priority - b.priority);
|
|
87
|
+
levels.push({ stories: [...queue] });
|
|
88
|
+
const nextQueue = [];
|
|
89
|
+
for (const s of queue) {
|
|
90
|
+
for (const depId of dependents.get(s.id) ?? []) {
|
|
91
|
+
const newDegree = (inDegree.get(depId) ?? 1) - 1;
|
|
92
|
+
inDegree.set(depId, newDegree);
|
|
93
|
+
if (newDegree === 0) {
|
|
94
|
+
const story = storyMap.get(depId);
|
|
95
|
+
if (story) nextQueue.push(story);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
queue = nextQueue;
|
|
100
|
+
}
|
|
101
|
+
const totalInLevels = levels.reduce((sum, l) => sum + l.stories.length, 0);
|
|
102
|
+
if (totalInLevels !== incomplete.length) {
|
|
103
|
+
const placed = new Set(levels.flatMap((l) => l.stories.map((s) => s.id)));
|
|
104
|
+
const cycled = incomplete.filter((s) => !placed.has(s.id)).map((s) => s.id);
|
|
105
|
+
throw new Error(`Dependency cycle detected involving: ${cycled.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
return levels;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/core/executor.ts
|
|
111
|
+
function emit(event) {
|
|
112
|
+
process.stdout.write(JSON.stringify(event) + "\n");
|
|
113
|
+
}
|
|
114
|
+
async function main() {
|
|
115
|
+
const cwd = process.cwd();
|
|
116
|
+
const prdPath = path.join(cwd, "prd.json");
|
|
117
|
+
const prd = JSON.parse(fs.readFileSync(prdPath, "utf-8"));
|
|
118
|
+
const incomplete = prd.userStories.filter((s) => !s.passes);
|
|
119
|
+
if (incomplete.length === 0) {
|
|
120
|
+
emit({ type: "done", total_time_secs: 0, stats: { stories_completed: prd.userStories.length, stories_skipped: 0, total_commits: 0, files_created: 0, files_modified: 0 } });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
emit({ type: "init", project: prd.project, stories: prd.userStories.map((s) => ({ id: s.id, title: s.title, depends_on: s.dependsOn })) });
|
|
124
|
+
const levels = buildDag(prd.userStories);
|
|
125
|
+
emit({ type: "dag", levels: levels.map((l) => l.stories.map((s) => ({ id: s.id, title: s.title }))) });
|
|
126
|
+
const startTime = Date.now();
|
|
127
|
+
let completed = 0;
|
|
128
|
+
let skipped = 0;
|
|
129
|
+
const total = incomplete.length;
|
|
130
|
+
for (const level of levels) {
|
|
131
|
+
await Promise.allSettled(level.stories.map(async (story) => {
|
|
132
|
+
const maxAttempts = story.retries + 1;
|
|
133
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
134
|
+
emit({ type: "story_start", id: story.id, title: story.title });
|
|
135
|
+
try {
|
|
136
|
+
const prompt = buildPrompt(story, cwd);
|
|
137
|
+
const task = new CliTask({
|
|
138
|
+
id: story.id,
|
|
139
|
+
command: "claude",
|
|
140
|
+
args: ["--dangerously-skip-permissions", "--output-format", "stream-json", "-p", prompt],
|
|
141
|
+
cwd,
|
|
142
|
+
onStdout: (line) => {
|
|
143
|
+
try {
|
|
144
|
+
const ev = JSON.parse(line);
|
|
145
|
+
if (ev.type === "assistant" && ev.message?.content) {
|
|
146
|
+
for (const block of ev.message.content) {
|
|
147
|
+
if (block.type === "text" && block.text) {
|
|
148
|
+
for (const l of block.text.split("\n").filter(Boolean)) {
|
|
149
|
+
emit({ type: "story_log", id: story.id, line: l });
|
|
150
|
+
}
|
|
151
|
+
} else if (block.type === "tool_use") {
|
|
152
|
+
emit({ type: "story_log", id: story.id, line: `\u2699 ${block.name}: ${JSON.stringify(block.input).slice(0, 100)}` });
|
|
153
|
+
} else if (block.type === "tool_result") {
|
|
154
|
+
const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
|
|
155
|
+
if (text.length > 0) {
|
|
156
|
+
emit({ type: "story_log", id: story.id, line: text.slice(0, 150) });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (ev.type === "result") {
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
if (line.trim()) {
|
|
164
|
+
emit({ type: "story_log", id: story.id, line });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
onStderr: (line) => emit({ type: "story_log", id: story.id, line })
|
|
169
|
+
});
|
|
170
|
+
const result = await task.execute();
|
|
171
|
+
const dur = Math.round(result.durationMs / 1e3);
|
|
172
|
+
completed++;
|
|
173
|
+
const raw = JSON.parse(fs.readFileSync(prdPath, "utf-8"));
|
|
174
|
+
for (const s of raw.userStories) {
|
|
175
|
+
if (s.id === story.id) {
|
|
176
|
+
s.passes = true;
|
|
177
|
+
s.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
178
|
+
s.durationSecs = dur;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
fs.writeFileSync(prdPath, JSON.stringify(raw, null, 2) + "\n");
|
|
182
|
+
emit({ type: "story_complete", id: story.id, duration_secs: dur, files_created: 0, files_modified: 0 });
|
|
183
|
+
emit({ type: "progress", completed, total, percentage: Math.round(completed / total * 100) });
|
|
184
|
+
return;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
emit({ type: "story_error", id: story.id, error: err.message, attempt, max_retries: maxAttempts });
|
|
187
|
+
if (attempt < maxAttempts) {
|
|
188
|
+
emit({ type: "story_retry", id: story.id, attempt: attempt + 1 });
|
|
189
|
+
} else {
|
|
190
|
+
skipped++;
|
|
191
|
+
emit({ type: "progress", completed, total, percentage: Math.round(completed / total * 100) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
emit({ type: "done", total_time_secs: Math.round((Date.now() - startTime) / 1e3), stats: { stories_completed: completed, stories_skipped: skipped, total_commits: completed, files_created: 0, files_modified: 0 } });
|
|
198
|
+
}
|
|
199
|
+
function buildPrompt(story, cwd) {
|
|
200
|
+
const templatePath = path.join(cwd, "prompt.md");
|
|
201
|
+
let template;
|
|
202
|
+
if (fs.existsSync(templatePath)) {
|
|
203
|
+
template = fs.readFileSync(templatePath, "utf-8");
|
|
204
|
+
} else {
|
|
205
|
+
template = [
|
|
206
|
+
"You are working on story STORY_ID: STORY_TITLE",
|
|
207
|
+
"",
|
|
208
|
+
"STORY_DESCRIPTION",
|
|
209
|
+
"",
|
|
210
|
+
"ACCEPTANCE CRITERIA:",
|
|
211
|
+
"ACCEPTANCE_CRITERIA",
|
|
212
|
+
"",
|
|
213
|
+
"Run tests: TEST_COMMANDS",
|
|
214
|
+
'If tests pass, commit: git add . && git commit -m "feat(STORY_ID): STORY_TITLE"'
|
|
215
|
+
].join("\n");
|
|
216
|
+
}
|
|
217
|
+
return template.replace(/STORY_ID/g, story.id).replace(/STORY_TITLE/g, story.title).replace(/STORY_DESCRIPTION/g, story.description).replace(/ACCEPTANCE_CRITERIA/g, story.acceptance.map((a) => "- " + a).join("\n")).replace(/TEST_COMMANDS/g, story.tests.join(" && "));
|
|
218
|
+
}
|
|
219
|
+
main().catch((err) => {
|
|
220
|
+
process.stderr.write("Fatal: " + err + "\n");
|
|
221
|
+
process.exit(1);
|
|
222
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "baro-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Autonomous parallel coding - plan and execute with AI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"baro": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/",
|
|
11
|
+
"bin/",
|
|
12
|
+
"scripts/postinstall.js"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup src/cli.tsx src/core/executor.ts --format esm --external ink --external react --external ink-spinner",
|
|
16
|
+
"postinstall": "node scripts/postinstall.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"ink": "^5.2.0",
|
|
20
|
+
"ink-spinner": "^5.0.0",
|
|
21
|
+
"react": "^18.3.1",
|
|
22
|
+
"zod": "^3.24.0",
|
|
23
|
+
"zod-to-json-schema": "^3.25.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/react": "^18.3.0",
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"tsup": "^8.0.2",
|
|
29
|
+
"typescript": "^5.3.3"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/Lotus015/baro"
|
|
34
|
+
},
|
|
35
|
+
"keywords": ["ai", "coding", "cli", "parallel", "autonomous", "claude"],
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall script - downloads the baro-tui binary for the current platform.
|
|
4
|
+
* Binary is fetched from GitHub Releases.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "child_process"
|
|
8
|
+
import * as fs from "fs"
|
|
9
|
+
import * as path from "path"
|
|
10
|
+
import { fileURLToPath } from "url"
|
|
11
|
+
import * as https from "https"
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..")
|
|
15
|
+
const BIN_DIR = path.join(PACKAGE_ROOT, "bin")
|
|
16
|
+
const BINARY_NAME = "baro-tui"
|
|
17
|
+
const REPO = "Lotus015/baro"
|
|
18
|
+
|
|
19
|
+
function getPlatformKey() {
|
|
20
|
+
const platform = process.platform // darwin, linux, win32
|
|
21
|
+
const arch = process.arch // arm64, x64
|
|
22
|
+
|
|
23
|
+
const map = {
|
|
24
|
+
"darwin-arm64": "darwin-arm64",
|
|
25
|
+
"darwin-x64": "darwin-x64",
|
|
26
|
+
"linux-x64": "linux-x64",
|
|
27
|
+
"linux-arm64": "linux-arm64",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const key = `${platform}-${arch}`
|
|
31
|
+
if (!map[key]) {
|
|
32
|
+
console.warn(`⚠ baro-tui: no prebuilt binary for ${key}. Execution dashboard won't be available.`)
|
|
33
|
+
console.warn(` You can build it manually: cargo build --release in the baro repo.`)
|
|
34
|
+
process.exit(0) // Don't fail install
|
|
35
|
+
}
|
|
36
|
+
return map[key]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getVersion() {
|
|
40
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8"))
|
|
41
|
+
return pkg.version
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function download(url, dest) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const follow = (url) => {
|
|
47
|
+
https.get(url, { headers: { "User-Agent": "baro-cli" } }, (res) => {
|
|
48
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
49
|
+
follow(res.headers.location)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (res.statusCode !== 200) {
|
|
53
|
+
reject(new Error(`Download failed: ${res.statusCode} from ${url}`))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const file = fs.createWriteStream(dest)
|
|
57
|
+
res.pipe(file)
|
|
58
|
+
file.on("finish", () => { file.close(); resolve() })
|
|
59
|
+
file.on("error", reject)
|
|
60
|
+
}).on("error", reject)
|
|
61
|
+
}
|
|
62
|
+
follow(url)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main() {
|
|
67
|
+
// Skip if binary already exists (e.g. local dev)
|
|
68
|
+
const binaryPath = path.join(BIN_DIR, BINARY_NAME)
|
|
69
|
+
if (fs.existsSync(binaryPath)) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const platformKey = getPlatformKey()
|
|
74
|
+
const version = getVersion()
|
|
75
|
+
|
|
76
|
+
const url = `https://github.com/${REPO}/releases/download/v${version}/${BINARY_NAME}-${platformKey}`
|
|
77
|
+
|
|
78
|
+
console.log(`Downloading baro-tui for ${platformKey}...`)
|
|
79
|
+
|
|
80
|
+
fs.mkdirSync(BIN_DIR, { recursive: true })
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await download(url, binaryPath)
|
|
84
|
+
fs.chmodSync(binaryPath, 0o755)
|
|
85
|
+
console.log(`✓ baro-tui installed`)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.warn(`⚠ Could not download baro-tui: ${err.message}`)
|
|
88
|
+
console.warn(` Planning will work. Execution dashboard requires the binary.`)
|
|
89
|
+
console.warn(` Build manually: cargo build --release`)
|
|
90
|
+
// Don't fail the install
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main()
|