glab-agent 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,6 +153,16 @@ No extra dashboards needed. glab-agent maps to GitLab's native features:
153
153
  - **Metrics** — Append-only JSONL per agent (`.glab-agent/metrics/`)
154
154
  - **Heartbeat** — Cycle count and last error in `.glab-agent/heartbeat/`
155
155
 
156
+ ## Documentation
157
+
158
+ Development documentation lives in [`docs/`](docs/index.md):
159
+
160
+ - **[Design Principles](docs/design-principles.md)** — P1-P17, architecture decision criteria
161
+ - **[Test Strategy](docs/test-strategy.md)** — coverage policy, key modules, known gaps
162
+ - **[Harness Engineering](docs/harness-engineering.md)** — human + AI collaboration methodology
163
+
164
+ Run `glab-agent doctor` for environment diagnostics.
165
+
156
166
  ## Requirements
157
167
 
158
168
  - Node.js >= 20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glab-agent",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "description": "Multi-agent GitLab To-Do watcher with YAML-defined agents, skills, and GitLab registry.",
6
6
  "license": "MIT",
@@ -34,7 +34,9 @@
34
34
  "prepare": "git config core.hooksPath scripts/hooks",
35
35
  "build": "tsc -p tsconfig.build.json",
36
36
  "test": "vitest run",
37
+ "test:coverage": "vitest run --coverage",
37
38
  "agent": "tsx src/local-agent/cli.ts",
39
+ "check:deliver": "zsh scripts/delivery-check.sh",
38
40
  "smoke-test": "zsh scripts/smoke-test.sh",
39
41
  "smoke-test:full": "zsh scripts/smoke-test.sh --wait-finish",
40
42
  "docs-lint": "zsh scripts/docs-lint.sh",
