glab-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,25 +15,38 @@ GitLab-native AI agent orchestration platform. Define coding agents in YAML, man
15
15
 
16
16
  ## Quick Start
17
17
 
18
+ ### Prerequisites
19
+
20
+ - **Node.js >= 20** — `node --version`
21
+ - **glab CLI** — [Install](https://gitlab.com/gitlab-org/cli#installation), then `glab auth login`
22
+ - **Claude CLI** or **Codex CLI** — the AI provider your agent will use
23
+ - **GitLab bot account** (recommended) — a dedicated user account so agent actions appear separately from yours
24
+
25
+ ### Setup
26
+
18
27
  ```bash
19
- # Install globally
28
+ # 1. Install
20
29
  npm install -g glab-agent
21
30
 
22
- # Initialize in your project
31
+ # 2. Initialize in your project
23
32
  cd your-project
24
33
  glab-agent init my-bot --provider claude
25
34
 
26
- # Set your GitLab token
35
+ # 3. Create a GitLab Personal Access Token for the bot account
36
+ # → Settings > Access Tokens > scopes: api, read_user, read_repository, write_repository
27
37
  echo "GITLAB_TOKEN=glpat-xxx" >> .glab-agent/.env
28
38
 
29
- # Start the agent
30
- glab-agent start my-bot
39
+ # 4. Verify dependencies
40
+ glab-agent doctor
41
+
42
+ # 5. Test with a single poll cycle (synchronous, you see output immediately)
43
+ glab-agent run-once my-bot
31
44
 
32
- # Verify it's running
33
- glab-agent status
45
+ # 6. Start background watcher
46
+ glab-agent start my-bot
34
47
  ```
35
48
 
36
- Then go to any GitLab issue and type `@your-bot-username` — the agent will pick it up, write code, run tests, and create a merge request.
49
+ Go to any GitLab issue and type `@your-bot-username` — the agent will pick it up, write code, run tests, and create a merge request. Use `glab-agent watch my-bot` to see what it's doing.
37
50
 
38
51
  ## How It Works
39
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glab-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",
@@ -33,6 +33,24 @@ export interface AgentNotifications {
33
33
  webhook_url?: string;
34
34
  }
35
35
 
36
+ export interface AgentLabelConfig {
37
+ backlog: string;
38
+ todo: string;
39
+ in_progress: string;
40
+ in_review: string;
41
+ done: string;
42
+ error: string;
43
+ }
44
+
45
+ export const DEFAULT_LABELS: AgentLabelConfig = {
46
+ backlog: "Backlog",
47
+ todo: "Todo",
48
+ in_progress: "In Progress",
49
+ in_review: "In Review",
50
+ done: "Done",
51
+ error: "Error",
52
+ };
53
+
36
54
  export interface AgentDefinition {
37
55
  name: string;
38
56
  display_name: string;
@@ -52,6 +70,7 @@ export interface AgentDefinition {
52
70
  timeout_seconds?: number;
53
71
  model?: string;
54
72
  notifications: AgentNotifications;
73
+ labels: AgentLabelConfig;
55
74
  }
56
75
 
57
76
  // ── Loader ───────────────────────────────────────────────────────────────────
@@ -146,6 +165,16 @@ export function parseAgentDefinition(raw: unknown, fileName: string): AgentDefin
146
165
  throw new Error(`Invalid provider "${provider}" in ${fileName}. Must be "codex" or "claude".`);
147
166
  }
148
167
 
168
+ const rawLabels = (obj.labels ?? {}) as RawValue;
169
+ const labels: AgentLabelConfig = {
170
+ backlog: asString(rawLabels.backlog, DEFAULT_LABELS.backlog),
171
+ todo: asString(rawLabels.todo, DEFAULT_LABELS.todo),
172
+ in_progress: asString(rawLabels.in_progress, DEFAULT_LABELS.in_progress),
173
+ in_review: asString(rawLabels.in_review, DEFAULT_LABELS.in_review),
174
+ done: asString(rawLabels.done, DEFAULT_LABELS.done),
175
+ error: asString(rawLabels.error, DEFAULT_LABELS.error),
176
+ };
177
+
149
178
  return {
150
179
  name,
151
180
  display_name: asString(obj.display_name, name),
@@ -163,7 +192,8 @@ export function parseAgentDefinition(raw: unknown, fileName: string): AgentDefin
163
192
  ? obj.timeout_seconds
164
193
  : undefined,
165
194
  model: typeof obj.model === "string" ? obj.model : undefined,
166
- notifications: parseNotifications(obj.notifications)
195
+ notifications: parseNotifications(obj.notifications),
196
+ labels,
167
197
  };
168
198
  }
