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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/config.example.json +68 -0
- package/config.schema.json +413 -0
- package/package.json +72 -0
- package/src/agent/acpx.ts +197 -0
- package/src/agent/driver.ts +38 -0
- package/src/agent/events.ts +228 -0
- package/src/agent/issuefence.ts +42 -0
- package/src/agent/report.ts +44 -0
- package/src/cli.ts +910 -0
- package/src/config/load.ts +45 -0
- package/src/config/persist.ts +58 -0
- package/src/config/schema.ts +181 -0
- package/src/config/store.ts +119 -0
- package/src/core/accept.ts +25 -0
- package/src/core/continuation.ts +57 -0
- package/src/core/deadletter.ts +55 -0
- package/src/core/decision.ts +8 -0
- package/src/core/doctor.ts +223 -0
- package/src/core/drift.ts +59 -0
- package/src/core/gc.ts +223 -0
- package/src/core/inputquality.ts +30 -0
- package/src/core/issuetemplate.ts +175 -0
- package/src/core/mcp.ts +191 -0
- package/src/core/newissue.ts +343 -0
- package/src/core/notify.ts +151 -0
- package/src/core/prompts.ts +165 -0
- package/src/core/qualitygate.ts +70 -0
- package/src/core/queue.ts +40 -0
- package/src/core/review.ts +266 -0
- package/src/core/run.ts +1075 -0
- package/src/core/runstore.ts +144 -0
- package/src/core/runsview.ts +111 -0
- package/src/core/setup.ts +203 -0
- package/src/core/sla.ts +39 -0
- package/src/core/template.ts +65 -0
- package/src/core/watch.ts +825 -0
- package/src/core/worktree.ts +74 -0
- package/src/core/writeback.ts +88 -0
- package/src/index.ts +154 -0
- package/src/model/types.ts +35 -0
- package/src/prompts/defaults/continuation.md +9 -0
- package/src/prompts/defaults/implement.md +13 -0
- package/src/prompts/defaults/issue-enrich.md +30 -0
- package/src/prompts/defaults/issues/bug.md +35 -0
- package/src/prompts/defaults/issues/feature.md +24 -0
- package/src/prompts/defaults/issues/generic.md +16 -0
- package/src/prompts/defaults/issues/spike.md +24 -0
- package/src/prompts/defaults/report.md +20 -0
- package/src/prompts/defaults/review.md +34 -0
- package/src/prompts/defaults/spec.md +11 -0
- package/src/prompts/defaults/task.md +6 -0
- package/src/prompts/defaults/triage.md +11 -0
- package/src/prompts/text-modules.d.ts +4 -0
- package/src/resolve/jobkind.ts +11 -0
- package/src/resolve/metadata.ts +103 -0
- package/src/resolve/precedence.ts +104 -0
- package/src/trackers/factory.ts +17 -0
- package/src/trackers/linear/adapter.ts +416 -0
- package/src/trackers/linear/client.ts +264 -0
- package/src/trackers/linear/map.ts +113 -0
- package/src/trackers/linear/types.ts +44 -0
- package/src/trackers/marker.ts +20 -0
- package/src/trackers/plane/adapter.ts +754 -0
- package/src/trackers/plane/client.ts +302 -0
- package/src/trackers/plane/map.ts +168 -0
- package/src/trackers/plane/types.ts +134 -0
- package/src/trackers/tracker.ts +135 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { cancel, confirm, isCancel, multiselect, note, select, text } from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
import type { AgentDriver } from "../agent/driver.ts";
|
|
4
|
+
import { extractIssueFence, type IssueFence } from "../agent/issuefence.ts";
|
|
5
|
+
import type { Issue } from "../model/types.ts";
|
|
6
|
+
import type { IssueDraft, Tracker } from "../trackers/tracker.ts";
|
|
7
|
+
import {
|
|
8
|
+
listIssueTemplates,
|
|
9
|
+
loadIssueTemplate,
|
|
10
|
+
renderIssueBody,
|
|
11
|
+
type IssueTemplate,
|
|
12
|
+
type IssueTemplateResolveDeps,
|
|
13
|
+
type Question,
|
|
14
|
+
} from "./issuetemplate.ts";
|
|
15
|
+
import { renderTemplate } from "./prompts.ts";
|
|
16
|
+
import type { Logger } from "./run.ts";
|
|
17
|
+
|
|
18
|
+
// Injected IO boundaries; the clack-backed defaults below are the TTY edge, so the
|
|
19
|
+
// orchestration core stays fully unit-testable without a TTY.
|
|
20
|
+
export type AskTemplate = (templates: { name: string; description: string }[]) => Promise<string>;
|
|
21
|
+
export type AskQuestions = (questions: Question[]) => Promise<Record<string, string>>;
|
|
22
|
+
export type AskConfirm = (preview: string) => Promise<boolean>;
|
|
23
|
+
|
|
24
|
+
// The agent-enrich boundary: given the form answers + the seed draft, an agent
|
|
25
|
+
// investigates the repo READ-ONLY and returns a complete issue (or null to fall
|
|
26
|
+
// back to the form draft). Injected so the core stays unit-testable.
|
|
27
|
+
export interface EnrichInput {
|
|
28
|
+
template: IssueTemplate;
|
|
29
|
+
answers: Record<string, string>;
|
|
30
|
+
seedTitle: string;
|
|
31
|
+
seedBody: string;
|
|
32
|
+
}
|
|
33
|
+
export type EnrichIssue = (input: EnrichInput) => Promise<IssueFence | null>;
|
|
34
|
+
|
|
35
|
+
function formatAnswers(answers: Record<string, string>): string {
|
|
36
|
+
return Object.entries(answers)
|
|
37
|
+
.map(([key, value]) => `${key}: ${value}`)
|
|
38
|
+
.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Builds the default agent-backed enrich function: it runs the authoring agent
|
|
42
|
+
// one-shot and READ-ONLY (acpx `exec` + `--approve-reads` +
|
|
43
|
+
// `--non-interactive-permissions deny`) in the project's repo, then extracts the
|
|
44
|
+
// `beflow-issue` block. Any driver error degrades to null so enrich never crashes
|
|
45
|
+
// issue creation.
|
|
46
|
+
export function defaultEnrichIssue(opts: {
|
|
47
|
+
driver: AgentDriver;
|
|
48
|
+
resolveAcp: (agent: string) => string;
|
|
49
|
+
enrichPrompt: string;
|
|
50
|
+
repoPath: string;
|
|
51
|
+
defaultAgent: string;
|
|
52
|
+
log?: Logger;
|
|
53
|
+
}): EnrichIssue {
|
|
54
|
+
return async (input: EnrichInput): Promise<IssueFence | null> => {
|
|
55
|
+
const agent = input.template.agent ?? opts.defaultAgent;
|
|
56
|
+
const acpCommand = opts.resolveAcp(agent);
|
|
57
|
+
const task = renderTemplate("issue-enrich", opts.enrichPrompt, {
|
|
58
|
+
answers: formatAnswers(input.answers),
|
|
59
|
+
draft: input.seedBody,
|
|
60
|
+
format: input.template.body,
|
|
61
|
+
title: input.seedTitle,
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
const result = await opts.driver.run({
|
|
65
|
+
acpCommand,
|
|
66
|
+
cwd: opts.repoPath,
|
|
67
|
+
nonInteractive: "deny",
|
|
68
|
+
oneShot: true,
|
|
69
|
+
runMode: "supervised",
|
|
70
|
+
sessionKey: "beflow-new",
|
|
71
|
+
task,
|
|
72
|
+
});
|
|
73
|
+
return extractIssueFence(result.stream.assistantText);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
opts.log?.(`beflow: enrich agent failed (${msg}); using the form draft`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Shared cancel path for every clack prompt: a Ctrl-C aborts cleanly so nothing is
|
|
83
|
+
// created. Like defaultAskOutcome in run.ts, these defaults are NOT unit-tested.
|
|
84
|
+
function cancelled(): never {
|
|
85
|
+
cancel("Cancelled.");
|
|
86
|
+
throw new Error("beflow: issue creation cancelled");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function defaultAskTemplate(templates: { name: string; description: string }[]): Promise<string> {
|
|
90
|
+
const choice = await select({
|
|
91
|
+
message: "Pick an issue template",
|
|
92
|
+
options: templates.map((t) => ({ hint: t.description, label: t.name, value: t.name })),
|
|
93
|
+
});
|
|
94
|
+
if (isCancel(choice)) {
|
|
95
|
+
cancelled();
|
|
96
|
+
}
|
|
97
|
+
return choice;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function defaultAskQuestions(questions: Question[]): Promise<Record<string, string>> {
|
|
101
|
+
const answers: Record<string, string> = {};
|
|
102
|
+
for (const question of questions) {
|
|
103
|
+
const { required } = question;
|
|
104
|
+
switch (question.type) {
|
|
105
|
+
case "text": {
|
|
106
|
+
const value = await text({
|
|
107
|
+
message: question.label,
|
|
108
|
+
...(required
|
|
109
|
+
? {
|
|
110
|
+
validate: (v: string | undefined): string | undefined =>
|
|
111
|
+
(v ?? "").trim() === "" ? "Required" : undefined,
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
});
|
|
115
|
+
if (isCancel(value)) {
|
|
116
|
+
cancelled();
|
|
117
|
+
}
|
|
118
|
+
answers[question.key] = value;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "longtext": {
|
|
122
|
+
// clack has no native multiline input, so longtext is a single-line
|
|
123
|
+
// text prompt; Enter submits. Multi-paragraph bodies aren't supported
|
|
124
|
+
// here — author them in a template file instead.
|
|
125
|
+
const value = await text({
|
|
126
|
+
message: question.label,
|
|
127
|
+
placeholder: "single line — Enter submits",
|
|
128
|
+
...(required
|
|
129
|
+
? {
|
|
130
|
+
validate: (v: string | undefined): string | undefined =>
|
|
131
|
+
(v ?? "").trim() === "" ? "Required" : undefined,
|
|
132
|
+
}
|
|
133
|
+
: {}),
|
|
134
|
+
});
|
|
135
|
+
if (isCancel(value)) {
|
|
136
|
+
cancelled();
|
|
137
|
+
}
|
|
138
|
+
answers[question.key] = value;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "bool": {
|
|
142
|
+
const value = await confirm({ message: question.label });
|
|
143
|
+
if (isCancel(value)) {
|
|
144
|
+
cancelled();
|
|
145
|
+
}
|
|
146
|
+
answers[question.key] = value ? "Yes" : "No";
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "number": {
|
|
150
|
+
const value = await text({
|
|
151
|
+
message: question.label,
|
|
152
|
+
validate: (v: string | undefined): string | undefined => {
|
|
153
|
+
const trimmed = (v ?? "").trim();
|
|
154
|
+
if (trimmed !== "" && Number.isNaN(Number(trimmed))) {
|
|
155
|
+
return "Must be a number";
|
|
156
|
+
}
|
|
157
|
+
return required && trimmed === "" ? "Required" : undefined;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
if (isCancel(value)) {
|
|
161
|
+
cancelled();
|
|
162
|
+
}
|
|
163
|
+
answers[question.key] = value.trim();
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "options": {
|
|
167
|
+
// The schema guarantees options is non-empty for this type.
|
|
168
|
+
const value = await select({
|
|
169
|
+
message: question.label,
|
|
170
|
+
options: (question.options ?? []).map((o) => ({ label: o, value: o })),
|
|
171
|
+
});
|
|
172
|
+
if (isCancel(value)) {
|
|
173
|
+
cancelled();
|
|
174
|
+
}
|
|
175
|
+
answers[question.key] = value;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "multiselect": {
|
|
179
|
+
const value = await multiselect({
|
|
180
|
+
message: question.label,
|
|
181
|
+
options: (question.options ?? []).map((o) => ({ label: o, value: o })),
|
|
182
|
+
required,
|
|
183
|
+
});
|
|
184
|
+
if (isCancel(value)) {
|
|
185
|
+
cancelled();
|
|
186
|
+
}
|
|
187
|
+
answers[question.key] = value.join(", ");
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return answers;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function defaultAskConfirm(preview: string): Promise<boolean> {
|
|
196
|
+
note(preview, "New issue");
|
|
197
|
+
const ok = await confirm({ message: "Create this work item?" });
|
|
198
|
+
if (isCancel(ok)) {
|
|
199
|
+
cancelled();
|
|
200
|
+
}
|
|
201
|
+
return ok;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface NewIssueDeps {
|
|
205
|
+
tracker: Tracker;
|
|
206
|
+
templateDeps: IssueTemplateResolveDeps;
|
|
207
|
+
askTemplate: AskTemplate;
|
|
208
|
+
askQuestions: AskQuestions;
|
|
209
|
+
askConfirm: AskConfirm;
|
|
210
|
+
enrich?: EnrichIssue;
|
|
211
|
+
log?: Logger;
|
|
212
|
+
defaultState?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const DEFAULT_STATE = "Backlog";
|
|
216
|
+
|
|
217
|
+
function fillAnswers(questions: Question[], raw: Record<string, string>): Record<string, string> {
|
|
218
|
+
const answers: Record<string, string> = {};
|
|
219
|
+
for (const q of questions) {
|
|
220
|
+
answers[q.key] = raw[q.key] ?? "";
|
|
221
|
+
}
|
|
222
|
+
return answers;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function deriveTitle(template: IssueTemplate, answers: Record<string, string>): string {
|
|
226
|
+
const raw =
|
|
227
|
+
template.title !== undefined
|
|
228
|
+
? renderTemplate(`issue:${template.name}:title`, template.title, answers)
|
|
229
|
+
: (answers.title ?? answers.summary ?? answers[template.questions[0]?.key ?? ""] ?? "");
|
|
230
|
+
const title = raw.trim();
|
|
231
|
+
if (title === "") {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`beflow: issue template "${template.name}" produced an empty title — add a "title" pattern or a "title"/"summary" question`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return title;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function mapLabels(template: IssueTemplate, extra: string[] = []): string[] {
|
|
240
|
+
const labels = [...(template.labels ?? [])];
|
|
241
|
+
if (template.agent !== undefined) {
|
|
242
|
+
labels.push(`agent:${template.agent}`);
|
|
243
|
+
}
|
|
244
|
+
if (template.runMode !== undefined) {
|
|
245
|
+
labels.push(`run:${template.runMode}`);
|
|
246
|
+
}
|
|
247
|
+
if (template.jobKind !== undefined) {
|
|
248
|
+
labels.push(`jobkind:${template.jobKind}`);
|
|
249
|
+
}
|
|
250
|
+
labels.push(...extra);
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
const deduped: string[] = [];
|
|
253
|
+
for (const label of labels) {
|
|
254
|
+
if (!seen.has(label)) {
|
|
255
|
+
seen.add(label);
|
|
256
|
+
deduped.push(label);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return deduped;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function buildPreview(draft: IssueDraft): string {
|
|
263
|
+
return [
|
|
264
|
+
`Title: ${draft.title}`,
|
|
265
|
+
`State: ${draft.state ?? DEFAULT_STATE}`,
|
|
266
|
+
`Type: ${draft.type ?? "—"}`,
|
|
267
|
+
`Labels: ${draft.labels !== undefined && draft.labels.length > 0 ? draft.labels.join(", ") : "—"}`,
|
|
268
|
+
"─────────────────────────────",
|
|
269
|
+
draft.body,
|
|
270
|
+
].join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function newIssue(
|
|
274
|
+
project: string,
|
|
275
|
+
templateName: string | undefined,
|
|
276
|
+
deps: NewIssueDeps,
|
|
277
|
+
): Promise<Issue | null> {
|
|
278
|
+
const log =
|
|
279
|
+
deps.log ??
|
|
280
|
+
((): void => {
|
|
281
|
+
/* no-op: logging disabled */
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
let template: IssueTemplate;
|
|
285
|
+
if (templateName !== undefined) {
|
|
286
|
+
template = loadIssueTemplate(templateName, deps.templateDeps);
|
|
287
|
+
} else {
|
|
288
|
+
const list = listIssueTemplates(deps.templateDeps);
|
|
289
|
+
if (list.length === 0) {
|
|
290
|
+
throw new Error("beflow: no issue templates available to choose from");
|
|
291
|
+
}
|
|
292
|
+
const chosen = await deps.askTemplate(list);
|
|
293
|
+
template = loadIssueTemplate(chosen, deps.templateDeps);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const raw = await deps.askQuestions(template.questions);
|
|
297
|
+
const answers = fillAnswers(template.questions, raw);
|
|
298
|
+
|
|
299
|
+
let title = deriveTitle(template, answers);
|
|
300
|
+
let body = renderIssueBody(template, answers);
|
|
301
|
+
|
|
302
|
+
// Template values stay authoritative; enrich only fills what the template
|
|
303
|
+
// leaves unset. Enrich is skipped for enrich:false templates and on the form
|
|
304
|
+
// path (deps.enrich undefined).
|
|
305
|
+
let type: string | undefined = template.type;
|
|
306
|
+
let priority: string | undefined = template.priority;
|
|
307
|
+
let extraLabels: string[] = [];
|
|
308
|
+
if (template.enrich && deps.enrich !== undefined) {
|
|
309
|
+
const fence = await deps.enrich({ answers, seedBody: body, seedTitle: title, template });
|
|
310
|
+
if (fence !== null) {
|
|
311
|
+
body = fence.body;
|
|
312
|
+
if (fence.title !== undefined && fence.title.trim() !== "") {
|
|
313
|
+
title = fence.title.trim();
|
|
314
|
+
}
|
|
315
|
+
type = template.type ?? fence.type;
|
|
316
|
+
priority = template.priority ?? fence.priority;
|
|
317
|
+
extraLabels = fence.labels ?? [];
|
|
318
|
+
} else {
|
|
319
|
+
log("beflow: enrich produced no issue block; using the form draft");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const labels = mapLabels(template, extraLabels);
|
|
324
|
+
|
|
325
|
+
const draft: IssueDraft = {
|
|
326
|
+
body,
|
|
327
|
+
state: template.state ?? deps.defaultState ?? DEFAULT_STATE,
|
|
328
|
+
title,
|
|
329
|
+
...(type !== undefined ? { type } : {}),
|
|
330
|
+
...(priority !== undefined ? { priority } : {}),
|
|
331
|
+
...(labels.length > 0 ? { labels } : {}),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const ok = await deps.askConfirm(buildPreview(draft));
|
|
335
|
+
if (!ok) {
|
|
336
|
+
log("beflow: issue creation cancelled");
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const issue = await deps.tracker.createIssue(project, draft);
|
|
341
|
+
log(`beflow: created ${issue.key}`);
|
|
342
|
+
return issue;
|
|
343
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export type NotifyFormat = "discord" | "generic" | "slack";
|
|
2
|
+
|
|
3
|
+
export type NotifyReason = "needs_input" | "blocked" | "failed" | "reminder" | "resolved";
|
|
4
|
+
|
|
5
|
+
function parseHost(url: string): string | null {
|
|
6
|
+
try {
|
|
7
|
+
const { host } = new URL(url);
|
|
8
|
+
return host;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function detectFormat(url: string): NotifyFormat {
|
|
15
|
+
const host = parseHost(url);
|
|
16
|
+
if (host === null) {
|
|
17
|
+
return "generic";
|
|
18
|
+
}
|
|
19
|
+
if (host.includes("hooks.slack.com")) {
|
|
20
|
+
return "slack";
|
|
21
|
+
}
|
|
22
|
+
if (host.includes("discord.com") || host.includes("discordapp.com")) {
|
|
23
|
+
return "discord";
|
|
24
|
+
}
|
|
25
|
+
return "generic";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NotifyEvent {
|
|
29
|
+
detail?: string;
|
|
30
|
+
key: string;
|
|
31
|
+
reason: NotifyReason;
|
|
32
|
+
title: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Notifier {
|
|
36
|
+
notify: (evt: NotifyEvent) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Minimal fetch signature used for injection / testing. */
|
|
40
|
+
export type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
export const noopNotifier: Notifier = {
|
|
43
|
+
notify: async (): Promise<void> => {
|
|
44
|
+
/* No-op: notification disabled */
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class WebhookNotifier implements Notifier {
|
|
49
|
+
private readonly fetchImpl?: FetchFn;
|
|
50
|
+
private readonly format: NotifyFormat;
|
|
51
|
+
private readonly log: (m: string) => void;
|
|
52
|
+
private readonly url: string;
|
|
53
|
+
|
|
54
|
+
public constructor(opts: { fetchImpl?: FetchFn; format?: NotifyFormat; log?: (m: string) => void; url: string }) {
|
|
55
|
+
this.fetchImpl = opts.fetchImpl;
|
|
56
|
+
this.format = opts.format ?? detectFormat(opts.url);
|
|
57
|
+
this.log =
|
|
58
|
+
opts.log ??
|
|
59
|
+
((): void => {
|
|
60
|
+
/* No-op */
|
|
61
|
+
});
|
|
62
|
+
this.url = opts.url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private buildBody(evt: NotifyEvent, message: string): Record<string, unknown> {
|
|
66
|
+
if (this.format === "slack") {
|
|
67
|
+
return { text: message };
|
|
68
|
+
}
|
|
69
|
+
if (this.format === "discord") {
|
|
70
|
+
const content = message.length > 2000 ? `${message.slice(0, 1999)}…` : message;
|
|
71
|
+
return { content };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...(evt.detail !== undefined ? { detail: evt.detail } : {}),
|
|
75
|
+
event: "beflow.escalation",
|
|
76
|
+
issue: { key: evt.key, title: evt.title },
|
|
77
|
+
reason: evt.reason,
|
|
78
|
+
text: message,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public async notify(evt: NotifyEvent): Promise<void> {
|
|
83
|
+
const phrases: Record<NotifyReason, string> = {
|
|
84
|
+
blocked: "is blocked",
|
|
85
|
+
failed: "failed",
|
|
86
|
+
needs_input: "needs input",
|
|
87
|
+
reminder: "is still waiting",
|
|
88
|
+
resolved: "is resolved",
|
|
89
|
+
};
|
|
90
|
+
const phrase = phrases[evt.reason];
|
|
91
|
+
const headline = `🔔 beflow: ${evt.key} ${phrase} — ${evt.title}`;
|
|
92
|
+
const message = headline + (evt.detail !== undefined ? `\n${evt.detail}` : "");
|
|
93
|
+
|
|
94
|
+
const body: Record<string, unknown> = this.buildBody(evt, message);
|
|
95
|
+
|
|
96
|
+
const fetchFn: FetchFn = this.fetchImpl ?? fetch;
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetchFn(this.url, {
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
method: "POST",
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
this.log(`beflow: webhook notify failed (${String(res.status)} ${res.statusText})`);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
this.log(`beflow: webhook notify error: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createNotifier(opts: {
|
|
113
|
+
fetchImpl?: FetchFn;
|
|
114
|
+
format?: NotifyFormat;
|
|
115
|
+
log?: (m: string) => void;
|
|
116
|
+
webhookUrl?: string;
|
|
117
|
+
}): Notifier {
|
|
118
|
+
if (opts.webhookUrl !== undefined && opts.webhookUrl !== "") {
|
|
119
|
+
return new WebhookNotifier({
|
|
120
|
+
fetchImpl: opts.fetchImpl,
|
|
121
|
+
...(opts.format !== undefined ? { format: opts.format } : {}),
|
|
122
|
+
log: opts.log,
|
|
123
|
+
url: opts.webhookUrl,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return noopNotifier;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function escalationDetail(report: { questions?: string[]; summary: string }): string {
|
|
130
|
+
if (report.questions !== undefined && report.questions.length > 0) {
|
|
131
|
+
return report.questions.map((q) => `- ${q}`).join("\n");
|
|
132
|
+
}
|
|
133
|
+
return report.summary;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function notifyEscalation(
|
|
137
|
+
notifier: Notifier | undefined,
|
|
138
|
+
issue: { key: string; title: string },
|
|
139
|
+
reason: NotifyReason,
|
|
140
|
+
detail?: string,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
if (notifier === undefined) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await notifier.notify({
|
|
146
|
+
...(detail !== undefined ? { detail } : {}),
|
|
147
|
+
key: issue.key,
|
|
148
|
+
reason,
|
|
149
|
+
title: issue.title,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { Issue, JobKind } from "../model/types.ts";
|
|
6
|
+
import continuationDefault from "../prompts/defaults/continuation.md" with { type: "text" };
|
|
7
|
+
import implementDefault from "../prompts/defaults/implement.md" with { type: "text" };
|
|
8
|
+
import issueEnrichDefault from "../prompts/defaults/issue-enrich.md" with { type: "text" };
|
|
9
|
+
import reportDefault from "../prompts/defaults/report.md" with { type: "text" };
|
|
10
|
+
import reviewDefault from "../prompts/defaults/review.md" with { type: "text" };
|
|
11
|
+
import specDefault from "../prompts/defaults/spec.md" with { type: "text" };
|
|
12
|
+
import taskDefault from "../prompts/defaults/task.md" with { type: "text" };
|
|
13
|
+
import triageDefault from "../prompts/defaults/triage.md" with { type: "text" };
|
|
14
|
+
import type { IssueContext } from "../trackers/tracker.ts";
|
|
15
|
+
|
|
16
|
+
export const PROMPT_NAMES = ["triage", "spec", "implement", "report", "task", "continuation", "review"] as const;
|
|
17
|
+
export type PromptName = (typeof PROMPT_NAMES)[number];
|
|
18
|
+
|
|
19
|
+
export interface PromptSet {
|
|
20
|
+
triage: string;
|
|
21
|
+
spec: string;
|
|
22
|
+
implement: string;
|
|
23
|
+
report: string;
|
|
24
|
+
task: string;
|
|
25
|
+
continuation: string;
|
|
26
|
+
review: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const COMPILED_DEFAULTS: PromptSet = {
|
|
30
|
+
continuation: continuationDefault,
|
|
31
|
+
implement: implementDefault,
|
|
32
|
+
report: reportDefault,
|
|
33
|
+
review: reviewDefault,
|
|
34
|
+
spec: specDefault,
|
|
35
|
+
task: taskDefault,
|
|
36
|
+
triage: triageDefault,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Injectable IO so the cascade is testable without touching the real filesystem.
|
|
40
|
+
export interface PromptResolveDeps {
|
|
41
|
+
configDir: string;
|
|
42
|
+
promptsDir?: string;
|
|
43
|
+
home: string;
|
|
44
|
+
exists: (p: string) => boolean;
|
|
45
|
+
read: (p: string) => string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function defaultPromptResolveDeps(configDir: string, promptsDir?: string): PromptResolveDeps {
|
|
49
|
+
return {
|
|
50
|
+
configDir,
|
|
51
|
+
exists: existsSync,
|
|
52
|
+
home: homedir(),
|
|
53
|
+
read: (p) => readFileSync(p, "utf8"),
|
|
54
|
+
...(promptsDir !== undefined ? { promptsDir } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expandHome(p: string, home: string): string {
|
|
59
|
+
return p.startsWith("~") ? join(home, p.slice(1)) : p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Override cascade candidate paths for a `<basename>.md`, highest priority first:
|
|
63
|
+
// project-local (beside config.json), the configured prompts.dir, then
|
|
64
|
+
// ~/.beflow/prompts.
|
|
65
|
+
function promptCandidates(basename: string, deps: PromptResolveDeps): string[] {
|
|
66
|
+
const candidates: string[] = [join(deps.configDir, "prompts", `${basename}.md`)];
|
|
67
|
+
if (deps.promptsDir !== undefined) {
|
|
68
|
+
candidates.push(join(expandHome(deps.promptsDir, deps.home), `${basename}.md`));
|
|
69
|
+
}
|
|
70
|
+
candidates.push(join(deps.home, ".beflow", "prompts", `${basename}.md`));
|
|
71
|
+
return candidates;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Override cascade, highest priority first: first readable file from
|
|
75
|
+
// promptCandidates wins; otherwise the compiled-in default.
|
|
76
|
+
function resolvePrompt(name: PromptName, deps: PromptResolveDeps): string {
|
|
77
|
+
for (const path of promptCandidates(name, deps)) {
|
|
78
|
+
if (deps.exists(path)) {
|
|
79
|
+
return deps.read(path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return COMPILED_DEFAULTS[name];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The issue-authoring enrich prompt. It rides the SAME override cascade as the
|
|
86
|
+
// threaded PromptSet but is loaded on demand (only `beflow new --enrich` needs
|
|
87
|
+
// it), so it stays out of PromptSet/PROMPT_NAMES.
|
|
88
|
+
export function loadEnrichPrompt(deps: PromptResolveDeps): string {
|
|
89
|
+
for (const path of promptCandidates("issue-enrich", deps)) {
|
|
90
|
+
if (deps.exists(path)) {
|
|
91
|
+
return deps.read(path);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return issueEnrichDefault;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function loadPromptSet(deps: PromptResolveDeps): PromptSet {
|
|
98
|
+
return {
|
|
99
|
+
continuation: resolvePrompt("continuation", deps),
|
|
100
|
+
implement: resolvePrompt("implement", deps),
|
|
101
|
+
report: resolvePrompt("report", deps),
|
|
102
|
+
review: resolvePrompt("review", deps),
|
|
103
|
+
spec: resolvePrompt("spec", deps),
|
|
104
|
+
task: resolvePrompt("task", deps),
|
|
105
|
+
triage: resolvePrompt("triage", deps),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
|
|
110
|
+
|
|
111
|
+
export function renderTemplate(name: string, tpl: string, ctx: Record<string, string>): string {
|
|
112
|
+
return tpl.replace(PLACEHOLDER, (_match, key: string) => {
|
|
113
|
+
if (!Object.prototype.hasOwnProperty.call(ctx, key)) {
|
|
114
|
+
throw new Error(`beflow: unknown placeholder "{{${key}}}" in ${name} prompt`);
|
|
115
|
+
}
|
|
116
|
+
return ctx[key] ?? "";
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildPromptContext(issue: Issue, repo: string): Record<string, string> {
|
|
121
|
+
const type = issue.type ?? "Unspecified";
|
|
122
|
+
const description = issue.body.trim() === "" ? "(no description provided)" : issue.body;
|
|
123
|
+
return {
|
|
124
|
+
description,
|
|
125
|
+
key: issue.key,
|
|
126
|
+
repo,
|
|
127
|
+
title: issue.title,
|
|
128
|
+
type,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderTask(set: PromptSet, issue: Issue, repo: string): string {
|
|
133
|
+
return renderTemplate("task", set.task, buildPromptContext(issue, repo));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function renderContract(set: PromptSet, jobKind: JobKind, issue: Issue, repo: string): string {
|
|
137
|
+
const ctx = buildPromptContext(issue, repo);
|
|
138
|
+
return `${renderTemplate(jobKind, set[jobKind], ctx)}\n\n${renderTemplate("report", set.report, ctx)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function renderReviewContract(set: PromptSet, issue: Issue, repo: string): string {
|
|
142
|
+
return renderTemplate("review", set.review, buildPromptContext(issue, repo));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Render the linked context (parent epic + attachments) appended to the agent
|
|
146
|
+
// Task. Pure and deterministic; returns "" when there is nothing to inline.
|
|
147
|
+
export function renderLinkedContext(ctx: IssueContext): string {
|
|
148
|
+
const { attachments, parent } = ctx;
|
|
149
|
+
if (parent === undefined && attachments.length === 0) {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let out = "\n\n## Linked context\n";
|
|
154
|
+
if (parent !== undefined) {
|
|
155
|
+
const label = parent.type ?? "item";
|
|
156
|
+
out += `\nParent ${label} ${parent.key} "${parent.title}":\n${parent.body || "(no description)"}\n`;
|
|
157
|
+
}
|
|
158
|
+
if (attachments.length > 0) {
|
|
159
|
+
out += "\nAttachments (download URLs are temporary):\n";
|
|
160
|
+
for (const a of attachments) {
|
|
161
|
+
out += a.url !== "" ? `- ${a.name} (${a.url})\n` : `- ${a.name} (no direct download link)\n`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|