@@ -22,6 +22,12 @@ export interface AgentRunResult {
22
22
  export interface AgentRunContext {
23
23
  todoId?: number;
24
24
  signal?: AbortSignal;
25
+ /** Hint when issue labels don't match trigger config. Passed to agent prompt. */
26
+ labelMismatchHint?: string;
27
+ /** Path to the target project's repo (cached bare clone). Used by runners for git operations. */
28
+ targetRepoPath?: string;
29
+ /** Override gitlabProjectId for the prompt (target project, not agents repo) */
30
+ gitlabProjectId?: number;
25
31
  }
26
32
 
27
33
  export interface SpawnedRunResult {
@@ -116,6 +122,8 @@ export interface AgentPromptContext {
116
122
  append?: string;
117
123
  templatePath?: string;
118
124
  labels?: AgentLabelConfig;
125
+ /** Hint when issue labels don't match trigger config. Agent decides how to respond. */
126
+ labelMismatchHint?: string;
119
127
  }
120
128
 
121
129
  export interface ContextualPromptContext {
@@ -226,7 +234,9 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
226
234
  "",
227
235
  "## 当前环境",
228
236
  `- 分支: ${ctx.branch}`,
229
- "- 工作目录: 项目的独立工作副本",
237
+ "- 工作目录: issue 对应项目的独立工作副本",
238
+ "- 如果任务涉及其他仓库,可以用 git clone 拉取(GITLAB_TOKEN 已配置,支持认证)",
239
+ ctx.labelMismatchHint ? `\n## 注意\n${ctx.labelMismatchHint}` : "",
230
240
  "",
231
241
  "## 最终输出",
232
242
  "标准输出不超过 5 行的简短总结(不是 GitLab 评论)。"
@@ -67,7 +67,7 @@ export class ClaudeRunner implements AgentRunner {
67
67
  issueIid: issue.iid,
68
68
  issueTitle: issue.title,
69
69
  gitlabHost: this.gitlabHost,
70
- gitlabProjectId: this.gitlabProjectId,
70
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
71
71
  worktreePath: worktree.worktreePath,
72
72
  branch: worktree.branch,
73
73
  provider: "claude",
@@ -75,6 +75,7 @@ export class ClaudeRunner implements AgentRunner {
75
75
  preamble: this.agentDefinition?.prompt.preamble,
76
76
  append: this.agentDefinition?.prompt.append,
77
77
  labels: this.agentDefinition?.labels,
78
+ labelMismatchHint: context?.labelMismatchHint,
78
79
  // skills injected via .claude/skills/*.md files, not in prompt
79
80
  });
80
81
  const claudeCommand = resolveClaudeCommand(this.env);
@@ -150,7 +151,7 @@ export class ClaudeRunner implements AgentRunner {
150
151
  issueIid: issue.iid,
151
152
  issueTitle: issue.title,
152
153
  gitlabHost: this.gitlabHost,
153
- gitlabProjectId: this.gitlabProjectId,
154
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
154
155
  worktreePath: worktree.worktreePath,
155
156
  branch: worktree.branch,
156
157
  provider: "claude",
@@ -158,6 +159,7 @@ export class ClaudeRunner implements AgentRunner {
158
159
  preamble: this.agentDefinition?.prompt.preamble,
159
160
  append: this.agentDefinition?.prompt.append,
160
161
  labels: this.agentDefinition?.labels,
162
+ labelMismatchHint: context?.labelMismatchHint,
161
163
  });
162
164
  const claudeCommand = resolveClaudeCommand(this.env);
163
165
 
@@ -97,6 +97,7 @@ Commands:
97
97
  watch <name> Watch agent status in real-time (refreshes every 5s)
98
98
  history <name> Show agent execution history (--last N, default 20)
99
99
  report <name> Show execution report (--since <N>d, default 7d) [--publish]
100
+ dashboard Multi-agent overview (status + recent runs + alerts)
100
101
  sweep <name|--all> Move merged In Review issues to Done
101
102
  gc <name|--all> Remove worktrees for closed/merged issues
102
103
  install <name|--all> Generate launchd plist for agent(s)
@@ -2214,6 +2215,113 @@ async function cmdWikiSetup(): Promise<void> {
2214
2215
  console.log(` ${wikiUrl(remote.host, remote.projectPath, "home")}`);
2215
2216
  }
2216
2217
 
2218
+ // ── Dashboard command ─────────────────────────────────────────────────────
2219
+
2220
+ async function cmdDashboard(): Promise<void> {
2221
+ const agents = await discoverAgents(AGENTS_DIR).catch(() => []);
2222
+ if (agents.length === 0) {
2223
+ console.log("No agents found. Run 'glab-agent init <name>' first.");
2224
+ return;
2225
+ }
2226
+
2227
+ // ── Agent Status Table ──
2228
+ console.log("AGENTS");
2229
+ console.log("-".repeat(80));
2230
+ const nameW = 20;
2231
+ const statusW = 10;
2232
+ const taskW = 35;
2233
+ const lastW = 15;
2234
+ console.log(
2235
+ `${"Name".padEnd(nameW)}${"Status".padEnd(statusW)}${"Current Task".padEnd(taskW)}${"Last Run".padEnd(lastW)}`
2236
+ );
2237
+ console.log("-".repeat(80));
2238
+
2239
+ const alerts: string[] = [];
2240
+
2241
+ for (const agent of agents) {
2242
+ const status = await getAgentStatus(PROJECT_DIR, agent.name);
2243
+ const statePath = agentStatePath(PROJECT_DIR, agent.name);
2244
+ const store = new FileStateStore(statePath);
2245
+ const state = await store.load();
2246
+
2247
+ // Status
2248
+ const statusStr = status.alive ? "busy" : "idle";
2249
+
2250
+ // Current task
2251
+ let taskStr = "\u2014";
2252
+ if (state.activeRun) {
2253
+ taskStr = `#${state.activeRun.issueIid}`;
2254
+ }
2255
+
2256
+ // Last run info
2257
+ const history = state.runHistory ?? [];
2258
+ let lastRunStr = "\u2014";
2259
+ if (history.length > 0) {
2260
+ const last = history[history.length - 1];
2261
+ const ago = Math.round((Date.now() - new Date(last.finishedAt).getTime()) / 60000);
2262
+ if (ago < 60) lastRunStr = `${ago}min ago`;
2263
+ else if (ago < 1440) lastRunStr = `${Math.round(ago / 60)}hr ago`;
2264
+ else lastRunStr = `${Math.round(ago / 1440)}d ago`;
2265
+ }
2266
+
2267
+ console.log(
2268
+ `${agent.name.padEnd(nameW)}${statusStr.padEnd(statusW)}${taskStr.slice(0, taskW - 1).padEnd(taskW)}${lastRunStr.padEnd(lastW)}`
2269
+ );
2270
+
2271
+ // Collect alerts
2272
+ if (history.length > 0) {
2273
+ const last = history[history.length - 1];
2274
+ if (last.status === "failed") {
2275
+ alerts.push(`${agent.name} #${last.issueIid} failed — check: pnpm agent logs ${agent.name}`);
2276
+ }
2277
+ }
2278
+
2279
+ // Check heartbeat staleness
2280
+ const hbPath = agentHeartbeatPath(PROJECT_DIR, agent.name);
2281
+ const hb = await readHeartbeat(hbPath);
2282
+ if (hb && isHeartbeatStale(hb, 300)) {
2283
+ alerts.push(`${agent.name} heartbeat stale (>5min) — may need restart`);
2284
+ }
2285
+ }
2286
+
2287
+ // ── Recent Runs ──
2288
+ console.log("\nRECENT (last 10 across all agents)");
2289
+ console.log("-".repeat(80));
2290
+
2291
+ const allRuns: Array<{ agentName: string; entry: RunHistoryEntry }> = [];
2292
+ for (const agent of agents) {
2293
+ const statePath = agentStatePath(PROJECT_DIR, agent.name);
2294
+ const store = new FileStateStore(statePath);
2295
+ const state = await store.load();
2296
+ for (const entry of state.runHistory ?? []) {
2297
+ allRuns.push({ agentName: agent.name, entry });
2298
+ }
2299
+ }
2300
+
2301
+ allRuns.sort((a, b) => new Date(b.entry.finishedAt).getTime() - new Date(a.entry.finishedAt).getTime());
2302
+
2303
+ const recentRuns = allRuns.slice(0, 10);
2304
+ if (recentRuns.length === 0) {
2305
+ console.log(" No execution history yet.");
2306
+ } else {
2307
+ for (const { agentName, entry } of recentRuns) {
2308
+ const status = entry.status === "completed" ? "done" : entry.status;
2309
+ const duration = formatDuration(entry.startedAt, entry.finishedAt);
2310
+ const title = (entry.issueTitle ?? "").slice(0, 30);
2311
+ console.log(` ${agentName.padEnd(16)} #${String(entry.issueIid).padEnd(6)} ${status.padEnd(10)} ${duration.padEnd(8)} ${title}`);
2312
+ }
2313
+ }
2314
+
2315
+ // ── Alerts ──
2316
+ if (alerts.length > 0) {
2317
+ console.log("\nALERTS");
2318
+ console.log("-".repeat(80));
2319
+ for (const alert of alerts) {
2320
+ console.log(` ${alert}`);
2321
+ }
2322
+ }
2323
+ }
2324
+
2217
2325
  async function main(): Promise<void> {
2218
2326
  const args = stripProjectFlag(process.argv.slice(2));
2219
2327
  const command = args[0];
@@ -2350,6 +2458,9 @@ Examples:
2350
2458
  case "doctor":
2351
2459
  await cmdDoctor();
2352
2460
  break;
2461
+ case "dashboard":
2462
+ await cmdDashboard();
2463
+ break;
2353
2464
  case "wiki": {
2354
2465
  const wikiSub = args[1];
2355
2466
  if (wikiSub === "setup") {
@@ -67,7 +67,7 @@ export class CodexRunner implements AgentRunner {
67
67
  issueIid: issue.iid,
68
68
  issueTitle: issue.title,
69
69
  gitlabHost: this.gitlabHost,
70
- gitlabProjectId: this.gitlabProjectId,
70
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
71
71
  worktreePath: worktree.worktreePath,
72
72
  branch: worktree.branch,
73
73
  provider: "codex",
@@ -75,6 +75,7 @@ export class CodexRunner implements AgentRunner {
75
75
  preamble: this.agentDefinition?.prompt.preamble,
76
76
  append: this.agentDefinition?.prompt.append,
77
77
  labels: this.agentDefinition?.labels,
78
+ labelMismatchHint: context?.labelMismatchHint,
78
79
  // skills injected via .agent_context/skills/*.md files, not in prompt
79
80
  });
80
81
  const codexCommand = resolveCodexCommand(this.env);
@@ -153,7 +154,7 @@ export class CodexRunner implements AgentRunner {
153
154
  issueIid: issue.iid,
154
155
  issueTitle: issue.title,
155
156
  gitlabHost: this.gitlabHost,
156
- gitlabProjectId: this.gitlabProjectId,
157
+ gitlabProjectId: context?.gitlabProjectId ?? this.gitlabProjectId,
157
158
  worktreePath: worktree.worktreePath,
158
159
  branch: worktree.branch,
159
160
  provider: "codex",
@@ -161,6 +162,7 @@ export class CodexRunner implements AgentRunner {
161
162
  preamble: this.agentDefinition?.prompt.preamble,
162
163
  append: this.agentDefinition?.prompt.append,
163
164
  labels: this.agentDefinition?.labels,
165
+ labelMismatchHint: context?.labelMismatchHint,
164
166
  });
165
167
  const codexCommand = resolveCodexCommand(this.env);
166
168
 
@@ -101,7 +101,7 @@ export interface GitlabClient {
101
101
  getWikiPage(projectId: number | string, slug: string): Promise<WikiPage>;
102
102
  createWikiPage(projectId: number | string, title: string, content: string): Promise<WikiPage>;
103
103
  updateWikiPage(projectId: number | string, slug: string, title: string, content: string): Promise<WikiPage>;
104
- getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined>;
104
+ getProject(projectIdOrPath: number | string): Promise<{ id: number; httpUrlToRepo?: string; pathWithNamespace?: string } | undefined>;
105
105
  createProject(name: string, options?: { visibility?: string; initializeWithReadme?: boolean }): Promise<{ id: number }>;
106
106
  getRepositoryFile(projectId: number | string, filePath: string, ref?: string): Promise<{ content: string } | undefined>;
107
107
  createOrUpdateRepositoryFile(projectId: number | string, filePath: string, content: string, commitMessage: string, options?: { create?: boolean }): Promise<void>;
@@ -723,13 +723,18 @@ export class GitlabGlabClient implements GitlabClient {
723
723
  };
724
724
  }
725
725
 
726
- async getProject(projectIdOrPath: number | string): Promise<{ id: number } | undefined> {
726
+ async getProject(projectIdOrPath: number | string): Promise<{ id: number; httpUrlToRepo?: string; pathWithNamespace?: string } | undefined> {
727
727
  try {
728
728
  const encoded = typeof projectIdOrPath === "string" ? encodeURIComponent(projectIdOrPath) : projectIdOrPath;
729
729
  const payload = await this.readJson(`projects/${encoded}`);
730
730
  const p = (payload ?? {}) as Payload;
731
731
  const id = Number(p.id);
732
- return Number.isNaN(id) ? undefined : { id };
732
+ if (Number.isNaN(id)) return undefined;
733
+ return {
734
+ id,
735
+ httpUrlToRepo: typeof p.http_url_to_repo === "string" ? p.http_url_to_repo as string : undefined,
736
+ pathWithNamespace: typeof p.path_with_namespace === "string" ? p.path_with_namespace as string : undefined,
737
+ };
733
738
  } catch {
734
739
  return undefined;
735
740
  }
@@ -122,6 +122,21 @@ export class RepoCache {
122
122
  return true;
123
123
  }
124
124
 
125
+ /**
126
+ * Build a git clone URL with embedded token for authentication.
127
+ * e.g., https://oauth2:TOKEN@gitlab.example.com/group/project.git
128
+ */
129
+ static authenticatedUrl(httpUrl: string, token: string): string {
130
+ try {
131
+ const url = new URL(httpUrl);
132
+ url.username = "oauth2";
133
+ url.password = token;
134
+ return url.toString();
135
+ } catch {
136
+ return httpUrl;
137
+ }
138
+ }
139
+
125
140
  /**
126
141
  * Get the cache directory path for a given repo URL.
127
142
  * Uses SHA-256 hash of the URL for the directory name.
@@ -25,6 +25,10 @@ export interface ReportResult {
25
25
  timestamp: string;
26
26
  error?: string;
27
27
  }[];
28
+ insights?: {
29
+ frequentFiles: { file: string; count: number }[];
30
+ retryHotspots: { issueIid: number; retryCount: number }[];
31
+ };
28
32
  }
29
33
 
30
34
  export async function generateReport(
@@ -95,6 +99,45 @@ export async function generateReport(
95
99
  error: e.error
96
100
  }));
97
101
 
102
+ // Pattern insights (HX-3)
103
+ // Frequent files: from git log
104
+ const frequentFiles: { file: string; count: number }[] = [];
105
+ try {
106
+ const { execFile: execFileCb } = await import("node:child_process");
107
+ const { promisify } = await import("node:util");
108
+ const execFileAsync = promisify(execFileCb);
109
+ const sinceStr = `${sinceDays} days ago`;
110
+ const { stdout } = await execFileAsync("git", [
111
+ "log", "--since", sinceStr, "--name-only", "--pretty=format:", "--diff-filter=M"
112
+ ], { maxBuffer: 1024 * 1024 });
113
+ const fileCounts = new Map<string, number>();
114
+ for (const line of stdout.toString().split("\n")) {
115
+ const f = line.trim();
116
+ if (f && f.startsWith("src/")) {
117
+ fileCounts.set(f, (fileCounts.get(f) ?? 0) + 1);
118
+ }
119
+ }
120
+ const sorted = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
121
+ for (const [file, count] of sorted) {
122
+ frequentFiles.push({ file, count });
123
+ }
124
+ } catch {
125
+ // git not available or not a git repo — skip
126
+ }
127
+
128
+ // Retry hotspots: issues that appear multiple times in events
129
+ const issueOccurrences = new Map<number, number>();
130
+ for (const e of events) {
131
+ if (e.issueIid) {
132
+ issueOccurrences.set(e.issueIid, (issueOccurrences.get(e.issueIid) ?? 0) + 1);
133
+ }
134
+ }
135
+ const retryHotspots = [...issueOccurrences.entries()]
136
+ .filter(([_, count]) => count > 1)
137
+ .sort((a, b) => b[1] - a[1])
138
+ .slice(0, 5)
139
+ .map(([issueIid, retryCount]) => ({ issueIid, retryCount }));
140
+
98
141
  return {
99
142
  agentName,
100
143
  period: { from: sinceDate, to: now },
@@ -106,7 +149,8 @@ export async function generateReport(
106
149
  successRate,
107
150
  avgDurationMs,
108
151
  topFailures,
109
- recentRuns
152
+ recentRuns,
153
+ insights: { frequentFiles, retryHotspots }
110
154
  };
111
155
  }
112
156
 
@@ -180,6 +224,27 @@ export function formatReport(result: ReportResult): string {
180
224
  }
181
225
  }
182
226
 
227
+ // Pattern insights (HX-3)
228
+ const insights = result.insights;
229
+ if (insights && (insights.frequentFiles.length > 0 || insights.retryHotspots.length > 0)) {
230
+ lines.push("", "PATTERN INSIGHTS");
231
+ lines.push("-".repeat(40));
232
+
233
+ if (insights.frequentFiles.length > 0) {
234
+ lines.push("Frequently modified:");
235
+ for (const f of insights.frequentFiles) {
236
+ lines.push(` ${f.file} (${f.count}x)`);
237
+ }
238
+ }
239
+
240
+ if (insights.retryHotspots.length > 0) {
241
+ lines.push("Retry hotspots:");
242
+ for (const h of insights.retryHotspots) {
243
+ lines.push(` #${h.issueIid} (${h.retryCount} runs)`);
244
+ }
245
+ }
246
+ }
247
+
183
248
  return lines.join("\n");
184
249
  }
185
250
 
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { readFileSync } from "node:fs";
3
+ import { mkdir } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
5
6
  import { AgentRunnerError, type AgentRunner, type SpawnedRun } from "./agent-runner.js";
@@ -33,6 +34,7 @@ import { rotateIfNeeded } from "./log-rotate.js";
33
34
  import { validateToken } from "./token-check.js";
34
35
  import { WorktreeManager } from "./worktree-manager.js";
35
36
  import type { WorktreeInfo } from "./worktree-manager.js";
37
+ import { RepoCache } from "./repo-cache.js";
36
38
  import { injectSkillFiles } from "./skill-inject.js";
37
39
  import { startHealthServer } from "./health-server.js";
38
40
  import type { HealthStatus } from "./health-server.js";
@@ -103,6 +105,8 @@ export interface WatcherDependencies {
103
105
  issueCloseCheckIntervalMs?: number;
104
106
  /** Feishu app client for targeted personal notifications */
105
107
  feishuClient?: import("./feishu-client.js").FeishuClient;
108
+ /** Cache for bare clones of target project repos */
109
+ repoCache?: RepoCache;
106
110
  }
107
111
 
108
112
  export interface WatcherCycleResult {
@@ -338,6 +342,52 @@ async function replaceReactionOnTodo(
338
342
  }
339
343
  }
340
344
 
345
+ /**
346
+ * Resolve the target project's repo and create a worktree for the given issue/MR.
347
+ * Uses RepoCache to clone/fetch the target project, then creates a worktree from it.
348
+ * Falls back to the agents repo worktree if the target project can't be resolved.
349
+ */
350
+ async function resolveTargetWorktree(
351
+ projectId: number,
352
+ iid: number,
353
+ title: string,
354
+ config: LocalAgentConfig,
355
+ dependencies: WatcherDependencies,
356
+ logger: Logger | typeof console
357
+ ): Promise<{ worktree: WorktreeInfo }> {
358
+ if (dependencies.repoCache) {
359
+ try {
360
+ const project = await dependencies.gitlabClient.getProject(projectId);
361
+ if (project?.httpUrlToRepo) {
362
+ const authedUrl = RepoCache.authenticatedUrl(project.httpUrlToRepo, config.gitlabToken);
363
+ const cachedRepoPath = await dependencies.repoCache.ensureRepo(authedUrl);
364
+
365
+ const agentName = config.agentDefinition?.name;
366
+ const worktreeRoot = path.join(config.agentRepoPath, ".glab-agent", "worktrees", agentName ?? "agent");
367
+
368
+ await mkdir(worktreeRoot, { recursive: true });
369
+
370
+ // Use a WorktreeManager pointing at the cached bare repo
371
+ const targetWtm = new WorktreeManager({
372
+ repoPath: cachedRepoPath,
373
+ worktreeRoot,
374
+ agentName,
375
+ });
376
+ const worktree = await targetWtm.ensureWorktree(iid, title);
377
+
378
+ (logger as Logger | undefined)?.info?.(`Target repo resolved: ${project.pathWithNamespace ?? projectId} → ${cachedRepoPath}`);
379
+ return { worktree };
380
+ }
381
+ } catch (err) {
382
+ (logger as Logger | undefined)?.warn?.(`Failed to resolve target repo for project ${projectId}, falling back to agents repo: ${String(err).slice(0, 150)}`);
383
+ }
384
+ }
385
+
386
+ // Fallback: use agents repo worktree (original behavior)
387
+ const worktree = await dependencies.worktreeManager.ensureWorktree(iid, title);
388
+ return { worktree };
389
+ }
390
+
341
391
  export async function runWatcherCycle(
342
392
  config: LocalAgentConfig,
343
393
  dependencies: WatcherDependencies
@@ -429,7 +479,9 @@ export async function runWatcherCycle(
429
479
  webUrl: ""
430
480
  };
431
481
 
432
- const worktree = await dependencies.worktreeManager.ensureWorktree(targetIid, `mr-${targetIid}`);
482
+ const { worktree } = await resolveTargetWorktree(
483
+ candidate.projectId, targetIid, `mr-${targetIid}`, config, dependencies, logger
484
+ );
433
485
  state.activeRun = {
434
486
  pid,
435
487
  todoId: candidate.id,
@@ -456,7 +508,7 @@ export async function runWatcherCycle(
456
508
  logger.info(`Starting agent for MR !${targetIid} branch=${worktree.branch}`);
457
509
 
458
510
  try {
459
- const result = await dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id });
511
+ const result = await dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, gitlabProjectId: candidate.projectId });
460
512
  logger.info(`Agent completed MR !${targetIid}. Summary: ${result.summary.slice(0, 200)}`);
461
513
  } catch (error) {
462
514
  const summary = error instanceof AgentRunnerError ? error.summary : String(error);
@@ -474,35 +526,21 @@ export async function runWatcherCycle(
474
526
 
475
527
  const issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
476
528
 
477
- // NT-4: Three-tier routing classify @mention before trigger label check
478
- const mentionType = classifyMention(candidate.body ?? "", issue.title, issue.labels, issue.description);
479
-
480
- if (mentionType === "contextual" && dependencies.agentRunner.runContextual) {
481
- logger.info(`NT-4 contextual reply for issue #${issue.iid}: "${issue.title}"`);
482
- await updateAgentUserStatus(dependencies, "busy", `#${issue.iid}`);
483
-
484
- try {
485
- const result = await dependencies.agentRunner.runContextual(issue, candidate.body ?? "", { todoId: candidate.id });
486
- logger.info(`Contextual reply completed for #${issue.iid}: ${result.summary.slice(0, 100)}`);
487
- } catch (error) {
488
- logger.warn(`Contextual reply failed for #${issue.iid}: ${String(error).slice(0, 200)}`);
489
- }
490
-
491
- await updateAgentUserStatus(dependencies, "idle", undefined, config);
492
- state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
493
- state.activeRun = undefined;
494
- await dependencies.stateStore.save(state);
495
- try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
496
-
497
- return { status: "completed", issueIid: issue.iid, summary: `Contextual reply for #${issue.iid}` };
498
- }
499
-
500
- // Label-based trigger filtering (full_work path only)
529
+ // Label mismatch: don't skip (P1: @mention must always get a response),
530
+ // but pass hint to agent so it can decide how to respond (P3: agent autonomy)
531
+ let labelMismatchHint: string | undefined;
501
532
  if (triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
502
- logger.info(`Issue #${issue.iid} does not match trigger labels for agent "${config.agentDefinition?.name}", skipping.`);
503
- return { status: "idle" };
533
+ const agentName = config.agentDefinition?.name ?? "agent";
534
+ const required = triggers.labels?.join(", ") || "(无)";
535
+ const excluded = triggers.exclude_labels?.join(", ") || "(无)";
536
+ const actual = issue.labels.join(", ") || "(无)";
537
+ labelMismatchHint = `此 issue 的 label [${actual}] 不在 ${agentName} 的触发范围内(要求: [${required}],排除: [${excluded}])。你仍然需要回复这个 @mention(P1),但可以选择只回复说明而不执行代码修改。`;
538
+ logger.info(`Issue #${issue.iid}: label mismatch, passing hint to agent`);
504
539
  }
505
- const worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
540
+
541
+ const { worktree } = await resolveTargetWorktree(
542
+ issue.projectId, issue.iid, issue.title, config, dependencies, logger
543
+ );
506
544
 
507
545
  state.activeRun = {
508
546
  pid,
@@ -582,7 +620,7 @@ export async function runWatcherCycle(
582
620
 
583
621
  try {
584
622
  const raceResult = await Promise.race([
585
- dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal }).then(r => ({ type: "completed" as const, result: r })),
623
+ dependencies.agentRunner.run(issue, worktree, { todoId: candidate.id, signal: abortController.signal, labelMismatchHint, gitlabProjectId: issue.projectId }).then(r => ({ type: "completed" as const, result: r })),
586
624
  closeDetection.then(() => ({ type: "closed" as const }))
587
625
  ]);
588
626
 
@@ -740,17 +778,21 @@ export async function runConcurrentWatcherCycle(
740
778
 
741
779
  if (result.error) {
742
780
  // Failed
743
- logger.error(`Agent failed issue #${issueIid} after ${durationSec}s. Error: ${result.error.slice(0, 200)}`);
744
- try {
745
- await dependencies.gitlabClient.updateIssueLabels(
746
- spawned.issue.projectId, issueIid,
747
- transitionStatusLabels(spawned.issue.labels, getLabelConfig(config).error, getStatusLabelsList(config))
748
- );
749
- await dependencies.gitlabClient.addIssueNote(
750
- spawned.issue.projectId, issueIid,
751
- `⚠️ Agent 执行失败。\n\n${result.summary}`
752
- );
753
- } catch (e) { logger.warn(`Post-run cleanup failed: ${String(e).slice(0, 100)}`); }
781
+ const isMr = spawned.todo.targetType === "MergeRequest";
782
+ const targetLabel = isMr ? `MR !${issueIid}` : `issue #${issueIid}`;
783
+ logger.error(`Agent failed ${targetLabel} after ${durationSec}s. Error: ${result.error.slice(0, 200)}`);
784
+ if (!isMr) {
785
+ try {
786
+ await dependencies.gitlabClient.updateIssueLabels(
787
+ spawned.issue.projectId, issueIid,
788
+ transitionStatusLabels(spawned.issue.labels, getLabelConfig(config).error, getStatusLabelsList(config))
789
+ );
790
+ await dependencies.gitlabClient.addIssueNote(
791
+ spawned.issue.projectId, issueIid,
792
+ `⚠️ Agent 执行失败。\n\n${result.summary}`
793
+ );
794
+ } catch (e) { logger.warn(`Post-run cleanup failed: ${String(e).slice(0, 100)}`); }
795
+ }
754
796
  await notifyFailed(config.agentDefinition?.name ?? "agent", issueIid, spawned.issue.title, result.summary, config.webhookUrl);
755
797
  // Feishu: update card to failed state
756
798
  if (dependencies.feishuClient && spawned.feishuMessageId) {
@@ -833,34 +875,35 @@ export async function runConcurrentWatcherCycle(
833
875
  if (!candidate) break;
834
876
  processedInThisCycle.add(candidate.id);
835
877
 
836
- // Skip MR todos and contextual replies in concurrent mode — handle them synchronously
837
878
  const isIssueTodo = candidate.targetType === "Issue";
838
- if (!isIssueTodo) {
839
- // Mark as processed, skip for now (MR handling stays synchronous)
840
- state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
841
- try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
842
- continue;
843
- }
879
+ const targetIid = isIssueTodo ? candidate.issueIid : (candidate.targetIid ?? 0);
844
880
 
845
- const issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
846
-
847
- // Check mention type — contextual replies still run synchronously
848
- const mentionType = classifyMention(candidate.body ?? "", issue.title, issue.labels, issue.description);
849
- if (mentionType === "contextual" && dependencies.agentRunner.runContextual) {
850
- // Run contextual reply synchronously (quick, non-blocking conceptually)
851
- try {
852
- await dependencies.agentRunner.runContextual(issue, candidate.body ?? "", { todoId: candidate.id });
853
- logger.info(`Contextual reply completed for #${issue.iid}`);
854
- } catch (e) { logger.warn(`Contextual reply failed: ${String(e).slice(0, 100)}`); }
855
- state.processedTodoIds = rememberIds(state.processedTodoIds, candidate.id);
856
- try { await dependencies.gitlabClient.markTodoDone(candidate.id); } catch { /* silent */ }
857
- continue;
881
+ // Build issue object real for Issues, synthetic for MRs
882
+ let issue: GitlabIssue;
883
+ if (isIssueTodo) {
884
+ issue = await dependencies.gitlabClient.getIssue(candidate.projectId, candidate.issueIid);
885
+ } else {
886
+ issue = {
887
+ id: 0,
888
+ iid: targetIid,
889
+ projectId: candidate.projectId,
890
+ title: `MR !${targetIid} @mention`,
891
+ description: candidate.body ?? "",
892
+ labels: [],
893
+ webUrl: candidate.targetUrl ?? ""
894
+ };
895
+ logger.info(`MR todo !${targetIid}: spawning into slot`);
858
896
  }
859
897
 
860
- // Label trigger check
861
- if (triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
862
- logger.info(`Issue #${issue.iid} does not match trigger labels, skipping.`);
863
- continue;
898
+ // Label mismatch: pass hint to agent (P1: always respond, P3: agent decides)
899
+ let labelMismatchHint: string | undefined;
900
+ if (isIssueTodo && triggers && !matchesTriggerLabels(issue.labels, triggers.labels, triggers.exclude_labels)) {
901
+ const agentName = config.agentDefinition?.name ?? "agent";
902
+ const required = triggers.labels?.join(", ") || "(无)";
903
+ const excluded = triggers.exclude_labels?.join(", ") || "(无)";
904
+ const actual = issue.labels.join(", ") || "(无)";
905
+ labelMismatchHint = `此 issue 的 label [${actual}] 不在 ${agentName} 的触发范围内(要求: [${required}],排除: [${excluded}])。你仍然需要回复这个 @mention(P1),但可以选择只回复说明而不执行代码修改。`;
906
+ logger.info(`Issue #${issue.iid}: label mismatch, passing hint to agent`);
864
907
  }
865
908
 
866
909
  // Ensure spawn() is available
@@ -870,7 +913,11 @@ export async function runConcurrentWatcherCycle(
870
913
  }
871
914
 
872
915
  // Prepare and spawn
873
- const worktree = await dependencies.worktreeManager.ensureWorktree(issue.iid, issue.title);
916
+ const worktreeTitle = isIssueTodo ? issue.title : `mr-${targetIid}`;
917
+ const { worktree } = await resolveTargetWorktree(
918
+ isIssueTodo ? issue.projectId : candidate.projectId,
919
+ targetIid, worktreeTitle, config, dependencies, logger
920
+ );
874
921
 
875
922
  const skills = config.agentDefinition?.skills ?? [];
876
923
  if (skills.length > 0) {
@@ -880,7 +927,7 @@ export async function runConcurrentWatcherCycle(
880
927
  } catch (err) { logger.warn(`Failed to inject skill files: ${String(err)}`); }
881
928
  }
882
929
 
883
- const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id });
930
+ const spawnedRun = await dependencies.agentRunner.spawn(issue, worktree, { todoId: candidate.id, labelMismatchHint, gitlabProjectId: isIssueTodo ? issue.projectId : candidate.projectId });
884
931
  const now = new Date().toISOString();
885
932
 
886
933
  spawnedRuns.set(issue.iid, {
@@ -1057,7 +1104,9 @@ async function finalizeTodo(
1057
1104
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
1058
1105
 
1059
1106
  // P11 post-run compensation: agent is probabilistic, verify critical state transitions
1060
- if (result.status === "completed") {
1107
+ // Skip for MR todos — MRs don't have issue labels/notes to compensate
1108
+ const isMrTodo = todo.targetType === "MergeRequest";
1109
+ if (result.status === "completed" && !isMrTodo) {
1061
1110
  try {
1062
1111
  const freshIssue = await dependencies.gitlabClient.getIssue(issue.projectId, issue.iid);
1063
1112
  const logger = dependencies.logger ?? console;
@@ -1087,23 +1136,25 @@ async function finalizeTodo(
1087
1136
  }
1088
1137
  }
1089
1138
 
1090
- // NT-1: Post structured run summary note to GitLab issue (history visible in GitLab)
1139
+ // NT-1: Post structured run summary note to GitLab (history visible in GitLab)
1091
1140
  // Posted AFTER agent reply and P11 compensation so it appears last in the timeline
1092
- try {
1093
- const durationStr = durationMs < 60000
1094
- ? `${Math.round(durationMs / 1000)}s`
1095
- : `${Math.round(durationMs / 60000)}m ${Math.round((durationMs % 60000) / 1000)}s`;
1096
- const statusEmoji = result.status === "completed" ? "✅" : "❌";
1097
- const noteBody = [
1098
- `${statusEmoji} **Agent run ${result.status}** (${durationStr})`,
1099
- "",
1100
- result.summary.slice(0, 500),
1101
- "",
1102
- `<!-- agent-run-log ${JSON.stringify({ agent: _config.agentDefinition?.name, status: result.status, startedAt, finishedAt, durationMs, branch: result.branch })} -->`
1103
- ].join("\n");
1104
- await dependencies.gitlabClient.addIssueNote(issue.projectId, issue.iid, noteBody);
1105
- } catch (err) {
1106
- (dependencies.logger ?? console).warn(`Failed to post run summary note: ${String(err)}`);
1141
+ if (!isMrTodo) {
1142
+ try {
1143
+ const durationStr = durationMs < 60000
1144
+ ? `${Math.round(durationMs / 1000)}s`
1145
+ : `${Math.round(durationMs / 60000)}m ${Math.round((durationMs % 60000) / 1000)}s`;
1146
+ const statusEmoji = result.status === "completed" ? "✅" : "❌";
1147
+ const noteBody = [
1148
+ `${statusEmoji} **Agent run ${result.status}** (${durationStr})`,
1149
+ "",
1150
+ result.summary.slice(0, 500),
1151
+ "",
1152
+ `<!-- agent-run-log ${JSON.stringify({ agent: _config.agentDefinition?.name, status: result.status, startedAt, finishedAt, durationMs, branch: result.branch })} -->`
1153
+ ].join("\n");
1154
+ await dependencies.gitlabClient.addIssueNote(issue.projectId, issue.iid, noteBody);
1155
+ } catch (err) {
1156
+ (dependencies.logger ?? console).warn(`Failed to post run summary note: ${String(err)}`);
1157
+ }
1107
1158
  }
1108
1159
 
1109
1160
  appendRunHistory(state, {
@@ -1511,13 +1562,17 @@ function pickNextTodo(
1511
1562
  const acceptedActions = triggerActions ?? ACCEPTED_TODO_ACTIONS;
1512
1563
 
1513
1564
  const candidates = todos.filter((todo) => {
1565
+ const todoLabel = todo.targetType === "MergeRequest"
1566
+ ? `MR !${todo.targetIid ?? 0}`
1567
+ : `issue #${todo.issueIid}`;
1568
+
1514
1569
  if (processedTodos.has(todo.id)) {
1515
- logger?.info?.(`Todo ${todo.id} (issue #${todo.issueIid}): skipped — already processed`);
1570
+ logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — already processed`);
1516
1571
  return false;
1517
1572
  }
1518
1573
 
1519
1574
  if (!acceptedActions.has(todo.actionName)) {
1520
- logger?.info?.(`Todo ${todo.id} (issue #${todo.issueIid}): skipped — action "${todo.actionName}" not in triggers`);
1575
+ logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — action "${todo.actionName}" not in triggers`);
1521
1576
  return false;
1522
1577
  }
1523
1578
 
@@ -1525,8 +1580,10 @@ function pickNextTodo(
1525
1580
  return false;
1526
1581
  }
1527
1582
 
1528
- if (activeIssueIids?.has(todo.issueIid)) {
1529
- logger?.info?.(`Todo ${todo.id} (issue #${todo.issueIid}): skipped already being processed`);
1583
+ // For MR todos, check by targetIid; for Issues, check by issueIid
1584
+ const effectiveIid = todo.targetType === "MergeRequest" ? (todo.targetIid ?? 0) : todo.issueIid;
1585
+ if (activeIssueIids?.has(effectiveIid)) {
1586
+ logger?.info?.(`Todo ${todo.id} (${todoLabel}): skipped — already being processed`);
1530
1587
  return false;
1531
1588
  }
1532
1589
 
@@ -1602,6 +1659,9 @@ function createDependencies(config: LocalAgentConfig, logger?: Logger): WatcherD
1602
1659
  agentName: config.agentDefinition?.name
1603
1660
  }),
1604
1661
  agentRunner,
1662
+ repoCache: new RepoCache({
1663
+ cacheDir: path.join(config.agentRepoPath, ".glab-agent", "repo-cache")
1664
+ }),
1605
1665
  logger
1606
1666
  };
1607
1667
  }
@@ -1805,9 +1865,8 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
1805
1865
  config.botName = botUser.name;
1806
1866
  logger.info(`Token validated. Bot identity: @${botUser.username} (${botUser.name})`);
1807
1867
 
1808
- // Update GitLab user bio with agent profile (P1: Agent = 员工)
1868
+ // Update GitLab user profile (P1: Agent = 员工)
1809
1869
  await updateAgentUserBio(config, dependencies);
1810
- await publishAgentProfileWiki(config, dependencies);
1811
1870
  await publishProfileReadme(config, dependencies);
1812
1871
  await updateAgentUserStatus(dependencies, "idle", undefined, config);
1813
1872
  } catch (error) {