glab-agent 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.
@@ -0,0 +1,1497 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
5
+ import { execFile as execFileCb } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+
8
+ import { discoverAgents, loadAgentByName, agentAgentsDir, agentEnvPath, agentBaseDir, agentLogPath, agentStatePath, agentWorktreeRoot, discoverSharedSkills, agentSkillsDir, agentHeartbeatPath } from "./agent-config.js";
9
+ import { readHeartbeat, isHeartbeatStale } from "./heartbeat.js";
10
+ import { FileStateStore, appendRunHistory } from "./state-store.js";
11
+ import type { RunHistoryEntry } from "./state-store.js";
12
+ import { GitlabGlabClient } from "./gitlab-glab-client.js";
13
+ import { loadConfigFromAgentDefinition, transitionStatusLabels, markAgentOffline, parseGitRemoteUrl } from "./watcher.js";
14
+ import { sendNotification } from "./notifier.js";
15
+ import { generateReport, formatReport } from "./report.js";
16
+ import { parseGitHubUrl, importSkill, importAllSkills } from "./skill-import.js";
17
+ import { initWikiDir, wikiPath } from "./wiki-sync.js";
18
+
19
+ const execFileAsync = promisify(execFileCb);
20
+
21
+ // Load .env file into process.env (simple dotenv without dependency)
22
+ function loadDotenv(filePath: string): void {
23
+ try {
24
+ const content = readFileSync(filePath, "utf8");
25
+ for (const line of content.split("\n")) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed || trimmed.startsWith("#")) continue;
28
+ const eqIdx = trimmed.indexOf("=");
29
+ if (eqIdx <= 0) continue;
30
+ const key = trimmed.slice(0, eqIdx).trim();
31
+ const value = trimmed.slice(eqIdx + 1).trim();
32
+ if (!(key in process.env)) {
33
+ process.env[key] = value;
34
+ }
35
+ }
36
+ } catch {
37
+ // .env not found, ignore
38
+ }
39
+ }
40
+
41
+ // Parse --project <dir> early, before loadDotenv
42
+ function parseProjectFlag(argv: string[]): string | undefined {
43
+ const idx = argv.indexOf("--project");
44
+ if (idx !== -1 && idx + 1 < argv.length) {
45
+ return path.resolve(argv[idx + 1]);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function stripProjectFlag(args: string[]): string[] {
51
+ const result: string[] = [];
52
+ for (let i = 0; i < args.length; i++) {
53
+ if (args[i] === "--project") {
54
+ i++; // skip the value too
55
+ } else {
56
+ result.push(args[i]);
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ // INSTALL_DIR: the glab-agent repo (2 levels up from src/local-agent/cli.ts)
63
+ const INSTALL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
64
+ // SCRIPT_DIR: always points to src/local-agent/ in the glab-agent repo
65
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
66
+ // PROJECT_DIR: target project directory (--project flag or cwd)
67
+ const PROJECT_DIR = parseProjectFlag(process.argv) ?? process.cwd();
68
+ const AGENTS_DIR = agentAgentsDir(PROJECT_DIR);
69
+
70
+ loadDotenv(agentEnvPath(PROJECT_DIR));
71
+ import {
72
+ getAgentStatus,
73
+ startAgent,
74
+ stopAgent,
75
+ generateLaunchdPlist,
76
+ launchdPlistPath
77
+ } from "./process-manager.js";
78
+
79
+ function usage(): void {
80
+ console.log(`Usage: glab-agent <command> [--project <dir>] [options]
81
+ pnpm agent <command> [--project <dir>] [options]
82
+
83
+ Options:
84
+ --project <dir> Target project directory (default: cwd)
85
+
86
+ Commands:
87
+ init [name] Initialize .glab-agent/ and optionally create an agent
88
+ --provider <claude|codex> --labels <l1,l2> --token-env <VAR>
89
+ list List all agent definitions
90
+ start <name|--all> Start agent(s) as background watcher process
91
+ stop <name|--all> Stop agent(s)
92
+ status [name] Show agent status (PID, alive)
93
+ logs <name> Tail agent logs
94
+ run-once <name> Run a single watcher cycle
95
+ cancel <name> Cancel current running task
96
+ retry <name> <iid> Retry a failed issue (--resume to keep worktree)
97
+ why <issue-iid> Diagnose why an issue hasn't been picked up
98
+ watch <name> Watch agent status in real-time (refreshes every 5s)
99
+ history <name> Show agent execution history (--last N, default 20)
100
+ report <name> Show execution report (--since <N>d, default 7d)
101
+ sweep <name|--all> Move merged In Review issues to Done
102
+ gc <name|--all> Remove worktrees for closed/merged issues
103
+ install <name|--all> Generate launchd plist for agent(s)
104
+ skills List all shared skills
105
+ skills import <url> Import skills from GitHub repo (--force to overwrite)
106
+ wiki init Initialize Knowledge Wiki directory structure
107
+ doctor Check prerequisites and environment
108
+ `);
109
+ }
110
+
111
+ async function cmdList(): Promise<void> {
112
+ const agents = await discoverAgents(AGENTS_DIR);
113
+ if (agents.length === 0) {
114
+ console.log("No agent definitions found in agents/*.yaml");
115
+ console.log("Create a YAML file in the agents/ directory to define an agent.");
116
+ return;
117
+ }
118
+
119
+ console.log(`Found ${agents.length} agent(s):\n`);
120
+ for (const agent of agents) {
121
+ const status = await getAgentStatus(PROJECT_DIR, agent.name);
122
+ const statusIcon = status.alive ? "🟢" : "⚫";
123
+ const pidInfo = status.alive ? ` (PID ${status.pid})` : "";
124
+ console.log(` ${statusIcon} ${agent.name}${pidInfo}`);
125
+ console.log(` ${agent.display_name} — ${agent.description || "(no description)"}`);
126
+ console.log(` provider: ${agent.provider} | poll: ${agent.poll_interval_seconds}s`);
127
+ if (agent.skills.length > 0) {
128
+ console.log(` skills: ${agent.skills.map((s) => s.name).join(", ")}`);
129
+ }
130
+ if (agent.triggers.labels.length > 0) {
131
+ console.log(` labels: ${agent.triggers.labels.join(", ")}`);
132
+ }
133
+ console.log();
134
+ }
135
+ }
136
+
137
+ async function cmdSkills(): Promise<void> {
138
+ const skillsDir = agentSkillsDir(PROJECT_DIR);
139
+ const skills = await discoverSharedSkills(skillsDir);
140
+ if (skills.length === 0) {
141
+ console.log("No shared skills found in .glab-agent/skills/");
142
+ console.log("Create skill YAML files to share across agents.");
143
+ return;
144
+ }
145
+ console.log(`Found ${skills.length} shared skill(s):\n`);
146
+ for (const skill of skills) {
147
+ console.log(` ${skill.name}`);
148
+ if (skill.description) {
149
+ console.log(` ${skill.description}`);
150
+ }
151
+ console.log();
152
+ }
153
+ }
154
+
155
+ async function cmdSkillImport(input: string, options?: { force?: boolean }): Promise<void> {
156
+ const skillsDir = agentSkillsDir(PROJECT_DIR);
157
+ const parsed = parseGitHubUrl(input);
158
+ if (!parsed) {
159
+ console.error(`Invalid GitHub URL or shorthand: ${input}`);
160
+ console.error("Supported formats: https://github.com/owner/repo, owner/repo, owner/repo/path/skill.yaml, owner/repo/path/SKILL.md");
161
+ process.exit(1);
162
+ }
163
+
164
+ const { owner, repo, path: filePath, ref } = parsed;
165
+
166
+ if (filePath && (filePath.endsWith(".yaml") || filePath.endsWith(".yml") || filePath.endsWith(".md"))) {
167
+ // Import a single skill file
168
+ const result = await importSkill(owner, repo, filePath, skillsDir, { ref, force: options?.force });
169
+ if (result.status === "failed") {
170
+ console.error(` Failed: ${result.name} — ${result.message}`);
171
+ process.exit(1);
172
+ }
173
+ console.log(` ${result.status === "imported" ? "+" : result.status === "updated" ? "~" : "="} ${result.name} — ${result.message}`);
174
+ } else {
175
+ // Import all skills from the repo
176
+ const results = await importAllSkills(owner, repo, skillsDir, { ref, force: options?.force });
177
+ if (results.length === 0) {
178
+ console.log("No skill files found in the repository.");
179
+ console.log("Searched: skills/, .agent_context/skills/, .glab-agent/skills/");
180
+ return;
181
+ }
182
+ for (const r of results) {
183
+ const icon = r.status === "imported" ? "+" : r.status === "updated" ? "~" : r.status === "failed" ? "x" : "=";
184
+ console.log(` ${icon} ${r.name} — ${r.message}`);
185
+ }
186
+ const imported = results.filter(r => r.status === "imported" || r.status === "updated").length;
187
+ console.log(`\n${imported} skill(s) imported/updated to ${skillsDir}`);
188
+ }
189
+ }
190
+
191
+ async function cmdStart(nameOrFlag: string): Promise<void> {
192
+ if (nameOrFlag === "--all") {
193
+ const agents = await discoverAgents(AGENTS_DIR);
194
+ if (agents.length === 0) {
195
+ console.log("No agent definitions found.");
196
+ return;
197
+ }
198
+ for (const agent of agents) {
199
+ try {
200
+ const pid = await startAgent(PROJECT_DIR, agent.name, SCRIPT_DIR, INSTALL_DIR);
201
+ console.log(`Started ${agent.name} (PID ${pid})`);
202
+ } catch (error) {
203
+ console.error(`Failed to start ${agent.name}: ${String(error)}`);
204
+ }
205
+ }
206
+ return;
207
+ }
208
+
209
+ // Validate agent exists
210
+ await loadAgentByName(AGENTS_DIR, nameOrFlag);
211
+ const pid = await startAgent(PROJECT_DIR, nameOrFlag, SCRIPT_DIR, INSTALL_DIR);
212
+ console.log(`Started ${nameOrFlag} (PID ${pid})`);
213
+ }
214
+
215
+ async function markOffline(name: string): Promise<void> {
216
+ try {
217
+ const agent = await loadAgentByName(AGENTS_DIR, name);
218
+ const config = loadConfigFromAgentDefinition(agent, process.env, { cwd: PROJECT_DIR });
219
+ const client = new GitlabGlabClient({ host: config.gitlabHost, token: config.gitlabToken });
220
+ const store = new FileStateStore(agentStatePath(PROJECT_DIR, name));
221
+ await markAgentOffline(config, client, store);
222
+ } catch (err) {
223
+ console.warn(`Warning: could not update GitLab offline status for ${name}: ${String(err)}`);
224
+ }
225
+ }
226
+
227
+ async function cmdStop(nameOrFlag: string): Promise<void> {
228
+ if (nameOrFlag === "--all") {
229
+ const agents = await discoverAgents(AGENTS_DIR);
230
+ for (const agent of agents) {
231
+ const stopped = await stopAgent(PROJECT_DIR, agent.name);
232
+ console.log(stopped ? `Stopped ${agent.name}` : `${agent.name} was not running`);
233
+ await markOffline(agent.name);
234
+ }
235
+ return;
236
+ }
237
+
238
+ const stopped = await stopAgent(PROJECT_DIR, nameOrFlag);
239
+ console.log(stopped ? `Stopped ${nameOrFlag}` : `${nameOrFlag} was not running`);
240
+ await markOffline(nameOrFlag);
241
+ }
242
+
243
+ async function cmdCancel(name: string): Promise<void> {
244
+ const agent = await loadAgentByName(AGENTS_DIR, name);
245
+ const statePath = agentStatePath(PROJECT_DIR, name);
246
+ const store = new FileStateStore(statePath);
247
+ const state = await store.load();
248
+
249
+ if (!state.activeRun) {
250
+ console.log(`Agent ${name} 没有正在运行的任务。`);
251
+ return;
252
+ }
253
+
254
+ const { issueIid, startedAt, branch } = state.activeRun;
255
+ console.log(`正在取消 agent ${name} 对 issue #${issueIid} 的处理...`);
256
+
257
+ // 1. Stop the watcher process
258
+ await stopAgent(PROJECT_DIR, name);
259
+
260
+ // 2. Update GitLab issue: revert label to Todo and add cancellation note
261
+ const config = loadConfigFromAgentDefinition(agent, process.env, { cwd: PROJECT_DIR });
262
+ const client = new GitlabGlabClient({ host: config.gitlabHost, token: config.gitlabToken });
263
+
264
+ try {
265
+ const issue = await client.getIssue(config.gitlabProjectId!, issueIid);
266
+ if (issue.labels.includes("In Progress")) {
267
+ await client.updateIssueLabels(
268
+ config.gitlabProjectId!,
269
+ issueIid,
270
+ transitionStatusLabels(issue.labels, "Todo")
271
+ );
272
+ }
273
+ await client.addIssueNote(config.gitlabProjectId!, issueIid, "⏹️ 任务已被用户取消。");
274
+ } catch (err) {
275
+ console.warn(`更新 GitLab issue 失败: ${String(err)}`);
276
+ }
277
+
278
+ // 3. Record history and clear activeRun
279
+ appendRunHistory(state, {
280
+ issueIid,
281
+ status: "failed",
282
+ startedAt,
283
+ finishedAt: new Date().toISOString(),
284
+ branch,
285
+ summary: "Cancelled by user"
286
+ });
287
+ state.activeRun = undefined;
288
+ state.lastRun = { status: "failed", at: new Date().toISOString(), issueIid, summary: "Cancelled by user" };
289
+ await store.save(state);
290
+
291
+ console.log(`已取消。Issue #${issueIid} 已回滚到 Todo 状态。`);
292
+ }
293
+
294
+ async function formatAgentStatus(agentName: string): Promise<string> {
295
+ const status = await getAgentStatus(PROJECT_DIR, agentName);
296
+ const icon = status.alive ? "🟢" : "⚫";
297
+ let statusText = status.alive ? `running (PID ${status.pid})` : "not running";
298
+
299
+ if (status.alive) {
300
+ const heartbeat = await readHeartbeat(agentHeartbeatPath(PROJECT_DIR, agentName));
301
+ if (heartbeat) {
302
+ const agent = await loadAgentByName(AGENTS_DIR, agentName);
303
+ if (isHeartbeatStale(heartbeat, agent.poll_interval_seconds)) {
304
+ statusText += " ⚠️ heartbeat stale";
305
+ } else {
306
+ statusText += ` (${heartbeat.cycleCount} cycles)`;
307
+ }
308
+ if (heartbeat.lastError) {
309
+ statusText += ` last_error: ${heartbeat.lastError.slice(0, 60)}`;
310
+ }
311
+ }
312
+ }
313
+
314
+ return `${icon} ${agentName}: ${statusText}`;
315
+ }
316
+
317
+ async function cmdStatus(name?: string): Promise<void> {
318
+ if (name) {
319
+ console.log(await formatAgentStatus(name));
320
+ return;
321
+ }
322
+
323
+ const agents = await discoverAgents(AGENTS_DIR);
324
+ if (agents.length === 0) {
325
+ console.log("No agent definitions found.");
326
+ return;
327
+ }
328
+ for (const agent of agents) {
329
+ console.log(await formatAgentStatus(agent.name));
330
+ }
331
+ }
332
+
333
+ async function cmdLogs(name: string): Promise<void> {
334
+ const logFile = path.join(agentLogPath(PROJECT_DIR, name), "watcher.log");
335
+ const { execFile } = await import("node:child_process");
336
+ const child = execFile("tail", ["-f", "-n", "50", logFile], { encoding: "utf8" });
337
+ child.stdout?.pipe(process.stdout);
338
+ child.stderr?.pipe(process.stderr);
339
+ await new Promise((resolve) => child.on("exit", resolve));
340
+ }
341
+
342
+ async function cmdRunOnce(name: string): Promise<void> {
343
+ // Import and run the watcher main with --agent flag
344
+ const { main } = await import("./watcher.js");
345
+ await main(["run-once", "--agent", name, "--project", PROJECT_DIR]);
346
+ }
347
+
348
+ async function cmdInstall(nameOrFlag: string): Promise<void> {
349
+ const install = async (agentName: string) => {
350
+ const plistContent = generateLaunchdPlist(agentName, PROJECT_DIR, SCRIPT_DIR, INSTALL_DIR);
351
+ const plistPath = launchdPlistPath(agentName, PROJECT_DIR);
352
+ await mkdir(path.dirname(plistPath), { recursive: true });
353
+ await writeFile(plistPath, plistContent, "utf8");
354
+ console.log(`Wrote ${plistPath}`);
355
+ console.log(` Load: launchctl load ${plistPath}`);
356
+ console.log(` Unload: launchctl unload ${plistPath}`);
357
+ };
358
+
359
+ if (nameOrFlag === "--all") {
360
+ const agents = await discoverAgents(AGENTS_DIR);
361
+ for (const agent of agents) {
362
+ await install(agent.name);
363
+ }
364
+ return;
365
+ }
366
+
367
+ // Validate agent exists
368
+ await loadAgentByName(AGENTS_DIR, nameOrFlag);
369
+ await install(nameOrFlag);
370
+ }
371
+
372
+ function extractFlag(args: string[], flag: string): string | undefined {
373
+ const idx = args.indexOf(flag);
374
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
375
+ }
376
+
377
+ export function generateAgentYaml(
378
+ name: string,
379
+ provider: "claude" | "codex",
380
+ labels: string[],
381
+ tokenEnv: string,
382
+ options?: { host?: string }
383
+ ): string {
384
+ const displayName = name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
385
+
386
+ const gitlabLines: string[] = [`gitlab:`];
387
+ if (options?.host) {
388
+ gitlabLines.push(` host: ${options.host}`);
389
+ }
390
+ gitlabLines.push(` token_env: ${tokenEnv}`);
391
+
392
+ const lines: string[] = [
393
+ `name: ${name}`,
394
+ `display_name: "${displayName}"`,
395
+ `description: ""`,
396
+ ``,
397
+ ...gitlabLines,
398
+ ``,
399
+ `provider: ${provider}`,
400
+ ``,
401
+ `prompt:`,
402
+ ` preamble: |`,
403
+ ` 你是一个编码 agent,负责处理 GitLab issue。`,
404
+ ``,
405
+ `skills:`,
406
+ ` - test-before-commit`,
407
+ ``,
408
+ `triggers:`,
409
+ ` actions:`,
410
+ ` - mentioned`,
411
+ ` - directly_addressed`,
412
+ ];
413
+
414
+ if (labels.length > 0) {
415
+ lines.push(` labels:`);
416
+ for (const label of labels) {
417
+ lines.push(` - ${label}`);
418
+ }
419
+ }
420
+
421
+ lines.push(``, `poll_interval_seconds: 60`);
422
+
423
+ return lines.join("\n") + "\n";
424
+ }
425
+
426
+ export async function detectGitRemote(projectDir: string): Promise<{ host: string; projectPath: string } | undefined> {
427
+ try {
428
+ const { stdout } = await execFileAsync("git", ["-C", projectDir, "remote", "get-url", "origin"], { timeout: 5000 });
429
+ const remoteUrl = stdout.trim();
430
+ if (!remoteUrl) return undefined;
431
+ return parseGitRemoteUrl(remoteUrl);
432
+ } catch {
433
+ return undefined;
434
+ }
435
+ }
436
+
437
+ export async function validateGitlabToken(host: string, token: string): Promise<{ valid: boolean; username?: string }> {
438
+ try {
439
+ const { stdout } = await execFileAsync("glab", ["api", "/user"], {
440
+ timeout: 10_000,
441
+ env: { ...process.env, GITLAB_HOST: host, GITLAB_TOKEN: token }
442
+ });
443
+ const user = JSON.parse(stdout) as { username?: string };
444
+ return { valid: true, username: user.username };
445
+ } catch {
446
+ return { valid: false };
447
+ }
448
+ }
449
+
450
+ async function cmdInit(name?: string, options?: { provider?: string; labels?: string; tokenEnv?: string }): Promise<void> {
451
+ const base = agentBaseDir(PROJECT_DIR);
452
+ const dirs = [
453
+ path.join(base, "agents"),
454
+ path.join(base, "skills"),
455
+ path.join(base, "state"),
456
+ path.join(base, "pid"),
457
+ path.join(base, "logs"),
458
+ path.join(base, "worktrees"),
459
+ path.join(base, "metrics"),
460
+ path.join(base, "heartbeat"),
461
+ ];
462
+ for (const dir of dirs) {
463
+ await mkdir(dir, { recursive: true });
464
+ }
465
+
466
+ // Create .glab-agent/.gitignore for runtime artifacts
467
+ const gitignorePath = path.join(base, ".gitignore");
468
+ const gitignoreContent = `# Runtime artifacts (do not commit)
469
+ state/
470
+ pid/
471
+ logs/
472
+ worktrees/
473
+ metrics/
474
+ heartbeat/
475
+ .env
476
+ `;
477
+ // Only write if not exists
478
+ try {
479
+ await readFile(gitignorePath, "utf8");
480
+ console.log(`.glab-agent/.gitignore already exists, skipping.`);
481
+ } catch {
482
+ await writeFile(gitignorePath, gitignoreContent, "utf8");
483
+ console.log(`Created ${gitignorePath}`);
484
+ }
485
+
486
+ console.log(`Initialized ${base}/`);
487
+
488
+ // Auto-detect GitLab remote (silent failure is fine)
489
+ const remote = await detectGitRemote(PROJECT_DIR);
490
+
491
+ if (name) {
492
+ const provider = options?.provider ?? "claude";
493
+ if (provider !== "claude" && provider !== "codex") {
494
+ console.error(`Invalid provider "${provider}". Must be "claude" or "codex".`);
495
+ process.exit(1);
496
+ }
497
+
498
+ const labels = options?.labels ? options.labels.split(",").map(l => l.trim()) : [];
499
+ const tokenEnv = options?.tokenEnv ?? "GITLAB_TOKEN";
500
+ const yamlPath = path.join(base, "agents", `${name}.yaml`);
501
+
502
+ try {
503
+ await readFile(yamlPath, "utf8");
504
+ console.error(`Agent "${name}" already exists at ${yamlPath}`);
505
+ process.exit(1);
506
+ } catch { /* does not exist, continue */ }
507
+
508
+ if (remote) {
509
+ console.log(`检测到 GitLab: ${remote.host}/${remote.projectPath}`);
510
+ }
511
+
512
+ const yamlContent = generateAgentYaml(name, provider as "claude" | "codex", labels, tokenEnv, {
513
+ host: remote?.host
514
+ });
515
+ await writeFile(yamlPath, yamlContent, "utf8");
516
+ console.log(`Created agent definition: ${yamlPath}`);
517
+
518
+ // Validate token if available
519
+ const tokenValue = process.env[tokenEnv];
520
+ if (tokenValue && remote) {
521
+ console.log(`正在验证 ${tokenEnv}...`);
522
+ const validation = await validateGitlabToken(remote.host, tokenValue);
523
+ if (validation.valid) {
524
+ console.log(`✅ Token 有效,连接为 @${validation.username}`);
525
+ } else {
526
+ console.log(`⚠️ Token 验证失败,请检查 ${tokenEnv} 是否正确`);
527
+ }
528
+ }
529
+
530
+ console.log(`\nAgent "${name}" 已创建。`);
531
+ const steps: string[] = [];
532
+ if (!tokenValue) {
533
+ steps.push(`设置 token: echo "${tokenEnv}=glpat-xxx" >> ${path.join(base, ".env")}`);
534
+ }
535
+ steps.push(`自定义配置: edit ${yamlPath}`);
536
+ steps.push(`启动: pnpm agent start ${name}`);
537
+ steps.push(`验证: pnpm agent run-once ${name}`);
538
+ console.log("\n下一步:");
539
+ steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
540
+ } else {
541
+ if (remote) {
542
+ console.log(`\n检测到 GitLab 仓库: ${remote.host}/${remote.projectPath}`);
543
+ }
544
+ console.log(`\n下一步:`);
545
+ console.log(` 1. Create agent definition: ${path.join(base, "agents", "<name>.yaml")}`);
546
+ console.log(` 2. Add token: echo "GITLAB_TOKEN=glpat-xxx" > ${path.join(base, ".env")}`);
547
+ console.log(` 3. Verify: pnpm agent list --project ${PROJECT_DIR}`);
548
+ }
549
+ }
550
+
551
+ // ── Doctor command ──────────────────────────────────────────────────────────
552
+
553
+ export interface DoctorCheckResult {
554
+ label: string;
555
+ ok: boolean;
556
+ optional: boolean;
557
+ message: string;
558
+ }
559
+
560
+ export async function checkToolVersion(
561
+ cmd: string,
562
+ args: string[],
563
+ label: string,
564
+ optional: boolean,
565
+ installHint?: string
566
+ ): Promise<DoctorCheckResult> {
567
+ try {
568
+ const { stdout, stderr } = await execFileAsync(cmd, args, { timeout: 10_000 });
569
+ const version = (stdout || stderr).trim().split("\n")[0];
570
+ return { label, ok: true, optional, message: version };
571
+ } catch {
572
+ const hint = installHint ? ` — install from ${installHint}` : "";
573
+ const suffix = optional ? " (optional)" : "";
574
+ return { label, ok: false, optional, message: `not found${suffix}${hint}` };
575
+ }
576
+ }
577
+
578
+ export function formatCheck(result: DoctorCheckResult): string {
579
+ if (result.ok) {
580
+ return `✅ ${result.label}: ${result.message}`;
581
+ }
582
+ const icon = result.optional ? "⚠️ " : "❌";
583
+ return `${icon} ${result.label}: ${result.message}`;
584
+ }
585
+
586
+ async function cmdDoctor(): Promise<void> {
587
+ console.log("glab-agent doctor");
588
+ console.log("==================\n");
589
+
590
+ const results: DoctorCheckResult[] = [];
591
+
592
+ // 1. git
593
+ results.push(
594
+ await checkToolVersion("git", ["--version"], "git", false, "https://git-scm.com")
595
+ );
596
+
597
+ // 2. glab
598
+ results.push(
599
+ await checkToolVersion("glab", ["--version"], "glab", false, "https://gitlab.com/gitlab-org/cli")
600
+ );
601
+
602
+ // 3. claude (optional)
603
+ results.push(
604
+ await checkToolVersion("claude", ["-v"], "claude", true)
605
+ );
606
+
607
+ // 4. codex (optional)
608
+ results.push(
609
+ await checkToolVersion("codex", ["--version"], "codex", true)
610
+ );
611
+
612
+ // 5. .glab-agent/ directory
613
+ const baseDir = agentBaseDir(PROJECT_DIR);
614
+ const baseDirExists = existsSync(baseDir);
615
+ results.push({
616
+ label: ".glab-agent/ directory",
617
+ ok: baseDirExists,
618
+ optional: false,
619
+ message: baseDirExists ? "exists" : `not found — run 'pnpm agent init'`
620
+ });
621
+
622
+ // 6. Agent definitions
623
+ let agents: Awaited<ReturnType<typeof discoverAgents>> = [];
624
+ try {
625
+ agents = await discoverAgents(AGENTS_DIR);
626
+ } catch {
627
+ // ignore
628
+ }
629
+ if (agents.length > 0) {
630
+ const names = agents.map((a) => a.name).join(", ");
631
+ results.push({
632
+ label: "Agent definitions",
633
+ ok: true,
634
+ optional: false,
635
+ message: `Found ${agents.length} agent definition(s): ${names}`
636
+ });
637
+ } else {
638
+ results.push({
639
+ label: "Agent definitions",
640
+ ok: false,
641
+ optional: false,
642
+ message: "no .yaml files found in .glab-agent/agents/"
643
+ });
644
+ }
645
+
646
+ // 7. .env file
647
+ const envPath = agentEnvPath(PROJECT_DIR);
648
+ const envExists = existsSync(envPath);
649
+ results.push({
650
+ label: ".env file",
651
+ ok: envExists,
652
+ optional: false,
653
+ message: envExists ? "exists" : "not found — create .glab-agent/.env with token variables"
654
+ });
655
+
656
+ // 8. Token validity for each agent definition
657
+ const seenTokenEnvs = new Set<string>();
658
+ let firstToken: { value: string; host?: string } | undefined;
659
+ for (const agent of agents) {
660
+ const tokenEnv = agent.gitlab.token_env;
661
+ if (seenTokenEnvs.has(tokenEnv)) continue;
662
+ seenTokenEnvs.add(tokenEnv);
663
+
664
+ const tokenValue = process.env[tokenEnv];
665
+ if (tokenValue && tokenValue.length > 0) {
666
+ results.push({
667
+ label: `Token ${tokenEnv}`,
668
+ ok: true,
669
+ optional: false,
670
+ message: "is set"
671
+ });
672
+ if (!firstToken) {
673
+ firstToken = { value: tokenValue, host: agent.gitlab.host };
674
+ }
675
+ } else {
676
+ results.push({
677
+ label: `Token ${tokenEnv}`,
678
+ ok: false,
679
+ optional: false,
680
+ message: `environment variable ${tokenEnv} is not set or empty`
681
+ });
682
+ }
683
+ }
684
+
685
+ // 9. GitLab API connectivity
686
+ if (firstToken) {
687
+ try {
688
+ const glabArgs = ["api", "/user"];
689
+ const glabEnv: NodeJS.ProcessEnv = { ...process.env, GITLAB_TOKEN: firstToken.value };
690
+ if (firstToken.host) {
691
+ glabEnv.GITLAB_HOST = firstToken.host;
692
+ }
693
+ const { stdout } = await execFileAsync("glab", glabArgs, {
694
+ timeout: 15_000,
695
+ env: glabEnv
696
+ });
697
+ const user = JSON.parse(stdout);
698
+ const username = user.username ?? "unknown";
699
+ const host = firstToken.host ?? "gitlab.com";
700
+ results.push({
701
+ label: "GitLab API",
702
+ ok: true,
703
+ optional: false,
704
+ message: `connected as @${username} to ${host}`
705
+ });
706
+ } catch (err) {
707
+ const errMsg = err instanceof Error ? err.message : String(err);
708
+ results.push({
709
+ label: "GitLab API",
710
+ ok: false,
711
+ optional: false,
712
+ message: `connection failed — ${errMsg}`
713
+ });
714
+ }
715
+ }
716
+
717
+ // Print results
718
+ for (const r of results) {
719
+ console.log(formatCheck(r));
720
+ }
721
+
722
+ // Summary
723
+ const issues = results.filter((r) => !r.ok && !r.optional);
724
+ console.log();
725
+ if (issues.length === 0) {
726
+ console.log("All checks passed!");
727
+ } else {
728
+ console.log(`${issues.length} issue(s) found.`);
729
+ }
730
+ }
731
+
732
+ // ── History command ─────────────────────────────────────────────────────────
733
+
734
+ export function formatDuration(startedAt: string, finishedAt: string): string {
735
+ const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
736
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
737
+ const hours = Math.floor(totalSeconds / 3600);
738
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
739
+ const seconds = totalSeconds % 60;
740
+ if (hours > 0) return `${hours}h ${minutes}m`;
741
+ if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
742
+ return `${seconds}s`;
743
+ }
744
+
745
+ async function cmdHistory(name: string, last: number): Promise<void> {
746
+ // Validate agent exists
747
+ await loadAgentByName(AGENTS_DIR, name);
748
+
749
+ const statePath = agentStatePath(PROJECT_DIR, name);
750
+ const store = new FileStateStore(statePath);
751
+ const state = await store.load();
752
+
753
+ const history = state.runHistory ?? [];
754
+ if (history.length === 0) {
755
+ console.log(`Agent: ${name} -- No execution history found.`);
756
+ return;
757
+ }
758
+
759
+ const entries = history.slice(-last).reverse();
760
+ console.log(`Agent: ${name} -- Last ${Math.min(last, history.length)} runs\n`);
761
+
762
+ // Column headers
763
+ const numW = 4;
764
+ const issueW = 8;
765
+ const statusW = 11;
766
+ const durationW = 10;
767
+ const branchW = 40;
768
+
769
+ console.log(
770
+ `${"#".padEnd(numW)}${"Issue".padEnd(issueW)}${"Status".padEnd(statusW)}${"Duration".padEnd(durationW)}${"Branch".padEnd(branchW)}Title`
771
+ );
772
+ console.log("-".repeat(numW + issueW + statusW + durationW + branchW + 20));
773
+
774
+ for (let i = 0; i < entries.length; i++) {
775
+ const entry = entries[i];
776
+ const num = String(i + 1).padEnd(numW);
777
+ const issueStr = `#${entry.issueIid}`.padEnd(issueW);
778
+ const statusStr = entry.status.padEnd(statusW);
779
+ const duration = formatDuration(entry.startedAt, entry.finishedAt).padEnd(durationW);
780
+ const branch = (entry.branch ?? "-").padEnd(branchW);
781
+ const title = entry.issueTitle ?? "";
782
+ console.log(`${num}${issueStr}${statusStr}${duration}${branch}${title}`);
783
+ }
784
+
785
+ // Summary
786
+ const total = history.length;
787
+ const completed = history.filter((e) => e.status === "completed").length;
788
+ const failed = history.filter((e) => e.status === "failed").length;
789
+
790
+ const durations = history.map((e) =>
791
+ new Date(e.finishedAt).getTime() - new Date(e.startedAt).getTime()
792
+ );
793
+ const avgMs = durations.reduce((a, b) => a + b, 0) / durations.length;
794
+ const avgSeconds = Math.round(avgMs / 1000);
795
+ const avgHours = Math.floor(avgSeconds / 3600);
796
+ const avgMinutes = Math.floor((avgSeconds % 3600) / 60);
797
+ const avgSecs = avgSeconds % 60;
798
+ let avgStr: string;
799
+ if (avgHours > 0) avgStr = `${avgHours}h ${avgMinutes}m`;
800
+ else if (avgMinutes > 0) avgStr = `${avgMinutes}m ${String(avgSecs).padStart(2, "0")}s`;
801
+ else avgStr = `${avgSecs}s`;
802
+
803
+ console.log(`\nTotal: ${total} runs | ${completed} completed | ${failed} failed | Avg duration: ${avgStr}`);
804
+ }
805
+
806
+ // ── Report command ────────────────────────────────────────────────────────
807
+
808
+ async function cmdReport(name: string, sinceDays: number): Promise<void> {
809
+ const metricsDir = path.join(PROJECT_DIR, ".glab-agent", "metrics");
810
+ const result = await generateReport(metricsDir, name, { sinceDays });
811
+ console.log(formatReport(result));
812
+ }
813
+
814
+ // ── Watch command ─────────────────────────────────────────────────────────
815
+
816
+ export function renderWatchScreen(
817
+ name: string,
818
+ status: { alive: boolean; pid?: number },
819
+ state: import("./state-store.js").LocalAgentState,
820
+ now: Date
821
+ ): string {
822
+ const lines: string[] = [];
823
+
824
+ lines.push(`glab-agent watch: ${name} [Ctrl+C 退出]`);
825
+ lines.push("\u2501".repeat(50));
826
+ lines.push("");
827
+
828
+ if (status.alive) {
829
+ lines.push(`状态: 🟢 运行中 (PID ${status.pid})`);
830
+ } else {
831
+ lines.push(`状态: ⚫ 未运行`);
832
+ }
833
+
834
+ if (state.activeRun) {
835
+ const elapsed = formatDuration(state.activeRun.startedAt, now.toISOString());
836
+ const startTime = new Date(state.activeRun.startedAt).toLocaleString("zh-CN", {
837
+ timeZone: "Asia/Shanghai",
838
+ month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"
839
+ });
840
+ lines.push(`当前: 🔧 处理 issue #${state.activeRun.issueIid}`);
841
+ lines.push(`分支: ${state.activeRun.branch}`);
842
+ lines.push(`耗时: ${elapsed}`);
843
+ lines.push(`开始: ${startTime}`);
844
+ } else {
845
+ lines.push(`当前: 💤 空闲`);
846
+ }
847
+
848
+ if (state.lastRun) {
849
+ const icon = state.lastRun.status === "completed" ? "✅"
850
+ : state.lastRun.status === "failed" ? "❌" : "⏸️";
851
+ const ago = formatDuration(state.lastRun.at, now.toISOString());
852
+ const summary = state.lastRun.summary?.slice(0, 60) ?? "";
853
+ lines.push(`\n上次: ${icon} #${state.lastRun.issueIid ?? "?"} ${summary} — ${ago}前`);
854
+ }
855
+
856
+ const history = state.runHistory ?? [];
857
+ if (history.length > 0) {
858
+ lines.push("\n" + "\u2501".repeat(50));
859
+ const count = Math.min(5, history.length);
860
+ lines.push(`\n最近 ${count} 条历史:`);
861
+ const recent = history.slice(-5).reverse();
862
+ for (const e of recent) {
863
+ const eIcon = e.status === "completed" ? "✅" : "❌";
864
+ const dur = formatDuration(e.startedAt, e.finishedAt);
865
+ const title = e.issueTitle ? ` ${e.issueTitle.slice(0, 30)}` : "";
866
+ lines.push(` ${eIcon} #${e.issueIid} ${e.status.padEnd(10)} ${dur.padEnd(8)} ${e.branch ?? "-"}${title}`);
867
+ }
868
+ }
869
+
870
+ const timeStr = now.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
871
+ lines.push(`\n刷新于 ${timeStr} (每 5s)`);
872
+
873
+ return lines.join("\n");
874
+ }
875
+
876
+ async function cmdWatch(name: string): Promise<void> {
877
+ await loadAgentByName(AGENTS_DIR, name);
878
+
879
+ const refresh = async () => {
880
+ process.stdout.write("\x1B[2J\x1B[H");
881
+ const status = await getAgentStatus(PROJECT_DIR, name);
882
+ const store = new FileStateStore(agentStatePath(PROJECT_DIR, name));
883
+ const state = await store.load();
884
+ console.log(renderWatchScreen(name, status, state, new Date()));
885
+ };
886
+
887
+ await refresh();
888
+ const interval = setInterval(refresh, 5000);
889
+
890
+ process.on("SIGINT", () => {
891
+ clearInterval(interval);
892
+ console.log("\n再见。");
893
+ process.exit(0);
894
+ });
895
+
896
+ await new Promise(() => {});
897
+ }
898
+
899
+ // ── Retry command ──────────────────────────────────────────────────────────
900
+
901
+ export interface RetryDependencies {
902
+ stateStore: { load(): Promise<import("./state-store.js").LocalAgentState>; save(state: import("./state-store.js").LocalAgentState): Promise<void> };
903
+ gitlabClient: Pick<import("./gitlab-glab-client.js").GitlabClient, "getIssue" | "updateIssueLabels" | "addIssueNote">;
904
+ projectId: number;
905
+ isPidAlive: (pid: number) => boolean;
906
+ /** Check if a worktree path exists on disk */
907
+ worktreeExists?: (worktreePath: string) => Promise<boolean>;
908
+ }
909
+
910
+ export interface RetryResult {
911
+ status: "retried" | "busy" | "not_error";
912
+ message: string;
913
+ /** When resume=true and worktree exists, this is the preserved worktree path */
914
+ resumeWorktree?: string;
915
+ }
916
+
917
+ export async function retryIssue(
918
+ name: string,
919
+ issueIid: number,
920
+ deps: RetryDependencies,
921
+ options?: { resume?: boolean }
922
+ ): Promise<RetryResult> {
923
+ const { stateStore, gitlabClient, projectId, isPidAlive: checkPid } = deps;
924
+ const resume = options?.resume ?? false;
925
+
926
+ // 1. Load state
927
+ const state = await stateStore.load();
928
+
929
+ // 2. Check if agent is busy
930
+ if (state.activeRun && checkPid(state.activeRun.pid)) {
931
+ return {
932
+ status: "busy",
933
+ message:
934
+ `Agent "${name}" is busy processing issue #${state.activeRun.issueIid} (PID ${state.activeRun.pid}).` +
935
+ `\nPlease wait for the current run to finish before retrying.`
936
+ };
937
+ }
938
+
939
+ // 3. Fetch issue from GitLab
940
+ const issue = await gitlabClient.getIssue(projectId, issueIid);
941
+
942
+ // 4. Verify issue is in Error state
943
+ if (!issue.labels.includes("Error")) {
944
+ return {
945
+ status: "not_error",
946
+ message:
947
+ `Issue #${issueIid} is not in Error state (current labels: ${issue.labels.join(", ")}).` +
948
+ `\nOnly issues with the "Error" label can be retried.`
949
+ };
950
+ }
951
+
952
+ // 5. Check for existing worktree if --resume
953
+ let resumeWorktree: string | undefined;
954
+ if (resume) {
955
+ // Look for worktree path in last run history for this issue
956
+ const historyEntry = state.runHistory?.slice().reverse().find(e => e.issueIid === issueIid);
957
+ const lastRunWorktree = state.lastRun?.issueIid === issueIid ? state.lastRun.worktreePath : undefined;
958
+ const candidatePath = lastRunWorktree ?? historyEntry?.branch;
959
+
960
+ // Also check the conventional worktree path
961
+ const worktreeRoot = agentWorktreeRoot(PROJECT_DIR, name);
962
+ const { worktreePathForIssue } = await import("./worktree-manager.js");
963
+ const conventionalPath = worktreePathForIssue(worktreeRoot, issueIid, issue.title);
964
+
965
+ if (deps.worktreeExists) {
966
+ if (lastRunWorktree && await deps.worktreeExists(lastRunWorktree)) {
967
+ resumeWorktree = lastRunWorktree;
968
+ } else if (await deps.worktreeExists(conventionalPath)) {
969
+ resumeWorktree = conventionalPath;
970
+ }
971
+ }
972
+ }
973
+
974
+ // 6. Remove issue from processedIssueIds
975
+ state.processedIssueIds = state.processedIssueIds.filter((id) => id !== issue.id);
976
+
977
+ // 7. Clear notifiedBusyTodoIds so the agent does not skip re-notifications
978
+ state.notifiedBusyTodoIds = [];
979
+
980
+ // 8. Save state
981
+ await stateStore.save(state);
982
+
983
+ // 9. Transition labels from Error to Todo
984
+ const newLabels = transitionStatusLabels(issue.labels, "Todo");
985
+ await gitlabClient.updateIssueLabels(projectId, issueIid, newLabels);
986
+
987
+ // 10. Add note on issue
988
+ const noteText = resumeWorktree
989
+ ? "🔄 已重置为待处理(恢复模式),agent 将在现有 worktree 上继续工作。"
990
+ : "🔄 已重置为待处理,等待 agent 下一轮接单。";
991
+ await gitlabClient.addIssueNote(projectId, issueIid, noteText);
992
+
993
+ const resumeInfo = resumeWorktree ? `\n Resume: worktree preserved at ${resumeWorktree}` : "";
994
+
995
+ return {
996
+ status: "retried",
997
+ message:
998
+ `Retried issue #${issueIid} for agent "${name}".\n` +
999
+ ` Labels: ${issue.labels.join(", ")} → ${newLabels.join(", ")}\n` +
1000
+ ` Issue removed from processedIssueIds.\n` +
1001
+ ` Agent will pick it up in the next poll cycle.` + resumeInfo,
1002
+ resumeWorktree
1003
+ };
1004
+ }
1005
+
1006
+ async function cmdRetry(name: string, issueIid: number, resume?: boolean): Promise<void> {
1007
+ const agentDef = await loadAgentByName(AGENTS_DIR, name);
1008
+ const config = loadConfigFromAgentDefinition(agentDef, process.env, { cwd: PROJECT_DIR });
1009
+
1010
+ const store = new FileStateStore(agentStatePath(PROJECT_DIR, name));
1011
+ const client = new GitlabGlabClient({
1012
+ host: config.gitlabHost,
1013
+ token: config.gitlabToken
1014
+ });
1015
+
1016
+ const { isPidAlive } = await import("./process-manager.js");
1017
+ const { stat: fsStat } = await import("node:fs/promises");
1018
+ const worktreeExists = async (p: string) => {
1019
+ try { await fsStat(p); return true; } catch { return false; }
1020
+ };
1021
+
1022
+ const result = await retryIssue(name, issueIid, {
1023
+ stateStore: store,
1024
+ gitlabClient: client,
1025
+ projectId: config.gitlabProjectId!,
1026
+ isPidAlive,
1027
+ worktreeExists
1028
+ }, { resume });
1029
+
1030
+ if (result.status === "retried") {
1031
+ console.log(result.message);
1032
+ } else {
1033
+ console.error(result.message);
1034
+ process.exit(1);
1035
+ }
1036
+ }
1037
+
1038
+ // ── Why command ───────────────────────────────────────────────────────────
1039
+
1040
+ export interface WhyDiagnosticResult {
1041
+ agent: string;
1042
+ reason: string;
1043
+ icon: "pass" | "fail" | "warn" | "busy";
1044
+ }
1045
+
1046
+ export interface WhyDependencies {
1047
+ getAgentStatus: (repoPath: string, agentName: string) => Promise<{ alive: boolean; pid?: number }>;
1048
+ loadState: (statePath: string) => Promise<import("./state-store.js").LocalAgentState>;
1049
+ getIssue: (host: string, token: string, projectId: number, issueIid: number) => Promise<import("./gitlab-glab-client.js").GitlabIssue>;
1050
+ getIssueNotes: (host: string, token: string, projectId: number, issueIid: number) => Promise<import("./gitlab-glab-client.js").GitlabNote[]>;
1051
+ listTodos: (host: string, token: string, projectId: number) => Promise<import("./gitlab-glab-client.js").GitlabTodoItem[]>;
1052
+ env: NodeJS.ProcessEnv;
1053
+ }
1054
+
1055
+ export async function diagnoseIssue(
1056
+ issueIid: number,
1057
+ agents: import("./agent-config.js").AgentDefinition[],
1058
+ projectDir: string,
1059
+ deps: WhyDependencies
1060
+ ): Promise<WhyDiagnosticResult[]> {
1061
+ const results: WhyDiagnosticResult[] = [];
1062
+
1063
+ for (const agent of agents) {
1064
+ const status = await deps.getAgentStatus(projectDir, agent.name);
1065
+ if (!status.alive) {
1066
+ results.push({ agent: agent.name, icon: "fail", reason: `Agent 未运行。运行 'pnpm agent start ${agent.name}' 启动。` });
1067
+ continue;
1068
+ }
1069
+
1070
+ const token = deps.env[agent.gitlab.token_env];
1071
+ if (!token) {
1072
+ results.push({ agent: agent.name, icon: "fail", reason: `Token ${agent.gitlab.token_env} 未设置。` });
1073
+ continue;
1074
+ }
1075
+
1076
+ let config;
1077
+ try {
1078
+ config = loadConfigFromAgentDefinition(agent, deps.env, { cwd: projectDir });
1079
+ } catch (err) {
1080
+ results.push({ agent: agent.name, icon: "fail", reason: `配置加载失败: ${String(err)}` });
1081
+ continue;
1082
+ }
1083
+
1084
+ const state = await deps.loadState(agentStatePath(projectDir, agent.name));
1085
+
1086
+ if (state.activeRun) {
1087
+ results.push({ agent: agent.name, icon: "busy", reason: `Agent 正忙,正在处理 issue #${state.activeRun.issueIid}。` });
1088
+ continue;
1089
+ }
1090
+
1091
+ let issue;
1092
+ try {
1093
+ issue = await deps.getIssue(config.gitlabHost, config.gitlabToken, config.gitlabProjectId!, issueIid);
1094
+ } catch {
1095
+ results.push({ agent: agent.name, icon: "fail", reason: `无法获取 issue #${issueIid}(可能不属于此 agent 的项目)。` });
1096
+ continue;
1097
+ }
1098
+
1099
+ if (state.processedIssueIds.includes(issue.id)) {
1100
+ if (issue.labels.includes("Error")) {
1101
+ results.push({ agent: agent.name, icon: "warn", reason: `Issue 已处理过且处于 Error 状态。Agent 会在下一轮重新接单(re-trigger 机制)。` });
1102
+ } else {
1103
+ results.push({ agent: agent.name, icon: "fail", reason: `Issue 已在 processedIssueIds 中。用 'pnpm agent retry ${agent.name} ${issueIid}' 重置。` });
1104
+ }
1105
+ continue;
1106
+ }
1107
+
1108
+ const triggers = agent.triggers;
1109
+ if (triggers.labels.length > 0) {
1110
+ const missing = triggers.labels.filter(l => !issue.labels.includes(l));
1111
+ if (missing.length > 0) {
1112
+ results.push({ agent: agent.name, icon: "fail", reason: `Issue 缺少必要 label: ${missing.join(", ")}(agent 要求: ${triggers.labels.join(", ")})` });
1113
+ continue;
1114
+ }
1115
+ }
1116
+ if (triggers.exclude_labels.length > 0) {
1117
+ const excluded = triggers.exclude_labels.filter(l => issue.labels.includes(l));
1118
+ if (excluded.length > 0) {
1119
+ results.push({ agent: agent.name, icon: "fail", reason: `Issue 包含排除 label: ${excluded.join(", ")}` });
1120
+ continue;
1121
+ }
1122
+ }
1123
+
1124
+ try {
1125
+ const notes = await deps.getIssueNotes(config.gitlabHost, config.gitlabToken, config.gitlabProjectId!, issueIid);
1126
+ const accepted = notes.some(n => !n.system && /^(Claude Code|Codex) 已接单。/.test(n.body));
1127
+ if (accepted) {
1128
+ results.push({ agent: agent.name, icon: "fail", reason: `Issue 已被其他 agent 接走(发现 "已接单" note)。` });
1129
+ continue;
1130
+ }
1131
+ } catch { /* ignore */ }
1132
+
1133
+ try {
1134
+ const todos = await deps.listTodos(config.gitlabHost, config.gitlabToken, config.gitlabProjectId!);
1135
+ const hasTodo = todos.some(t => t.issueIid === issueIid && t.state === "pending");
1136
+ if (!hasTodo) {
1137
+ results.push({ agent: agent.name, icon: "fail", reason: `没有对应的 pending todo。请确认是否已在 issue 中 @mention bot。` });
1138
+ continue;
1139
+ }
1140
+ } catch { /* ignore */ }
1141
+
1142
+ results.push({ agent: agent.name, icon: "pass", reason: `一切正常。Agent 应该会在下一轮轮询时处理此 issue。` });
1143
+ }
1144
+
1145
+ return results;
1146
+ }
1147
+
1148
+ async function cmdWhy(issueIid: number): Promise<void> {
1149
+ const agents = await discoverAgents(AGENTS_DIR);
1150
+ if (agents.length === 0) {
1151
+ console.log("❌ 没有发现任何 agent 定义。");
1152
+ return;
1153
+ }
1154
+
1155
+ console.log(`诊断 issue #${issueIid} 未被接单的原因...\n`);
1156
+
1157
+ const icons = { pass: "✅", fail: "❌", warn: "⚠️ ", busy: "⏳" };
1158
+ const deps: WhyDependencies = {
1159
+ getAgentStatus: (rp, n) => getAgentStatus(rp, n),
1160
+ loadState: (sp) => new FileStateStore(sp).load(),
1161
+ getIssue: (h, t, p, i) => new GitlabGlabClient({ host: h, token: t }).getIssue(p, i),
1162
+ getIssueNotes: (h, t, p, i) => new GitlabGlabClient({ host: h, token: t }).getIssueNotes(p, i),
1163
+ listTodos: (h, t, p) => new GitlabGlabClient({ host: h, token: t }).listMentionedIssueTodos(p),
1164
+ env: process.env
1165
+ };
1166
+
1167
+ const results = await diagnoseIssue(issueIid, agents, PROJECT_DIR, deps);
1168
+ for (const r of results) {
1169
+ console.log(`── Agent: ${r.agent} ──`);
1170
+ console.log(` ${icons[r.icon]} ${r.reason}`);
1171
+ }
1172
+ }
1173
+
1174
+ // ── Sweep command ────────────────────────────────────────────────────────
1175
+
1176
+ export interface SweepDependencies {
1177
+ gitlabClient: Pick<import("./gitlab-glab-client.js").GitlabClient, "listClosedIssuesByLabel" | "updateIssueLabels" | "addIssueNote">;
1178
+ projectId: number;
1179
+ logger?: Pick<typeof console, "log">;
1180
+ }
1181
+
1182
+ export interface SweepResult {
1183
+ transitioned: number;
1184
+ skipped: number;
1185
+ }
1186
+
1187
+ export async function sweepMergedIssues(deps: SweepDependencies): Promise<SweepResult> {
1188
+ const { gitlabClient, projectId, logger } = deps;
1189
+ const closed = await gitlabClient.listClosedIssuesByLabel(projectId, "In Review");
1190
+ let transitioned = 0;
1191
+ let skipped = 0;
1192
+ for (const issue of closed) {
1193
+ if (issue.stateReason !== "merged") {
1194
+ logger?.log(` 跳过 #${issue.iid} "${issue.title}" (关闭原因: ${issue.stateReason ?? "unknown"})`);
1195
+ skipped++;
1196
+ continue;
1197
+ }
1198
+ await gitlabClient.updateIssueLabels(projectId, issue.iid, transitionStatusLabels(issue.labels, "Done"));
1199
+ await gitlabClient.addIssueNote(projectId, issue.iid, "🏁 MR 已合并,issue 自动流转到 Done。");
1200
+ logger?.log(` ✅ #${issue.iid} "${issue.title}" → Done`);
1201
+ transitioned++;
1202
+ }
1203
+ return { transitioned, skipped };
1204
+ }
1205
+
1206
+ // ── GC command ────────────────────────────────────────────────────────────
1207
+
1208
+ export interface GcDependencies {
1209
+ gitlabClient: Pick<import("./gitlab-glab-client.js").GitlabClient, "getIssue">;
1210
+ projectId: number;
1211
+ worktreeRoot: string;
1212
+ repoPath: string;
1213
+ activeWorktreePaths: string[];
1214
+ listWorktreeDirs: () => Promise<string[]>;
1215
+ removeWorktree: (worktreePath: string) => Promise<void>;
1216
+ logger?: Pick<typeof console, "log">;
1217
+ }
1218
+
1219
+ export interface GcResult {
1220
+ removed: number;
1221
+ skipped: number;
1222
+ errors: number;
1223
+ }
1224
+
1225
+ export async function gcWorktrees(deps: GcDependencies): Promise<GcResult> {
1226
+ const { gitlabClient, projectId, worktreeRoot, activeWorktreePaths, listWorktreeDirs, removeWorktree, logger } = deps;
1227
+
1228
+ let removed = 0;
1229
+ let skipped = 0;
1230
+ let errors = 0;
1231
+
1232
+ let dirs: string[];
1233
+ try {
1234
+ dirs = await listWorktreeDirs();
1235
+ } catch {
1236
+ return { removed, skipped, errors };
1237
+ }
1238
+
1239
+ for (const dir of dirs) {
1240
+ const match = dir.match(/^issue-(\d+)-/);
1241
+ if (!match) {
1242
+ logger?.log(` 跳过 ${dir} (无法解析 issue IID)`);
1243
+ skipped++;
1244
+ continue;
1245
+ }
1246
+
1247
+ const issueIid = Number.parseInt(match[1], 10);
1248
+ const worktreePath = path.join(worktreeRoot, dir);
1249
+
1250
+ if (activeWorktreePaths.includes(worktreePath)) {
1251
+ logger?.log(` 跳过 ${dir} (active run)`);
1252
+ skipped++;
1253
+ continue;
1254
+ }
1255
+
1256
+ let issue: import("./gitlab-glab-client.js").GitlabIssue;
1257
+ try {
1258
+ issue = await gitlabClient.getIssue(projectId, issueIid);
1259
+ } catch {
1260
+ logger?.log(` 跳过 ${dir} (无法获取 issue #${issueIid})`);
1261
+ skipped++;
1262
+ continue;
1263
+ }
1264
+
1265
+ if (issue.state !== "closed") {
1266
+ logger?.log(` 跳过 ${dir} (issue #${issueIid} 仍开放)`);
1267
+ skipped++;
1268
+ continue;
1269
+ }
1270
+
1271
+ try {
1272
+ await removeWorktree(worktreePath);
1273
+ logger?.log(` ✅ 删除 ${dir} (issue #${issueIid} 已关闭)`);
1274
+ removed++;
1275
+ } catch (err) {
1276
+ logger?.log(` ❌ 删除 ${dir} 失败: ${String(err)}`);
1277
+ errors++;
1278
+ }
1279
+ }
1280
+
1281
+ return { removed, skipped, errors };
1282
+ }
1283
+
1284
+ async function cmdGc(nameOrAll: string): Promise<void> {
1285
+ const { readdir } = await import("node:fs/promises");
1286
+
1287
+ const runGc = async (agentName: string): Promise<GcResult> => {
1288
+ const agent = await loadAgentByName(AGENTS_DIR, agentName);
1289
+ const config = loadConfigFromAgentDefinition(agent, process.env, { cwd: PROJECT_DIR });
1290
+ const client = new GitlabGlabClient({ host: config.gitlabHost, token: config.gitlabToken });
1291
+
1292
+ const worktreeRoot = agentWorktreeRoot(PROJECT_DIR, agentName);
1293
+ const store = new FileStateStore(agentStatePath(PROJECT_DIR, agentName));
1294
+ const state = await store.load();
1295
+ const activeWorktreePaths = state.activeRun ? [state.activeRun.worktreePath] : [];
1296
+
1297
+ return gcWorktrees({
1298
+ gitlabClient: client,
1299
+ projectId: config.gitlabProjectId!,
1300
+ worktreeRoot,
1301
+ repoPath: config.agentRepoPath,
1302
+ activeWorktreePaths,
1303
+ listWorktreeDirs: async () => {
1304
+ const entries = await readdir(worktreeRoot, { withFileTypes: true });
1305
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
1306
+ },
1307
+ removeWorktree: async (worktreePath: string) => {
1308
+ await execFileAsync("git", ["-C", config.agentRepoPath, "worktree", "remove", "--force", worktreePath]);
1309
+ },
1310
+ logger: console
1311
+ });
1312
+ };
1313
+
1314
+ if (nameOrAll === "--all") {
1315
+ const agents = await discoverAgents(AGENTS_DIR);
1316
+ let totalRemoved = 0;
1317
+ for (const agent of agents) {
1318
+ console.log(`GC for ${agent.name}...`);
1319
+ const result = await runGc(agent.name);
1320
+ totalRemoved += result.removed;
1321
+ }
1322
+ console.log(`\n共清理 ${totalRemoved} 个 worktree。`);
1323
+ } else {
1324
+ console.log(`GC for ${nameOrAll}...`);
1325
+ const result = await runGc(nameOrAll);
1326
+ console.log(`\n已清理 ${result.removed} 个 worktree,跳过 ${result.skipped} 个,错误 ${result.errors} 个。`);
1327
+ }
1328
+ }
1329
+
1330
+ async function cmdSweep(nameOrAll: string): Promise<void> {
1331
+ const runSweep = async (agentName: string): Promise<number> => {
1332
+ const agent = await loadAgentByName(AGENTS_DIR, agentName);
1333
+ const config = loadConfigFromAgentDefinition(agent, process.env, { cwd: PROJECT_DIR });
1334
+ const client = new GitlabGlabClient({ host: config.gitlabHost, token: config.gitlabToken });
1335
+ const result = await sweepMergedIssues({ gitlabClient: client, projectId: config.gitlabProjectId!, logger: console });
1336
+ return result.transitioned;
1337
+ };
1338
+
1339
+ if (nameOrAll === "--all") {
1340
+ const agents = await discoverAgents(AGENTS_DIR);
1341
+ const seen = new Set<string>();
1342
+ let total = 0;
1343
+ for (const agent of agents) {
1344
+ const key = `${agent.gitlab.host ?? ""}:${agent.gitlab.project_id ?? ""}`;
1345
+ if (seen.has(key)) continue;
1346
+ seen.add(key);
1347
+ console.log(`Sweeping for ${agent.name}...`);
1348
+ total += await runSweep(agent.name);
1349
+ }
1350
+ console.log(`\n共 ${total} 个 issue 从 In Review 流转到 Done。`);
1351
+ if (total > 0) await sendNotification({ title: "glab-agent sweep", message: `${total} issue(s) moved to Done` });
1352
+ } else {
1353
+ console.log(`Sweeping for ${nameOrAll}...`);
1354
+ const count = await runSweep(nameOrAll);
1355
+ console.log(`\n共 ${count} 个 issue 从 In Review 流转到 Done。`);
1356
+ if (count > 0) await sendNotification({ title: "glab-agent sweep", message: `${count} issue(s) moved to Done` });
1357
+ }
1358
+ }
1359
+
1360
+ // ── Wiki command ──────────────────────────────────────────────────────────
1361
+
1362
+ async function cmdWiki(args: string[]): Promise<void> {
1363
+ const subcommand = args[0];
1364
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
1365
+ console.log("Usage: glab-agent wiki <command>\n");
1366
+ console.log("Commands:");
1367
+ console.log(" init Initialize Knowledge Wiki directory structure");
1368
+ return;
1369
+ }
1370
+
1371
+ if (subcommand === "init") {
1372
+ const repoRoot = process.cwd();
1373
+ await initWikiDir(repoRoot);
1374
+ const dir = wikiPath(repoRoot);
1375
+ console.log(`✅ Knowledge Wiki initialized at ${dir}`);
1376
+ console.log(" Created: index.md, log.md, patterns/, gotchas/, decisions/, runbooks/");
1377
+ return;
1378
+ }
1379
+
1380
+ console.error(`Unknown wiki command: ${subcommand}`);
1381
+ process.exit(1);
1382
+ }
1383
+
1384
+ async function main(): Promise<void> {
1385
+ const args = stripProjectFlag(process.argv.slice(2));
1386
+ const command = args[0];
1387
+ const target = args[1];
1388
+
1389
+ switch (command) {
1390
+ case "init": {
1391
+ const initProvider = extractFlag(args, "--provider");
1392
+ const initLabels = extractFlag(args, "--labels");
1393
+ const initTokenEnv = extractFlag(args, "--token-env");
1394
+ await cmdInit(target, { provider: initProvider, labels: initLabels, tokenEnv: initTokenEnv });
1395
+ break;
1396
+ }
1397
+ case "list":
1398
+ await cmdList();
1399
+ break;
1400
+ case "start":
1401
+ if (!target) { console.error("Usage: pnpm agent start <name|--all>"); process.exit(1); }
1402
+ await cmdStart(target);
1403
+ break;
1404
+ case "stop":
1405
+ if (!target) { console.error("Usage: pnpm agent stop <name|--all>"); process.exit(1); }
1406
+ await cmdStop(target);
1407
+ break;
1408
+ case "cancel":
1409
+ if (!target) { console.error("Usage: pnpm agent cancel <name>"); process.exit(1); }
1410
+ await cmdCancel(target);
1411
+ break;
1412
+ case "retry": {
1413
+ if (!target) { console.error("Usage: pnpm agent retry <name> <iid> [--resume]"); process.exit(1); }
1414
+ const iid = Number.parseInt(args[2], 10);
1415
+ if (Number.isNaN(iid)) { console.error("issue iid must be a number"); process.exit(1); }
1416
+ const resume = args.includes("--resume");
1417
+ await cmdRetry(target, iid, resume);
1418
+ break;
1419
+ }
1420
+ case "why": {
1421
+ if (!target) { console.error("Usage: pnpm agent why <issue-iid>"); process.exit(1); }
1422
+ const iid = Number.parseInt(target, 10);
1423
+ if (Number.isNaN(iid)) { console.error("issue-iid 必须是数字"); process.exit(1); }
1424
+ await cmdWhy(iid);
1425
+ break;
1426
+ }
1427
+ case "status":
1428
+ await cmdStatus(target);
1429
+ break;
1430
+ case "logs":
1431
+ if (!target) { console.error("Usage: pnpm agent logs <name>"); process.exit(1); }
1432
+ await cmdLogs(target);
1433
+ break;
1434
+ case "run-once":
1435
+ if (!target) { console.error("Usage: pnpm agent run-once <name>"); process.exit(1); }
1436
+ await cmdRunOnce(target);
1437
+ break;
1438
+ case "watch":
1439
+ if (!target) { console.error("Usage: pnpm agent watch <name>"); process.exit(1); }
1440
+ await cmdWatch(target);
1441
+ break;
1442
+ case "history": {
1443
+ if (!target) { console.error("Usage: pnpm agent history <name> [--last N]"); process.exit(1); }
1444
+ const lastIdx = args.indexOf("--last");
1445
+ const last = lastIdx !== -1 && args[lastIdx + 1] ? Number.parseInt(args[lastIdx + 1], 10) : 20;
1446
+ await cmdHistory(target, Number.isNaN(last) || last <= 0 ? 20 : last);
1447
+ break;
1448
+ }
1449
+ case "report": {
1450
+ if (!target) { console.error("Usage: pnpm agent report <name> [--since <N>d]"); process.exit(1); }
1451
+ const sinceIdx = args.indexOf("--since");
1452
+ let sinceDays = 7;
1453
+ if (sinceIdx !== -1 && args[sinceIdx + 1]) {
1454
+ const match = args[sinceIdx + 1].match(/^(\d+)d$/);
1455
+ if (match) sinceDays = Number.parseInt(match[1], 10);
1456
+ }
1457
+ await cmdReport(target, sinceDays);
1458
+ break;
1459
+ }
1460
+ case "sweep":
1461
+ if (!target) { console.error("Usage: pnpm agent sweep <name|--all>"); process.exit(1); }
1462
+ await cmdSweep(target);
1463
+ break;
1464
+ case "gc":
1465
+ if (!target) { console.error("Usage: pnpm agent gc <name|--all>"); process.exit(1); }
1466
+ await cmdGc(target);
1467
+ break;
1468
+ case "install":
1469
+ if (!target) { console.error("Usage: pnpm agent install <name|--all>"); process.exit(1); }
1470
+ await cmdInstall(target);
1471
+ break;
1472
+ case "skills":
1473
+ if (target === "import") {
1474
+ const importTarget = args[2];
1475
+ if (!importTarget) { console.error("Usage: pnpm agent skills import <github-url|owner/repo> [--force]"); process.exit(1); }
1476
+ const force = args.includes("--force");
1477
+ await cmdSkillImport(importTarget, { force });
1478
+ } else {
1479
+ await cmdSkills();
1480
+ }
1481
+ break;
1482
+ case "wiki":
1483
+ await cmdWiki(args.slice(1));
1484
+ break;
1485
+ case "doctor":
1486
+ await cmdDoctor();
1487
+ break;
1488
+ default:
1489
+ usage();
1490
+ break;
1491
+ }
1492
+ }
1493
+
1494
+ main().catch((error) => {
1495
+ console.error(String(error));
1496
+ process.exit(1);
1497
+ });