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,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { configSchema, fileSchema, registrySchema } from "./schema.ts";
|
|
7
|
+
import type { Config, ConfigFile, Registry } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
function loadFile<T>(path: string, schema: z.ZodType<T, z.ZodTypeDef, unknown>): T {
|
|
10
|
+
let raw: string;
|
|
11
|
+
try {
|
|
12
|
+
raw = readFileSync(path, "utf8");
|
|
13
|
+
} catch {
|
|
14
|
+
throw new Error(`beflow: cannot read config file at ${path}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
22
|
+
throw new Error(`beflow: ${path} is not valid JSON: ${reason}`, { cause: err });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const result = schema.safeParse(parsed);
|
|
26
|
+
if (!result.success) {
|
|
27
|
+
const issues = result.error.issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
28
|
+
throw new Error(`beflow: ${path} failed validation:\n${issues}`);
|
|
29
|
+
}
|
|
30
|
+
return result.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadConfigFile(dir: string): ConfigFile {
|
|
34
|
+
return loadFile(join(dir, "config.json"), fileSchema);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadConfig(dir: string = process.cwd()): Config {
|
|
38
|
+
const file = loadConfigFile(dir);
|
|
39
|
+
return configSchema.parse({ ...file, agents: file.agents ?? {} });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadRegistry(dir: string = process.cwd()): Registry {
|
|
43
|
+
const file = loadConfigFile(dir);
|
|
44
|
+
return registrySchema.parse(file);
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { fileSchema } from "./schema.ts";
|
|
7
|
+
import type { Project } from "./schema.ts";
|
|
8
|
+
|
|
9
|
+
export interface PersistDeps {
|
|
10
|
+
read?: (path: string) => string;
|
|
11
|
+
write?: (path: string, data: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const projectsExtractSchema = z.object({ projects: z.record(z.string(), z.unknown()).optional() });
|
|
15
|
+
|
|
16
|
+
export function addProject(dir: string, key: string, project: Project, deps?: PersistDeps): void {
|
|
17
|
+
const path = join(dir, "config.json");
|
|
18
|
+
|
|
19
|
+
function read(p: string): string {
|
|
20
|
+
return deps?.read ? deps.read(p) : readFileSync(p, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function write(p: string, data: string): void {
|
|
24
|
+
if (deps?.write) {
|
|
25
|
+
deps.write(p, data);
|
|
26
|
+
} else {
|
|
27
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
28
|
+
writeFileSync(p, data, "utf8");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const raw = read(path);
|
|
33
|
+
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
39
|
+
throw new Error(`beflow: ${path} is not valid JSON: ${reason}`, { cause: err });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
43
|
+
throw new Error(`beflow: ${path} must be a JSON object`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { projects: existingProjects } = projectsExtractSchema.parse(parsed);
|
|
47
|
+
|
|
48
|
+
if (existingProjects?.[key] !== undefined) {
|
|
49
|
+
throw new Error(`beflow: project "${key}" already exists in config.json`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const projects: Record<string, unknown> = { ...existingProjects, [key]: project };
|
|
53
|
+
const merged = Object.assign({}, parsed, { projects });
|
|
54
|
+
|
|
55
|
+
fileSchema.parse(merged);
|
|
56
|
+
|
|
57
|
+
write(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
58
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const runModeSchema = z.enum(["autonomous", "supervised"]);
|
|
4
|
+
export const jobKindSchema = z.enum(["triage", "spec", "implement"]);
|
|
5
|
+
|
|
6
|
+
export const projectDefaultsSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
agent: z.string().optional(),
|
|
9
|
+
runMode: runModeSchema.optional(),
|
|
10
|
+
})
|
|
11
|
+
.optional();
|
|
12
|
+
|
|
13
|
+
export const routingSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
implement: z.string().optional(),
|
|
16
|
+
spec: z.string().optional(),
|
|
17
|
+
triage: z.string().optional(),
|
|
18
|
+
})
|
|
19
|
+
.optional();
|
|
20
|
+
|
|
21
|
+
export const projectSchema = z.object({
|
|
22
|
+
default_repo: z.string(),
|
|
23
|
+
defaults: projectDefaultsSchema,
|
|
24
|
+
ci: z.object({ autoReworkOnRed: z.boolean().optional() }).optional(),
|
|
25
|
+
deadLetter: z.object({ maxAttempts: z.number().optional() }).optional(),
|
|
26
|
+
inputQuality: z.object({ minBodyChars: z.number().optional() }).optional(),
|
|
27
|
+
limits: z
|
|
28
|
+
.object({
|
|
29
|
+
inReview: z.number().optional(),
|
|
30
|
+
inProgress: z.number().optional(),
|
|
31
|
+
maxRunMinutes: z.number().optional(),
|
|
32
|
+
})
|
|
33
|
+
.optional(),
|
|
34
|
+
module_repo_map: z.record(z.string(), z.string()),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
plane_project_id: z.string().optional(),
|
|
37
|
+
qualityGate: z.object({ commands: z.array(z.string()).optional() }).optional(),
|
|
38
|
+
repos: z.record(z.string(), z.string()),
|
|
39
|
+
review: z.object({ enabled: z.boolean().optional(), postToPr: z.boolean().optional() }).optional(),
|
|
40
|
+
root: z.string(),
|
|
41
|
+
routing: routingSchema,
|
|
42
|
+
scheduling: z.object({ activeCycleOnly: z.boolean().optional() }).optional(),
|
|
43
|
+
sla: z.object({ inReviewMinutes: z.number().optional(), needsInputMinutes: z.number().optional() }).optional(),
|
|
44
|
+
telemetry: z.object({ inComment: z.boolean().optional() }).optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type Project = z.infer<typeof projectSchema>;
|
|
48
|
+
|
|
49
|
+
export const agentConfigSchema = z.object({
|
|
50
|
+
// Interactive CLI binary used by the `--open` direct spawn. REQUIRED.
|
|
51
|
+
command: z.string(),
|
|
52
|
+
// Extra args appended to the `--open` direct spawn (before the task).
|
|
53
|
+
args: z.array(z.string()).optional(),
|
|
54
|
+
// ACP-server binary used by acpx `--auto`/`--attend`; defaults to `command`.
|
|
55
|
+
acpCommand: z.string().optional(),
|
|
56
|
+
// Args for the ACP server; beflow passes acpx
|
|
57
|
+
// `--agent "<acpCommand ?? command> <acpArgs...>"` for --auto/--attend.
|
|
58
|
+
acpArgs: z.array(z.string()).optional(),
|
|
59
|
+
// Acpx `--model` for `--auto`/`--attend`.
|
|
60
|
+
model: z.string().optional(),
|
|
61
|
+
permissionPolicy: z.unknown().optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export type AgentConfig = z.infer<typeof agentConfigSchema>;
|
|
65
|
+
|
|
66
|
+
export const agentsMapSchema = z.record(z.string(), agentConfigSchema);
|
|
67
|
+
|
|
68
|
+
export const workspaceSchema = z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
slug: z.string(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// The single on-disk shape of config.json: tracker settings + registry
|
|
74
|
+
// (workspace + projects) + the per-agent map, all in one file.
|
|
75
|
+
export const fileSchema = z.object({
|
|
76
|
+
$schema: z.string().optional(),
|
|
77
|
+
_comment: z.string().optional(),
|
|
78
|
+
tracker: z.enum(["plane", "linear"]),
|
|
79
|
+
trackers: z.object({
|
|
80
|
+
linear: z
|
|
81
|
+
.object({
|
|
82
|
+
apiKeyEnv: z.string(),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
plane: z
|
|
86
|
+
.object({
|
|
87
|
+
baseUrl: z.string(),
|
|
88
|
+
workspaceSlug: z.string(),
|
|
89
|
+
apiKeyEnv: z.string(),
|
|
90
|
+
})
|
|
91
|
+
.optional(),
|
|
92
|
+
}),
|
|
93
|
+
defaults: z.object({
|
|
94
|
+
agent: z.string(),
|
|
95
|
+
runMode: runModeSchema,
|
|
96
|
+
// Optional tracker user id; when set, beflow assigns the issue to this user
|
|
97
|
+
// As it picks it up (moves it to In Progress), for both --auto and --attend.
|
|
98
|
+
assignee: z.string().optional(),
|
|
99
|
+
// Unified dead-letter cap: how many accumulated failed attempts (across crash
|
|
100
|
+
// Resume + CI rework) before beflow quarantines the item to Needs Input.
|
|
101
|
+
// Per-project `projects.<KEY>.deadLetter` overrides this global; default 3.
|
|
102
|
+
deadLetter: z.object({ maxAttempts: z.number().optional() }).optional(),
|
|
103
|
+
// Opt-in input-quality gate. When `minBodyChars` > 0, a fresh autonomous
|
|
104
|
+
// Dispatch of a too-thin issue is parked to Needs Input instead of burning an
|
|
105
|
+
// Agent run. Per-project `projects.<KEY>.inputQuality` overrides this global.
|
|
106
|
+
inputQuality: z.object({ minBodyChars: z.number().optional() }).optional(),
|
|
107
|
+
// Inline parent-epic + attachment context into the agent task. Default on; set false to disable.
|
|
108
|
+
linkedContext: z.boolean().optional(),
|
|
109
|
+
// How beflow reacts when a human moves a card out of beflow's hands (out of
|
|
110
|
+
// The started group) while a run is live. `yield` lets the run finish but
|
|
111
|
+
// Skips writeback so the human's move stands; `abort` additionally cancels
|
|
112
|
+
// The agent mid-run. Always present after parse thanks to the default.
|
|
113
|
+
onManualMove: z.enum(["yield", "abort"]).default("yield"),
|
|
114
|
+
// Opt-in quality gate: project check command(s) run in the worktree before an
|
|
115
|
+
// Implement `done` report opens a PR / advances to In Review. On RED beflow
|
|
116
|
+
// Auto-reworks the live agent session once, then re-checks; still-red is failed.
|
|
117
|
+
// Per-project `projects.<KEY>.qualityGate` overrides this global.
|
|
118
|
+
qualityGate: z.object({ commands: z.array(z.string()).optional() }).optional(),
|
|
119
|
+
// Opt-in PR review assist. When `enabled`, watch dispatches a reviewer agent over
|
|
120
|
+
// In-Review items and posts its findings as an issue comment; `postToPr` also posts
|
|
121
|
+
// Them on the PR. Per-project `projects.<KEY>.review` overrides this global.
|
|
122
|
+
review: z.object({ enabled: z.boolean().optional(), postToPr: z.boolean().optional() }).optional(),
|
|
123
|
+
// Opt-in agent routing by jobkind. Keys are jobkind names; values are agent names
|
|
124
|
+
// From config.agents. Per-project `projects.<KEY>.routing` overrides this global.
|
|
125
|
+
routing: routingSchema,
|
|
126
|
+
// Opt-in SLA aging: minutes an item may sit in Needs Input / In Review before
|
|
127
|
+
// Beflow re-pings the escalation channel. Per-project `projects.<KEY>.sla`
|
|
128
|
+
// Overrides this global.
|
|
129
|
+
sla: z.object({ inReviewMinutes: z.number().optional(), needsInputMinutes: z.number().optional() }).optional(),
|
|
130
|
+
// Opt-in run telemetry: when `inComment`, beflow appends a compact token/cost
|
|
131
|
+
// Line to its writeback comment on the issue. Default off. Per-project
|
|
132
|
+
// `projects.<KEY>.telemetry` overrides this global.
|
|
133
|
+
telemetry: z.object({ inComment: z.boolean().optional() }).optional(),
|
|
134
|
+
}),
|
|
135
|
+
// Where `--auto` runs create their per-issue git worktrees. `~` expands to the
|
|
136
|
+
// Home dir; defaults to ~/.beflow/worktrees (outside any repo).
|
|
137
|
+
worktrees: z
|
|
138
|
+
.object({
|
|
139
|
+
dir: z.string(),
|
|
140
|
+
})
|
|
141
|
+
.optional(),
|
|
142
|
+
// When enabled, beflow reads a user `.mcp.json` cascade and injects the
|
|
143
|
+
// Translated servers as a managed `.acpxrc.json` into the agent cwd for
|
|
144
|
+
// Acpx-driven runs (`--auto`/`watch`/`--attend`). Disabled by default.
|
|
145
|
+
mcp: z.object({ enabled: z.boolean().default(false) }).optional(),
|
|
146
|
+
// Where `--auto` runs persist their per-issue run-records so an interrupted
|
|
147
|
+
// Run can resume. `~` expands to home; defaults to ~/.beflow/runs.
|
|
148
|
+
runs: z.object({ dir: z.string() }).optional(),
|
|
149
|
+
// External tool launchers. `acpx` is the command array beflow spawns to run
|
|
150
|
+
// Acpx (command + leading args); defaults to `["bunx", "acpx"]` (bun-first).
|
|
151
|
+
tools: z.object({ acpx: z.array(z.string()).optional() }).optional(),
|
|
152
|
+
// Directory of user-editable prompt templates that override the compiled-in
|
|
153
|
+
// Defaults. `~` expands to home; each `<name>.md` overrides that prompt.
|
|
154
|
+
prompts: z.object({ dir: z.string() }).optional(),
|
|
155
|
+
workspace: workspaceSchema,
|
|
156
|
+
projects: z.record(z.string(), projectSchema),
|
|
157
|
+
agents: agentsMapSchema.optional(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type ConfigFile = z.infer<typeof fileSchema>;
|
|
161
|
+
|
|
162
|
+
// The Config slice consumed by tracker/run/doctor code. `agents` is always
|
|
163
|
+
// Present (loaders default it to {}), so downstream may read config.agents
|
|
164
|
+
// Without an undefined guard.
|
|
165
|
+
export const configSchema = fileSchema
|
|
166
|
+
.omit({
|
|
167
|
+
_comment: true,
|
|
168
|
+
projects: true,
|
|
169
|
+
workspace: true,
|
|
170
|
+
})
|
|
171
|
+
.extend({ agents: agentsMapSchema });
|
|
172
|
+
|
|
173
|
+
export type Config = z.infer<typeof configSchema>;
|
|
174
|
+
|
|
175
|
+
// The Registry slice: workspace + projects.
|
|
176
|
+
export const registrySchema = z.object({
|
|
177
|
+
projects: z.record(z.string(), projectSchema),
|
|
178
|
+
workspace: workspaceSchema,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export type Registry = z.infer<typeof registrySchema>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { watch as fsWatch } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { loadConfig, loadRegistry } from "./load.ts";
|
|
5
|
+
import type { Config, Registry } from "./schema.ts";
|
|
6
|
+
|
|
7
|
+
export interface ConfigSnapshot {
|
|
8
|
+
config: Config;
|
|
9
|
+
registry: Registry;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Injectable file-watcher seam so ConfigStore is testable without real fs.
|
|
13
|
+
export interface ConfigWatcher {
|
|
14
|
+
// Returns an unwatch fn that stops the watcher.
|
|
15
|
+
watch(filePath: string, onChange: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WATCH_DEBOUNCE_MS = 100;
|
|
19
|
+
|
|
20
|
+
export const nodeConfigWatcher: ConfigWatcher = {
|
|
21
|
+
watch(filePath, onChange) {
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
const watcher = fsWatch(filePath, () => {
|
|
24
|
+
// Editors fire multiple events per save; debounce to a single reload.
|
|
25
|
+
if (timer !== undefined) {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
}
|
|
28
|
+
timer = setTimeout(onChange, WATCH_DEBOUNCE_MS);
|
|
29
|
+
});
|
|
30
|
+
return () => {
|
|
31
|
+
if (timer !== undefined) {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
watcher.close();
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface ConfigStoreOpts {
|
|
40
|
+
log?: (m: string) => void;
|
|
41
|
+
loadConfig?: (dir: string) => Config;
|
|
42
|
+
loadRegistry?: (dir: string) => Registry;
|
|
43
|
+
watcher?: ConfigWatcher;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ConfigStore {
|
|
47
|
+
private readonly dir: string;
|
|
48
|
+
private readonly log: (m: string) => void;
|
|
49
|
+
private readonly loadConfig: (dir: string) => Config;
|
|
50
|
+
private readonly loadRegistry: (dir: string) => Registry;
|
|
51
|
+
private readonly watcher: ConfigWatcher;
|
|
52
|
+
private snapshot: ConfigSnapshot | undefined;
|
|
53
|
+
private unwatch: (() => void) | undefined;
|
|
54
|
+
|
|
55
|
+
public constructor(dir: string, opts: ConfigStoreOpts = {}) {
|
|
56
|
+
this.dir = dir;
|
|
57
|
+
this.log =
|
|
58
|
+
opts.log ??
|
|
59
|
+
((): void => {
|
|
60
|
+
/* no-op: logging disabled */
|
|
61
|
+
});
|
|
62
|
+
this.loadConfig = opts.loadConfig ?? loadConfig;
|
|
63
|
+
this.loadRegistry = opts.loadRegistry ?? loadRegistry;
|
|
64
|
+
this.watcher = opts.watcher ?? nodeConfigWatcher;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Eager initial load; a failure here is fatal (the app cannot start without
|
|
68
|
+
// A valid config) so it rethrows.
|
|
69
|
+
public init(): void {
|
|
70
|
+
this.snapshot = this.read();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public get(): ConfigSnapshot {
|
|
74
|
+
if (this.snapshot === undefined) {
|
|
75
|
+
throw new Error("beflow: ConfigStore.get() called before init()");
|
|
76
|
+
}
|
|
77
|
+
return this.snapshot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Re-run the loaders; validate-before-swap. Never throws: a bad save keeps
|
|
81
|
+
// The last-good snapshot and logs a warning.
|
|
82
|
+
public reload(): void {
|
|
83
|
+
let next: ConfigSnapshot;
|
|
84
|
+
try {
|
|
85
|
+
next = this.read();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
88
|
+
this.log(`beflow: config reload failed: ${reason}; keeping previous config`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.snapshot = next;
|
|
92
|
+
this.log("beflow: config reloaded");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public start(): void {
|
|
96
|
+
if (this.unwatch !== undefined) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const filePath = join(this.dir, "config.json");
|
|
100
|
+
this.unwatch = this.watcher.watch(filePath, () => {
|
|
101
|
+
this.reload();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public stop(): void {
|
|
106
|
+
if (this.unwatch === undefined) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.unwatch();
|
|
110
|
+
this.unwatch = undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private read(): ConfigSnapshot {
|
|
114
|
+
return {
|
|
115
|
+
config: this.loadConfig(this.dir),
|
|
116
|
+
registry: this.loadRegistry(this.dir),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IntakeItem, Tracker } from "../trackers/tracker.ts";
|
|
2
|
+
import type { Logger } from "./run.ts";
|
|
3
|
+
|
|
4
|
+
export interface AcceptDeps {
|
|
5
|
+
tracker: Tracker;
|
|
6
|
+
log?: Logger;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function acceptIntake(projectKey: string, intakeId: string, deps: AcceptDeps): Promise<IntakeItem> {
|
|
10
|
+
const log =
|
|
11
|
+
deps.log ??
|
|
12
|
+
((): void => {
|
|
13
|
+
/* no-op: logging disabled */
|
|
14
|
+
});
|
|
15
|
+
const inbox = await deps.tracker.listInbox(projectKey);
|
|
16
|
+
const item = inbox.find((i) => i.id === intakeId);
|
|
17
|
+
if (item === undefined) {
|
|
18
|
+
const available = inbox.map((i) => i.id).join(", ");
|
|
19
|
+
throw new Error(`beflow: unknown intake id "${intakeId}" in ${projectKey} (available: ${available})`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await deps.tracker.acceptInbox(projectKey, item);
|
|
23
|
+
log(`beflow: accepted intake ${intakeId} ("${item.title}") in ${projectKey}`);
|
|
24
|
+
return item;
|
|
25
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Report } from "../agent/report.ts";
|
|
2
|
+
import type { Issue } from "../model/types.ts";
|
|
3
|
+
import type { Comment, Tracker } from "../trackers/tracker.ts";
|
|
4
|
+
import type { PromptSet } from "./prompts.ts";
|
|
5
|
+
import { renderTemplate } from "./prompts.ts";
|
|
6
|
+
import type { RunRecord } from "./runstore.ts";
|
|
7
|
+
|
|
8
|
+
export interface ContinuationContext {
|
|
9
|
+
newComments: Comment[];
|
|
10
|
+
prUrl?: string;
|
|
11
|
+
priorReport?: Report;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AssembleOptions {
|
|
15
|
+
since?: string;
|
|
16
|
+
record?: RunRecord | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function assembleContinuation(
|
|
20
|
+
tracker: Tracker,
|
|
21
|
+
issue: Issue,
|
|
22
|
+
opts: AssembleOptions = {},
|
|
23
|
+
): Promise<ContinuationContext> {
|
|
24
|
+
const all = await tracker.listComments(issue);
|
|
25
|
+
|
|
26
|
+
const newComments = all.filter((c) => {
|
|
27
|
+
if (c.isBot) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (opts.since !== undefined) {
|
|
31
|
+
if (!c.createdAt) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return c.createdAt > opts.since;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
newComments,
|
|
41
|
+
...(opts.record?.prUrl !== undefined ? { prUrl: opts.record.prUrl } : {}),
|
|
42
|
+
...(opts.record?.report !== undefined ? { priorReport: opts.record.report } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderContinuation(prompts: PromptSet, ctx: ContinuationContext): string {
|
|
47
|
+
const priorReport =
|
|
48
|
+
ctx.priorReport !== undefined ? `${ctx.priorReport.status} — ${ctx.priorReport.summary}` : "(none)";
|
|
49
|
+
const prUrl = ctx.prUrl ?? "(none)";
|
|
50
|
+
const reviewComments =
|
|
51
|
+
ctx.newComments.length > 0 ? ctx.newComments.map((c) => `- ${c.body}`).join("\n") : "No new comments.";
|
|
52
|
+
return renderTemplate("continuation", prompts.continuation, {
|
|
53
|
+
pr_url: prUrl,
|
|
54
|
+
prior_report: priorReport,
|
|
55
|
+
review_comments: reviewComments,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Config, Registry } from "../config/schema.ts";
|
|
2
|
+
import type { Issue } from "../model/types.ts";
|
|
3
|
+
import type { Tracker } from "../trackers/tracker.ts";
|
|
4
|
+
import { notifyEscalation } from "./notify.ts";
|
|
5
|
+
import type { Notifier } from "./notify.ts";
|
|
6
|
+
import type { Clock, RunRecord, RunStoreFs } from "./runstore.ts";
|
|
7
|
+
import { saveRecord } from "./runstore.ts";
|
|
8
|
+
|
|
9
|
+
export const QUARANTINED_LABEL = "quarantined";
|
|
10
|
+
|
|
11
|
+
const NEEDS_INPUT_STATE = "Needs Input";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
14
|
+
|
|
15
|
+
/** Universal failure cap: quarantine once the accumulated attempt count reaches the threshold. */
|
|
16
|
+
export function shouldQuarantine(attempts: number, threshold: number): boolean {
|
|
17
|
+
return attempts >= threshold;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Project-over-default-over-3 resolution of the unified dead-letter threshold. */
|
|
21
|
+
export function resolveDeadLetterThreshold(config: Config, registry: Registry, projectKey: string): number {
|
|
22
|
+
const projectMax = registry.projects[projectKey]?.deadLetter?.maxAttempts;
|
|
23
|
+
const globalMax = config.defaults.deadLetter?.maxAttempts;
|
|
24
|
+
return projectMax ?? globalMax ?? DEFAULT_MAX_ATTEMPTS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QuarantineDeps {
|
|
28
|
+
clock: Clock;
|
|
29
|
+
notify?: Notifier;
|
|
30
|
+
record: RunRecord;
|
|
31
|
+
runsDir: string;
|
|
32
|
+
runsFs?: RunStoreFs;
|
|
33
|
+
tracker: Tracker;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Shared dead-letter: label the issue `quarantined`, park it in Needs Input, notify the
|
|
38
|
+
* Escalation channel, and persist the run record as a quarantine hold. Degrade-safe — a
|
|
39
|
+
* Tracker/notify failure must not throw out and halt the watch tick.
|
|
40
|
+
*/
|
|
41
|
+
export async function quarantine(issue: Issue, detail: string, deps: QuarantineDeps): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await deps.tracker.addProperty(issue, QUARANTINED_LABEL);
|
|
44
|
+
await deps.tracker.updateState(issue, NEEDS_INPUT_STATE);
|
|
45
|
+
await deps.tracker.comment(issue, detail);
|
|
46
|
+
await notifyEscalation(deps.notify, issue, "failed", detail);
|
|
47
|
+
} catch {
|
|
48
|
+
// Best-effort escalation: a transient tracker/notify error must not crash the tick.
|
|
49
|
+
}
|
|
50
|
+
saveRecord(
|
|
51
|
+
deps.runsDir,
|
|
52
|
+
{ ...deps.record, heldReason: "quarantine", status: "failed", updatedAt: deps.clock() },
|
|
53
|
+
deps.runsFs,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const NEEDS_DECISION_LABEL = "needs-decision";
|
|
2
|
+
|
|
3
|
+
export const DECISION_HOLD_MESSAGE =
|
|
4
|
+
"This work item is held for a human decision (it carries the `needs-decision` label). Make the call and REMOVE the `needs-decision` label — beflow will then release it back to Todo and proceed. (Leave the rationale as a comment if useful.)";
|
|
5
|
+
|
|
6
|
+
export function isDecisionHeld(labels: string[]): boolean {
|
|
7
|
+
return labels.includes(NEEDS_DECISION_LABEL);
|
|
8
|
+
}
|