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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { defineCommand, runCommand, showUsage } from "citty";
|
|
6
|
+
import type { ArgsDef, CommandContext, CommandDef } from "citty";
|
|
7
|
+
|
|
8
|
+
import { AcpxDriver, resolveAcpCommand, resolveAcpxCommand } from "./agent/acpx.ts";
|
|
9
|
+
import type { AgentDriver } from "./agent/driver.ts";
|
|
10
|
+
import { loadConfig, loadRegistry } from "./config/load.ts";
|
|
11
|
+
import type { Config, Registry } from "./config/schema.ts";
|
|
12
|
+
import { ConfigStore, nodeConfigWatcher } from "./config/store.ts";
|
|
13
|
+
import type { ConfigWatcher } from "./config/store.ts";
|
|
14
|
+
import { acceptIntake } from "./core/accept.ts";
|
|
15
|
+
import { isDecisionHeld } from "./core/decision.ts";
|
|
16
|
+
import { doctor } from "./core/doctor.ts";
|
|
17
|
+
import type { DoctorCheck } from "./core/doctor.ts";
|
|
18
|
+
import { assertBoardReady, boardDrift } from "./core/drift.ts";
|
|
19
|
+
import { runGc } from "./core/gc.ts";
|
|
20
|
+
import { isThinIssue, resolveMinBodyChars } from "./core/inputquality.ts";
|
|
21
|
+
import { defaultIssueTemplateResolveDeps } from "./core/issuetemplate.ts";
|
|
22
|
+
import { defaultMcpDeps, loadMcpServers } from "./core/mcp.ts";
|
|
23
|
+
import type { McpServer } from "./core/mcp.ts";
|
|
24
|
+
import {
|
|
25
|
+
defaultAskConfirm,
|
|
26
|
+
defaultAskQuestions,
|
|
27
|
+
defaultAskTemplate,
|
|
28
|
+
defaultEnrichIssue,
|
|
29
|
+
newIssue,
|
|
30
|
+
} from "./core/newissue.ts";
|
|
31
|
+
import type { AskConfirm, AskQuestions, AskTemplate, EnrichIssue, NewIssueDeps } from "./core/newissue.ts";
|
|
32
|
+
import { createNotifier } from "./core/notify.ts";
|
|
33
|
+
import type { NotifyFormat } from "./core/notify.ts";
|
|
34
|
+
import { defaultPromptResolveDeps, loadEnrichPrompt, loadPromptSet } from "./core/prompts.ts";
|
|
35
|
+
import type { PromptSet } from "./core/prompts.ts";
|
|
36
|
+
import { resolveQualityGate } from "./core/qualitygate.ts";
|
|
37
|
+
import { queueView } from "./core/queue.ts";
|
|
38
|
+
import type { QueueRow } from "./core/queue.ts";
|
|
39
|
+
import { runReview } from "./core/review.ts";
|
|
40
|
+
import type { RunReviewDeps } from "./core/review.ts";
|
|
41
|
+
import { defaultOpenIssue, resolveRun, runIssue, runOpen, runSupervised } from "./core/run.ts";
|
|
42
|
+
import type { OpenIssue, ResolvedRun, RunIssueDeps, RunOpenDeps, RunSupervisedDeps } from "./core/run.ts";
|
|
43
|
+
import { listRecords, loadRecord, resolveRunsDir } from "./core/runstore.ts";
|
|
44
|
+
import type { RunStoreFs } from "./core/runstore.ts";
|
|
45
|
+
import { formatRunDetail, formatRunList } from "./core/runsview.ts";
|
|
46
|
+
import { setupProject } from "./core/setup.ts";
|
|
47
|
+
import { beflowBoardTemplate } from "./core/template.ts";
|
|
48
|
+
import { defaultPrChecks, defaultPrMerged, watch, watchTick } from "./core/watch.ts";
|
|
49
|
+
import type { WatchControl, WatchDeps } from "./core/watch.ts";
|
|
50
|
+
import { bunExec, expandHome, resolveWorktreeDir } from "./core/worktree.ts";
|
|
51
|
+
import type { Exec } from "./core/worktree.ts";
|
|
52
|
+
import type { Resolved } from "./model/types.ts";
|
|
53
|
+
import { createTracker } from "./trackers/factory.ts";
|
|
54
|
+
import type { Tracker } from "./trackers/tracker.ts";
|
|
55
|
+
|
|
56
|
+
export type WatchRunner = (projectKey: string, deps: WatchDeps, ctrl: WatchControl) => Promise<void>;
|
|
57
|
+
|
|
58
|
+
export type Ping = (config: Config, registry: Registry) => Promise<string>;
|
|
59
|
+
|
|
60
|
+
export interface CliDeps {
|
|
61
|
+
loadConfig: (dir: string) => Config;
|
|
62
|
+
loadRegistry: (dir: string) => Registry;
|
|
63
|
+
createTracker: (config: Config, registry: Registry) => Tracker;
|
|
64
|
+
createDriver: (acpxCommand: string[]) => AgentDriver;
|
|
65
|
+
git?: Exec;
|
|
66
|
+
launchInteractive?: RunSupervisedDeps["launchInteractive"];
|
|
67
|
+
askOutcome?: RunSupervisedDeps["askOutcome"];
|
|
68
|
+
askTemplate?: AskTemplate;
|
|
69
|
+
askQuestions?: AskQuestions;
|
|
70
|
+
askConfirm?: AskConfirm;
|
|
71
|
+
// Test-injection seam: lets tests supply a fake enrich without a real driver,
|
|
72
|
+
// mirroring the other optional IO seams above.
|
|
73
|
+
enrich?: EnrichIssue;
|
|
74
|
+
openIssue?: OpenIssue;
|
|
75
|
+
// Test-injection seam for `beflow review <KEY>`; defaults to the real `runReview`.
|
|
76
|
+
runReview?: (key: string, deps: RunReviewDeps) => Promise<unknown>;
|
|
77
|
+
watch?: WatchRunner;
|
|
78
|
+
// Test-injection seam for the read-only `runs` inspector; defaults to the real
|
|
79
|
+
// Node fs-backed run store.
|
|
80
|
+
runsFs?: RunStoreFs;
|
|
81
|
+
// File-watcher for the `watch` command's config hot-reload. Defaults to the
|
|
82
|
+
// Node fs watcher in production; tests omit it to skip real fs watching.
|
|
83
|
+
configWatcher?: ConfigWatcher;
|
|
84
|
+
fileExists?: (path: string) => boolean;
|
|
85
|
+
onPath?: (cmd: string) => boolean;
|
|
86
|
+
ping?: Ping;
|
|
87
|
+
log?: (msg: string) => void;
|
|
88
|
+
cwd?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function notifyFormat(): NotifyFormat | undefined {
|
|
92
|
+
const raw = process.env.BEFLOW_NOTIFY_FORMAT;
|
|
93
|
+
if (raw === "slack" || raw === "discord" || raw === "generic") {
|
|
94
|
+
return raw;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function onPathDefault(cmd: string): boolean {
|
|
100
|
+
const pathEnv = process.env.PATH ?? "";
|
|
101
|
+
for (const dir of pathEnv.split(":")) {
|
|
102
|
+
if (dir === "") {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(join(dir, cmd))) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function defaultPing(config: Config, registry: Registry): Promise<string> {
|
|
113
|
+
const tracker = createTracker(config, registry);
|
|
114
|
+
const first = Object.keys(registry.projects)[0];
|
|
115
|
+
if (first === undefined) {
|
|
116
|
+
throw new Error("no projects in registry");
|
|
117
|
+
}
|
|
118
|
+
const queue = await tracker.listQueue({ project: first, state: "Todo" });
|
|
119
|
+
return `reached ${config.tracker}; ${String(queue.length)} Todo item(s) in ${first}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function defaultCliDeps(): CliDeps {
|
|
123
|
+
return {
|
|
124
|
+
configWatcher: nodeConfigWatcher,
|
|
125
|
+
createDriver: (acpxCommand) => new AcpxDriver({ command: acpxCommand }),
|
|
126
|
+
createTracker: (config, registry) => createTracker(config, registry),
|
|
127
|
+
fileExists: existsSync,
|
|
128
|
+
git: bunExec,
|
|
129
|
+
loadConfig,
|
|
130
|
+
loadRegistry,
|
|
131
|
+
onPath: onPathDefault,
|
|
132
|
+
openIssue: defaultOpenIssue,
|
|
133
|
+
ping: defaultPing,
|
|
134
|
+
watch,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface RunArgs {
|
|
139
|
+
agent?: string | undefined;
|
|
140
|
+
repo?: string | undefined;
|
|
141
|
+
auto?: boolean | undefined;
|
|
142
|
+
attend?: boolean | undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cliOverrides(values: RunArgs): Partial<Resolved> {
|
|
146
|
+
const cli: Partial<Resolved> = {};
|
|
147
|
+
if (values.agent !== undefined) {
|
|
148
|
+
cli.agent = values.agent;
|
|
149
|
+
}
|
|
150
|
+
if (values.repo !== undefined) {
|
|
151
|
+
cli.repo = values.repo;
|
|
152
|
+
}
|
|
153
|
+
if (values.auto === true) {
|
|
154
|
+
cli.runMode = "autonomous";
|
|
155
|
+
} else if (values.attend === true) {
|
|
156
|
+
cli.runMode = "supervised";
|
|
157
|
+
}
|
|
158
|
+
return cli;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface CliContext {
|
|
162
|
+
deps: CliDeps;
|
|
163
|
+
dir: string;
|
|
164
|
+
config: Config;
|
|
165
|
+
registry: Registry;
|
|
166
|
+
tracker: Tracker;
|
|
167
|
+
prompts: PromptSet;
|
|
168
|
+
log: (msg: string) => void;
|
|
169
|
+
fail: (msg: string) => number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Builds the CliContext shared by every command that needs a tracker + prompts.
|
|
173
|
+
// Config is loaded here (inside command `run` handlers) rather than in `runCli`
|
|
174
|
+
// so that `--help` — which citty resolves without invoking `run` — works even
|
|
175
|
+
// when config.json is missing or invalid.
|
|
176
|
+
function loadContext(deps: CliDeps, log: (msg: string) => void, fail: (msg: string) => number): CliContext {
|
|
177
|
+
const dir = deps.cwd ?? process.cwd();
|
|
178
|
+
const config = deps.loadConfig(dir);
|
|
179
|
+
const registry = deps.loadRegistry(dir);
|
|
180
|
+
const tracker = deps.createTracker(config, registry);
|
|
181
|
+
const prompts = loadPromptSet(defaultPromptResolveDeps(dir, config.prompts?.dir));
|
|
182
|
+
return { config, deps, dir, fail, log, prompts, registry, tracker };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function makeLog(deps: CliDeps): (msg: string) => void {
|
|
186
|
+
return deps.log ?? ((msg: string): void => void process.stdout.write(`${msg}\n`));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function makeFail(): (msg: string) => number {
|
|
190
|
+
return (msg: string): number => {
|
|
191
|
+
process.stderr.write(`${msg}\n`);
|
|
192
|
+
return 1;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// citty's `ParsedArgs<ArgsDef>` values are loosely typed; these narrow a single
|
|
197
|
+
// parsed value to the concrete shape each command handler expects.
|
|
198
|
+
function asStr(v: unknown): string | undefined {
|
|
199
|
+
return typeof v === "string" ? v : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function asBool(v: unknown): boolean | undefined {
|
|
203
|
+
return typeof v === "boolean" ? v : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
interface Cli {
|
|
207
|
+
root: CommandDef;
|
|
208
|
+
subCommands: Record<string, CommandDef>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Builds the citty command tree, closing over `deps` so the run handlers can
|
|
212
|
+
// inject their collaborators (citty's `run({ args })` carries no `deps`). We
|
|
213
|
+
// keep an explicit `subCommands` map so `runCli` can resolve the target command
|
|
214
|
+
// for help/dispatch without reaching into citty's loosely-typed `CommandDef`.
|
|
215
|
+
function buildCli(deps: CliDeps): Cli {
|
|
216
|
+
function ctx(): CliContext {
|
|
217
|
+
return loadContext(deps, makeLog(deps), makeFail());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const runCmd = defineCommand({
|
|
221
|
+
args: {
|
|
222
|
+
"agent": { description: "Override the resolved agent", type: "string" },
|
|
223
|
+
"attend": { description: "interactive via acpx", type: "boolean" },
|
|
224
|
+
"auto": { description: "headless via acpx", type: "boolean" },
|
|
225
|
+
"dry-run": { description: "preview the plan without any side effects", type: "boolean" },
|
|
226
|
+
"fresh": { description: "reset the run; ignore any saved session", type: "boolean" },
|
|
227
|
+
"key": { description: "Work item key, e.g. CG-42", required: true, type: "positional" },
|
|
228
|
+
"open": { description: "interactive via the agent's native TUI", type: "boolean" },
|
|
229
|
+
"repo": { description: "Override the resolved repo", type: "string" },
|
|
230
|
+
} satisfies ArgsDef as ArgsDef,
|
|
231
|
+
meta: { description: "Run a work item through an agent", name: "run" },
|
|
232
|
+
run: async ({ args }) =>
|
|
233
|
+
cmdRun(
|
|
234
|
+
{
|
|
235
|
+
agent: asStr(args.agent),
|
|
236
|
+
attend: asBool(args.attend),
|
|
237
|
+
auto: asBool(args.auto),
|
|
238
|
+
dryRun: asBool(args["dry-run"]),
|
|
239
|
+
fresh: asBool(args.fresh),
|
|
240
|
+
key: String(args.key),
|
|
241
|
+
open: asBool(args.open),
|
|
242
|
+
repo: asStr(args.repo),
|
|
243
|
+
},
|
|
244
|
+
ctx(),
|
|
245
|
+
),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// setup and update share the same args + handler (update is an alias). citty
|
|
249
|
+
// has no first-class alias, so we register both subcommands with one handler.
|
|
250
|
+
const setupArgs = {
|
|
251
|
+
project: { description: "Registry project key, e.g. CG", required: true, type: "positional" },
|
|
252
|
+
prune: { description: "delete orphan modules / agent: labels", type: "boolean" },
|
|
253
|
+
} satisfies ArgsDef as ArgsDef;
|
|
254
|
+
async function setupRun({ args }: CommandContext): Promise<number> {
|
|
255
|
+
return cmdSetup({ project: String(args.project), prune: asBool(args.prune) }, ctx());
|
|
256
|
+
}
|
|
257
|
+
const setupCmd = defineCommand({
|
|
258
|
+
args: setupArgs,
|
|
259
|
+
meta: { description: "Provision/reconcile a project's board to the beflow template", name: "setup" },
|
|
260
|
+
run: setupRun,
|
|
261
|
+
});
|
|
262
|
+
const updateCmd = defineCommand({
|
|
263
|
+
args: setupArgs,
|
|
264
|
+
meta: { description: "Alias of setup: reconcile a project's board to the template", name: "update" },
|
|
265
|
+
run: setupRun,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const queueCmd = defineCommand({
|
|
269
|
+
args: {
|
|
270
|
+
project: { description: "Restrict to a single project key", type: "string" },
|
|
271
|
+
state: { description: "Restrict to a single state name", type: "string" },
|
|
272
|
+
} satisfies ArgsDef as ArgsDef,
|
|
273
|
+
meta: { description: "Print the work queue across projects", name: "queue" },
|
|
274
|
+
run: async ({ args }) => cmdQueue({ project: asStr(args.project), state: asStr(args.state) }, ctx()),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const watchCmd = defineCommand({
|
|
278
|
+
args: {
|
|
279
|
+
"dry-run": {
|
|
280
|
+
description: "run a single tick and preview the decision without any side effects",
|
|
281
|
+
type: "boolean",
|
|
282
|
+
},
|
|
283
|
+
"interval": { description: "Poll interval in seconds (default 30)", type: "string" },
|
|
284
|
+
"project": { description: "Registry project key", required: true, type: "positional" },
|
|
285
|
+
} satisfies ArgsDef as ArgsDef,
|
|
286
|
+
meta: { description: "Continuously poll a project's queue and dispatch work", name: "watch" },
|
|
287
|
+
run: async ({ args }) =>
|
|
288
|
+
cmdWatch(
|
|
289
|
+
{ dryRun: asBool(args["dry-run"]), interval: asStr(args.interval), project: String(args.project) },
|
|
290
|
+
ctx(),
|
|
291
|
+
),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const reviewCmd = defineCommand({
|
|
295
|
+
args: {
|
|
296
|
+
key: { description: "Work item key, e.g. CG-42", required: true, type: "positional" },
|
|
297
|
+
} satisfies ArgsDef as ArgsDef,
|
|
298
|
+
meta: { description: "Run an agent-driven review over a work item's open PR", name: "review" },
|
|
299
|
+
run: async ({ args }) => cmdReview({ key: String(args.key) }, ctx()),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const runsCmd = defineCommand({
|
|
303
|
+
args: {
|
|
304
|
+
key: {
|
|
305
|
+
description: "Work item key to inspect; omit to list all run records",
|
|
306
|
+
required: false,
|
|
307
|
+
type: "positional",
|
|
308
|
+
},
|
|
309
|
+
} satisfies ArgsDef as ArgsDef,
|
|
310
|
+
meta: { description: "Inspect persisted run records (read-only)", name: "runs" },
|
|
311
|
+
run: ({ args }) => cmdRuns({ key: asStr(args.key) }, ctx()),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const acceptCmd = defineCommand({
|
|
315
|
+
// Positional order is semantic: citty binds positionals by declaration
|
|
316
|
+
// order, so `project` must come before `intake`. The sort-keys warning
|
|
317
|
+
// this triggers is intentional — do not alphabetize these two keys.
|
|
318
|
+
args: {
|
|
319
|
+
project: { description: "Registry project key, e.g. CG", required: true, type: "positional" },
|
|
320
|
+
intake: { description: "Intake item id", required: true, type: "positional" },
|
|
321
|
+
} satisfies ArgsDef as ArgsDef,
|
|
322
|
+
meta: { description: "Accept an intake item into the backlog", name: "accept" },
|
|
323
|
+
run: async ({ args }) => cmdAccept({ intake: String(args.intake), project: String(args.project) }, ctx()),
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const newCmd = defineCommand({
|
|
327
|
+
args: {
|
|
328
|
+
project: { description: "Registry project key, e.g. CG", required: true, type: "positional" },
|
|
329
|
+
template: { description: "Issue template name (e.g. bug); omit to pick interactively", type: "positional" },
|
|
330
|
+
} satisfies ArgsDef as ArgsDef,
|
|
331
|
+
meta: { description: "Author a new work item from a template", name: "new" },
|
|
332
|
+
run: async ({ args }) => cmdNew({ project: String(args.project), template: asStr(args.template) }, ctx()),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const doctorCmd = defineCommand({
|
|
336
|
+
args: {
|
|
337
|
+
ping: { description: "hit the tracker read API", type: "boolean" },
|
|
338
|
+
} satisfies ArgsDef as ArgsDef,
|
|
339
|
+
meta: { description: "Diagnose the local beflow environment", name: "doctor" },
|
|
340
|
+
// doctor intentionally runs WITHOUT loadContext so it works with no config.
|
|
341
|
+
run: async ({ args }) => cmdDoctor({ ping: asBool(args.ping) }, deps, deps.cwd ?? process.cwd(), makeLog(deps)),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const gcCmd = defineCommand({
|
|
345
|
+
args: {
|
|
346
|
+
"force": {
|
|
347
|
+
description: "also remove worktrees with uncommitted/unpushed work (DESTROYS that work)",
|
|
348
|
+
type: "boolean",
|
|
349
|
+
},
|
|
350
|
+
"older-than": { description: "only consider worktrees older than N days", type: "string" },
|
|
351
|
+
"prune": { description: "actually remove orphan worktrees (default: report only)", type: "boolean" },
|
|
352
|
+
} satisfies ArgsDef as ArgsDef,
|
|
353
|
+
meta: { description: "Find and prune orphaned git worktrees beflow left behind", name: "gc" },
|
|
354
|
+
// gc is a local disk op: like doctor, it runs WITHOUT loadContext (no tracker/API key).
|
|
355
|
+
run: async ({ args }) =>
|
|
356
|
+
cmdGc(
|
|
357
|
+
{ force: asBool(args.force), olderThan: asStr(args["older-than"]), prune: asBool(args.prune) },
|
|
358
|
+
deps,
|
|
359
|
+
deps.cwd ?? process.cwd(),
|
|
360
|
+
makeLog(deps),
|
|
361
|
+
),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const subCommands: Record<string, CommandDef> = {
|
|
365
|
+
accept: acceptCmd,
|
|
366
|
+
doctor: doctorCmd,
|
|
367
|
+
gc: gcCmd,
|
|
368
|
+
new: newCmd,
|
|
369
|
+
queue: queueCmd,
|
|
370
|
+
review: reviewCmd,
|
|
371
|
+
run: runCmd,
|
|
372
|
+
runs: runsCmd,
|
|
373
|
+
setup: setupCmd,
|
|
374
|
+
update: updateCmd,
|
|
375
|
+
watch: watchCmd,
|
|
376
|
+
};
|
|
377
|
+
const root = defineCommand({
|
|
378
|
+
meta: { description: "beflow — drive work items through coding agents", name: "beflow" },
|
|
379
|
+
subCommands,
|
|
380
|
+
});
|
|
381
|
+
return { root, subCommands };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Test seam + entrypoint. `argv` is the raw arg list WITHOUT node/script (e.g.
|
|
385
|
+
// ["run", "BFT-9", "--open"]). Returns 0 on success, 1 on failure, writes
|
|
386
|
+
// errors to stderr. citty does NOT auto-handle `--help` through `runCommand`
|
|
387
|
+
// (only `runMain` does, and it calls `process.exit`), so we detect help
|
|
388
|
+
// ourselves and render usage via the exported `showUsage` without exiting.
|
|
389
|
+
export async function runCli(argv: string[], deps: CliDeps): Promise<number> {
|
|
390
|
+
const cli = buildCli(deps);
|
|
391
|
+
try {
|
|
392
|
+
const target = resolveCliTarget(cli, argv);
|
|
393
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
394
|
+
await showUsage(target.cmd, target.parent);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
// Dispatch directly to the resolved subcommand so its `run` return value
|
|
398
|
+
// (the exit code) propagates — citty's `runCommand` discards a
|
|
399
|
+
// subcommand's result when it recurses internally.
|
|
400
|
+
const r =
|
|
401
|
+
target.cmd === cli.root
|
|
402
|
+
? await runCommand(cli.root, { rawArgs: argv })
|
|
403
|
+
: await runCommand(target.cmd, { rawArgs: target.rest });
|
|
404
|
+
return typeof r.result === "number" ? r.result : 0;
|
|
405
|
+
} catch (err) {
|
|
406
|
+
process.stderr.write(`beflow: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface CliTarget {
|
|
412
|
+
cmd: CommandDef;
|
|
413
|
+
parent: CommandDef | undefined;
|
|
414
|
+
rest: string[];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Resolves which (sub)command `argv` addresses. Returns the root itself when the
|
|
418
|
+
// first token is not a known subcommand, letting `runCommand`/`showUsage` handle
|
|
419
|
+
// the unknown-command / top-level-help cases natively.
|
|
420
|
+
function resolveCliTarget(cli: Cli, argv: string[]): CliTarget {
|
|
421
|
+
const first = argv[0];
|
|
422
|
+
if (first !== undefined && !first.startsWith("-")) {
|
|
423
|
+
const sub = cli.subCommands[first];
|
|
424
|
+
if (sub !== undefined) {
|
|
425
|
+
return { cmd: sub, parent: cli.root, rest: argv.slice(1) };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { cmd: cli.root, parent: undefined, rest: argv };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function cmdRun(
|
|
432
|
+
args: RunArgs & { key: string; open?: boolean; fresh?: boolean; dryRun?: boolean },
|
|
433
|
+
ctx: CliContext,
|
|
434
|
+
): Promise<number> {
|
|
435
|
+
const { deps, config, registry, tracker, prompts, log } = ctx;
|
|
436
|
+
const { key } = args;
|
|
437
|
+
const cli = cliOverrides(args);
|
|
438
|
+
const fmt = notifyFormat();
|
|
439
|
+
const notify = createNotifier({
|
|
440
|
+
...(fmt !== undefined ? { format: fmt } : {}),
|
|
441
|
+
log,
|
|
442
|
+
webhookUrl: process.env.BEFLOW_NOTIFY_WEBHOOK,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const preResolved: ResolvedRun = await resolveRun(key, cli, config, registry, tracker);
|
|
446
|
+
|
|
447
|
+
if (args.dryRun === true) {
|
|
448
|
+
printRunPlan(key, preResolved, ctx);
|
|
449
|
+
return 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
await assertBoardReady(projectKeyOf(key), tracker, log);
|
|
453
|
+
|
|
454
|
+
// Acpx-driven runs (--auto / --attend) get the `.mcp.json` cascade injected as
|
|
455
|
+
// `.acpxrc.json`; --open is the agent's native client and is left untouched.
|
|
456
|
+
const mcpServers = config.mcp?.enabled === true ? loadMcpServers(defaultMcpDeps(ctx.dir)) : [];
|
|
457
|
+
|
|
458
|
+
if (args.open === true) {
|
|
459
|
+
const openDeps: RunOpenDeps = {
|
|
460
|
+
tracker,
|
|
461
|
+
config,
|
|
462
|
+
registry,
|
|
463
|
+
prompts,
|
|
464
|
+
...(deps.openIssue !== undefined ? { openIssue: deps.openIssue } : {}),
|
|
465
|
+
...(deps.askOutcome !== undefined ? { askOutcome: deps.askOutcome } : {}),
|
|
466
|
+
log,
|
|
467
|
+
notify,
|
|
468
|
+
preResolved,
|
|
469
|
+
};
|
|
470
|
+
await runOpen(key, cli, openDeps);
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (preResolved.resolved.runMode === "autonomous") {
|
|
475
|
+
const runDeps: RunIssueDeps = {
|
|
476
|
+
config,
|
|
477
|
+
driver: deps.createDriver(resolveAcpxCommand(config)),
|
|
478
|
+
fresh: args.fresh === true,
|
|
479
|
+
git: deps.git,
|
|
480
|
+
log,
|
|
481
|
+
notify,
|
|
482
|
+
preResolved,
|
|
483
|
+
prompts,
|
|
484
|
+
registry,
|
|
485
|
+
tracker,
|
|
486
|
+
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
487
|
+
};
|
|
488
|
+
const result = await runIssue(key, cli, runDeps);
|
|
489
|
+
log(`beflow: ${key} done (status: ${result.result.report?.status ?? "no report"})`);
|
|
490
|
+
return 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const supervisedDriver = deps.createDriver(resolveAcpxCommand(config));
|
|
494
|
+
const supervisedDeps: RunSupervisedDeps = {
|
|
495
|
+
askOutcome: deps.askOutcome,
|
|
496
|
+
config,
|
|
497
|
+
ensureSession: async (sessionName, sessionCwd, acpCommand) =>
|
|
498
|
+
supervisedDriver.ensureSession(sessionName, sessionCwd, acpCommand),
|
|
499
|
+
launchInteractive: deps.launchInteractive,
|
|
500
|
+
log,
|
|
501
|
+
notify,
|
|
502
|
+
preResolved,
|
|
503
|
+
prompts,
|
|
504
|
+
registry,
|
|
505
|
+
tracker,
|
|
506
|
+
...(mcpServers.length > 0 ? { mcpServers } : {}),
|
|
507
|
+
};
|
|
508
|
+
const result = await runSupervised(key, cli, supervisedDeps);
|
|
509
|
+
log(`beflow: ${key} done (status: ${result.report.status})`);
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Renders the read-only `run --dry-run` plan: what beflow WOULD do (resolution +
|
|
514
|
+
// applicable gates), with zero side effects. Mirrors the resolution `runIssue`
|
|
515
|
+
// performs but stops before any claim, board mutation, or agent dispatch.
|
|
516
|
+
function printRunPlan(key: string, preResolved: ResolvedRun, ctx: CliContext): void {
|
|
517
|
+
const { config, registry, log } = ctx;
|
|
518
|
+
const { issue, resolved } = preResolved;
|
|
519
|
+
const projectKey = projectKeyOf(key);
|
|
520
|
+
const model = config.agents[resolved.agent]?.model;
|
|
521
|
+
log(`beflow: DRY RUN — ${key} (no side effects)`);
|
|
522
|
+
log(` jobKind: ${resolved.jobKind}`);
|
|
523
|
+
log(` agent: ${resolved.agent}${model !== undefined ? ` (model: ${model})` : " (default model)"}`);
|
|
524
|
+
log(` repo: ${resolved.repo} (${resolved.repoPath})`);
|
|
525
|
+
log(` runMode: ${resolved.runMode}`);
|
|
526
|
+
log(" would create worktree for an autonomous run");
|
|
527
|
+
if (isDecisionHeld(issue.labels)) {
|
|
528
|
+
log(" gate: decision-hold — would park to Needs Input (needs-decision label present)");
|
|
529
|
+
}
|
|
530
|
+
if (isThinIssue(issue.body, resolveMinBodyChars(config, registry, projectKey))) {
|
|
531
|
+
log(" gate: thin-issue — would park to Needs Input (description too thin)");
|
|
532
|
+
}
|
|
533
|
+
const gateCommands = resolveQualityGate(config, registry, projectKey);
|
|
534
|
+
if (gateCommands.length > 0) {
|
|
535
|
+
log(` gate: quality — would run: ${gateCommands.join("; ")}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function cmdReview(args: { key: string }, ctx: CliContext): Promise<number> {
|
|
540
|
+
const { deps, config, registry, tracker, prompts, log } = ctx;
|
|
541
|
+
const { key } = args;
|
|
542
|
+
const review = deps.runReview ?? runReview;
|
|
543
|
+
const reviewDeps: RunReviewDeps = {
|
|
544
|
+
config,
|
|
545
|
+
driver: deps.createDriver(resolveAcpxCommand(config)),
|
|
546
|
+
git: deps.git,
|
|
547
|
+
log,
|
|
548
|
+
prompts,
|
|
549
|
+
registry,
|
|
550
|
+
reviewSha: async (prUrl) => (await defaultPrChecks(prUrl)).sha,
|
|
551
|
+
tracker,
|
|
552
|
+
};
|
|
553
|
+
await review(key, reviewDeps);
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Read-only run-record inspector. With a KEY it prints that record's detail; with
|
|
558
|
+
// None it lists every record. Touches only the local run store — no tracker calls,
|
|
559
|
+
// No mutation.
|
|
560
|
+
function cmdRuns(args: { key?: string | undefined }, ctx: CliContext): number {
|
|
561
|
+
const { deps, config, log, fail } = ctx;
|
|
562
|
+
const runsDir = resolveRunsDir(config.runs?.dir);
|
|
563
|
+
if (args.key !== undefined) {
|
|
564
|
+
const record =
|
|
565
|
+
deps.runsFs !== undefined ? loadRecord(runsDir, args.key, deps.runsFs) : loadRecord(runsDir, args.key);
|
|
566
|
+
if (record === null) {
|
|
567
|
+
return fail(`beflow: no run record for "${args.key}"`);
|
|
568
|
+
}
|
|
569
|
+
const model = config.agents[record.agent]?.model;
|
|
570
|
+
for (const line of formatRunDetail(record, model)) {
|
|
571
|
+
log(line);
|
|
572
|
+
}
|
|
573
|
+
return 0;
|
|
574
|
+
}
|
|
575
|
+
const records = deps.runsFs !== undefined ? listRecords(runsDir, deps.runsFs) : listRecords(runsDir);
|
|
576
|
+
for (const line of formatRunList(records)) {
|
|
577
|
+
log(line);
|
|
578
|
+
}
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function cmdSetup(args: { project: string; prune?: boolean | undefined }, ctx: CliContext): Promise<number> {
|
|
583
|
+
const { tracker, config, registry, dir, log } = ctx;
|
|
584
|
+
const agents = [...new Set([config.defaults.agent, ...Object.keys(config.agents)])].sort();
|
|
585
|
+
await setupProject(args.project, {
|
|
586
|
+
agents,
|
|
587
|
+
dir,
|
|
588
|
+
log,
|
|
589
|
+
prune: args.prune === true,
|
|
590
|
+
registry,
|
|
591
|
+
tracker,
|
|
592
|
+
trackerName: config.tracker,
|
|
593
|
+
});
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function cmdQueue(
|
|
598
|
+
args: { project?: string | undefined; state?: string | undefined },
|
|
599
|
+
ctx: CliContext,
|
|
600
|
+
): Promise<number> {
|
|
601
|
+
const { tracker, registry, log } = ctx;
|
|
602
|
+
const rows = await queueView(
|
|
603
|
+
{ registry, tracker },
|
|
604
|
+
{
|
|
605
|
+
...(args.project !== undefined ? { projects: [args.project] } : {}),
|
|
606
|
+
...(args.state !== undefined ? { state: args.state } : {}),
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
printQueue(rows, log);
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function cmdWatch(
|
|
614
|
+
args: { project: string; interval?: string | undefined; dryRun?: boolean | undefined },
|
|
615
|
+
ctx: CliContext,
|
|
616
|
+
): Promise<number> {
|
|
617
|
+
const { deps, config, registry, tracker, prompts, log, fail } = ctx;
|
|
618
|
+
const projectKey = args.project;
|
|
619
|
+
const intervalSec = args.interval !== undefined ? Number(args.interval) : 30;
|
|
620
|
+
if (Number.isNaN(intervalSec) || intervalSec <= 0) {
|
|
621
|
+
return fail(`beflow: invalid --interval "${String(args.interval)}"`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
await assertBoardReady(projectKey, tracker, log);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Hot-reload: the watch loop is the only long-running command, so it gets a
|
|
631
|
+
// ConfigStore that re-reads config.json on change. The store reuses the
|
|
632
|
+
// Injected loaders so test seams keep working; production gets live reload.
|
|
633
|
+
const store = new ConfigStore(ctx.dir, {
|
|
634
|
+
loadConfig: deps.loadConfig,
|
|
635
|
+
loadRegistry: deps.loadRegistry,
|
|
636
|
+
log,
|
|
637
|
+
...(deps.configWatcher !== undefined ? { watcher: deps.configWatcher } : {}),
|
|
638
|
+
});
|
|
639
|
+
store.init();
|
|
640
|
+
if (deps.configWatcher !== undefined) {
|
|
641
|
+
store.start();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const watchFmt = notifyFormat();
|
|
645
|
+
const watchMcpServers: McpServer[] = config.mcp?.enabled === true ? loadMcpServers(defaultMcpDeps(ctx.dir)) : [];
|
|
646
|
+
const watchDeps: WatchDeps = {
|
|
647
|
+
config,
|
|
648
|
+
driver: deps.createDriver(resolveAcpxCommand(config)),
|
|
649
|
+
getSnapshot: () => store.get(),
|
|
650
|
+
git: deps.git,
|
|
651
|
+
log,
|
|
652
|
+
notify: createNotifier({
|
|
653
|
+
...(watchFmt !== undefined ? { format: watchFmt } : {}),
|
|
654
|
+
log,
|
|
655
|
+
webhookUrl: process.env.BEFLOW_NOTIFY_WEBHOOK,
|
|
656
|
+
}),
|
|
657
|
+
prChecks: defaultPrChecks,
|
|
658
|
+
prMerged: defaultPrMerged,
|
|
659
|
+
prompts,
|
|
660
|
+
registry,
|
|
661
|
+
tracker,
|
|
662
|
+
...(args.dryRun === true ? { dryRun: true } : {}),
|
|
663
|
+
...(watchMcpServers.length > 0 ? { mcpServers: watchMcpServers } : {}),
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// --dry-run: a single read-only tick previews the dispatch decision with zero
|
|
667
|
+
// Side effects (no loop, no mutating passes). watchTick honors deps.dryRun.
|
|
668
|
+
if (args.dryRun === true) {
|
|
669
|
+
try {
|
|
670
|
+
await watchTick(projectKey, watchDeps);
|
|
671
|
+
} finally {
|
|
672
|
+
store.stop();
|
|
673
|
+
}
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let stopped = false;
|
|
678
|
+
function onSigint(): void {
|
|
679
|
+
stopped = true;
|
|
680
|
+
}
|
|
681
|
+
process.once("SIGINT", onSigint);
|
|
682
|
+
const runner = deps.watch ?? watch;
|
|
683
|
+
try {
|
|
684
|
+
await runner(projectKey, watchDeps, {
|
|
685
|
+
shouldStop: () => stopped,
|
|
686
|
+
sleepMs: intervalSec * 1000,
|
|
687
|
+
});
|
|
688
|
+
} finally {
|
|
689
|
+
process.removeListener("SIGINT", onSigint);
|
|
690
|
+
store.stop();
|
|
691
|
+
}
|
|
692
|
+
return 0;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function cmdAccept(args: { project: string; intake: string }, ctx: CliContext): Promise<number> {
|
|
696
|
+
const { tracker, log } = ctx;
|
|
697
|
+
const item = await acceptIntake(args.project, args.intake, { log, tracker });
|
|
698
|
+
log(`beflow: ${args.project} accepted ${item.id} → Backlog`);
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function cmdNew(args: { project: string; template?: string | undefined }, ctx: CliContext): Promise<number> {
|
|
703
|
+
const templateDeps = defaultIssueTemplateResolveDeps(ctx.dir, ctx.config.prompts?.dir);
|
|
704
|
+
const enrich = resolveEnrich(args.project, ctx);
|
|
705
|
+
const deps: NewIssueDeps = {
|
|
706
|
+
askConfirm: ctx.deps.askConfirm ?? defaultAskConfirm,
|
|
707
|
+
askQuestions: ctx.deps.askQuestions ?? defaultAskQuestions,
|
|
708
|
+
askTemplate: ctx.deps.askTemplate ?? defaultAskTemplate,
|
|
709
|
+
log: ctx.log,
|
|
710
|
+
templateDeps,
|
|
711
|
+
tracker: ctx.tracker,
|
|
712
|
+
...(enrich !== undefined ? { enrich } : {}),
|
|
713
|
+
};
|
|
714
|
+
await newIssue(args.project, args.template, deps);
|
|
715
|
+
return 0;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Resolves the agent-enrich boundary for `beflow new`. Prefers a test-injected
|
|
719
|
+
// enrich; otherwise builds the read-only driver-backed one when the project's
|
|
720
|
+
// default_repo path resolves. Returns undefined (→ form draft) when no repo path
|
|
721
|
+
// is available, warning so enrich:true templates degrade visibly.
|
|
722
|
+
function resolveEnrich(project: string, ctx: CliContext): EnrichIssue | undefined {
|
|
723
|
+
if (ctx.deps.enrich !== undefined) {
|
|
724
|
+
return ctx.deps.enrich;
|
|
725
|
+
}
|
|
726
|
+
const proj = ctx.registry.projects[project];
|
|
727
|
+
const repoName = proj?.default_repo;
|
|
728
|
+
const rawPath = repoName !== undefined ? proj?.repos[repoName] : undefined;
|
|
729
|
+
if (rawPath === undefined) {
|
|
730
|
+
ctx.log(`beflow: no default_repo path for "${project}"; enrich:true templates will use the form draft`);
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
const enrichPrompt = loadEnrichPrompt(defaultPromptResolveDeps(ctx.dir, ctx.config.prompts?.dir));
|
|
734
|
+
return defaultEnrichIssue({
|
|
735
|
+
defaultAgent: ctx.config.defaults.agent,
|
|
736
|
+
driver: ctx.deps.createDriver(resolveAcpxCommand(ctx.config)),
|
|
737
|
+
enrichPrompt,
|
|
738
|
+
log: ctx.log,
|
|
739
|
+
repoPath: expandHome(rawPath),
|
|
740
|
+
resolveAcp: (a) => resolveAcpCommand(a, ctx.config.agents[a]),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function cmdDoctor(
|
|
745
|
+
args: { ping?: boolean | undefined },
|
|
746
|
+
deps: CliDeps,
|
|
747
|
+
dir: string,
|
|
748
|
+
log: (msg: string) => void,
|
|
749
|
+
): Promise<number> {
|
|
750
|
+
const fileExists = deps.fileExists ?? existsSync;
|
|
751
|
+
const onPath = deps.onPath ?? onPathDefault;
|
|
752
|
+
const checks = await doctor({
|
|
753
|
+
env: process.env,
|
|
754
|
+
fileExists,
|
|
755
|
+
loadConfig: () => deps.loadConfig(dir),
|
|
756
|
+
loadRegistry: () => deps.loadRegistry(dir),
|
|
757
|
+
onPath,
|
|
758
|
+
...(args.ping === true && deps.ping !== undefined
|
|
759
|
+
? {
|
|
760
|
+
boardChecks: async (): Promise<DoctorCheck[]> => boardChecks(deps, dir),
|
|
761
|
+
ping: deps.ping,
|
|
762
|
+
}
|
|
763
|
+
: {}),
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
for (const check of checks) {
|
|
767
|
+
log(`${checkGlyph(check.level)} ${check.name} — ${check.detail}`);
|
|
768
|
+
}
|
|
769
|
+
return checks.some((c) => c.level === "fail") ? 1 : 0;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function cmdGc(
|
|
773
|
+
args: { prune?: boolean | undefined; force?: boolean | undefined; olderThan?: string | undefined },
|
|
774
|
+
deps: CliDeps,
|
|
775
|
+
dir: string,
|
|
776
|
+
log: (msg: string) => void,
|
|
777
|
+
): Promise<number> {
|
|
778
|
+
if (deps.git === undefined) {
|
|
779
|
+
return makeFail()("beflow: gc requires git, but no git executor is configured");
|
|
780
|
+
}
|
|
781
|
+
const config = deps.loadConfig(dir);
|
|
782
|
+
const worktreesDir = resolveWorktreeDir(config.worktrees?.dir);
|
|
783
|
+
const runsDir = resolveRunsDir(config.runs?.dir);
|
|
784
|
+
|
|
785
|
+
let olderThanDays: number | undefined;
|
|
786
|
+
if (args.olderThan !== undefined) {
|
|
787
|
+
const parsed = Number(args.olderThan);
|
|
788
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
789
|
+
return makeFail()(`beflow: invalid --older-than "${args.olderThan}" (expected a positive number of days)`);
|
|
790
|
+
}
|
|
791
|
+
olderThanDays = parsed;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await runGc({
|
|
795
|
+
force: args.force === true,
|
|
796
|
+
git: deps.git,
|
|
797
|
+
log,
|
|
798
|
+
prune: args.prune === true,
|
|
799
|
+
runsDir,
|
|
800
|
+
worktreesDir,
|
|
801
|
+
...(olderThanDays !== undefined ? { olderThanDays } : {}),
|
|
802
|
+
});
|
|
803
|
+
return 0;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function boardChecks(deps: CliDeps, dir: string): Promise<DoctorCheck[]> {
|
|
807
|
+
const config = deps.loadConfig(dir);
|
|
808
|
+
const registry = deps.loadRegistry(dir);
|
|
809
|
+
const agents = [...new Set([config.defaults.agent, ...Object.keys(config.agents)])].sort();
|
|
810
|
+
const tracker = deps.createTracker(config, registry);
|
|
811
|
+
|
|
812
|
+
const checks: DoctorCheck[] = [];
|
|
813
|
+
for (const key of Object.keys(registry.projects)) {
|
|
814
|
+
const template = beflowBoardTemplate(registry, key, agents);
|
|
815
|
+
try {
|
|
816
|
+
const board = await tracker.inspectBoard(key);
|
|
817
|
+
const drift = boardDrift(template, board);
|
|
818
|
+
const missing = drift.missingStates.length + drift.missingLabels.length + drift.missingModules.length;
|
|
819
|
+
if (missing === 0) {
|
|
820
|
+
const hint =
|
|
821
|
+
drift.extraStates.length > 0 ? ` (unexpected states present: ${drift.extraStates.join(", ")})` : "";
|
|
822
|
+
checks.push({
|
|
823
|
+
detail: `matches template${hint}`,
|
|
824
|
+
level: "pass",
|
|
825
|
+
name: `board:${key}`,
|
|
826
|
+
});
|
|
827
|
+
} else {
|
|
828
|
+
const parts: string[] = [];
|
|
829
|
+
if (drift.missingStates.length > 0) {
|
|
830
|
+
parts.push(`missing state(s): ${drift.missingStates.join(", ")}`);
|
|
831
|
+
}
|
|
832
|
+
if (drift.missingLabels.length > 0) {
|
|
833
|
+
parts.push(`label(s): ${drift.missingLabels.join(", ")}`);
|
|
834
|
+
}
|
|
835
|
+
if (drift.missingModules.length > 0) {
|
|
836
|
+
parts.push(`module(s): ${drift.missingModules.join(", ")}`);
|
|
837
|
+
}
|
|
838
|
+
const extra =
|
|
839
|
+
drift.extraStates.length > 0
|
|
840
|
+
? ` — unexpected states present: ${drift.extraStates.join(", ")} (renamed?)`
|
|
841
|
+
: "";
|
|
842
|
+
checks.push({
|
|
843
|
+
detail: `${parts.join("; ")}${extra}`,
|
|
844
|
+
level: "fail",
|
|
845
|
+
name: `board:${key}`,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
850
|
+
checks.push({
|
|
851
|
+
detail: `could not inspect: ${msg}`,
|
|
852
|
+
level: "warn",
|
|
853
|
+
name: `board:${key}`,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return checks;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function checkGlyph(level: DoctorCheck["level"]): string {
|
|
861
|
+
const glyphs: Record<DoctorCheck["level"], string> = {
|
|
862
|
+
fail: "✗",
|
|
863
|
+
pass: "✓",
|
|
864
|
+
warn: "!",
|
|
865
|
+
};
|
|
866
|
+
return glyphs[level];
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function printQueue(rows: QueueRow[], log: (msg: string) => void): void {
|
|
870
|
+
if (rows.length === 0) {
|
|
871
|
+
log("beflow: queue empty");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const cells = rows.map((r) => ({
|
|
875
|
+
key: r.key,
|
|
876
|
+
priority: r.priority ?? "",
|
|
877
|
+
project: r.project,
|
|
878
|
+
state: r.state,
|
|
879
|
+
title: r.title,
|
|
880
|
+
}));
|
|
881
|
+
const widths = {
|
|
882
|
+
key: colWidth(cells, "key", "KEY"),
|
|
883
|
+
priority: colWidth(cells, "priority", "PRIORITY"),
|
|
884
|
+
project: colWidth(cells, "project", "PROJECT"),
|
|
885
|
+
state: colWidth(cells, "state", "STATE"),
|
|
886
|
+
};
|
|
887
|
+
log(
|
|
888
|
+
`${"PROJECT".padEnd(widths.project)} ${"KEY".padEnd(widths.key)} ${"STATE".padEnd(widths.state)} ${"PRIORITY".padEnd(widths.priority)} TITLE`,
|
|
889
|
+
);
|
|
890
|
+
for (const c of cells) {
|
|
891
|
+
log(
|
|
892
|
+
`${c.project.padEnd(widths.project)} ${c.key.padEnd(widths.key)} ${c.state.padEnd(widths.state)} ${c.priority.padEnd(widths.priority)} ${c.title}`,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function colWidth(cells: Record<string, string>[], key: string, header: string): number {
|
|
898
|
+
return cells.reduce((w, c) => Math.max(w, (c[key] ?? "").length), header.length);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function projectKeyOf(issueKey: string): string {
|
|
902
|
+
const dash = issueKey.lastIndexOf("-");
|
|
903
|
+
return dash === -1 ? issueKey : issueKey.slice(0, dash);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (import.meta.main) {
|
|
907
|
+
void runCli(process.argv.slice(2), defaultCliDeps()).then((code) => {
|
|
908
|
+
process.exit(code);
|
|
909
|
+
});
|
|
910
|
+
}
|