beflow 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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/config.example.json +68 -0
  4. package/config.schema.json +413 -0
  5. package/package.json +72 -0
  6. package/src/agent/acpx.ts +197 -0
  7. package/src/agent/driver.ts +38 -0
  8. package/src/agent/events.ts +228 -0
  9. package/src/agent/issuefence.ts +42 -0
  10. package/src/agent/report.ts +44 -0
  11. package/src/cli.ts +910 -0
  12. package/src/config/load.ts +45 -0
  13. package/src/config/persist.ts +58 -0
  14. package/src/config/schema.ts +181 -0
  15. package/src/config/store.ts +119 -0
  16. package/src/core/accept.ts +25 -0
  17. package/src/core/continuation.ts +57 -0
  18. package/src/core/deadletter.ts +55 -0
  19. package/src/core/decision.ts +8 -0
  20. package/src/core/doctor.ts +223 -0
  21. package/src/core/drift.ts +59 -0
  22. package/src/core/gc.ts +223 -0
  23. package/src/core/inputquality.ts +30 -0
  24. package/src/core/issuetemplate.ts +175 -0
  25. package/src/core/mcp.ts +191 -0
  26. package/src/core/newissue.ts +343 -0
  27. package/src/core/notify.ts +151 -0
  28. package/src/core/prompts.ts +165 -0
  29. package/src/core/qualitygate.ts +70 -0
  30. package/src/core/queue.ts +40 -0
  31. package/src/core/review.ts +266 -0
  32. package/src/core/run.ts +1075 -0
  33. package/src/core/runstore.ts +144 -0
  34. package/src/core/runsview.ts +111 -0
  35. package/src/core/setup.ts +203 -0
  36. package/src/core/sla.ts +39 -0
  37. package/src/core/template.ts +65 -0
  38. package/src/core/watch.ts +825 -0
  39. package/src/core/worktree.ts +74 -0
  40. package/src/core/writeback.ts +88 -0
  41. package/src/index.ts +154 -0
  42. package/src/model/types.ts +35 -0
  43. package/src/prompts/defaults/continuation.md +9 -0
  44. package/src/prompts/defaults/implement.md +13 -0
  45. package/src/prompts/defaults/issue-enrich.md +30 -0
  46. package/src/prompts/defaults/issues/bug.md +35 -0
  47. package/src/prompts/defaults/issues/feature.md +24 -0
  48. package/src/prompts/defaults/issues/generic.md +16 -0
  49. package/src/prompts/defaults/issues/spike.md +24 -0
  50. package/src/prompts/defaults/report.md +20 -0
  51. package/src/prompts/defaults/review.md +34 -0
  52. package/src/prompts/defaults/spec.md +11 -0
  53. package/src/prompts/defaults/task.md +6 -0
  54. package/src/prompts/defaults/triage.md +11 -0
  55. package/src/prompts/text-modules.d.ts +4 -0
  56. package/src/resolve/jobkind.ts +11 -0
  57. package/src/resolve/metadata.ts +103 -0
  58. package/src/resolve/precedence.ts +104 -0
  59. package/src/trackers/factory.ts +17 -0
  60. package/src/trackers/linear/adapter.ts +416 -0
  61. package/src/trackers/linear/client.ts +264 -0
  62. package/src/trackers/linear/map.ts +113 -0
  63. package/src/trackers/linear/types.ts +44 -0
  64. package/src/trackers/marker.ts +20 -0
  65. package/src/trackers/plane/adapter.ts +754 -0
  66. package/src/trackers/plane/client.ts +302 -0
  67. package/src/trackers/plane/map.ts +168 -0
  68. package/src/trackers/plane/types.ts +134 -0
  69. package/src/trackers/tracker.ts +135 -0
