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,223 @@
|
|
|
1
|
+
import { resolveAcpxCommand } from "../agent/acpx.ts";
|
|
2
|
+
import type { Config, Registry } from "../config/schema.ts";
|
|
3
|
+
|
|
4
|
+
export type CheckLevel = "pass" | "warn" | "fail";
|
|
5
|
+
|
|
6
|
+
export interface DoctorCheck {
|
|
7
|
+
name: string;
|
|
8
|
+
level: CheckLevel;
|
|
9
|
+
detail: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DoctorDeps {
|
|
13
|
+
loadConfig: () => Config;
|
|
14
|
+
loadRegistry: () => Registry;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
fileExists: (path: string) => boolean;
|
|
17
|
+
onPath: (cmd: string) => boolean;
|
|
18
|
+
ping?: (config: Config, registry: Registry) => Promise<string>;
|
|
19
|
+
boardChecks?: () => Promise<DoctorCheck[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
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";
|
|
24
|
+
|
|
25
|
+
export async function doctor(deps: DoctorDeps): Promise<DoctorCheck[]> {
|
|
26
|
+
const checks: DoctorCheck[] = [];
|
|
27
|
+
|
|
28
|
+
let config: Config | undefined;
|
|
29
|
+
try {
|
|
30
|
+
config = deps.loadConfig();
|
|
31
|
+
checks.push({
|
|
32
|
+
detail: `loaded; active tracker "${config.tracker}"`,
|
|
33
|
+
level: "pass",
|
|
34
|
+
name: "config.json",
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
checks.push({
|
|
38
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
39
|
+
level: "fail",
|
|
40
|
+
name: "config.json",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const trackerConfig = config !== undefined ? config.trackers[config.tracker] : undefined;
|
|
45
|
+
if (config !== undefined) {
|
|
46
|
+
if (trackerConfig === undefined) {
|
|
47
|
+
checks.push({
|
|
48
|
+
detail: `no config for active tracker "${config.tracker}" under "trackers"`,
|
|
49
|
+
level: "fail",
|
|
50
|
+
name: "tracker config",
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
checks.push({
|
|
54
|
+
detail: `"${config.tracker}" configured`,
|
|
55
|
+
level: "pass",
|
|
56
|
+
name: "tracker config",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
checks.push({
|
|
61
|
+
detail: "skipped — config.json did not load",
|
|
62
|
+
level: "fail",
|
|
63
|
+
name: "tracker config",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let registry: Registry | undefined;
|
|
68
|
+
try {
|
|
69
|
+
registry = deps.loadRegistry();
|
|
70
|
+
const count = Object.keys(registry.projects).length;
|
|
71
|
+
if (count === 0) {
|
|
72
|
+
checks.push({
|
|
73
|
+
detail: "loaded but has no projects",
|
|
74
|
+
level: "fail",
|
|
75
|
+
name: "projects",
|
|
76
|
+
});
|
|
77
|
+
registry = undefined;
|
|
78
|
+
} else {
|
|
79
|
+
checks.push({
|
|
80
|
+
detail: `loaded; ${String(count)} project(s)`,
|
|
81
|
+
level: "pass",
|
|
82
|
+
name: "projects",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
checks.push({
|
|
87
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
88
|
+
level: "fail",
|
|
89
|
+
name: "projects",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let apiKeyOk = false;
|
|
94
|
+
if (config !== undefined && trackerConfig !== undefined) {
|
|
95
|
+
const envName = trackerConfig.apiKeyEnv;
|
|
96
|
+
const value = deps.env[envName];
|
|
97
|
+
if (value === undefined || value === "") {
|
|
98
|
+
checks.push({
|
|
99
|
+
detail: `${envName} is unset — ${API_KEY_HINT} ${envName}=…`,
|
|
100
|
+
level: "fail",
|
|
101
|
+
name: "API key",
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
apiKeyOk = true;
|
|
105
|
+
checks.push({
|
|
106
|
+
detail: `${envName} is set`,
|
|
107
|
+
level: "pass",
|
|
108
|
+
name: "API key",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
checks.push({
|
|
113
|
+
detail: "skipped — config or tracker config did not load",
|
|
114
|
+
level: "fail",
|
|
115
|
+
name: "API key",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (registry !== undefined) {
|
|
120
|
+
const missing: string[] = [];
|
|
121
|
+
for (const [key, project] of Object.entries(registry.projects)) {
|
|
122
|
+
if (!deps.fileExists(project.root)) {
|
|
123
|
+
missing.push(`${key} root ${project.root}`);
|
|
124
|
+
}
|
|
125
|
+
for (const [repoName, repoPath] of Object.entries(project.repos)) {
|
|
126
|
+
if (!deps.fileExists(repoPath)) {
|
|
127
|
+
missing.push(`${key} repo ${repoName} ${repoPath}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (missing.length > 0) {
|
|
132
|
+
checks.push({
|
|
133
|
+
detail: `missing: ${missing.join("; ")}`,
|
|
134
|
+
level: "fail",
|
|
135
|
+
name: "repos on disk",
|
|
136
|
+
});
|
|
137
|
+
} else {
|
|
138
|
+
checks.push({
|
|
139
|
+
detail: "all project roots and repos exist",
|
|
140
|
+
level: "pass",
|
|
141
|
+
name: "repos on disk",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
checks.push({
|
|
146
|
+
detail: "skipped — registry did not load",
|
|
147
|
+
level: "fail",
|
|
148
|
+
name: "repos on disk",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const acpxCmd = config !== undefined ? resolveAcpxCommand(config) : ["bunx", "acpx"];
|
|
153
|
+
const launcher = acpxCmd[0];
|
|
154
|
+
if (launcher === undefined) {
|
|
155
|
+
throw new Error("beflow: acpx command resolved to an empty array");
|
|
156
|
+
}
|
|
157
|
+
if (deps.onPath(launcher)) {
|
|
158
|
+
checks.push({
|
|
159
|
+
detail: `found on PATH (${acpxCmd.join(" ")})`,
|
|
160
|
+
level: "pass",
|
|
161
|
+
name: "acpx",
|
|
162
|
+
});
|
|
163
|
+
} else {
|
|
164
|
+
checks.push({
|
|
165
|
+
detail: `launcher "${launcher}" not on PATH — default is "bunx acpx" (ships with bun); or set tools.acpx, or run: bun add -g acpx`,
|
|
166
|
+
level: "fail",
|
|
167
|
+
name: "acpx",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (deps.onPath("gh")) {
|
|
172
|
+
checks.push({ detail: "found on PATH", level: "pass", name: "gh" });
|
|
173
|
+
} else {
|
|
174
|
+
checks.push({
|
|
175
|
+
detail: "not on PATH — only needed for the PR step of implement mode",
|
|
176
|
+
level: "warn",
|
|
177
|
+
name: "gh",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (deps.ping !== undefined) {
|
|
182
|
+
if (config === undefined || trackerConfig === undefined || registry === undefined || !apiKeyOk) {
|
|
183
|
+
checks.push({
|
|
184
|
+
detail: "skipped — an earlier check failed",
|
|
185
|
+
level: "fail",
|
|
186
|
+
name: "live ping",
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
try {
|
|
190
|
+
const detail = await deps.ping(config, registry);
|
|
191
|
+
checks.push({ detail, level: "pass", name: "live ping" });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
checks.push({
|
|
194
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
195
|
+
level: "fail",
|
|
196
|
+
name: "live ping",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (deps.boardChecks !== undefined) {
|
|
203
|
+
if (config === undefined || trackerConfig === undefined || registry === undefined || !apiKeyOk) {
|
|
204
|
+
checks.push({
|
|
205
|
+
detail: "skipped — an earlier check failed",
|
|
206
|
+
level: "fail",
|
|
207
|
+
name: "board drift",
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
checks.push(...(await deps.boardChecks()));
|
|
212
|
+
} catch (err) {
|
|
213
|
+
checks.push({
|
|
214
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
215
|
+
level: "fail",
|
|
216
|
+
name: "board drift",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return checks;
|
|
223
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BoardTemplate, BoardState, Tracker } from "../trackers/tracker.ts";
|
|
2
|
+
|
|
3
|
+
// States beflow operationally depends on (queue + writeback targets).
|
|
4
|
+
export const REQUIRED_STATES = ["Backlog", "Todo", "In Progress", "Needs Input", "In Review", "Done"];
|
|
5
|
+
// Labels beflow's writeback creates/uses.
|
|
6
|
+
export const REQUIRED_LABELS = ["blocked", "quarantined", "triaged"];
|
|
7
|
+
|
|
8
|
+
export interface DriftReport {
|
|
9
|
+
missingStates: string[]; // In template, absent on tracker
|
|
10
|
+
missingLabels: string[];
|
|
11
|
+
missingModules: string[];
|
|
12
|
+
extraStates: string[]; // On tracker, not in template (helps spot renames)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function boardDrift(template: BoardTemplate, board: BoardState): DriftReport {
|
|
16
|
+
const boardStates = new Set(board.states);
|
|
17
|
+
const boardLabels = new Set(board.labels);
|
|
18
|
+
const boardModules = new Set(board.modules);
|
|
19
|
+
const templateStates = new Set(template.states.map((s) => s.name));
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
extraStates: board.states.filter((name) => !templateStates.has(name)),
|
|
23
|
+
missingLabels: template.labels.map((l) => l.name).filter((name) => !boardLabels.has(name)),
|
|
24
|
+
missingModules: template.modules.map((m) => m.name).filter((name) => !boardModules.has(name)),
|
|
25
|
+
missingStates: template.states.map((s) => s.name).filter((name) => !boardStates.has(name)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pre-run guard: throws a clear, actionable error if a REQUIRED state/label is
|
|
30
|
+
// Missing. If the board cannot be inspected (network / not implemented), it logs
|
|
31
|
+
// And PROCEEDS (inability to verify is not the same as drift).
|
|
32
|
+
export async function assertBoardReady(projectKey: string, tracker: Tracker, log?: (m: string) => void): Promise<void> {
|
|
33
|
+
let board: BoardState;
|
|
34
|
+
try {
|
|
35
|
+
board = await tracker.inspectBoard(projectKey);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
log?.(`beflow: could not verify board for ${projectKey} (${msg}); proceeding`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const missingStates = REQUIRED_STATES.filter((s) => !board.states.includes(s));
|
|
43
|
+
const missingLabels = REQUIRED_LABELS.filter((l) => !board.labels.includes(l));
|
|
44
|
+
if (missingStates.length === 0 && missingLabels.length === 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parts: string[] = [];
|
|
49
|
+
if (missingStates.length > 0) {
|
|
50
|
+
parts.push(`state(s): ${missingStates.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
if (missingLabels.length > 0) {
|
|
53
|
+
parts.push(`label(s): ${missingLabels.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(
|
|
57
|
+
`beflow: board for ${projectKey} has drifted — missing ${parts.join("; ")} (present states: ${board.states.join(", ")}). Run \`beflow update ${projectKey}\` to reconcile.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
package/src/core/gc.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { readdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { listRecords, nodeRunStoreFs, systemClock } from "./runstore.ts";
|
|
5
|
+
import type { Clock, RunStoreFs } from "./runstore.ts";
|
|
6
|
+
import { removeWorktree, sanitizeKey } from "./worktree.ts";
|
|
7
|
+
import type { Exec } from "./worktree.ts";
|
|
8
|
+
|
|
9
|
+
export interface GcFs {
|
|
10
|
+
listDirs(dir: string): string[];
|
|
11
|
+
mtimeMs(path: string): number;
|
|
12
|
+
removeDir(path: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const nodeGcFs: GcFs = {
|
|
16
|
+
listDirs(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
19
|
+
.filter((e) => e.isDirectory())
|
|
20
|
+
.map((e) => e.name);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
mtimeMs(path) {
|
|
26
|
+
try {
|
|
27
|
+
return statSync(path).mtimeMs;
|
|
28
|
+
} catch {
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
removeDir(path) {
|
|
33
|
+
rmSync(path, { force: true, recursive: true });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface OrphanWorktree {
|
|
38
|
+
name: string;
|
|
39
|
+
path: string;
|
|
40
|
+
repoPath?: string;
|
|
41
|
+
dirty: boolean;
|
|
42
|
+
unpushed: boolean;
|
|
43
|
+
ageDays: number;
|
|
44
|
+
safe: boolean;
|
|
45
|
+
heldReason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MS_PER_DAY = 86_400_000;
|
|
49
|
+
|
|
50
|
+
function clockMs(clock: Clock): number {
|
|
51
|
+
const parsed = Date.parse(clock());
|
|
52
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Parses `git worktree list --porcelain` and returns the FIRST `worktree <path>`
|
|
56
|
+
// line, which is always the source (main) repository. undefined when the output
|
|
57
|
+
// has no such line.
|
|
58
|
+
function parseSourceRepo(stdout: string): string | undefined {
|
|
59
|
+
for (const line of stdout.split("\n")) {
|
|
60
|
+
if (line.startsWith("worktree ")) {
|
|
61
|
+
return line.slice("worktree ".length).trim();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function inspectOrphan(path: string, ageDays: number, git: Exec): Promise<OrphanWorktree> {
|
|
68
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
69
|
+
|
|
70
|
+
const listed = await git("git", ["-C", path, "worktree", "list", "--porcelain"]);
|
|
71
|
+
const repoPath = listed.code === 0 ? parseSourceRepo(listed.stdout) : undefined;
|
|
72
|
+
if (repoPath === undefined) {
|
|
73
|
+
return {
|
|
74
|
+
ageDays,
|
|
75
|
+
dirty: false,
|
|
76
|
+
heldReason: "unregistered/broken worktree",
|
|
77
|
+
name,
|
|
78
|
+
path,
|
|
79
|
+
safe: false,
|
|
80
|
+
unpushed: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
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;
|
|
89
|
+
|
|
90
|
+
const reason = dirty ? "uncommitted changes" : unpushed ? "unpushed commits" : undefined;
|
|
91
|
+
return {
|
|
92
|
+
ageDays,
|
|
93
|
+
dirty,
|
|
94
|
+
name,
|
|
95
|
+
path,
|
|
96
|
+
repoPath,
|
|
97
|
+
safe: !dirty && !unpushed,
|
|
98
|
+
unpushed,
|
|
99
|
+
...(reason !== undefined ? { heldReason: reason } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function collectOrphans(opts: {
|
|
104
|
+
worktreesDir: string;
|
|
105
|
+
runsDir: string;
|
|
106
|
+
git: Exec;
|
|
107
|
+
fs?: GcFs;
|
|
108
|
+
runsFs?: RunStoreFs;
|
|
109
|
+
clock?: Clock;
|
|
110
|
+
}): Promise<OrphanWorktree[]> {
|
|
111
|
+
const fs = opts.fs ?? nodeGcFs;
|
|
112
|
+
const runsFs = opts.runsFs ?? nodeRunStoreFs;
|
|
113
|
+
const clock = opts.clock ?? systemClock;
|
|
114
|
+
const now = clockMs(clock);
|
|
115
|
+
|
|
116
|
+
const recorded = new Set(listRecords(opts.runsDir, runsFs).map((r) => sanitizeKey(r.key)));
|
|
117
|
+
|
|
118
|
+
const orphans: OrphanWorktree[] = [];
|
|
119
|
+
for (const name of fs.listDirs(opts.worktreesDir)) {
|
|
120
|
+
if (recorded.has(name)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const path = join(opts.worktreesDir, name);
|
|
124
|
+
const ageDays = (now - fs.mtimeMs(path)) / MS_PER_DAY;
|
|
125
|
+
orphans.push(await inspectOrphan(path, ageDays, opts.git));
|
|
126
|
+
}
|
|
127
|
+
return orphans;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface GcPlan {
|
|
131
|
+
pruned: OrphanWorktree[];
|
|
132
|
+
held: OrphanWorktree[];
|
|
133
|
+
skippedByAge: OrphanWorktree[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function removeOrphan(orphan: OrphanWorktree, git: Exec, fs: GcFs): Promise<void> {
|
|
137
|
+
if (orphan.repoPath !== undefined) {
|
|
138
|
+
try {
|
|
139
|
+
await removeWorktree(orphan.repoPath, orphan.path, git);
|
|
140
|
+
return;
|
|
141
|
+
} catch {
|
|
142
|
+
// `git worktree remove` failed (e.g. locked/corrupt); fall back to
|
|
143
|
+
// a raw recursive delete plus a best-effort prune of the dangling
|
|
144
|
+
// administrative entry in the source repo.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
fs.removeDir(orphan.path);
|
|
148
|
+
if (orphan.repoPath !== undefined) {
|
|
149
|
+
await git("git", ["-C", orphan.repoPath, "worktree", "prune"]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function runGc(opts: {
|
|
154
|
+
worktreesDir: string;
|
|
155
|
+
runsDir: string;
|
|
156
|
+
git: Exec;
|
|
157
|
+
fs?: GcFs;
|
|
158
|
+
runsFs?: RunStoreFs;
|
|
159
|
+
clock?: Clock;
|
|
160
|
+
prune?: boolean;
|
|
161
|
+
force?: boolean;
|
|
162
|
+
olderThanDays?: number;
|
|
163
|
+
log?: (m: string) => void;
|
|
164
|
+
}): Promise<GcPlan> {
|
|
165
|
+
const fs = opts.fs ?? nodeGcFs;
|
|
166
|
+
const log =
|
|
167
|
+
opts.log ??
|
|
168
|
+
((): void => {
|
|
169
|
+
/* no-op: logging disabled */
|
|
170
|
+
});
|
|
171
|
+
const collectOpts: Parameters<typeof collectOrphans>[0] = {
|
|
172
|
+
git: opts.git,
|
|
173
|
+
runsDir: opts.runsDir,
|
|
174
|
+
worktreesDir: opts.worktreesDir,
|
|
175
|
+
...(opts.fs !== undefined ? { fs: opts.fs } : {}),
|
|
176
|
+
...(opts.runsFs !== undefined ? { runsFs: opts.runsFs } : {}),
|
|
177
|
+
...(opts.clock !== undefined ? { clock: opts.clock } : {}),
|
|
178
|
+
};
|
|
179
|
+
const orphans = await collectOrphans(collectOpts);
|
|
180
|
+
|
|
181
|
+
const plan: GcPlan = { held: [], pruned: [], skippedByAge: [] };
|
|
182
|
+
const force = opts.force === true;
|
|
183
|
+
const prune = opts.prune === true;
|
|
184
|
+
|
|
185
|
+
for (const orphan of orphans) {
|
|
186
|
+
if (opts.olderThanDays !== undefined && orphan.ageDays < opts.olderThanDays) {
|
|
187
|
+
plan.skippedByAge.push(orphan);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const isTarget = force || orphan.safe;
|
|
191
|
+
if (isTarget) {
|
|
192
|
+
plan.pruned.push(orphan);
|
|
193
|
+
} else {
|
|
194
|
+
plan.held.push(orphan);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (prune) {
|
|
199
|
+
for (const orphan of plan.pruned) {
|
|
200
|
+
await removeOrphan(orphan, opts.git, fs);
|
|
201
|
+
log(`gc: removed orphan worktree ${orphan.path}`);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
for (const orphan of plan.pruned) {
|
|
205
|
+
log(`gc: would remove orphan worktree ${orphan.path}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const orphan of plan.held) {
|
|
210
|
+
log(`gc: held ${orphan.path} — ${orphan.heldReason ?? "not safe"} (re-run with --force to remove)`);
|
|
211
|
+
}
|
|
212
|
+
for (const orphan of plan.skippedByAge) {
|
|
213
|
+
log(`gc: skipped ${orphan.path} — newer than threshold`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
log(
|
|
217
|
+
`gc: ${String(orphans.length)} orphan worktree(s) — ${String(plan.pruned.length)} ${
|
|
218
|
+
prune ? "pruned" : "to prune"
|
|
219
|
+
}, ${String(plan.held.length)} held (use --force), ${String(plan.skippedByAge.length)} too new`,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
return plan;
|
|
223
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Config, Registry } from "../config/schema.ts";
|
|
2
|
+
|
|
3
|
+
export const THIN_ISSUE_MESSAGE =
|
|
4
|
+
"This work item was moved to Needs Input because its description looks too thin for an agent to act on safely. Please add a clear description — what needs to change, and how you'll know it's done — then leave a comment, and beflow will pick it up automatically.";
|
|
5
|
+
|
|
6
|
+
const ENTITIES: Record<string, string> = {
|
|
7
|
+
"&": "&",
|
|
8
|
+
">": ">",
|
|
9
|
+
"<": "<",
|
|
10
|
+
" ": " ",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Plane bodies are `description_html`, so a raw `.length` over-counts markup. Strip
|
|
15
|
+
* tags, decode the few common entities, collapse whitespace runs, and trim, so the
|
|
16
|
+
* returned length reflects the actual human-visible text.
|
|
17
|
+
*/
|
|
18
|
+
export function visibleBodyLength(body: string): number {
|
|
19
|
+
const stripped = body.replace(/<[^>]*>/g, "");
|
|
20
|
+
const decoded = stripped.replace(/ |&|<|>/g, (m) => ENTITIES[m] ?? m);
|
|
21
|
+
return decoded.replace(/\s+/g, " ").trim().length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isThinIssue(body: string, minBodyChars: number): boolean {
|
|
25
|
+
return minBodyChars > 0 && visibleBodyLength(body) < minBodyChars;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveMinBodyChars(config: Config, registry: Registry, projectKey: string): number {
|
|
29
|
+
return registry.projects[projectKey]?.inputQuality?.minBodyChars ?? config.defaults.inputQuality?.minBodyChars ?? 0;
|
|
30
|
+
}
|