169
199
 
@@ -9,6 +9,8 @@ import type { GitlabIssue } from "./gitlab-glab-client.js";
9
9
  import type { AgentProvider } from "./agent-provider.js";
10
10
  import type { WorktreeInfo } from "./worktree-manager.js";
11
11
  import type { Logger } from "./logger.js";
12
+ import { DEFAULT_LABELS } from "./agent-config.js";
13
+ import type { AgentLabelConfig } from "./agent-config.js";
12
14
 
13
15
  const execFileAsync = promisify(execFile);
14
16
 
@@ -24,6 +26,8 @@ export interface AgentRunContext {
24
26
 
25
27
  export interface AgentRunner {
26
28
  run(issue: GitlabIssue, worktree: WorktreeInfo, context?: AgentRunContext): Promise<AgentRunResult>;
29
+ /** Run a lightweight contextual reply — no worktree, read-only. Optional: falls back to full_work if absent. */
30
+ runContextual?(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult>;
27
31
  }
28
32
 
29
33
  export interface AgentRunnerOptions {
@@ -67,12 +71,53 @@ export interface AgentPromptContext {
67
71
  preamble?: string;
68
72
  append?: string;
69
73
  templatePath?: string;
74
+ labels?: AgentLabelConfig;
75
+ }
76
+
77
+ export interface ContextualPromptContext {
78
+ issueIid: number;
79
+ issueTitle: string;
80
+ gitlabHost: string;
81
+ gitlabProjectId: number;
82
+ todoBody: string;
83
+ }
84
+
85
+ /**
86
+ * Build a lightweight read-only prompt for contextual replies (NT-4).
87
+ * The agent answers questions and posts a note but does NOT modify code.
88
+ */
89
+ export function buildContextualPrompt(ctx: ContextualPromptContext): string {
90
+ const p = ctx.gitlabProjectId;
91
+ return [
92
+ "你是项目团队成员(AI agent),收到了一个 @mention 需要回复。",
93
+ "",
94
+ "## 任务",
95
+ `Issue #${ctx.issueIid}: ${truncate(ctx.issueTitle, 200)}`,
96
+ `@mention 内容: ${ctx.todoBody || "(空)"}`,
97
+ "",
98
+ "## GitLab 环境",
99
+ `- Host: ${ctx.gitlabHost} | Project ID: ${p}`,
100
+ "- GITLAB_TOKEN 和 GITLAB_HOST 已配置,可直接使用 glab 命令。",
101
+ "",
102
+ "## 指令",
103
+ `1. 读取 issue 详情: glab api projects/${p}/issues/${ctx.issueIid}`,
104
+ ` 读评论: glab api projects/${p}/issues/${ctx.issueIid}/notes`,
105
+ "2. 阅读项目代码和文档来回答问题。",
106
+ `3. 在 issue 上发评论回复: glab api --method POST projects/${p}/issues/${ctx.issueIid}/notes --raw-field "body=<回复>"`,
107
+ "4. **不要修改任何代码文件**。如果需要代码修改,在回复中建议创建新 issue。",
108
+ "5. 像团队成员一样沟通,自然、简洁、有判断力。",
109
+ "6. 不要暴露内部实现细节(worktree 路径、执行器名称等)。",
110
+ "",
111
+ "## 最终输出",
112
+ "标准输出不超过 3 行的简短总结(不是 GitLab 评论)。"
113
+ ].join("\n");
70
114
  }
71
115
 
72
116
  export function buildBasePrompt(ctx: AgentPromptContext): string {
73
117
  const p = ctx.gitlabProjectId;
74
118
  const iid = ctx.issueIid;
75
119
  const todoId = ctx.todoId;
120
+ const labels = ctx.labels ?? DEFAULT_LABELS;
76
121
  return [
77
122
  "你是这个项目的团队成员(AI agent),被分配处理一个 GitLab 任务。",
78
123
  "你在项目的独立工作分支中,可以读代码、读文档、运行命令、操作 GitLab。",
@@ -97,7 +142,9 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
97
142
  `- 读评论: glab api projects/${p}/issues/${iid}/notes`,
98
143
  `- 读关联 issue: glab api projects/${p}/issues/${iid}/links`,
99
144
  "- 阅读项目代码和文档,理解架构和设计原则。",
100
- "- 如果存在 .glab-agent/wiki/index.md,先查阅项目知识库了解已有经验和踩坑记录。",
145
+ `- 查阅项目 Wiki(如有): glab api projects/${p}/wikis`,
146
+ " - Wiki 是团队共享的知识库,包含踩坑记录、设计决策、常用模式等。",
147
+ ` - 读具体页面: glab api "projects/${p}/wikis/<slug>"(slug 含 / 时需 URL 编码为 %2F)`,
101
148
  "",
102
149
  "### 2. 判断并行动",
103
150
  "- **明确的技术任务**(bug fix、功能实现、重构、文档)→ 实现它",
@@ -105,9 +152,9 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
105
152
  "- **信息不足** → 在 issue 上提问,不要猜测需求",
106
153
  "",
107
154
  "### 3. 管理看板状态",
108
- `- 开始工作时: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=In Progress"`,
109
- `- 创建 MR 后: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=In Review"`,
110
- `- 遇到无法解决的问题: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=Error"`,
155
+ `- 开始工作时: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.in_progress}"`,
156
+ `- 创建 MR 后: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.in_review}"`,
157
+ `- 遇到无法解决的问题: glab api --method PUT projects/${p}/issues/${iid} --raw-field "labels=${labels.error}"`,
111
158
  todoId ? `- 处理完成后: glab api --method POST todos/${todoId}/mark_as_done` : "",
112
159
  "",
113
160
  "### 4. 代码交付(仅当决定实现时)",
@@ -125,6 +172,14 @@ export function buildBasePrompt(ctx: AgentPromptContext): string {
125
172
  "- **不要**暴露内部实现细节(worktree 路径、执行器名称、PID 等)。",
126
173
  "- **不要**输出内部思考过程。",
127
174
  "",
175
+ "### 6. 知识沉淀",
176
+ "- 如果在工作过程中发现了新的踩坑经验、设计决策或可复用模式,写入项目 Wiki。",
177
+ ` - 新建页面: glab api --method POST projects/${p}/wikis --raw-field "title=<标题>" --raw-field "content=<Markdown 内容>"`,
178
+ ` - 更新已有页面: glab api --method PUT "projects/${p}/wikis/<slug>" --raw-field "title=<原标题>" --raw-field "content=<新内容>"(必须传 title,否则目录前缀会丢失)`,
179
+ "- 推荐分类: gotchas/(踩坑)、patterns/(模式)、decisions/(决策)、runbooks/(操作手册)。",
180
+ "- 只记录**非显而易见的**经验。不要记录代码里能直接看到的信息。",
181
+ "- Wiki 标题用 `分类/标题` 格式,如 `gotchas/并发写入文件需要锁`。",
182
+ "",
128
183
  "## 当前环境",
129
184
  `- 分支: ${ctx.branch}`,
130
185
  "- 工作目录: 项目的独立工作副本",
@@ -12,6 +12,7 @@ import {
12
12
  type AgentRunner,
13
13
  type AgentRunnerOptions,
14
14
  buildAgentPrompt,
15
+ buildContextualPrompt,
15
16
  createOutputPath,
16
17
  isSmokeIssue,
17
18
  runSmokeTestChecks
@@ -67,6 +68,7 @@ export class ClaudeRunner implements AgentRunner {
67
68
  todoId: context?.todoId,
68
69
  preamble: this.agentDefinition?.prompt.preamble,
69
70
  append: this.agentDefinition?.prompt.append,
71
+ labels: this.agentDefinition?.labels,
70
72
  // skills injected via .claude/skills/*.md files, not in prompt
71
73
  });
72
74
  const claudeCommand = resolveClaudeCommand(this.env);
@@ -129,6 +131,72 @@ export class ClaudeRunner implements AgentRunner {
129
131
  );
130
132
  }
131
133
  }
134
+
135
+ async runContextual(issue: GitlabIssue, todoBody: string, context?: AgentRunContext): Promise<AgentRunResult> {
136
+ const signal = context?.signal;
137
+ const outputPath = await createOutputPath("claude-contextual", issue.iid);
138
+
139
+ const prompt = buildContextualPrompt({
140
+ issueIid: issue.iid,
141
+ issueTitle: issue.title,
142
+ gitlabHost: this.gitlabHost,
143
+ gitlabProjectId: this.gitlabProjectId,
144
+ todoBody,
145
+ });
146
+ const claudeCommand = resolveClaudeCommand(this.env);
147
+
148
+ this.logger.info(`Contextual reply: ${claudeCommand} -p ... cwd=${this.repoPath || process.cwd()}`);
149
+ this.logger.info(`Prompt (${prompt.length} chars): contextual issue #${issue.iid} "${issue.title}"`);
150
+
151
+ const timeoutMs = this.timeoutSeconds ? this.timeoutSeconds * 1000 : 0;
152
+
153
+ try {
154
+ const args = ["-p", "--output-format", "text", "--permission-mode", "bypassPermissions"];
155
+ if (this.agentDefinition?.model) {
156
+ args.push("--model", this.agentDefinition.model);
157
+ }
158
+ args.push(prompt);
159
+
160
+ const { stdout } = await this.execFileImpl(
161
+ claudeCommand,
162
+ args,
163
+ {
164
+ cwd: this.repoPath || process.cwd(),
165
+ encoding: "utf8",
166
+ maxBuffer: 10 * 1024 * 1024,
167
+ env: this.env,
168
+ ...(timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGTERM" } : {}),
169
+ ...(signal ? { signal } : {})
170
+ }
171
+ );
172
+
173
+ const summary = truncate(compactText(stdout || "Contextual reply sent."), 600);
174
+ this.logger.info(`Contextual reply finished. Output: ${outputPath}`);
175
+ await writeFile(outputPath, summary, "utf8");
176
+
177
+ return { summary, outputPath };
178
+ } catch (error) {
179
+ const execError = error as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
180
+ const isTimeout = execError.killed === true && timeoutMs > 0;
181
+ const summary = isTimeout
182
+ ? `Timed out after ${this.timeoutSeconds}s`
183
+ : truncate(
184
+ compactText(execError.stdout || execError.stderr || execError.message || "Contextual reply failed."),
185
+ 600
186
+ );
187
+ if (isTimeout) {
188
+ this.logger.error(`Contextual reply timed out after ${this.timeoutSeconds}s.`);
189
+ } else {
190
+ this.logger.error(`Contextual reply failed. stderr: ${execError.stderr?.slice(0, 300) ?? "(none)"}`);
191
+ }
192
+ await writeFile(outputPath, summary, "utf8");
193
+ throw new AgentRunnerError(
194
+ isTimeout ? `Claude Code timed out after ${this.timeoutSeconds}s.` : "Contextual reply failed.",
195
+ summary,
196
+ outputPath
197
+ );
198
+ }
199
+ }
132
200
  }
133
201
 
134
202
  export function resolveClaudeCommand(env: NodeJS.ProcessEnv = process.env): string {