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,264 @@
1
+ import type { LinearClient } from "@linear/sdk";
2
+
3
+ import type { RawBlocker, RawComment, RawIssue, RawLabel, RawWorkflowState } from "./types.ts";
4
+
5
+ export interface ListIssuesQuery {
6
+ stateName?: string;
7
+ stateType?: string;
8
+ }
9
+
10
+ export interface CreateIssueInput {
11
+ title: string;
12
+ description: string;
13
+ priority?: number;
14
+ labelIds?: string[];
15
+ stateId?: string;
16
+ assigneeId?: string;
17
+ }
18
+
19
+ // The narrow async surface the adapter depends on. The SDK lives ONLY behind
20
+ // LinearSdkGateway; the adapter + mappers depend on this interface so tests
21
+ // Can supply a fake without touching @linear/sdk or the network.
22
+ export interface LinearGateway {
23
+ getIssueByIdentifier(identifier: string): Promise<RawIssue>;
24
+ getBlockers(issueId: string): Promise<RawBlocker[]>;
25
+ createIssue(teamKey: string, input: CreateIssueInput): Promise<RawIssue>;
26
+ listIssues(teamKey: string, query?: ListIssuesQuery): Promise<RawIssue[]>;
27
+ listTriage(teamKey: string): Promise<RawIssue[]>;
28
+ updateIssueState(issueId: string, stateId: string): Promise<void>;
29
+ updateIssueAssignee(issueId: string, assigneeId: string): Promise<void>;
30
+ updateIssueLabels(issueId: string, labelIds: string[]): Promise<void>;
31
+ createComment(issueId: string, body: string): Promise<void>;
32
+ listComments(issueId: string): Promise<RawComment[]>;
33
+ createAttachment(issueId: string, url: string, title: string): Promise<void>;
34
+ listStates(teamKey: string): Promise<RawWorkflowState[]>;
35
+ listLabels(teamKey: string): Promise<RawLabel[]>;
36
+ createState(teamKey: string, state: { name: string; type: StateGroupLike; color: string }): Promise<void>;
37
+ createLabel(teamKey: string, label: { name: string; color?: string }): Promise<void>;
38
+ createTeam(input: { key: string; name: string }): Promise<{ id: string }>;
39
+ deleteLabel(labelId: string): Promise<void>;
40
+ }
41
+
42
+ // beflow's spelling for the cancelled state group. The Linear SDK uses the American
43
+ // spelling "canceled" (one L); fromLinearStateType / toLinearStateType normalize at
44
+ // the gateway boundary so all other code always sees "cancelled".
45
+ export type StateGroupLike = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | "triage";
46
+
47
+ export function fromLinearStateType(type: string): string {
48
+ return type === "canceled" ? "cancelled" : type;
49
+ }
50
+
51
+ export function toLinearStateType(type: string): string {
52
+ return type === "cancelled" ? "canceled" : type;
53
+ }
54
+
55
+ // The SDK Issue type as returned by client.issue() / client.issues().nodes.
56
+ // resolveIssue depends only on the structural subset declared below; deriving
57
+ // the type from the SDK avoids an unsafe narrowing cast.
58
+ type SdkIssueSource = Awaited<ReturnType<LinearClient["issue"]>>;
59
+
60
+ const DEFAULT_STATE_COLOR = "#95a2b3";
61
+ const DEFAULT_LABEL_COLOR = "#bec2c8";
62
+
63
+ export class LinearSdkGateway implements LinearGateway {
64
+ private readonly client: LinearClient;
65
+ private readonly teamIdByKey = new Map<string, string>();
66
+
67
+ public constructor(client: LinearClient) {
68
+ this.client = client;
69
+ }
70
+
71
+ private async resolveIssue(issue: SdkIssueSource): Promise<RawIssue> {
72
+ const [state, labelConn, team] = await Promise.all([issue.state, issue.labels(), issue.team]);
73
+ if (state === undefined) {
74
+ throw new Error(`linear: issue ${issue.identifier} has no state`);
75
+ }
76
+ if (team === undefined) {
77
+ throw new Error(`linear: issue ${issue.identifier} has no team`);
78
+ }
79
+ return {
80
+ archivedAt: issue.archivedAt?.toISOString() ?? null,
81
+ description: issue.description ?? undefined,
82
+ id: issue.id,
83
+ identifier: issue.identifier,
84
+ labels: labelConn.nodes.map((l) => ({ id: l.id, name: l.name })),
85
+ priority: issue.priority,
86
+ state: { id: state.id, name: state.name, type: fromLinearStateType(state.type) },
87
+ team: { id: team.id, key: team.key },
88
+ title: issue.title,
89
+ };
90
+ }
91
+
92
+ private async teamId(teamKey: string): Promise<string> {
93
+ const cached = this.teamIdByKey.get(teamKey);
94
+ if (cached !== undefined) {
95
+ return cached;
96
+ }
97
+ const conn = await this.client.teams({
98
+ filter: { key: { eq: teamKey } },
99
+ });
100
+ const team = conn.nodes[0];
101
+ if (team === undefined) {
102
+ throw new Error(`linear: unknown team key "${teamKey}"`);
103
+ }
104
+ this.teamIdByKey.set(teamKey, team.id);
105
+ return team.id;
106
+ }
107
+
108
+ public async getIssueByIdentifier(identifier: string): Promise<RawIssue> {
109
+ const issue = await this.client.issue(identifier);
110
+ return this.resolveIssue(issue);
111
+ }
112
+
113
+ // The issues that BLOCK this one. An inverseRelation of type "blocks" has its
114
+ // SOURCE issue (node.issue) as the blocker; node.relatedIssue is this issue.
115
+ public async getBlockers(issueId: string): Promise<RawBlocker[]> {
116
+ const issue = await this.client.issue(issueId);
117
+ const inv = await issue.inverseRelations();
118
+ const blockers: RawBlocker[] = [];
119
+ for (const node of inv.nodes) {
120
+ if (node.type !== "blocks") {
121
+ continue;
122
+ }
123
+ const src = await node.issue;
124
+ if (src === undefined) {
125
+ continue;
126
+ }
127
+ const st = await src.state;
128
+ blockers.push({ identifier: src.identifier, stateType: fromLinearStateType(st?.type ?? "") });
129
+ }
130
+ return blockers;
131
+ }
132
+
133
+ public async createIssue(teamKey: string, input: CreateIssueInput): Promise<RawIssue> {
134
+ const payload = await this.client.createIssue({
135
+ assigneeId: input.assigneeId,
136
+ description: input.description,
137
+ labelIds: input.labelIds,
138
+ priority: input.priority,
139
+ stateId: input.stateId,
140
+ teamId: await this.teamId(teamKey),
141
+ title: input.title,
142
+ });
143
+ const issue = await payload.issue;
144
+ if (issue === undefined) {
145
+ throw new Error(`linear: createIssue for team ${teamKey} returned no issue`);
146
+ }
147
+ return this.resolveIssue(issue);
148
+ }
149
+
150
+ public async listIssues(teamKey: string, query: ListIssuesQuery = {}): Promise<RawIssue[]> {
151
+ const filter: Record<string, unknown> = { team: { key: { eq: teamKey } } };
152
+ if (query.stateName !== undefined) {
153
+ filter.state = { name: { eq: query.stateName } };
154
+ } else if (query.stateType !== undefined) {
155
+ filter.state = { type: { eq: toLinearStateType(query.stateType) } };
156
+ }
157
+ return this.fetchIssues(filter);
158
+ }
159
+
160
+ public async listTriage(teamKey: string): Promise<RawIssue[]> {
161
+ return this.fetchIssues({
162
+ state: { type: { eq: "triage" } },
163
+ team: { key: { eq: teamKey } },
164
+ });
165
+ }
166
+
167
+ private async fetchIssues(filter: Record<string, unknown>): Promise<RawIssue[]> {
168
+ const results: RawIssue[] = [];
169
+ let connection = await this.client.issues({ filter, first: 100 });
170
+ for (;;) {
171
+ for (const node of connection.nodes) {
172
+ results.push(await this.resolveIssue(node));
173
+ }
174
+ if (!connection.pageInfo.hasNextPage) {
175
+ break;
176
+ }
177
+ connection = await connection.fetchNext();
178
+ }
179
+ return results;
180
+ }
181
+
182
+ public async updateIssueState(issueId: string, stateId: string): Promise<void> {
183
+ await this.client.updateIssue(issueId, { stateId });
184
+ }
185
+
186
+ public async updateIssueAssignee(issueId: string, assigneeId: string): Promise<void> {
187
+ await this.client.updateIssue(issueId, { assigneeId });
188
+ }
189
+
190
+ public async updateIssueLabels(issueId: string, labelIds: string[]): Promise<void> {
191
+ await this.client.updateIssue(issueId, { labelIds });
192
+ }
193
+
194
+ public async createComment(issueId: string, body: string): Promise<void> {
195
+ await this.client.createComment({ body, issueId });
196
+ }
197
+
198
+ public async listComments(issueId: string): Promise<RawComment[]> {
199
+ const issue = await this.client.issue(issueId);
200
+ const conn = await issue.comments();
201
+ const comments: RawComment[] = [];
202
+ for (const node of conn.nodes) {
203
+ const user = await node.user;
204
+ comments.push({
205
+ ...(user?.id !== undefined ? { authorId: user.id } : {}),
206
+ body: node.body,
207
+ createdAt: node.createdAt.toISOString(),
208
+ id: node.id,
209
+ });
210
+ }
211
+ return comments;
212
+ }
213
+
214
+ public async createAttachment(issueId: string, url: string, title: string): Promise<void> {
215
+ await this.client.createAttachment({ issueId, title, url });
216
+ }
217
+
218
+ public async listStates(teamKey: string): Promise<RawWorkflowState[]> {
219
+ const team = await this.client.team(await this.teamId(teamKey));
220
+ const conn = await team.states();
221
+ return conn.nodes.map((s) => ({ id: s.id, name: s.name, type: fromLinearStateType(s.type) }));
222
+ }
223
+
224
+ public async listLabels(teamKey: string): Promise<RawLabel[]> {
225
+ const team = await this.client.team(await this.teamId(teamKey));
226
+ const conn = await team.labels();
227
+ return conn.nodes.map((l) => ({ id: l.id, name: l.name }));
228
+ }
229
+
230
+ public async createState(
231
+ teamKey: string,
232
+ state: { name: string; type: StateGroupLike; color: string },
233
+ ): Promise<void> {
234
+ await this.client.createWorkflowState({
235
+ color: state.color,
236
+ name: state.name,
237
+ teamId: await this.teamId(teamKey),
238
+ type: toLinearStateType(state.type),
239
+ });
240
+ }
241
+
242
+ public async createLabel(teamKey: string, label: { name: string; color?: string }): Promise<void> {
243
+ await this.client.createIssueLabel({
244
+ color: label.color ?? DEFAULT_LABEL_COLOR,
245
+ name: label.name,
246
+ teamId: await this.teamId(teamKey),
247
+ });
248
+ }
249
+
250
+ public async deleteLabel(labelId: string): Promise<void> {
251
+ await this.client.deleteIssueLabel(labelId);
252
+ }
253
+
254
+ public async createTeam(input: { key: string; name: string }): Promise<{ id: string }> {
255
+ const payload = await this.client.createTeam({ key: input.key, name: input.name });
256
+ const team = await payload.team;
257
+ if (team === undefined) {
258
+ throw new Error(`linear: createTeam "${input.name}" returned no team`);
259
+ }
260
+ return { id: team.id };
261
+ }
262
+ }
263
+
264
+ export { DEFAULT_STATE_COLOR, DEFAULT_LABEL_COLOR };
@@ -0,0 +1,113 @@
1
+ import type { Issue, StateGroup } from "../../model/types.ts";
2
+ import { parseIssueMeta } from "../../resolve/metadata.ts";
3
+ import type { IntakeItem } from "../tracker.ts";
4
+ import type { RawIssue } from "./types.ts";
5
+
6
+ const PRIORITY_RANK: Record<string, number> = {
7
+ high: 1,
8
+ low: 3,
9
+ medium: 2,
10
+ none: 4,
11
+ urgent: 0,
12
+ };
13
+
14
+ export function priorityRank(priority?: string): number {
15
+ if (priority === undefined) {
16
+ return 4;
17
+ }
18
+ const rank = PRIORITY_RANK[priority];
19
+ return rank ?? 4;
20
+ }
21
+
22
+ const PRIORITY_BY_NUMBER: Record<number, string> = {
23
+ 0: "none",
24
+ 1: "urgent",
25
+ 2: "high",
26
+ 3: "medium",
27
+ 4: "low",
28
+ };
29
+
30
+ export function mapPriority(n?: number): string | undefined {
31
+ if (n === undefined) {
32
+ return undefined;
33
+ }
34
+ return PRIORITY_BY_NUMBER[n];
35
+ }
36
+
37
+ const PRIORITY_BY_NAME: Record<string, number> = {
38
+ high: 2,
39
+ low: 4,
40
+ medium: 3,
41
+ none: 0,
42
+ urgent: 1,
43
+ };
44
+
45
+ export function priorityToInt(priority?: string): number | undefined {
46
+ if (priority === undefined) {
47
+ return undefined;
48
+ }
49
+ const value = PRIORITY_BY_NAME[priority];
50
+ if (value === undefined) {
51
+ throw new Error(`linear: unknown priority "${priority}" (expected one of urgent|high|medium|low|none)`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ const VALID_STATE_GROUPS: ReadonlySet<StateGroup> = new Set<StateGroup>([
57
+ "backlog",
58
+ "unstarted",
59
+ "started",
60
+ "completed",
61
+ "cancelled",
62
+ ]);
63
+
64
+ function isStateGroup(type: string): type is StateGroup {
65
+ return (VALID_STATE_GROUPS as ReadonlySet<string>).has(type);
66
+ }
67
+
68
+ export function mapStateType(type: string): StateGroup {
69
+ if (type === "triage") {
70
+ throw new Error(
71
+ "linear: triage states are not queue states (triage issues arrive via listInbox, never as queue issues)",
72
+ );
73
+ }
74
+ if (!isStateGroup(type)) {
75
+ throw new Error(
76
+ `linear: unsupported state type "${type}" (expected one of backlog|unstarted|started|completed|cancelled)`,
77
+ );
78
+ }
79
+ return type;
80
+ }
81
+
82
+ export function mapIssue(raw: RawIssue): Issue {
83
+ const labels = raw.labels.map((l) => l.name);
84
+ const body = raw.description ?? "";
85
+ return {
86
+ archived: raw.archivedAt !== null && raw.archivedAt !== undefined,
87
+ id: raw.id,
88
+ key: raw.identifier,
89
+ title: raw.title,
90
+ body,
91
+ // Linear has no native work-item type in this mapping; left undefined.
92
+ type: undefined,
93
+ state: { group: mapStateType(raw.state.type), name: raw.state.name },
94
+ labels,
95
+ // Linear has no modules; areas === labels so module_repo_map keyed by an
96
+ // Area name still resolves a repo (DESIGN §3).
97
+ areas: labels,
98
+ priority: mapPriority(raw.priority),
99
+ meta: parseIssueMeta(body, labels),
100
+ };
101
+ }
102
+
103
+ export function mapIntakeItem(raw: RawIssue): IntakeItem {
104
+ return {
105
+ id: raw.id,
106
+ // Linear has no numeric intake status; 0 stands for "in triage / pending".
107
+ status: 0,
108
+ title: raw.title,
109
+ body: raw.description ?? "",
110
+ issueId: raw.id,
111
+ priority: mapPriority(raw.priority),
112
+ };
113
+ }
@@ -0,0 +1,44 @@
1
+ // Plain, already-resolved shapes the Linear gateway returns to the adapter +
2
+ // Pure mappers. The SDK exposes state/labels/team as Promises; the gateway
3
+ // Awaits them and hands back these flat objects so map.ts stays sync + pure.
4
+
5
+ export interface RawWorkflowState {
6
+ id: string;
7
+ name: string;
8
+ type: string; // Backlog|unstarted|started|completed|cancelled|triage
9
+ }
10
+
11
+ export interface RawLabel {
12
+ id: string;
13
+ name: string;
14
+ }
15
+
16
+ export interface RawTeam {
17
+ id: string;
18
+ key: string;
19
+ }
20
+
21
+ export interface RawComment {
22
+ authorId?: string;
23
+ body: string;
24
+ createdAt: string; // ISO
25
+ id: string;
26
+ }
27
+
28
+ export interface RawBlocker {
29
+ identifier: string;
30
+ stateType: string; // workflow state type: completed/cancelled ⇒ done
31
+ }
32
+
33
+ // A Linear issue with its async sub-fields already resolved by the gateway.
34
+ export interface RawIssue {
35
+ id: string;
36
+ identifier: string; // Human key, e.g. "ENG-42"
37
+ title: string;
38
+ description?: string; // Markdown, may be undefined
39
+ priority?: number; // 0 none, 1 urgent, 2 high, 3 medium, 4 low
40
+ state: RawWorkflowState;
41
+ labels: RawLabel[];
42
+ team: RawTeam;
43
+ archivedAt?: string | null; // ISO timestamp when archived; null/absent means active
44
+ }
@@ -0,0 +1,20 @@
1
+ export const BEFLOW_MARKER = "\n\n— beflow";
2
+
3
+ export function withMarker(body: string): string {
4
+ if (hasMarker(body)) {
5
+ return body;
6
+ }
7
+ return body + BEFLOW_MARKER;
8
+ }
9
+
10
+ export function hasMarker(text: string): boolean {
11
+ return text.includes("— beflow");
12
+ }
13
+
14
+ export function stripMarker(text: string): string {
15
+ const idx = text.lastIndexOf("\n\n— beflow");
16
+ if (idx === -1) {
17
+ return text.trimEnd();
18
+ }
19
+ return text.slice(0, idx).trimEnd();
20
+ }