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,144 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, 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
|
+
import { expandHome, sanitizeKey } from "./worktree.ts";
|
|
8
|
+
|
|
9
|
+
export const reportSchema = z.object({
|
|
10
|
+
notes: z.string().optional(),
|
|
11
|
+
prUrl: z.string().optional(),
|
|
12
|
+
questions: z.array(z.string()).optional(),
|
|
13
|
+
status: z.enum(["done", "needs_input", "blocked", "failed"]),
|
|
14
|
+
summary: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const usageSchema = z.object({
|
|
18
|
+
cacheReadTokens: z.number().optional(),
|
|
19
|
+
cacheWriteTokens: z.number().optional(),
|
|
20
|
+
costUsd: z.number().optional(),
|
|
21
|
+
inputTokens: z.number().optional(),
|
|
22
|
+
outputTokens: z.number().optional(),
|
|
23
|
+
totalTokens: z.number().optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const runRecordSchema = z.object({
|
|
27
|
+
agent: z.string(),
|
|
28
|
+
attempts: z.number().optional(),
|
|
29
|
+
branch: z.string().optional(),
|
|
30
|
+
ciAttempts: z.number().optional(),
|
|
31
|
+
ciReworkSha: z.string().optional(),
|
|
32
|
+
cwd: z.string(),
|
|
33
|
+
escalatedAt: z.string().optional(),
|
|
34
|
+
heldReason: z.enum(["decision", "quarantine"]).optional(),
|
|
35
|
+
jobKind: z.enum(["triage", "spec", "implement"]),
|
|
36
|
+
key: z.string(),
|
|
37
|
+
prUrl: z.string().optional(),
|
|
38
|
+
repoPath: z.string().optional(),
|
|
39
|
+
report: reportSchema.optional(),
|
|
40
|
+
reviewedSha: z.string().optional(),
|
|
41
|
+
runMode: z.enum(["autonomous", "supervised"]),
|
|
42
|
+
sessionName: z.string(),
|
|
43
|
+
status: z.enum(["in_progress", "done", "needs_input", "blocked", "failed"]),
|
|
44
|
+
tracker: z.enum(["plane", "linear"]).optional(),
|
|
45
|
+
updatedAt: z.string(),
|
|
46
|
+
usage: usageSchema.optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type RunRecord = z.infer<typeof runRecordSchema>;
|
|
50
|
+
|
|
51
|
+
/** Resolve the run-record base dir: the configured value (~-expanded) or the default. */
|
|
52
|
+
export function resolveRunsDir(configured?: string): string {
|
|
53
|
+
return configured !== undefined ? expandHome(configured) : join(homedir(), ".beflow", "runs");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recordPath(runsDir: string, key: string): string {
|
|
57
|
+
return join(runsDir, `${sanitizeKey(key)}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RunStoreFs {
|
|
61
|
+
read(path: string): string | null;
|
|
62
|
+
write(path: string, data: string): void;
|
|
63
|
+
remove(path: string): void;
|
|
64
|
+
list(dir: string): string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const nodeRunStoreFs: RunStoreFs = {
|
|
68
|
+
list(dir) {
|
|
69
|
+
try {
|
|
70
|
+
return readdirSync(dir);
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
read(path) {
|
|
76
|
+
try {
|
|
77
|
+
return readFileSync(path, "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
remove(path) {
|
|
83
|
+
rmSync(path, { force: true });
|
|
84
|
+
},
|
|
85
|
+
write(path, data) {
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
87
|
+
writeFileSync(path, data, "utf8");
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type Clock = () => string;
|
|
92
|
+
|
|
93
|
+
export function systemClock(): string {
|
|
94
|
+
return new Date().toISOString();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function loadRecord(runsDir: string, key: string, fs: RunStoreFs = nodeRunStoreFs): RunRecord | null {
|
|
98
|
+
const raw = fs.read(recordPath(runsDir, key));
|
|
99
|
+
if (raw === null) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let parsed: unknown;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = runRecordSchema.safeParse(parsed);
|
|
111
|
+
return result.success ? result.data : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function listRecords(runsDir: string, fs: RunStoreFs = nodeRunStoreFs): RunRecord[] {
|
|
115
|
+
const records: RunRecord[] = [];
|
|
116
|
+
for (const name of fs.list(runsDir)) {
|
|
117
|
+
if (!name.endsWith(".json")) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const raw = fs.read(join(runsDir, name));
|
|
121
|
+
if (raw === null) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
} catch {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const result = runRecordSchema.safeParse(parsed);
|
|
131
|
+
if (result.success) {
|
|
132
|
+
records.push(result.data);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return records;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function saveRecord(runsDir: string, record: RunRecord, fs: RunStoreFs = nodeRunStoreFs): void {
|
|
139
|
+
fs.write(recordPath(runsDir, record.key), `${JSON.stringify(record, null, 2)}\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function deleteRecord(runsDir: string, key: string, fs: RunStoreFs = nodeRunStoreFs): void {
|
|
143
|
+
fs.remove(recordPath(runsDir, key));
|
|
144
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Usage } from "../agent/events.ts";
|
|
2
|
+
import type { Config, Registry } from "../config/schema.ts";
|
|
3
|
+
import type { RunRecord } from "./runstore.ts";
|
|
4
|
+
|
|
5
|
+
// Project-over-default-over-false resolution of the per-project telemetry-in-comment
|
|
6
|
+
// Toggle. Returns false when neither layer opts in.
|
|
7
|
+
export function resolveTelemetryInComment(config: Config, registry: Registry, projectKey: string): boolean {
|
|
8
|
+
return registry.projects[projectKey]?.telemetry?.inComment ?? config.defaults.telemetry?.inComment ?? false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The token count beflow reports: prefer an explicit total, else derive it from
|
|
12
|
+
// Input+output when both are present. Returns undefined when neither is derivable.
|
|
13
|
+
export function totalTokensOf(usage: Usage): number | undefined {
|
|
14
|
+
if (usage.totalTokens !== undefined) {
|
|
15
|
+
return usage.totalTokens;
|
|
16
|
+
}
|
|
17
|
+
if (usage.inputTokens !== undefined && usage.outputTokens !== undefined) {
|
|
18
|
+
return usage.inputTokens + usage.outputTokens;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Compact one-line telemetry suffix for the writeback comment. Degrade-safe:
|
|
24
|
+
// Returns undefined when usage carries neither a token count nor a cost, so the
|
|
25
|
+
// Caller writes no line. The token segment is omitted when no count is derivable;
|
|
26
|
+
// The cost segment is appended only when the event reported a cost.
|
|
27
|
+
export function formatTelemetryLine(
|
|
28
|
+
usage: Usage,
|
|
29
|
+
model: string | undefined,
|
|
30
|
+
attempts: number | undefined,
|
|
31
|
+
): string | undefined {
|
|
32
|
+
const tokens = totalTokensOf(usage);
|
|
33
|
+
if (tokens === undefined && usage.costUsd === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const segments: string[] = [];
|
|
37
|
+
if (tokens !== undefined) {
|
|
38
|
+
segments.push(`${String(tokens)} tok`);
|
|
39
|
+
}
|
|
40
|
+
segments.push(`model ${model ?? "default"}`);
|
|
41
|
+
segments.push(`attempt ${attempts !== undefined ? String(attempts) : "n"}`);
|
|
42
|
+
let line = `beflow: ${segments.join(" · ")}`;
|
|
43
|
+
if (usage.costUsd !== undefined) {
|
|
44
|
+
line += ` · ~$${usage.costUsd.toFixed(4)}`;
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tokensLabel(usage: Usage | undefined): string {
|
|
50
|
+
if (usage === undefined) {
|
|
51
|
+
return "-";
|
|
52
|
+
}
|
|
53
|
+
const tokens = totalTokensOf(usage);
|
|
54
|
+
return tokens !== undefined ? `${String(tokens)} tok` : "-";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One compact line per record for the list view: key, status, attempts, tokens.
|
|
58
|
+
export function formatRunListLine(record: RunRecord): string {
|
|
59
|
+
const attempts = record.attempts ?? 0;
|
|
60
|
+
return `${record.key} ${record.status} attempts=${String(attempts)} ${tokensLabel(record.usage)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatRunList(records: RunRecord[]): string[] {
|
|
64
|
+
if (records.length === 0) {
|
|
65
|
+
return ["beflow: no run records"];
|
|
66
|
+
}
|
|
67
|
+
return [...records].sort((a, b) => a.key.localeCompare(b.key)).map(formatRunListLine);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Multi-line detail view for a single record. `model` is resolved by the caller
|
|
71
|
+
// (it lives in config, not the record) and may be undefined.
|
|
72
|
+
export function formatRunDetail(record: RunRecord, model: string | undefined): string[] {
|
|
73
|
+
const lines: string[] = [
|
|
74
|
+
`key: ${record.key}`,
|
|
75
|
+
`status: ${record.status}`,
|
|
76
|
+
`attempts: ${String(record.attempts ?? 0)}`,
|
|
77
|
+
`agent: ${record.agent}`,
|
|
78
|
+
`model: ${model ?? "default"}`,
|
|
79
|
+
`jobKind: ${record.jobKind}`,
|
|
80
|
+
`runMode: ${record.runMode}`,
|
|
81
|
+
];
|
|
82
|
+
if (record.usage !== undefined) {
|
|
83
|
+
const tokens = totalTokensOf(record.usage);
|
|
84
|
+
if (tokens !== undefined) {
|
|
85
|
+
lines.push(`tokens: ${String(tokens)}`);
|
|
86
|
+
}
|
|
87
|
+
if (record.usage.inputTokens !== undefined) {
|
|
88
|
+
lines.push(` input: ${String(record.usage.inputTokens)}`);
|
|
89
|
+
}
|
|
90
|
+
if (record.usage.outputTokens !== undefined) {
|
|
91
|
+
lines.push(` output: ${String(record.usage.outputTokens)}`);
|
|
92
|
+
}
|
|
93
|
+
if (record.usage.cacheReadTokens !== undefined) {
|
|
94
|
+
lines.push(` cacheRead: ${String(record.usage.cacheReadTokens)}`);
|
|
95
|
+
}
|
|
96
|
+
if (record.usage.cacheWriteTokens !== undefined) {
|
|
97
|
+
lines.push(` cacheWrite: ${String(record.usage.cacheWriteTokens)}`);
|
|
98
|
+
}
|
|
99
|
+
if (record.usage.costUsd !== undefined) {
|
|
100
|
+
lines.push(`cost: ~$${record.usage.costUsd.toFixed(4)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (record.prUrl !== undefined) {
|
|
104
|
+
lines.push(`prUrl: ${record.prUrl}`);
|
|
105
|
+
}
|
|
106
|
+
if (record.reviewedSha !== undefined) {
|
|
107
|
+
lines.push(`reviewedSha: ${record.reviewedSha}`);
|
|
108
|
+
}
|
|
109
|
+
lines.push(`updatedAt: ${record.updatedAt}`);
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { cancel, confirm, isCancel, select, text } from "@clack/prompts";
|
|
2
|
+
|
|
3
|
+
import { addProject } from "../config/persist.ts";
|
|
4
|
+
import type { Project, Registry } from "../config/schema.ts";
|
|
5
|
+
import type {
|
|
6
|
+
EnsureBoardResult,
|
|
7
|
+
ModuleChange,
|
|
8
|
+
ModuleChangeAction,
|
|
9
|
+
ProjectCreateSpec,
|
|
10
|
+
ResolveModuleChanges,
|
|
11
|
+
Tracker,
|
|
12
|
+
} from "../trackers/tracker.ts";
|
|
13
|
+
import type { Logger } from "./run.ts";
|
|
14
|
+
import { beflowBoardTemplate } from "./template.ts";
|
|
15
|
+
|
|
16
|
+
// The interactive create boundary: given the missing key + active tracker, gather
|
|
17
|
+
// a project-create spec plus the config entry to write back. Injected so the
|
|
18
|
+
// orchestration core stays unit-testable without a TTY.
|
|
19
|
+
export type AskProjectSpec = (ctx: {
|
|
20
|
+
key: string;
|
|
21
|
+
tracker: string;
|
|
22
|
+
}) => Promise<{ spec: ProjectCreateSpec; entry: Project }>;
|
|
23
|
+
|
|
24
|
+
export interface SetupDeps {
|
|
25
|
+
tracker: Tracker;
|
|
26
|
+
trackerName: string;
|
|
27
|
+
registry: Registry;
|
|
28
|
+
agents: string[];
|
|
29
|
+
prune?: boolean;
|
|
30
|
+
log?: Logger;
|
|
31
|
+
resolveModuleChanges?: ResolveModuleChanges;
|
|
32
|
+
askProjectSpec?: AskProjectSpec;
|
|
33
|
+
persist?: (dir: string, key: string, project: Project) => void;
|
|
34
|
+
dir?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function defaultResolveModuleChanges(change: ModuleChange): Promise<Record<string, ModuleChangeAction>> {
|
|
38
|
+
const out: Record<string, ModuleChangeAction> = {};
|
|
39
|
+
const available = [...change.added];
|
|
40
|
+
for (const orphan of change.removed) {
|
|
41
|
+
// Config is the source of truth: a module that is no longer in config was
|
|
42
|
+
// Either renamed to one of the new modules or removed. "Keep" is not offered —
|
|
43
|
+
// It would deliberately re-create the drift beflow exists to reconcile. Ctrl+C
|
|
44
|
+
// Bails the whole reconcile (nothing changes).
|
|
45
|
+
const options = [
|
|
46
|
+
...available.map((name) => ({
|
|
47
|
+
hint: "keep the module and its issues",
|
|
48
|
+
label: `Rename → ${name}`,
|
|
49
|
+
value: `rename:${name}`,
|
|
50
|
+
})),
|
|
51
|
+
{ hint: "delete; its issues become module-less", label: "Remove it", value: "remove" },
|
|
52
|
+
];
|
|
53
|
+
const choice = await select({ message: `Module "${orphan}" is no longer in config — what is it?`, options });
|
|
54
|
+
if (isCancel(choice)) {
|
|
55
|
+
cancel(`beflow: module reconcile cancelled — nothing changed for "${orphan}"`);
|
|
56
|
+
throw new Error("beflow: module reconcile cancelled");
|
|
57
|
+
}
|
|
58
|
+
if (typeof choice === "string" && choice.startsWith("rename:")) {
|
|
59
|
+
const to = choice.slice("rename:".length);
|
|
60
|
+
out[orphan] = { kind: "rename", to };
|
|
61
|
+
const idx = available.indexOf(to);
|
|
62
|
+
if (idx !== -1) {
|
|
63
|
+
available.splice(idx, 1);
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
out[orphan] = { kind: "remove" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Shared cancel path for the create prompts: a Ctrl-C aborts cleanly so no
|
|
73
|
+
// project is created or written back. Like the defaults above, not unit-tested.
|
|
74
|
+
function cancelledCreate(): never {
|
|
75
|
+
cancel("Cancelled.");
|
|
76
|
+
throw new Error("beflow: project creation cancelled");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function askText(message: string, opts?: { defaultValue?: string }): Promise<string> {
|
|
80
|
+
const value = await text({
|
|
81
|
+
message,
|
|
82
|
+
validate: (v: string | undefined): string | undefined => ((v ?? "").trim() === "" ? "Required" : undefined),
|
|
83
|
+
...(opts?.defaultValue !== undefined
|
|
84
|
+
? { defaultValue: opts.defaultValue, placeholder: opts.defaultValue }
|
|
85
|
+
: {}),
|
|
86
|
+
});
|
|
87
|
+
if (isCancel(value)) {
|
|
88
|
+
cancelledCreate();
|
|
89
|
+
}
|
|
90
|
+
return value.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function askYes(message: string): Promise<boolean> {
|
|
94
|
+
const value = await confirm({ message });
|
|
95
|
+
if (isCancel(value)) {
|
|
96
|
+
cancelledCreate();
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The clack-backed default asker: prompts for the project name + identifier, the
|
|
102
|
+
// repo map (default repo + extras), and optional module→repo entries, then
|
|
103
|
+
// returns the create spec plus the config entry (the orchestrator fills
|
|
104
|
+
// plane_project_id after the tracker create).
|
|
105
|
+
export async function defaultAskProjectSpec(ctx: {
|
|
106
|
+
key: string;
|
|
107
|
+
tracker: string;
|
|
108
|
+
}): Promise<{ spec: ProjectCreateSpec; entry: Project }> {
|
|
109
|
+
const name = await askText("Project name");
|
|
110
|
+
const identifier = await askText("Project identifier", { defaultValue: ctx.key });
|
|
111
|
+
const root = await askText("Project root (absolute path)");
|
|
112
|
+
|
|
113
|
+
const defaultRepoKey = await askText("Default repo key");
|
|
114
|
+
const repos: Record<string, string> = {
|
|
115
|
+
[defaultRepoKey]: await askText(`Absolute path for repo "${defaultRepoKey}"`),
|
|
116
|
+
};
|
|
117
|
+
while (await askYes("Add another repo?")) {
|
|
118
|
+
const repoKey = await askText("Repo key");
|
|
119
|
+
repos[repoKey] = await askText(`Absolute path for repo "${repoKey}"`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const repoKeys = Object.keys(repos);
|
|
123
|
+
const moduleRepoMap: Record<string, string> = {};
|
|
124
|
+
while (await askYes("Add a module → repo mapping?")) {
|
|
125
|
+
const moduleName = await askText("Module name");
|
|
126
|
+
const repoKey = await text({
|
|
127
|
+
message: `Repo key for module "${moduleName}"`,
|
|
128
|
+
validate: (v: string | undefined): string | undefined => {
|
|
129
|
+
const trimmed = (v ?? "").trim();
|
|
130
|
+
if (trimmed === "") {
|
|
131
|
+
return "Required";
|
|
132
|
+
}
|
|
133
|
+
return repoKeys.includes(trimmed) ? undefined : `Unknown repo key — one of: ${repoKeys.join(", ")}`;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
if (isCancel(repoKey)) {
|
|
137
|
+
cancelledCreate();
|
|
138
|
+
}
|
|
139
|
+
moduleRepoMap[moduleName] = repoKey.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
entry: {
|
|
144
|
+
default_repo: defaultRepoKey,
|
|
145
|
+
module_repo_map: moduleRepoMap,
|
|
146
|
+
name,
|
|
147
|
+
repos,
|
|
148
|
+
root,
|
|
149
|
+
},
|
|
150
|
+
spec: { identifier, name },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function setupProject(projectKey: string, deps: SetupDeps): Promise<EnsureBoardResult> {
|
|
155
|
+
const log =
|
|
156
|
+
deps.log ??
|
|
157
|
+
((): void => {
|
|
158
|
+
/* no-op: logging disabled */
|
|
159
|
+
});
|
|
160
|
+
if (deps.registry.projects[projectKey] === undefined) {
|
|
161
|
+
const ask = deps.askProjectSpec ?? (process.stdin.isTTY ? defaultAskProjectSpec : undefined);
|
|
162
|
+
if (ask === undefined) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`beflow: project "${projectKey}" is not in config.json; run setup in an interactive terminal to create it`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const { entry, spec } = await ask({ key: projectKey, tracker: deps.trackerName });
|
|
168
|
+
const { trackerProjectId } = await deps.tracker.createProject(spec);
|
|
169
|
+
if (deps.trackerName === "plane" && trackerProjectId !== undefined) {
|
|
170
|
+
entry.plane_project_id = trackerProjectId;
|
|
171
|
+
}
|
|
172
|
+
(deps.persist ?? addProject)(deps.dir ?? process.cwd(), projectKey, entry);
|
|
173
|
+
// The tracker holds a reference to this same registry object; mutate it in
|
|
174
|
+
// place so ensureBoard below sees the freshly created project.
|
|
175
|
+
deps.registry.projects[projectKey] = entry;
|
|
176
|
+
log(`beflow: created project ${projectKey} (${spec.identifier}) in ${deps.trackerName}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const template = beflowBoardTemplate(deps.registry, projectKey, deps.agents);
|
|
180
|
+
const resolveModuleChanges =
|
|
181
|
+
deps.resolveModuleChanges ?? (process.stdin.isTTY ? defaultResolveModuleChanges : undefined);
|
|
182
|
+
const result = await deps.tracker.ensureBoard(projectKey, template, {
|
|
183
|
+
prune: deps.prune,
|
|
184
|
+
...(resolveModuleChanges !== undefined ? { resolveModuleChanges } : {}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
log(
|
|
188
|
+
`beflow: setup ${projectKey} — ${String(result.created.length)} created, ${String(result.updated.length)} updated, ${String(result.skipped.length)} skipped, ${String(result.pruned.length)} pruned`,
|
|
189
|
+
);
|
|
190
|
+
for (const warning of result.warnings) {
|
|
191
|
+
log(`beflow: warning: ${warning}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (deps.prune !== true && result.orphans.length > 0) {
|
|
195
|
+
for (const orphan of result.orphans) {
|
|
196
|
+
log(
|
|
197
|
+
`beflow: orphan ${orphan} exists in Plane but not in config; run 'beflow update ${projectKey} --prune' to remove it`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return result;
|
|
203
|
+
}
|
package/src/core/sla.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Config, Registry } from "../config/schema.ts";
|
|
2
|
+
import type { RunRecord } from "./runstore.ts";
|
|
3
|
+
|
|
4
|
+
export interface SlaThresholds {
|
|
5
|
+
inReviewMinutes?: number;
|
|
6
|
+
needsInputMinutes?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveSla(config: Config, registry: Registry, projectKey: string): SlaThresholds {
|
|
10
|
+
const projectSla = registry.projects[projectKey]?.sla;
|
|
11
|
+
const globalSla = config.defaults.sla;
|
|
12
|
+
const inReviewMinutes = projectSla?.inReviewMinutes ?? globalSla?.inReviewMinutes;
|
|
13
|
+
const needsInputMinutes = projectSla?.needsInputMinutes ?? globalSla?.needsInputMinutes;
|
|
14
|
+
return {
|
|
15
|
+
...(inReviewMinutes !== undefined ? { inReviewMinutes } : {}),
|
|
16
|
+
...(needsInputMinutes !== undefined ? { needsInputMinutes } : {}),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ageMinutes(nowIso: string, sinceIso: string): number {
|
|
21
|
+
return (Date.parse(nowIso) - Date.parse(sinceIso)) / 60000;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatAge(minutes: number): string {
|
|
25
|
+
if (minutes < 60) {
|
|
26
|
+
return `${String(Math.round(minutes))}m`;
|
|
27
|
+
}
|
|
28
|
+
if (minutes < 1440) {
|
|
29
|
+
return `${String(Math.round(minutes / 60))}h`;
|
|
30
|
+
}
|
|
31
|
+
return `${String(Math.round(minutes / 1440))}d`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function shouldRemind(nowIso: string, record: RunRecord, thresholdMinutes: number): boolean {
|
|
35
|
+
if (ageMinutes(nowIso, record.updatedAt) < thresholdMinutes) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return record.escalatedAt === undefined || ageMinutes(nowIso, record.escalatedAt) >= thresholdMinutes;
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Registry } from "../config/schema.ts";
|
|
2
|
+
import type { BoardTemplate } from "../trackers/tracker.ts";
|
|
3
|
+
|
|
4
|
+
const STATES: BoardTemplate["states"] = [
|
|
5
|
+
{ color: "#60646C", group: "backlog", name: "Backlog", sequence: 15000 },
|
|
6
|
+
{ color: "#60646C", group: "unstarted", name: "Todo", sequence: 25000 },
|
|
7
|
+
{ color: "#F59E0B", group: "started", name: "In Progress", sequence: 35000 },
|
|
8
|
+
{ color: "#EC4899", group: "started", name: "Needs Input", sequence: 37500 },
|
|
9
|
+
{ color: "#3B82F6", group: "started", name: "In Review", sequence: 40000 },
|
|
10
|
+
{ color: "#46A758", group: "completed", name: "Done", sequence: 45000 },
|
|
11
|
+
{ color: "#9AA4BC", group: "cancelled", name: "Cancelled", sequence: 55000 },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const TYPES: BoardTemplate["types"] = [
|
|
15
|
+
{
|
|
16
|
+
description: "A defect with a known or to-be-found root cause. Ready to fix.",
|
|
17
|
+
name: "Bug",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
description: "New capability. May start as an idea, gets specced before build.",
|
|
21
|
+
name: "Feature",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
description: "Refactor, dependency update, maintenance. No new behavior.",
|
|
25
|
+
name: "Chore",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
description: "Time-boxed investigation. Output is findings/decision, not shipped code.",
|
|
29
|
+
name: "Spike",
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Functional/manual labels plus the runMode "picker" labels. The agent:<name>
|
|
34
|
+
// Pickers are appended per-project from the configured agent list.
|
|
35
|
+
const LABELS: BoardTemplate["labels"] = [
|
|
36
|
+
{ color: "#EF4444", name: "blocked" },
|
|
37
|
+
{ color: "#B91C1C", name: "failed" },
|
|
38
|
+
{ color: "#6B7280", name: "quarantined" },
|
|
39
|
+
{ color: "#14B8A6", name: "triaged" },
|
|
40
|
+
{ color: "#F59E0B", name: "needs-decision" },
|
|
41
|
+
{ color: "#8B5CF6", name: "customer-reported" },
|
|
42
|
+
{ color: "#F97316", name: "changes-requested" },
|
|
43
|
+
{ color: "#F59E0B", name: "run:autonomous" },
|
|
44
|
+
{ color: "#3B82F6", name: "run:supervised" },
|
|
45
|
+
{ color: "#A78BFA", name: "jobkind:triage" },
|
|
46
|
+
{ color: "#A78BFA", name: "jobkind:spec" },
|
|
47
|
+
{ color: "#A78BFA", name: "jobkind:implement" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function beflowBoardTemplate(registry: Registry, projectKey: string, agents: string[]): BoardTemplate {
|
|
51
|
+
const project = registry.projects[projectKey];
|
|
52
|
+
if (project === undefined) {
|
|
53
|
+
const known = Object.keys(registry.projects).join(", ");
|
|
54
|
+
throw new Error(`beflow: unknown project key "${projectKey}" (known: ${known})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const modules: BoardTemplate["modules"] = Object.keys(project.module_repo_map).map((name) => ({ name }));
|
|
58
|
+
|
|
59
|
+
const labels: BoardTemplate["labels"] = [
|
|
60
|
+
...LABELS,
|
|
61
|
+
...agents.map((name) => ({ color: "#10B981", name: `agent:${name}` })),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
return { labels, modules, states: STATES, types: TYPES };
|
|
65
|
+
}
|