@@ -0,0 +1,175 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { parse as parseYaml } from "yaml";
6
+ import { z } from "zod";
7
+
8
+ import bugDefault from "../prompts/defaults/issues/bug.md" with { type: "text" };
9
+ import featureDefault from "../prompts/defaults/issues/feature.md" with { type: "text" };
10
+ import genericDefault from "../prompts/defaults/issues/generic.md" with { type: "text" };
11
+ import spikeDefault from "../prompts/defaults/issues/spike.md" with { type: "text" };
12
+ import { renderTemplate } from "./prompts.ts";
13
+
14
+ const COMPILED_DEFAULTS: Record<string, string> = {
15
+ bug: bugDefault,
16
+ feature: featureDefault,
17
+ generic: genericDefault,
18
+ spike: spikeDefault,
19
+ };
20
+
21
+ const questionSchema = z
22
+ .object({
23
+ key: z.string(),
24
+ label: z.string(),
25
+ options: z.array(z.string()).optional(),
26
+ required: z.boolean().default(false),
27
+ type: z.enum(["text", "longtext", "bool", "number", "options", "multiselect"]),
28
+ })
29
+ .superRefine((q, ctx) => {
30
+ if ((q.type === "options" || q.type === "multiselect") && (q.options === undefined || q.options.length === 0)) {
31
+ ctx.addIssue({
32
+ code: z.ZodIssueCode.custom,
33
+ message: `question "${q.key}" of type "${q.type}" requires a non-empty "options" array`,
34
+ path: ["options"],
35
+ });
36
+ }
37
+ });
38
+
39
+ export type Question = z.infer<typeof questionSchema>;
40
+
41
+ export const issueTemplateSchema = z
42
+ .object({
43
+ agent: z.string().optional(),
44
+ description: z.string(),
45
+ enrich: z.boolean().default(false),
46
+ jobKind: z.enum(["triage", "spec", "implement"]).optional(),
47
+ labels: z.array(z.string()).optional(),
48
+ name: z.string(),
49
+ priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
50
+ questions: z.array(questionSchema).default([]),
51
+ runMode: z.enum(["autonomous", "supervised"]).optional(),
52
+ state: z.string().optional(),
53
+ title: z.string().optional(),
54
+ type: z.string().optional(),
55
+ })
56
+ .superRefine((tpl, ctx) => {
57
+ const seen = new Set<string>();
58
+ for (const q of tpl.questions) {
59
+ if (seen.has(q.key)) {
60
+ ctx.addIssue({
61
+ code: z.ZodIssueCode.custom,
62
+ message: `duplicate question key "${q.key}"`,
63
+ path: ["questions"],
64
+ });
65
+ }
66
+ seen.add(q.key);
67
+ }
68
+ });
69
+
70
+ type IssueTemplateFrontmatter = z.infer<typeof issueTemplateSchema>;
71
+
72
+ export type IssueTemplate = IssueTemplateFrontmatter & { body: string };
73
+
74
+ // Injectable IO so the cascade is testable without touching the real filesystem.
75
+ export interface IssueTemplateResolveDeps {
76
+ configDir: string;
77
+ promptsDir?: string;
78
+ home: string;
79
+ exists: (p: string) => boolean;
80
+ listDir: (dir: string) => string[];
81
+ read: (p: string) => string;
82
+ }
83
+
84
+ export function defaultIssueTemplateResolveDeps(configDir: string, promptsDir?: string): IssueTemplateResolveDeps {
85
+ return {
86
+ configDir,
87
+ exists: existsSync,
88
+ home: homedir(),
89
+ listDir: (dir) => (existsSync(dir) ? readdirSync(dir) : []),
90
+ read: (p) => readFileSync(p, "utf8"),
91
+ ...(promptsDir !== undefined ? { promptsDir } : {}),
92
+ };
93
+ }
94
+
95
+ function expandHome(p: string, home: string): string {
96
+ return p.startsWith("~") ? join(home, p.slice(1)) : p;
97
+ }
98
+
99
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
100
+
101
+ export function parseFrontmatter(raw: string): { frontmatter: unknown; body: string } {
102
+ const match = FRONTMATTER.exec(raw);
103
+ if (match === null) {
104
+ throw new Error("beflow: issue template has no YAML frontmatter block (must start with '---')");
105
+ }
106
+ return { body: match[2] ?? "", frontmatter: parseYaml(match[1] ?? "") };
107
+ }
108
+
109
+ function parseTemplate(name: string, raw: string): IssueTemplate {
110
+ const { body, frontmatter } = parseFrontmatter(raw);
111
+ const result = issueTemplateSchema.safeParse(frontmatter);
112
+ if (!result.success) {
113
+ const issue = result.error.issues[0];
114
+ const detail = issue !== undefined ? `${issue.path.join(".")}: ${issue.message}` : result.error.message;
115
+ throw new Error(`beflow: invalid issue template "${name}" (${detail})`);
116
+ }
117
+ return { ...result.data, body };
118
+ }
119
+
120
+ // Candidate dirs, highest priority first: project-local (beside config.json),
121
+ // the configured prompts.dir/issues, then ~/.beflow/prompts/issues.
122
+ function candidateDirs(deps: IssueTemplateResolveDeps): string[] {
123
+ const dirs: string[] = [join(deps.configDir, "prompts", "issues")];
124
+ if (deps.promptsDir !== undefined) {
125
+ dirs.push(join(expandHome(deps.promptsDir, deps.home), "issues"));
126
+ }
127
+ dirs.push(join(deps.home, ".beflow", "prompts", "issues"));
128
+ return dirs;
129
+ }
130
+
131
+ export function loadIssueTemplate(name: string, deps: IssueTemplateResolveDeps): IssueTemplate {
132
+ for (const dir of candidateDirs(deps)) {
133
+ const path = join(dir, `${name}.md`);
134
+ if (deps.exists(path)) {
135
+ return parseTemplate(name, deps.read(path));
136
+ }
137
+ }
138
+ const compiled = COMPILED_DEFAULTS[name];
139
+ if (compiled !== undefined) {
140
+ return parseTemplate(name, compiled);
141
+ }
142
+ const available = listIssueTemplates(deps)
143
+ .map((t) => t.name)
144
+ .join(", ");
145
+ throw new Error(`beflow: unknown issue template "${name}" (available: ${available})`);
146
+ }
147
+
148
+ export function listIssueTemplates(deps: IssueTemplateResolveDeps): { name: string; description: string }[] {
149
+ // Union of names across cascade dirs (highest priority first) + compiled
150
+ // defaults; first occurrence of a name wins for its description.
151
+ const names: string[] = [];
152
+ for (const dir of candidateDirs(deps)) {
153
+ for (const file of deps.listDir(dir)) {
154
+ if (file.endsWith(".md")) {
155
+ names.push(file.slice(0, -".md".length));
156
+ }
157
+ }
158
+ }
159
+ names.push(...Object.keys(COMPILED_DEFAULTS));
160
+
161
+ const seen = new Set<string>();
162
+ const entries: { name: string; description: string }[] = [];
163
+ for (const name of names) {
164
+ if (seen.has(name)) {
165
+ continue;
166
+ }
167
+ seen.add(name);
168
+ entries.push({ description: loadIssueTemplate(name, deps).description, name });
169
+ }
170
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
171
+ }
172
+
173
+ export function renderIssueBody(template: IssueTemplate, answers: Record<string, string>): string {
174
+ return renderTemplate(`issue:${template.name}`, template.body, answers);
175
+ }
@@ -0,0 +1,191 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { z } from "zod";
6
+
7
+ // ACP McpServer shapes beflow forwards to acpx via `.acpxrc.json`. The stdio
8
+ // Variant is the untagged union member: it carries NO `type` field. http/sse
9
+ // Are tagged and carry a URL plus optional name/value header pairs.
10
+ export interface McpStdioServer {
11
+ name: string;
12
+ command: string;
13
+ args: string[];
14
+ env: { name: string; value: string }[];
15
+ }
16
+
17
+ export interface McpHttpServer {
18
+ type: "http";
19
+ name: string;
20
+ url: string;
21
+ headers?: { name: string; value: string }[];
22
+ }
23
+
24
+ export interface McpSseServer {
25
+ type: "sse";
26
+ name: string;
27
+ url: string;
28
+ headers?: { name: string; value: string }[];
29
+ }
30
+
31
+ export type McpServer = McpHttpServer | McpSseServer | McpStdioServer;
32
+
33
+ // The user-authored `.mcp.json` map (oh-my-pi / Claude format): a record of
34
+ // Name → entry. `type` defaults to stdio; stdio needs `command`, http/sse need
35
+ // `url`. superRefine enforces those per-variant requirements.
36
+ const mcpEntrySchema = z
37
+ .object({
38
+ args: z.array(z.string()).optional(),
39
+ command: z.string().optional(),
40
+ env: z.record(z.string(), z.string()).optional(),
41
+ headers: z.record(z.string(), z.string()).optional(),
42
+ type: z.enum(["http", "sse", "stdio"]).optional(),
43
+ url: z.string().optional(),
44
+ })
45
+ .superRefine((entry, ctx) => {
46
+ const type = entry.type ?? "stdio";
47
+ if (type === "stdio") {
48
+ if (entry.command === undefined) {
49
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "stdio MCP server requires `command`" });
50
+ }
51
+ } else if (entry.url === undefined) {
52
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${type} MCP server requires \`url\`` });
53
+ }
54
+ });
55
+
56
+ export type McpEntry = z.infer<typeof mcpEntrySchema>;
57
+
58
+ export const mcpFileSchema = z.object({
59
+ mcpServers: z.record(z.string(), mcpEntrySchema),
60
+ });
61
+
62
+ export interface McpResolveDeps {
63
+ configDir: string;
64
+ home: string;
65
+ exists: (p: string) => boolean;
66
+ read: (p: string) => string;
67
+ }
68
+
69
+ export function defaultMcpDeps(configDir: string): McpResolveDeps {
70
+ return {
71
+ configDir,
72
+ exists: existsSync,
73
+ home: homedir(),
74
+ read: (p) => readFileSync(p, "utf8"),
75
+ };
76
+ }
77
+
78
+ function toHeaderPairs(headers: Record<string, string>): { name: string; value: string }[] {
79
+ return Object.entries(headers).map(([name, value]) => ({ name, value }));
80
+ }
81
+
82
+ // Translate a validated `.mcp.json` entry into the ACP McpServer shape.
83
+ function translate(name: string, entry: McpEntry): McpServer {
84
+ const type = entry.type ?? "stdio";
85
+ if (type === "http" || type === "sse") {
86
+ const url = entry.url ?? "";
87
+ return {
88
+ name,
89
+ type,
90
+ url,
91
+ ...(entry.headers !== undefined ? { headers: toHeaderPairs(entry.headers) } : {}),
92
+ };
93
+ }
94
+ return {
95
+ args: entry.args ?? [],
96
+ command: entry.command ?? "",
97
+ env: Object.entries(entry.env ?? {}).map(([envName, value]) => ({ name: envName, value })),
98
+ name,
99
+ };
100
+ }
101
+
102
+ function loadMap(path: string, deps: McpResolveDeps): Record<string, McpEntry> {
103
+ if (!deps.exists(path)) {
104
+ return {};
105
+ }
106
+ const parsed: unknown = JSON.parse(deps.read(path));
107
+ return mcpFileSchema.parse(parsed).mcpServers;
108
+ }
109
+
110
+ function expandHome(p: string, home: string): string {
111
+ return p.startsWith("~") ? join(home, p.slice(1)) : p;
112
+ }
113
+
114
+ // Load + merge the `.mcp.json` cascade and translate it to ACP McpServer shapes.
115
+ // Cascade (project wins per server NAME): GLOBAL `<home>/.beflow/.mcp.json`,
116
+ // Then PROJECT `<configDir>/.mcp.json`. Missing files are skipped. Returns `[]`
117
+ // When nothing is configured.
118
+ export function loadMcpServers(deps: McpResolveDeps): McpServer[] {
119
+ const globalPath = join(expandHome("~/.beflow", deps.home), ".mcp.json");
120
+ const projectPath = join(deps.configDir, ".mcp.json");
121
+ const merged: Record<string, McpEntry> = {
122
+ ...loadMap(globalPath, deps),
123
+ ...loadMap(projectPath, deps),
124
+ };
125
+ return Object.entries(merged).map(([name, entry]) => translate(name, entry));
126
+ }
127
+
128
+ export interface McpFs {
129
+ exists(p: string): boolean;
130
+ read(p: string): string;
131
+ write(p: string, data: string): void;
132
+ remove(p: string): void;
133
+ }
134
+
135
+ export const nodeMcpFs: McpFs = {
136
+ exists: (p) => existsSync(p),
137
+ read: (p) => readFileSync(p, "utf8"),
138
+ remove: (p) => {
139
+ rmSync(p, { force: true });
140
+ },
141
+ write: (p, data) => {
142
+ mkdirSync(dirname(p), { recursive: true });
143
+ writeFileSync(p, data, "utf8");
144
+ },
145
+ };
146
+
147
+ // Write a managed `.acpxrc.json` at `<cwd>/.acpxrc.json` setting `mcpServers`,
148
+ // Preserving any other keys from a pre-existing file, and return a CLEANUP that
149
+ // Restores the exact prior content (or removes the file when there was none).
150
+ // Empty `servers` is a no-op returning a no-op cleanup. Cleanup never throws.
151
+ export function injectAcpxMcp(cwd: string, servers: McpServer[], fs: McpFs): () => void {
152
+ if (servers.length === 0) {
153
+ return (): void => {
154
+ /* no-op: nothing was injected */
155
+ };
156
+ }
157
+
158
+ const path = join(cwd, ".acpxrc.json");
159
+ const prior: string | null = fs.exists(path) ? fs.read(path) : null;
160
+
161
+ const base: Record<string, unknown> = {};
162
+ if (prior !== null) {
163
+ try {
164
+ const parsed: unknown = JSON.parse(prior);
165
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
166
+ for (const [k, v] of Object.entries(parsed)) {
167
+ base[k] = v;
168
+ }
169
+ }
170
+ } catch {
171
+ // Unparseable prior: start from {} for the merge, but the exact prior
172
+ // String is still captured above for an exact restore on cleanup.
173
+ }
174
+ }
175
+
176
+ base.mcpServers = servers;
177
+ fs.write(path, `${JSON.stringify(base, null, 2)}\n`);
178
+
179
+ return (): void => {
180
+ try {
181
+ if (prior === null) {
182
+ fs.remove(path);
183
+ } else {
184
+ fs.write(path, prior);
185
+ }
186
+ } catch {
187
+ // Best-effort restore, mirroring the worktree-removal pattern: a failure
188
+ // To restore must never mask the run's own outcome.
189
+ }
190
+ };
191
+ }