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/README.md +109 -25
- package/config.example.json +37 -7
- package/config.schema.json +207 -102
- package/package.json +1 -1
- package/src/cli.ts +7 -6
- package/src/config/load.ts +19 -5
- package/src/config/paths.ts +41 -0
- package/src/config/persist.ts +1 -1
- package/src/config/schema.ts +92 -50
- package/src/core/continuation.ts +9 -1
- package/src/core/deadletter.ts +1 -1
- package/src/core/doctor.ts +4 -4
- package/src/core/gc.ts +58 -20
- package/src/core/inputquality.ts +1 -1
- package/src/core/policy.ts +214 -0
- package/src/core/pr.ts +169 -0
- package/src/core/prompts.ts +26 -2
- package/src/core/qualitygate.ts +1 -1
- package/src/core/review.ts +2 -2
- package/src/core/run.ts +217 -22
- package/src/core/runsview.ts +1 -1
- package/src/core/setup.ts +3 -2
- package/src/core/sla.ts +1 -1
- package/src/core/watch.ts +14 -4
- package/src/model/types.ts +27 -0
- package/src/prompts/defaults/continuation.md +1 -1
- package/src/prompts/defaults/implement.md +2 -2
- package/src/resolve/precedence.ts +40 -5
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 ??
|
|
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 ??
|
|
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.
|
|
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.
|
|
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.
|
|
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[] = [];
|
package/src/config/load.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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";
|
package/src/config/persist.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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(),
|
package/src/core/continuation.ts
CHANGED
|
@@ -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,
|
package/src/core/deadletter.ts
CHANGED
|
@@ -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.
|
|
23
|
+
const globalMax = config.deadLetter?.maxAttempts;
|
|
24
24
|
return projectMax ?? globalMax ?? DEFAULT_MAX_ATTEMPTS;
|
|
25
25
|
}
|
|
26
26
|
|
package/src/core/doctor.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
blockedBranch: `beflow/${name}`,
|
|
94
120
|
name,
|
|
95
121
|
path,
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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(
|
|
241
|
+
log(
|
|
242
|
+
`gc: would remove ${orphan.blockedBranch !== undefined ? "blocked" : "orphan"} worktree ${orphan.path}`,
|
|
243
|
+
);
|
|
206
244
|
}
|
|
207
245
|
}
|
|
208
246
|
|
package/src/core/inputquality.ts
CHANGED
|
@@ -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.
|
|
29
|
+
return registry.projects[projectKey]?.inputQuality?.minBodyChars ?? config.inputQuality?.minBodyChars ?? 0;
|
|
30
30
|
}
|