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,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
+ }