beflow 0.1.0 → 0.2.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/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import type { ArgsDef, CommandContext, CommandDef } from "citty";
8
8
  import { AcpxDriver, resolveAcpCommand, resolveAcpxCommand } from "./agent/acpx.ts";
9
9
  import type { AgentDriver } from "./agent/driver.ts";
10
10
  import { loadConfig, loadRegistry } from "./config/load.ts";
11
+ import { configDir } from "./config/paths.ts";
11
12
  import type { Config, Registry } from "./config/schema.ts";
12
13
  import { ConfigStore, nodeConfigWatcher } from "./config/store.ts";
13
14
  import type { ConfigWatcher } from "./config/store.ts";
@@ -172,9 +173,9 @@ interface CliContext {
172
173
  // Builds the CliContext shared by every command that needs a tracker + prompts.
173
174
  // Config is loaded here (inside command `run` handlers) rather than in `runCli`
174
175
  // so that `--help` — which citty resolves without invoking `run` — works even
175
- // when config.json is missing or invalid.
176
+ // when ~/beflow/config.json is missing or invalid.
176
177
  function loadContext(deps: CliDeps, log: (msg: string) => void, fail: (msg: string) => number): CliContext {
177
- const dir = deps.cwd ?? process.cwd();
178
+ const dir = deps.cwd ?? configDir();
178
179
  const config = deps.loadConfig(dir);
179
180
  const registry = deps.loadRegistry(dir);
180
181
  const tracker = deps.createTracker(config, registry);
@@ -356,7 +357,7 @@ function buildCli(deps: CliDeps): Cli {
356
357
  cmdGc(
357
358
  { force: asBool(args.force), olderThan: asStr(args["older-than"]), prune: asBool(args.prune) },
358
359
  deps,
359
- deps.cwd ?? process.cwd(),
360
+ deps.cwd ?? configDir(),
360
361
  makeLog(deps),
361
362
  ),
362
363
  });
@@ -581,7 +582,7 @@ function cmdRuns(args: { key?: string | undefined }, ctx: CliContext): number {
581
582
 
582
583
  async function cmdSetup(args: { project: string; prune?: boolean | undefined }, ctx: CliContext): Promise<number> {
583
584
  const { tracker, config, registry, dir, log } = ctx;
584
- const agents = [...new Set([config.defaults.agent, ...Object.keys(config.agents)])].sort();
585
+ const agents = [...new Set([config.agent, ...Object.keys(config.agents)])].sort();
585
586
  await setupProject(args.project, {
586
587
  agents,
587
588
  dir,
@@ -732,7 +733,7 @@ function resolveEnrich(project: string, ctx: CliContext): EnrichIssue | undefine
732
733
  }
733
734
  const enrichPrompt = loadEnrichPrompt(defaultPromptResolveDeps(ctx.dir, ctx.config.prompts?.dir));
734
735
  return defaultEnrichIssue({
735
- defaultAgent: ctx.config.defaults.agent,
736
+ defaultAgent: ctx.config.agent,
736
737
  driver: ctx.deps.createDriver(resolveAcpxCommand(ctx.config)),
737
738
  enrichPrompt,
738
739
  log: ctx.log,
@@ -806,7 +807,7 @@ async function cmdGc(
806
807
  async function boardChecks(deps: CliDeps, dir: string): Promise<DoctorCheck[]> {
807
808
  const config = deps.loadConfig(dir);
808
809
  const registry = deps.loadRegistry(dir);
809
- const agents = [...new Set([config.defaults.agent, ...Object.keys(config.agents)])].sort();
810
+ const agents = [...new Set([config.agent, ...Object.keys(config.agents)])].sort();
810
811
  const tracker = deps.createTracker(config, registry);
811
812
 
812
813
  const checks: DoctorCheck[] = [];
@@ -1,8 +1,9 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
 
4
4
  import type { z } from "zod";
5
5
 
6
+ import { CONFIG_BOOTSTRAP, configDir, configPath } from "./paths.ts";
6
7
  import { configSchema, fileSchema, registrySchema } from "./schema.ts";
7
8
  import type { Config, ConfigFile, Registry } from "./schema.ts";
8
9
 
@@ -10,7 +11,20 @@ function loadFile<T>(path: string, schema: z.ZodType<T, z.ZodTypeDef, unknown>):
10
11
  let raw: string;
11
12
  try {
12
13
  raw = readFileSync(path, "utf8");
13
- } catch {
14
+ } catch (err) {
15
+ if (
16
+ path === configPath() &&
17
+ typeof err === "object" &&
18
+ err !== null &&
19
+ "code" in err &&
20
+ err.code === "ENOENT"
21
+ ) {
22
+ mkdirSync(dirname(path), { recursive: true });
23
+ writeFileSync(path, CONFIG_BOOTSTRAP, "utf8");
24
+ throw new Error(
25
+ `beflow: created ${path} from the built-in template — fill in your workspace details and re-run`,
26
+ );
27
+ }
14
28
  throw new Error(`beflow: cannot read config file at ${path}`);
15
29
  }
16
30
 
@@ -34,12 +48,12 @@ function loadConfigFile(dir: string): ConfigFile {
34
48
  return loadFile(join(dir, "config.json"), fileSchema);
35
49
  }
36
50
 
37
- export function loadConfig(dir: string = process.cwd()): Config {
51
+ export function loadConfig(dir: string = configDir()): Config {
38
52
  const file = loadConfigFile(dir);
39
53
  return configSchema.parse({ ...file, agents: file.agents ?? {} });
40
54
  }
41
55
 
42
- export function loadRegistry(dir: string = process.cwd()): Registry {
56
+ export function loadRegistry(dir: string = configDir()): Registry {
43
57
  const file = loadConfigFile(dir);
44
58
  return registrySchema.parse(file);
45
59
  }
@@ -0,0 +1,41 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function configDir(): string {
5
+ return join(homedir(), "beflow");
6
+ }
7
+
8
+ export function configPath(): string {
9
+ return join(configDir(), "config.json");
10
+ }
11
+
12
+ export const CONFIG_BOOTSTRAP: string =
13
+ JSON.stringify(
14
+ {
15
+ $schema: "https://raw.githubusercontent.com/corrm/beflow/main/config.schema.json",
16
+ agent: "claude",
17
+ agents: {
18
+ claude: {
19
+ args: ["--dangerously-skip-permissions"],
20
+ command: "claude",
21
+ },
22
+ },
23
+ projects: {},
24
+ runMode: "supervised",
25
+ tracker: "plane",
26
+ trackers: {
27
+ linear: { apiKeyEnv: "LINEAR_API_KEY" },
28
+ plane: {
29
+ apiKeyEnv: "PLANE_API_KEY",
30
+ baseUrl: "https://api.plane.so",
31
+ workspaceSlug: "your-workspace",
32
+ },
33
+ },
34
+ workspace: {
35
+ id: "your-workspace-id",
36
+ slug: "your-workspace",
37
+ },
38
+ },
39
+ null,
40
+ 2,
41
+ ) + "\n";
@@ -46,7 +46,7 @@ export function addProject(dir: string, key: string, project: Project, deps?: Pe
46
46
  const { projects: existingProjects } = projectsExtractSchema.parse(parsed);
47
47
 
48
48
  if (existingProjects?.[key] !== undefined) {
49
- throw new Error(`beflow: project "${key}" already exists in config.json`);
49
+ throw new Error(`beflow: project "${key}" already exists in ${path}`);
50
50
  }
51
51
 
52
52
  const projects: Record<string, unknown> = { ...existingProjects, [key]: project };
@@ -3,13 +3,6 @@ import { z } from "zod";
3
3
  export const runModeSchema = z.enum(["autonomous", "supervised"]);
4
4
  export const jobKindSchema = z.enum(["triage", "spec", "implement"]);
5
5
 
6
- export const projectDefaultsSchema = z
7
- .object({
8
- agent: z.string().optional(),
9
- runMode: runModeSchema.optional(),
10
- })
11
- .optional();
12
-
13
6
  export const routingSchema = z
14
7
  .object({
15
8
  implement: z.string().optional(),
@@ -18,9 +11,47 @@ export const routingSchema = z
18
11
  })
19
12
  .optional();
20
13
 
14
+ export const prOwnerSchema = z.enum(["beflow", "agent"]);
15
+
16
+ export const prSchema = z
17
+ .object({
18
+ owner: prOwnerSchema.optional(),
19
+ baseBranch: z.string().optional(),
20
+ })
21
+ .optional();
22
+
23
+ export const policyEvaluatorSchema = z.enum(["globs", "command", "agentowners", "off"]);
24
+ export const policyDecisionSchema = z.enum(["block", "require_approval", "allow"]);
25
+ export const policyOnBlockSchema = z.enum(["comment"]);
26
+
27
+ export const policyRuleSchema = z.object({
28
+ paths: z.array(z.string()).optional(),
29
+ agent: z.string().optional(),
30
+ decision: policyDecisionSchema,
31
+ });
32
+
33
+ export const policySchema = z
34
+ .object({
35
+ evaluator: policyEvaluatorSchema.optional(),
36
+ command: z.array(z.string()).optional(),
37
+ rules: z.array(policyRuleSchema).optional(),
38
+ agentownersPath: z.string().optional(),
39
+ onBlock: policyOnBlockSchema.optional(),
40
+ })
41
+ .optional();
42
+
43
+ export type PrConfig = z.infer<typeof prSchema>;
44
+ export type PrOwner = z.infer<typeof prOwnerSchema>;
45
+ export type PolicyConfig = z.infer<typeof policySchema>;
46
+ export type PolicyEvaluator = z.infer<typeof policyEvaluatorSchema>;
47
+ export type PolicyDecision = z.infer<typeof policyDecisionSchema>;
48
+ export type PolicyOnBlock = z.infer<typeof policyOnBlockSchema>;
49
+ export type PolicyRule = z.infer<typeof policyRuleSchema>;
50
+
21
51
  export const projectSchema = z.object({
22
52
  default_repo: z.string(),
23
- defaults: projectDefaultsSchema,
53
+ agent: z.string().optional(),
54
+ runMode: runModeSchema.optional(),
24
55
  ci: z.object({ autoReworkOnRed: z.boolean().optional() }).optional(),
25
56
  deadLetter: z.object({ maxAttempts: z.number().optional() }).optional(),
26
57
  inputQuality: z.object({ minBodyChars: z.number().optional() }).optional(),
@@ -34,6 +65,8 @@ export const projectSchema = z.object({
34
65
  module_repo_map: z.record(z.string(), z.string()),
35
66
  name: z.string(),
36
67
  plane_project_id: z.string().optional(),
68
+ policy: policySchema,
69
+ pr: prSchema,
37
70
  qualityGate: z.object({ commands: z.array(z.string()).optional() }).optional(),
38
71
  repos: z.record(z.string(), z.string()),
39
72
  review: z.object({ enabled: z.boolean().optional(), postToPr: z.boolean().optional() }).optional(),
@@ -90,48 +123,51 @@ export const fileSchema = z.object({
90
123
  })
91
124
  .optional(),
92
125
  }),
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
- }),
126
+ agent: z.string(),
127
+ runMode: runModeSchema,
128
+ // Optional tracker user id; when set, beflow assigns the issue to this user
129
+ // As it picks it up (moves it to In Progress), for both --auto and --attend.
130
+ assignee: z.string().optional(),
131
+ // Unified dead-letter cap: how many accumulated failed attempts (across crash
132
+ // Resume + CI rework) before beflow quarantines the item to Needs Input.
133
+ // Per-project `projects.<KEY>.deadLetter` overrides this global; default 3.
134
+ deadLetter: z.object({ maxAttempts: z.number().optional() }).optional(),
135
+ // Opt-in input-quality gate. When `minBodyChars` > 0, a fresh autonomous
136
+ // Dispatch of a too-thin issue is parked to Needs Input instead of burning an
137
+ // Agent run. Per-project `projects.<KEY>.inputQuality` overrides this global.
138
+ inputQuality: z.object({ minBodyChars: z.number().optional() }).optional(),
139
+ // Inline parent-epic + attachment context into the agent task. Default on; set false to disable.
140
+ linkedContext: z.boolean().optional(),
141
+ // How beflow reacts when a human moves a card out of beflow's hands (out of
142
+ // The started group) while a run is live. `yield` lets the run finish but
143
+ // Skips writeback so the human's move stands; `abort` additionally cancels
144
+ // The agent mid-run. Always present after parse thanks to the default.
145
+ onManualMove: z.enum(["yield", "abort"]).default("yield"),
146
+ // PR mechanics. `owner` decides whether beflow or the agent opens the PR
147
+ // (default `agent`, the current back-compat behavior); `baseBranch` is the
148
+ // Target branch (`auto` detect the repo default branch at runtime).
149
+ // Per-project `projects.<KEY>.pr` overrides this global.
150
+ pr: prSchema,
151
+ // Opt-in quality gate: project check command(s) run in the worktree before an
152
+ // Implement `done` report opens a PR / advances to In Review. On RED beflow
153
+ // Auto-reworks the live agent session once, then re-checks; still-red is failed.
154
+ // Per-project `projects.<KEY>.qualityGate` overrides this global.
155
+ qualityGate: z.object({ commands: z.array(z.string()).optional() }).optional(),
156
+ // Opt-in PR review assist. When `enabled`, watch dispatches a reviewer agent over
157
+ // In-Review items and posts its findings as an issue comment; `postToPr` also posts
158
+ // Them on the PR. Per-project `projects.<KEY>.review` overrides this global.
159
+ review: z.object({ enabled: z.boolean().optional(), postToPr: z.boolean().optional() }).optional(),
160
+ // Opt-in agent routing by jobkind. Keys are jobkind names; values are agent names
161
+ // From config.agents. Per-project `projects.<KEY>.routing` overrides this global.
162
+ routing: routingSchema,
163
+ // Opt-in SLA aging: minutes an item may sit in Needs Input / In Review before
164
+ // Beflow re-pings the escalation channel. Per-project `projects.<KEY>.sla`
165
+ // Overrides this global.
166
+ sla: z.object({ inReviewMinutes: z.number().optional(), needsInputMinutes: z.number().optional() }).optional(),
167
+ // Opt-in run telemetry: when `inComment`, beflow appends a compact token/cost
168
+ // Line to its writeback comment on the issue. Default off. Per-project
169
+ // `projects.<KEY>.telemetry` overrides this global.
170
+ telemetry: z.object({ inComment: z.boolean().optional() }).optional(),
135
171
  // Where `--auto` runs create their per-issue git worktrees. `~` expands to the
136
172
  // Home dir; defaults to ~/.beflow/worktrees (outside any repo).
137
173
  worktrees: z
@@ -152,6 +188,12 @@ export const fileSchema = z.object({
152
188
  // Directory of user-editable prompt templates that override the compiled-in
153
189
  // Defaults. `~` expands to home; each `<name>.md` overrides that prompt.
154
190
  prompts: z.object({ dir: z.string() }).optional(),
191
+ // Opt-in post-run policy gate. `evaluator` selects how a finished run's diff is
192
+ // Judged before its PR is accepted: `globs` matches changed paths against `rules`,
193
+ // `command` shells out to an external argv, `off` disables the gate (default).
194
+ // `onBlock` is how a block surfaces (only `comment` for now). Per-project
195
+ // `projects.<KEY>.policy` overrides this global wholesale.
196
+ policy: policySchema,
155
197
  workspace: workspaceSchema,
156
198
  projects: z.record(z.string(), projectSchema),
157
199
  agents: agentsMapSchema.optional(),
@@ -5,6 +5,11 @@ import type { PromptSet } from "./prompts.ts";
5
5
  import { renderTemplate } from "./prompts.ts";
6
6
  import type { RunRecord } from "./runstore.ts";
7
7
 
8
+ const AGENT_OWNED_PR_CONTINUATION_INSTRUCTION =
9
+ "If a pull request is already open for this item, UPDATE it — do not open a new one.";
10
+ const BEFLOW_OWNED_PR_CONTINUATION_INSTRUCTION =
11
+ "Push your changes. The existing pull request updates automatically — do NOT run `gh pr create` or `gh pr edit`.";
12
+
8
13
  export interface ContinuationContext {
9
14
  newComments: Comment[];
10
15
  prUrl?: string;
@@ -43,13 +48,16 @@ export async function assembleContinuation(
43
48
  };
44
49
  }
45
50
 
46
- export function renderContinuation(prompts: PromptSet, ctx: ContinuationContext): string {
51
+ export function renderContinuation(prompts: PromptSet, ctx: ContinuationContext, beflowOwnsPr = false): string {
47
52
  const priorReport =
48
53
  ctx.priorReport !== undefined ? `${ctx.priorReport.status} — ${ctx.priorReport.summary}` : "(none)";
49
54
  const prUrl = ctx.prUrl ?? "(none)";
50
55
  const reviewComments =
51
56
  ctx.newComments.length > 0 ? ctx.newComments.map((c) => `- ${c.body}`).join("\n") : "No new comments.";
52
57
  return renderTemplate("continuation", prompts.continuation, {
58
+ pr_continuation_instruction: beflowOwnsPr
59
+ ? BEFLOW_OWNED_PR_CONTINUATION_INSTRUCTION
60
+ : AGENT_OWNED_PR_CONTINUATION_INSTRUCTION,
53
61
  pr_url: prUrl,
54
62
  prior_report: priorReport,
55
63
  review_comments: reviewComments,
@@ -20,7 +20,7 @@ export function shouldQuarantine(attempts: number, threshold: number): boolean {
20
20
  /** Project-over-default-over-3 resolution of the unified dead-letter threshold. */
21
21
  export function resolveDeadLetterThreshold(config: Config, registry: Registry, projectKey: string): number {
22
22
  const projectMax = registry.projects[projectKey]?.deadLetter?.maxAttempts;
23
- const globalMax = config.defaults.deadLetter?.maxAttempts;
23
+ const globalMax = config.deadLetter?.maxAttempts;
24
24
  return projectMax ?? globalMax ?? DEFAULT_MAX_ATTEMPTS;
25
25
  }
26
26
 
@@ -20,7 +20,7 @@ export interface DoctorDeps {
20
20
  }
21
21
 
22
22
  const API_KEY_HINT =
23
- "mint a personal API token (Plane: Profile → Settings → API tokens; Linear: Settings → API → Personal keys) and put it in .env as";
23
+ "mint a personal API token (Plane: Profile → Settings → API tokens; Linear: Settings → API → Personal keys) and set it in your shell profile (e.g. ~/.zshrc) as";
24
24
 
25
25
  export async function doctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
26
26
  const checks: DoctorCheck[] = [];
@@ -31,13 +31,13 @@ export async function doctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
31
31
  checks.push({
32
32
  detail: `loaded; active tracker "${config.tracker}"`,
33
33
  level: "pass",
34
- name: "config.json",
34
+ name: "config",
35
35
  });
36
36
  } catch (err) {
37
37
  checks.push({
38
38
  detail: err instanceof Error ? err.message : String(err),
39
39
  level: "fail",
40
- name: "config.json",
40
+ name: "config",
41
41
  });
42
42
  }
43
43
 
@@ -58,7 +58,7 @@ export async function doctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
58
58
  }
59
59
  } else {
60
60
  checks.push({
61
- detail: "skipped — config.json did not load",
61
+ detail: "skipped — config did not load",
62
62
  level: "fail",
63
63
  name: "tracker config",
64
64
  });
package/src/core/gc.ts CHANGED
@@ -43,6 +43,10 @@ export interface OrphanWorktree {
43
43
  ageDays: number;
44
44
  safe: boolean;
45
45
  heldReason?: string;
46
+ // Local branch to delete alongside the worktree. Set only for worktrees
47
+ // reaped because their run record is in the terminal `blocked` state: the
48
+ // worktree pins this branch and run.ts already deleted the remote.
49
+ blockedBranch?: string;
46
50
  }
47
51
 
48
52
  const MS_PER_DAY = 86_400_000;
@@ -64,6 +68,23 @@ function parseSourceRepo(stdout: string): string | undefined {
64
68
  return undefined;
65
69
  }
66
70
 
71
+ // Runs the dirty/unpushed cleanliness check used to decide whether a worktree is
72
+ // safe to reap. `safe` is true only when the worktree is clean and fully pushed;
73
+ // otherwise `heldReason` explains why it is held out of a plain `--prune`.
74
+ async function inspectCleanliness(
75
+ path: string,
76
+ git: Exec,
77
+ ): Promise<{ dirty: boolean; unpushed: boolean; safe: boolean; heldReason?: string }> {
78
+ const status = await git("git", ["-C", path, "status", "--porcelain"]);
79
+ const dirty = status.code !== 0 || status.stdout.trim().length > 0;
80
+
81
+ const revList = await git("git", ["-C", path, "rev-list", "HEAD", "--not", "--remotes"]);
82
+ const unpushed = revList.code !== 0 || revList.stdout.trim().length > 0;
83
+
84
+ const heldReason = dirty ? "uncommitted changes" : unpushed ? "unpushed commits" : undefined;
85
+ return { dirty, safe: !dirty && !unpushed, unpushed, ...(heldReason !== undefined ? { heldReason } : {}) };
86
+ }
87
+
67
88
  async function inspectOrphan(path: string, ageDays: number, git: Exec): Promise<OrphanWorktree> {
68
89
  const name = path.slice(path.lastIndexOf("/") + 1);
69
90
 
@@ -81,22 +102,25 @@ async function inspectOrphan(path: string, ageDays: number, git: Exec): Promise<
81
102
  };
82
103
  }
83
104
 
84
- const status = await git("git", ["-C", path, "status", "--porcelain"]);
85
- const dirty = status.code !== 0 || status.stdout.trim().length > 0;
86
-
87
- const revList = await git("git", ["-C", path, "rev-list", "HEAD", "--not", "--remotes"]);
88
- const unpushed = revList.code !== 0 || revList.stdout.trim().length > 0;
105
+ return { ageDays, name, path, repoPath, ...(await inspectCleanliness(path, git)) };
106
+ }
89
107
 
90
- const reason = dirty ? "uncommitted changes" : unpushed ? "unpushed commits" : undefined;
108
+ // A worktree whose run record is in the terminal `blocked` state: run.ts has
109
+ // already closed the PR + deleted the remote branch, leaving only the local
110
+ // worktree (which pins the local branch). It is reaped under a plain `--prune`
111
+ // only when clean and fully pushed; a dirty/unpushed blocked worktree is held
112
+ // (like an orphan) so a human's uncommitted edits survive a non-`--force` prune.
113
+ async function inspectBlocked(path: string, ageDays: number, git: Exec): Promise<OrphanWorktree> {
114
+ const name = path.slice(path.lastIndexOf("/") + 1);
115
+ const listed = await git("git", ["-C", path, "worktree", "list", "--porcelain"]);
116
+ const repoPath = listed.code === 0 ? parseSourceRepo(listed.stdout) : undefined;
91
117
  return {
92
118
  ageDays,
93
- dirty,
119
+ blockedBranch: `beflow/${name}`,
94
120
  name,
95
121
  path,
96
- repoPath,
97
- safe: !dirty && !unpushed,
98
- unpushed,
99
- ...(reason !== undefined ? { heldReason: reason } : {}),
122
+ ...(await inspectCleanliness(path, git)),
123
+ ...(repoPath !== undefined ? { repoPath } : {}),
100
124
  };
101
125
  }
102
126
 
@@ -113,15 +137,21 @@ export async function collectOrphans(opts: {
113
137
  const clock = opts.clock ?? systemClock;
114
138
  const now = clockMs(clock);
115
139
 
116
- const recorded = new Set(listRecords(opts.runsDir, runsFs).map((r) => sanitizeKey(r.key)));
140
+ const records = listRecords(opts.runsDir, runsFs);
141
+ const recorded = new Set(records.map((r) => sanitizeKey(r.key)));
142
+ const blocked = new Set(records.filter((r) => r.status === "blocked").map((r) => sanitizeKey(r.key)));
117
143
 
118
144
  const orphans: OrphanWorktree[] = [];
119
145
  for (const name of fs.listDirs(opts.worktreesDir)) {
146
+ const path = join(opts.worktreesDir, name);
147
+ const ageDays = (now - fs.mtimeMs(path)) / MS_PER_DAY;
148
+ if (blocked.has(name)) {
149
+ orphans.push(await inspectBlocked(path, ageDays, opts.git));
150
+ continue;
151
+ }
120
152
  if (recorded.has(name)) {
121
153
  continue;
122
154
  }
123
- const path = join(opts.worktreesDir, name);
124
- const ageDays = (now - fs.mtimeMs(path)) / MS_PER_DAY;
125
155
  orphans.push(await inspectOrphan(path, ageDays, opts.git));
126
156
  }
127
157
  return orphans;
@@ -134,19 +164,25 @@ export interface GcPlan {
134
164
  }
135
165
 
136
166
  async function removeOrphan(orphan: OrphanWorktree, git: Exec, fs: GcFs): Promise<void> {
167
+ let removedViaGit = false;
137
168
  if (orphan.repoPath !== undefined) {
138
169
  try {
139
170
  await removeWorktree(orphan.repoPath, orphan.path, git);
140
- return;
171
+ removedViaGit = true;
141
172
  } catch {
142
173
  // `git worktree remove` failed (e.g. locked/corrupt); fall back to
143
174
  // a raw recursive delete plus a best-effort prune of the dangling
144
175
  // administrative entry in the source repo.
145
176
  }
146
177
  }
147
- fs.removeDir(orphan.path);
148
- if (orphan.repoPath !== undefined) {
149
- await git("git", ["-C", orphan.repoPath, "worktree", "prune"]);
178
+ if (!removedViaGit) {
179
+ fs.removeDir(orphan.path);
180
+ if (orphan.repoPath !== undefined) {
181
+ await git("git", ["-C", orphan.repoPath, "worktree", "prune"]);
182
+ }
183
+ }
184
+ if (orphan.blockedBranch !== undefined && orphan.repoPath !== undefined) {
185
+ await git("git", ["-C", orphan.repoPath, "branch", "-D", orphan.blockedBranch]);
150
186
  }
151
187
  }
152
188
 
@@ -198,11 +234,13 @@ export async function runGc(opts: {
198
234
  if (prune) {
199
235
  for (const orphan of plan.pruned) {
200
236
  await removeOrphan(orphan, opts.git, fs);
201
- log(`gc: removed orphan worktree ${orphan.path}`);
237
+ log(`gc: removed ${orphan.blockedBranch !== undefined ? "blocked" : "orphan"} worktree ${orphan.path}`);
202
238
  }
203
239
  } else {
204
240
  for (const orphan of plan.pruned) {
205
- log(`gc: would remove orphan worktree ${orphan.path}`);
241
+ log(
242
+ `gc: would remove ${orphan.blockedBranch !== undefined ? "blocked" : "orphan"} worktree ${orphan.path}`,
243
+ );
206
244
  }
207
245
  }
208
246
 
@@ -26,5 +26,5 @@ export function isThinIssue(body: string, minBodyChars: number): boolean {
26
26
  }
27
27
 
28
28
  export function resolveMinBodyChars(config: Config, registry: Registry, projectKey: string): number {
29
- return registry.projects[projectKey]?.inputQuality?.minBodyChars ?? config.defaults.inputQuality?.minBodyChars ?? 0;
29
+ return registry.projects[projectKey]?.inputQuality?.minBodyChars ?? config.inputQuality?.minBodyChars ?? 0;
30
30
  }