teamai-cli 0.16.8 → 0.16.9

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
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="assets/teamai-cli-logo.svg" alt="teamai-cli" width="430">
3
+ </p>
4
+
1
5
  # TeamAI — The team harness for AI agents
2
6
 
3
7
  > [English](README.md) | [简体中文](README.zh-CN.md)
@@ -89,6 +93,7 @@ The CLI picks a provider automatically from the repo URL:
89
93
  | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views |
90
94
  | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) |
91
95
  | `teamai hooks` | Manage AI-tool hooks (list / inject / remove) |
96
+ | `teamai ci extract-mr --url <url> [--mode comment\|write\|both] [--individual-comments]` | CI pipeline integration: extract knowledge from MR/PR, post as comments, and write to team repo after merge. With `--individual-comments`, each suggestion is posted separately with reaction/reject support (GitHub 👎 / TGit ☝️) |
92
97
  | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` |
93
98
  | `teamai doctor` | Diagnose configuration problems |
94
99
 
@@ -361,6 +366,42 @@ Auto-update runs on the Stop hook at the end of a session. It can be controlled
361
366
 
362
367
  The user-level `updatePolicy` always wins over the team-level `autoUpdate`.
363
368
 
369
+ ## CI Integration
370
+
371
+ TeamAI can integrate into your CI pipeline to automatically extract knowledge from every MR/PR:
372
+
373
+ ```
374
+ MR opened/updated → CI extracts learning + codebase suggestions → posts as comments
375
+ → Reviewer rejects unwanted suggestions (GitHub 👎 / TGit ☝️)
376
+ → MR merged → CI writes approved items to team knowledge repo
377
+ ```
378
+
379
+ ### Quick Start
380
+
381
+ ```bash
382
+ # Comment mode: post suggestions to MR (run on PR open/update)
383
+ teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments
384
+
385
+ # Write mode: write approved items to knowledge repo (run after merge)
386
+ teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments
387
+ ```
388
+
389
+ ### CI Templates
390
+
391
+ Ready-to-use templates in `examples/ci/`:
392
+
393
+ | File | Platform |
394
+ |------|----------|
395
+ | `github-actions-mr-extract.yml` | GitHub Actions |
396
+ | `coding-ci-mr-extract.yaml` | Coding CI (TGit + ZhiYan QCI) |
397
+
398
+ ### Reject Interaction
399
+
400
+ | Platform | How to reject | Default |
401
+ |----------|--------------|---------|
402
+ | GitHub | Add 👎 reaction to the suggestion comment | Write all |
403
+ | TGit | Add ☝️ emoji to the suggestion note | Write all |
404
+
364
405
  ## License
365
406
 
366
407
  [MIT](LICENSE)
package/README.zh-CN.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="assets/teamai-cli-logo.svg" alt="teamai-cli" width="430">
3
+ </p>
4
+
1
5
  # TeamAI — The team harness for AI agents
2
6
 
3
7
  > [English](README.md) | [简体中文](README.zh-CN.md)
@@ -89,6 +93,7 @@ CLI 会根据用户传入的 repo URL 自动选择 provider:
89
93
  | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | 浏览并处理域漂移信号;`--apply` 把仓库重新归类到推荐域并刷新聚合视图 |
90
94
  | `teamai digest` | 生成团队 AI 使用周报(skill 排行、新增/更新 skill、session 摘要) |
91
95
  | `teamai hooks` | 管理 AI 工具 hooks(list / inject / remove) |
96
+ | `teamai ci extract-mr --url <url> [--mode comment\|write\|both] [--individual-comments]` | CI 流水线集成:从 MR/PR 中提取知识,发布为评论,合并后写入团队知识仓库。使用 `--individual-comments` 时每条建议单独发布,支持 reaction/reject 交互(GitHub 👎 / TGit ☝️) |
92
97
  | `teamai uninstall [--force]` | 卸载 teamai:移除 hooks、rules、skills、env、docs、~/.teamai/ |
93
98
  | `teamai doctor` | 诊断配置问题 |
94
99
 
@@ -361,6 +366,42 @@ npm update -g teamai-cli # 或手动触发 npm 升级
361
366
 
362
367
  用户级 `updatePolicy` 始终优先于团队级 `autoUpdate`。
363
368
 
369
+ ## CI 集成
370
+
371
+ TeamAI 可以集成到 CI 流水线中,从每次 MR/PR 自动提取知识:
372
+
373
+ ```
374
+ MR 创建/更新 → CI 提取 learning + codebase 建议 → 以评论形式发布
375
+ → Reviewer 拒绝不需要的建议(GitHub 👎 / TGit ☝️)
376
+ → MR 合并 → CI 将已通过的条目写入团队知识仓库
377
+ ```
378
+
379
+ ### 快速开始
380
+
381
+ ```bash
382
+ # Comment 模式:将建议发布到 MR(在 PR 打开/更新时运行)
383
+ teamai ci extract-mr --url "$MR_URL" --mode comment --individual-comments
384
+
385
+ # Write 模式:将已通过的条目写入知识仓库(在合并后运行)
386
+ teamai ci extract-mr --url "$MR_URL" --mode write --team-repo ./team-repo --individual-comments
387
+ ```
388
+
389
+ ### CI 模板
390
+
391
+ `examples/ci/` 目录下提供了开箱即用的模板:
392
+
393
+ | 文件 | 平台 |
394
+ |------|------|
395
+ | `github-actions-mr-extract.yml` | GitHub Actions |
396
+ | `coding-ci-mr-extract.yaml` | Coding CI(TGit + 智研 QCI) |
397
+
398
+ ### 拒绝交互
399
+
400
+ | 平台 | 拒绝方式 | 默认行为 |
401
+ |------|---------|---------|
402
+ | GitHub | 对建议评论添加 👎 reaction | 全部写入 |
403
+ | TGit | 对建议 note 添加 ☝️ emoji | 全部写入 |
404
+
364
405
  ## 许可证
365
406
 
366
407
  [MIT](LICENSE)
package/dist/index.js CHANGED
@@ -2354,6 +2354,13 @@ var init_repo_url2 = __esm({
2354
2354
  });
2355
2355
 
2356
2356
  // src/providers/github/index.ts
2357
+ var github_exports = {};
2358
+ __export(github_exports, {
2359
+ GitHubProvider: () => GitHubProvider,
2360
+ ghGetOAuthToken: () => ghGetOAuthToken,
2361
+ ghIsAuthenticated: () => ghIsAuthenticated,
2362
+ isGhInstalled: () => isGhInstalled
2363
+ });
2357
2364
  var GitHubProvider;
2358
2365
  var init_github = __esm({
2359
2366
  "src/providers/github/index.ts"() {
@@ -4385,6 +4392,9 @@ var init_docs = __esm({
4385
4392
  import path17 from "path";
4386
4393
  import { z as z3 } from "zod";
4387
4394
  import YAML5 from "yaml";
4395
+ function shellQuoteValue(value) {
4396
+ return `'${value.replace(/'/g, "'\\''")}'`;
4397
+ }
4388
4398
  var EnvVariableSchema, EnvYamlSchema, EnvHandler;
4389
4399
  var init_env = __esm({
4390
4400
  "src/resources/env.ts"() {
@@ -4518,9 +4528,15 @@ var init_env = __esm({
4518
4528
  }
4519
4529
  /**
4520
4530
  * Generate the content of ~/.teamai/env.sh with export statements.
4531
+ *
4532
+ * Values are single-quoted so shell metacharacters in an env value (quotes,
4533
+ * `$`, backticks, `\`, …) are taken literally and cannot break or inject into
4534
+ * the sourced script. An embedded single quote is encoded with the standard
4535
+ * `'\''` sequence. env.sh is sourced from every team member's shell profile,
4536
+ * so values (which originate from the team repo's env/env.yaml) must be safe.
4521
4537
  */
4522
4538
  generateEnvFile(variables) {
4523
- const lines = variables.map((v) => `export ${v.key}="${v.value}"`);
4539
+ const lines = variables.map((v) => `export ${v.key}=${shellQuoteValue(v.value)}`);
4524
4540
  return lines.join("\n") + "\n";
4525
4541
  }
4526
4542
  /**
@@ -5773,8 +5789,8 @@ async function push(options) {
5773
5789
  process.exitCode = 2;
5774
5790
  return;
5775
5791
  }
5776
- const os6 = await import("os");
5777
- const skillPath = options.skill.startsWith("~") ? path24.join(os6.homedir(), options.skill.slice(1)) : path24.resolve(options.skill);
5792
+ const os7 = await import("os");
5793
+ const skillPath = options.skill.startsWith("~") ? path24.join(os7.homedir(), options.skill.slice(1)) : path24.resolve(options.skill);
5778
5794
  let matchedItem;
5779
5795
  for (const item of allItems) {
5780
5796
  if (item.type !== "skills") continue;
@@ -5786,7 +5802,7 @@ async function push(options) {
5786
5802
  matchedItem = item;
5787
5803
  break;
5788
5804
  }
5789
- const skillInput = options.skill.replace(/^~/, os6.homedir());
5805
+ const skillInput = options.skill.replace(/^~/, os7.homedir());
5790
5806
  if (item.sourcePath.endsWith(skillInput) || item.sourcePath.includes(path24.sep + skillInput)) {
5791
5807
  matchedItem = item;
5792
5808
  break;
@@ -8811,6 +8827,20 @@ async function doctor(options) {
8811
8827
  fix: "Run `teamai init` to authenticate via gf auth login"
8812
8828
  }
8813
8829
  );
8830
+ } else if (providerName === "github") {
8831
+ const { isGhInstalled: isGhInstalled2, ghIsAuthenticated: ghIsAuthenticated2 } = await Promise.resolve().then(() => (init_github(), github_exports));
8832
+ checks.push(
8833
+ {
8834
+ name: "gh CLI is installed",
8835
+ check: async () => isGhInstalled2(),
8836
+ fix: "Install from https://cli.github.com/ or run `brew install gh`"
8837
+ },
8838
+ {
8839
+ name: "gh CLI is authenticated",
8840
+ check: async () => ghIsAuthenticated2(),
8841
+ fix: "Run `gh auth login` to authenticate"
8842
+ }
8843
+ );
8814
8844
  }
8815
8845
  checks.push(
8816
8846
  {
@@ -9739,7 +9769,7 @@ async function buildRemovalPlan(localConfig, teamConfig) {
9739
9769
  if (toolPath.claudemd) {
9740
9770
  const claudeMdPath = path38.join(baseDir, toolPath.claudemd);
9741
9771
  const content = await readFileSafe(claudeMdPath);
9742
- if (content && content.includes(TEAMAI_RULES_START)) {
9772
+ if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) {
9743
9773
  plan.claudeMdFiles.push(claudeMdPath);
9744
9774
  }
9745
9775
  }
@@ -9843,18 +9873,21 @@ async function executeRemoval(plan) {
9843
9873
  }
9844
9874
  for (const claudeMdPath of plan.claudeMdFiles) {
9845
9875
  try {
9846
- const content = await readFileSafe(claudeMdPath);
9847
- if (!content) continue;
9848
- const startIdx = content.indexOf(TEAMAI_RULES_START);
9849
- const endIdx = content.indexOf(TEAMAI_RULES_END);
9850
- if (startIdx === -1 || endIdx === -1) continue;
9851
- const before = content.substring(0, startIdx).replace(/\n+$/, "\n");
9852
- const after = content.substring(endIdx + TEAMAI_RULES_END.length).replace(/^\n+/, "\n");
9853
- const newContent = (before + after).trim();
9854
- if (newContent.length === 0) {
9876
+ const raw = await readFileSafe(claudeMdPath);
9877
+ if (!raw) continue;
9878
+ let content = raw;
9879
+ for (const [startMarker, endMarker] of CLAUDEMD_MARKER_PAIRS) {
9880
+ const startIdx = content.indexOf(startMarker);
9881
+ const endIdx = content.indexOf(endMarker);
9882
+ if (startIdx === -1 || endIdx === -1) continue;
9883
+ const before = content.substring(0, startIdx).replace(/\n+$/, "\n");
9884
+ const after = content.substring(endIdx + endMarker.length).replace(/^\n+/, "\n");
9885
+ content = (before + after).trim();
9886
+ }
9887
+ if (content.length === 0) {
9855
9888
  await remove(claudeMdPath);
9856
9889
  } else {
9857
- await writeFile(claudeMdPath, newContent + "\n");
9890
+ await writeFile(claudeMdPath, content + "\n");
9858
9891
  }
9859
9892
  log.success(`\u6E05\u7406 CLAUDE.md: ${claudeMdPath}`);
9860
9893
  } catch (e) {
@@ -9980,6 +10013,7 @@ async function uninstall(opts) {
9980
10013
  }
9981
10014
  }
9982
10015
  }
10016
+ var CLAUDEMD_MARKER_PAIRS;
9983
10017
  var init_uninstall = __esm({
9984
10018
  "src/uninstall.ts"() {
9985
10019
  "use strict";
@@ -9990,6 +10024,12 @@ var init_uninstall = __esm({
9990
10024
  init_fs();
9991
10025
  init_logger();
9992
10026
  init_prompt();
10027
+ CLAUDEMD_MARKER_PAIRS = [
10028
+ [TEAMAI_RULES_START, TEAMAI_RULES_END],
10029
+ [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END],
10030
+ [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END],
10031
+ [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END]
10032
+ ];
9993
10033
  }
9994
10034
  });
9995
10035
 
@@ -10121,22 +10161,19 @@ __export(hooks_cmd_exports, {
10121
10161
  hooksRemove: () => hooksRemove
10122
10162
  });
10123
10163
  import path40 from "path";
10124
- async function hooksInject(options) {
10125
- const { localConfig, teamConfig } = await autoDetectInit();
10126
- const baseDir = resolveBaseDir(localConfig);
10127
- await injectHooksToAllTools(teamConfig.toolPaths, baseDir);
10128
- if (localConfig.scope === "project") {
10129
- const userBaseDir = process.env.HOME ?? "";
10130
- await injectHooksToAllTools(teamConfig.toolPaths, userBaseDir);
10164
+ function resolveHookBaseDirs(localConfig) {
10165
+ const baseDir = resolveBaseDir(localConfig) ?? "";
10166
+ if (localConfig.scope !== "project") {
10167
+ return [baseDir];
10131
10168
  }
10132
- if (!options.silent) {
10133
- log.success("Hooks injected into all AI tool settings");
10169
+ const userBaseDir = process.env.HOME ?? "";
10170
+ if (!userBaseDir || userBaseDir === baseDir) {
10171
+ return [baseDir];
10134
10172
  }
10173
+ return [baseDir, userBaseDir];
10135
10174
  }
10136
- async function hooksRemove(_options) {
10137
- const { localConfig, teamConfig } = await autoDetectInit();
10138
- const baseDir = resolveBaseDir(localConfig);
10139
- for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) {
10175
+ async function removeHooksFromAllTools(toolPaths, baseDir) {
10176
+ for (const [tool, paths] of Object.entries(toolPaths)) {
10140
10177
  if (paths.settings) {
10141
10178
  const settingsPath = path40.join(baseDir, paths.settings);
10142
10179
  try {
@@ -10146,6 +10183,21 @@ async function hooksRemove(_options) {
10146
10183
  }
10147
10184
  }
10148
10185
  }
10186
+ }
10187
+ async function hooksInject(options) {
10188
+ const { localConfig, teamConfig } = await autoDetectInit();
10189
+ for (const baseDir of resolveHookBaseDirs(localConfig)) {
10190
+ await injectHooksToAllTools(teamConfig.toolPaths, baseDir);
10191
+ }
10192
+ if (!options.silent) {
10193
+ log.success("Hooks injected into all AI tool settings");
10194
+ }
10195
+ }
10196
+ async function hooksRemove(_options) {
10197
+ const { localConfig, teamConfig } = await autoDetectInit();
10198
+ for (const baseDir of resolveHookBaseDirs(localConfig)) {
10199
+ await removeHooksFromAllTools(teamConfig.toolPaths, baseDir);
10200
+ }
10149
10201
  log.success("Hooks removed from all AI tool settings");
10150
10202
  }
10151
10203
  var init_hooks_cmd = __esm({
@@ -10544,6 +10596,27 @@ var init_digest = __esm({
10544
10596
  }
10545
10597
  });
10546
10598
 
10599
+ // src/utils/session-id.ts
10600
+ function deriveSessionId(data, options = {}) {
10601
+ if (typeof data.session_id === "string" && data.session_id) {
10602
+ return data.session_id;
10603
+ }
10604
+ if (process.env.CLAUDE_SESSION_ID) {
10605
+ return process.env.CLAUDE_SESSION_ID;
10606
+ }
10607
+ const ppid = process.ppid ?? process.pid;
10608
+ if (options.includeCwd) {
10609
+ const cwd = typeof data.cwd === "string" ? data.cwd : process.cwd();
10610
+ return `pid-${ppid}-${cwd}`;
10611
+ }
10612
+ return `pid-${ppid}`;
10613
+ }
10614
+ var init_session_id = __esm({
10615
+ "src/utils/session-id.ts"() {
10616
+ "use strict";
10617
+ }
10618
+ });
10619
+
10547
10620
  // src/pid-monitor.ts
10548
10621
  import fs11 from "fs";
10549
10622
  import { execSync as execSync4 } from "child_process";
@@ -10684,17 +10757,6 @@ async function readLastAssistantOutput(transcriptPath) {
10684
10757
  return "";
10685
10758
  }
10686
10759
  }
10687
- function deriveSessionId(hookData) {
10688
- if (typeof hookData.session_id === "string" && hookData.session_id) {
10689
- return hookData.session_id;
10690
- }
10691
- if (process.env.CLAUDE_SESSION_ID) {
10692
- return process.env.CLAUDE_SESSION_ID;
10693
- }
10694
- const cwd = typeof hookData.cwd === "string" ? hookData.cwd : process.cwd();
10695
- const ppid = process.ppid ?? process.pid;
10696
- return `pid-${ppid}-${cwd}`;
10697
- }
10698
10760
  function mapEventType(hookEventName) {
10699
10761
  switch (hookEventName) {
10700
10762
  case "SessionStart":
@@ -10729,7 +10791,7 @@ async function parseHookEvent(raw, tool) {
10729
10791
  log.debug(`dashboard-collector: unknown hook event: ${hookEventName}`);
10730
10792
  return null;
10731
10793
  }
10732
- const sessionId = deriveSessionId(hookData);
10794
+ const sessionId = deriveSessionId(hookData, { includeCwd: true });
10733
10795
  const cwd = typeof hookData.cwd === "string" ? hookData.cwd : void 0;
10734
10796
  const event = {
10735
10797
  type: eventType,
@@ -10918,6 +10980,7 @@ var init_dashboard_collector = __esm({
10918
10980
  "src/dashboard-collector.ts"() {
10919
10981
  "use strict";
10920
10982
  init_logger();
10983
+ init_session_id();
10921
10984
  init_fs();
10922
10985
  init_pid_monitor();
10923
10986
  init_types();
@@ -11945,12 +12008,14 @@ var init_recall = __esm({
11945
12008
  var auto_recall_exports = {};
11946
12009
  __export(auto_recall_exports, {
11947
12010
  autoRecall: () => autoRecall,
12011
+ autoRecallFromInput: () => autoRecallFromInput,
11948
12012
  containsError: () => containsError,
11949
12013
  extractGrepQuery: () => extractGrepQuery,
11950
12014
  extractQuery: () => extractQuery,
11951
12015
  extractWebFetchQuery: () => extractWebFetchQuery,
11952
12016
  extractWebSearchQuery: () => extractWebSearchQuery,
11953
12017
  isReadOnlyCommand: () => isReadOnlyCommand,
12018
+ parseHookInput: () => parseHookInput,
11954
12019
  readRecallQuality: () => readRecallQuality,
11955
12020
  readStdin: () => readStdin3,
11956
12021
  shouldSkipQuery: () => shouldSkipQuery
@@ -12101,6 +12166,18 @@ function shouldSkipQuery(sessionId, query) {
12101
12166
  writeCache(sessionId, updated);
12102
12167
  return false;
12103
12168
  }
12169
+ function parseHookInput(data) {
12170
+ const toolName = typeof data.tool_name === "string" ? data.tool_name : "";
12171
+ if (!toolName) return null;
12172
+ const rawInput = data.tool_input;
12173
+ const toolInput = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
12174
+ const toolResponse = data.tool_response;
12175
+ const toolOutput = typeof data.tool_output === "string" ? data.tool_output : typeof data.tool_result === "string" ? data.tool_result : toolResponse ? [
12176
+ typeof toolResponse.stdout === "string" ? toolResponse.stdout : "",
12177
+ typeof toolResponse.stderr === "string" ? toolResponse.stderr : ""
12178
+ ].filter(Boolean).join("\n") : "";
12179
+ return { toolName, toolInput, toolOutput, sessionId: deriveSessionId(data) };
12180
+ }
12104
12181
  async function readStdin3() {
12105
12182
  if (process.stdin.isTTY) return null;
12106
12183
  const chunks = [];
@@ -12111,44 +12188,30 @@ async function readStdin3() {
12111
12188
  if (!raw.trim()) return null;
12112
12189
  try {
12113
12190
  const data = JSON.parse(raw);
12114
- const toolName = typeof data.tool_name === "string" ? data.tool_name : "";
12115
- const rawInput = data.tool_input;
12116
- const toolInput = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
12117
- const toolResponse = data.tool_response;
12118
- const toolOutput = typeof data.tool_output === "string" ? data.tool_output : typeof data.tool_result === "string" ? data.tool_result : toolResponse ? [
12119
- typeof toolResponse.stdout === "string" ? toolResponse.stdout : "",
12120
- typeof toolResponse.stderr === "string" ? toolResponse.stderr : ""
12121
- ].filter(Boolean).join("\n") : "";
12122
- const sessionId = typeof data.session_id === "string" && data.session_id || process.env.CLAUDE_SESSION_ID || `pid-${process.ppid ?? process.pid}`;
12123
- return { toolName, toolInput, toolOutput, sessionId };
12191
+ return parseHookInput(data);
12124
12192
  } catch {
12125
12193
  return null;
12126
12194
  }
12127
12195
  }
12128
- async function autoRecall() {
12196
+ async function autoRecallFromInput(input) {
12129
12197
  if (process.env.TEAMAI_RECALL_DISABLED === "1") {
12130
- return;
12131
- }
12132
- const input = await readStdin3();
12133
- if (!input) {
12134
- log.debug("auto-recall: no STDIN data");
12135
- return;
12198
+ return null;
12136
12199
  }
12137
12200
  const { toolName, toolInput, toolOutput, sessionId } = input;
12138
12201
  if (!RECALL_TOOLS.has(toolName)) {
12139
- return;
12202
+ return null;
12140
12203
  }
12141
12204
  let query = "";
12142
12205
  if (toolName === "Bash") {
12143
12206
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
12144
12207
  if (isReadOnlyCommand(command)) {
12145
- return;
12208
+ return null;
12146
12209
  }
12147
12210
  if (toolOutput.includes("[teamai:")) {
12148
- return;
12211
+ return null;
12149
12212
  }
12150
12213
  if (!containsError(toolOutput)) {
12151
- return;
12214
+ return null;
12152
12215
  }
12153
12216
  query = extractQuery(toolOutput);
12154
12217
  } else if (toolName === "Grep") {
@@ -12160,11 +12223,11 @@ async function autoRecall() {
12160
12223
  }
12161
12224
  if (!query) {
12162
12225
  log.debug(`auto-recall: no query extracted from ${toolName}`);
12163
- return;
12226
+ return null;
12164
12227
  }
12165
12228
  if (shouldSkipQuery(sessionId, query)) {
12166
12229
  log.debug(`auto-recall: skipping duplicate/rate-limited query: ${query.slice(0, 50)}`);
12167
- return;
12230
+ return null;
12168
12231
  }
12169
12232
  const { loadIndex: loadIndex2, search: search2 } = await Promise.resolve().then(() => (init_search_index(), search_index_exports));
12170
12233
  const { formatResults: formatResults2 } = await Promise.resolve().then(() => (init_recall(), recall_exports));
@@ -12188,7 +12251,7 @@ async function autoRecall() {
12188
12251
  missCount: 0
12189
12252
  };
12190
12253
  writeCache(sessionId, { ...cache, missCount: cache.missCount + 1, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
12191
- return;
12254
+ return null;
12192
12255
  }
12193
12256
  const searchStart = Date.now();
12194
12257
  const results = search2(query, index, 3);
@@ -12232,7 +12295,7 @@ async function autoRecall() {
12232
12295
  }
12233
12296
  if (results.length === 0) {
12234
12297
  log.debug(`auto-recall: no results for query: ${query.slice(0, 50)}`);
12235
- return;
12298
+ return null;
12236
12299
  }
12237
12300
  const titles = results.map((r) => r.entry.title).join(", ");
12238
12301
  log.debug(`auto-recall: [${toolName}] query="${query.slice(0, 60)}" \u2192 ${results.length} results: ${titles}`);
@@ -12248,7 +12311,6 @@ async function autoRecall() {
12248
12311
  additionalContext: context
12249
12312
  }
12250
12313
  });
12251
- process.stdout.write(hookOutput + "\n");
12252
12314
  try {
12253
12315
  const { autoUpvote: autoUpvote2 } = await Promise.resolve().then(() => (init_recall(), recall_exports));
12254
12316
  const { requireInit: requireInit3 } = await Promise.resolve().then(() => (init_config(), config_exports));
@@ -12256,6 +12318,21 @@ async function autoRecall() {
12256
12318
  await autoUpvote2(results, localConfig.username, localConfig.repo.localPath);
12257
12319
  } catch {
12258
12320
  }
12321
+ return hookOutput;
12322
+ }
12323
+ async function autoRecall() {
12324
+ if (process.env.TEAMAI_RECALL_DISABLED === "1") {
12325
+ return;
12326
+ }
12327
+ const input = await readStdin3();
12328
+ if (!input) {
12329
+ log.debug("auto-recall: no STDIN data");
12330
+ return;
12331
+ }
12332
+ const output = await autoRecallFromInput(input);
12333
+ if (output) {
12334
+ process.stdout.write(output + "\n");
12335
+ }
12259
12336
  }
12260
12337
  function readRecallQuality(sessionId) {
12261
12338
  const cache = readCache(sessionId);
@@ -12273,6 +12350,7 @@ var init_auto_recall = __esm({
12273
12350
  "use strict";
12274
12351
  init_logger();
12275
12352
  init_types();
12353
+ init_session_id();
12276
12354
  RECALL_TOOLS = /* @__PURE__ */ new Set(["Bash", "Grep", "WebSearch", "WebFetch"]);
12277
12355
  READ_ONLY_COMMANDS = ["cat", "head", "tail", "less", "more", "bat", "batcat"];
12278
12356
  ERROR_PATTERNS = [
@@ -12540,7 +12618,7 @@ async function readStdinAndDeriveSession() {
12540
12618
  if (!raw.trim()) return null;
12541
12619
  try {
12542
12620
  const hookData = JSON.parse(raw);
12543
- const sessionId = typeof hookData.session_id === "string" && hookData.session_id || process.env.CLAUDE_SESSION_ID || `pid-${process.ppid ?? process.pid}-${typeof hookData.cwd === "string" ? hookData.cwd : process.cwd()}`;
12621
+ const sessionId = deriveSessionId(hookData, { includeCwd: true });
12544
12622
  const cwd = typeof hookData.cwd === "string" ? hookData.cwd : void 0;
12545
12623
  return { sessionId, cwd };
12546
12624
  } catch {
@@ -12664,6 +12742,7 @@ var init_contribute_check = __esm({
12664
12742
  init_fs();
12665
12743
  init_dashboard_collector();
12666
12744
  init_auto_recall();
12745
+ init_session_id();
12667
12746
  init_types();
12668
12747
  STALE_SESSION_MS = 24 * 60 * 60 * 1e3;
12669
12748
  }
@@ -12830,8 +12909,7 @@ async function readStdin4() {
12830
12909
  try {
12831
12910
  const data = JSON.parse(raw);
12832
12911
  const toolName = typeof data.tool_name === "string" ? data.tool_name : "";
12833
- const sessionId = typeof data.session_id === "string" && data.session_id || process.env.CLAUDE_SESSION_ID || `pid-${process.ppid ?? process.pid}`;
12834
- return { toolName, sessionId };
12912
+ return { toolName, sessionId: deriveSessionId(data) };
12835
12913
  } catch {
12836
12914
  return null;
12837
12915
  }
@@ -12873,6 +12951,7 @@ var init_todowrite_hint = __esm({
12873
12951
  "src/todowrite-hint.ts"() {
12874
12952
  "use strict";
12875
12953
  init_logger();
12954
+ init_session_id();
12876
12955
  CACHE_TTL_MS3 = 24 * 60 * 60 * 1e3;
12877
12956
  }
12878
12957
  });
@@ -14126,7 +14205,7 @@ function parseGitHubPRUrl(url) {
14126
14205
  }
14127
14206
  return { owner: match[1], repo: match[2], number: match[3] };
14128
14207
  }
14129
- async function githubApiGet(path68) {
14208
+ async function githubApiGet(path69) {
14130
14209
  return new Promise((resolve, reject) => {
14131
14210
  const token = process.env["GITHUB_TOKEN"];
14132
14211
  const headers = {
@@ -14135,7 +14214,7 @@ async function githubApiGet(path68) {
14135
14214
  };
14136
14215
  if (token) headers["Authorization"] = `Bearer ${token}`;
14137
14216
  const req = https2.request(
14138
- { hostname: "api.github.com", path: path68, headers },
14217
+ { hostname: "api.github.com", path: path69, headers },
14139
14218
  (res) => {
14140
14219
  const chunks = [];
14141
14220
  res.on("data", (c) => chunks.push(c));
@@ -14254,12 +14333,16 @@ async function fetchTGitMRViaApi(group, project, mrIid) {
14254
14333
  const encodedPath = encodeURIComponent(`${group}/${project}`);
14255
14334
  const baseUrl = `https://git.woa.com/api/v3/projects/${encodedPath}`;
14256
14335
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
14257
- const mrResp = await fetch(`${baseUrl}/merge_requests/${mrIid}`, { headers });
14258
- if (!mrResp.ok) {
14259
- throw new Error(`TGit API \u8FD4\u56DE\u9519\u8BEF ${mrResp.status}\uFF1A${await mrResp.text()}`);
14336
+ const listResp = await fetch(`${baseUrl}/merge_requests?iid=${mrIid}`, { headers });
14337
+ if (!listResp.ok) {
14338
+ throw new Error(`TGit API \u8FD4\u56DE\u9519\u8BEF ${listResp.status}\uFF1A${await listResp.text()}`);
14339
+ }
14340
+ const mrList = await listResp.json();
14341
+ const mr = mrList.find((m) => String(m.id) !== void 0);
14342
+ if (!mr) {
14343
+ throw new Error(`TGit MR !${mrIid} \u4E0D\u5B58\u5728`);
14260
14344
  }
14261
- const mr = await mrResp.json();
14262
- const diffResp = await fetch(`${baseUrl}/merge_requests/${mrIid}/changes`, { headers });
14345
+ const diffResp = await fetch(`${baseUrl}/merge_requests/${mr.id}/changes`, { headers });
14263
14346
  let diff = "";
14264
14347
  if (diffResp.ok) {
14265
14348
  const diffData = await diffResp.json();
@@ -14671,6 +14754,10 @@ async function importFromMR(opts) {
14671
14754
  aiSpinner.fail("AI \u5206\u6790\u5931\u8D25");
14672
14755
  throw err;
14673
14756
  }
14757
+ const frontmatterStart = learningContent.indexOf("---");
14758
+ if (frontmatterStart > 0) {
14759
+ learningContent = learningContent.slice(frontmatterStart);
14760
+ }
14674
14761
  const parsed = matter6(learningContent);
14675
14762
  const learningTitle = parsed.data["title"] ?? mr.title;
14676
14763
  const draftKeywords = extractKeywords(learningContent);
@@ -19615,6 +19702,7 @@ var PULL_TIMEOUT_MS, UPDATE_TIMEOUT_MS, TRACK_TIMEOUT_MS, DASHBOARD_TIMEOUT_MS,
19615
19702
  var init_hook_handlers = __esm({
19616
19703
  "src/hook-handlers.ts"() {
19617
19704
  "use strict";
19705
+ init_session_id();
19618
19706
  PULL_TIMEOUT_MS = 6e4;
19619
19707
  UPDATE_TIMEOUT_MS = 1e4;
19620
19708
  TRACK_TIMEOUT_MS = 5e3;
@@ -19712,23 +19800,10 @@ var init_hook_handlers = __esm({
19712
19800
  autoRecallHandler = {
19713
19801
  name: "auto-recall",
19714
19802
  async execute(stdin, _tool) {
19715
- const { autoRecall: autoRecall2 } = await Promise.resolve().then(() => (init_auto_recall(), auto_recall_exports));
19716
- let capturedOutput = null;
19717
- const originalWrite = process.stdout.write.bind(process.stdout);
19718
- process.stdout.write = ((chunk) => {
19719
- if (typeof chunk === "string") {
19720
- capturedOutput = chunk;
19721
- } else if (Buffer.isBuffer(chunk)) {
19722
- capturedOutput = chunk.toString();
19723
- }
19724
- return true;
19725
- });
19726
- try {
19727
- await autoRecall2();
19728
- } finally {
19729
- process.stdout.write = originalWrite;
19730
- }
19731
- return capturedOutput;
19803
+ const { autoRecallFromInput: autoRecallFromInput2, parseHookInput: parseHookInput2 } = await Promise.resolve().then(() => (init_auto_recall(), auto_recall_exports));
19804
+ const input = parseHookInput2(stdin);
19805
+ if (!input) return null;
19806
+ return autoRecallFromInput2(input);
19732
19807
  }
19733
19808
  };
19734
19809
  todowriteHintHandler = {
@@ -19738,8 +19813,7 @@ var init_hook_handlers = __esm({
19738
19813
  const toolName = typeof stdin.tool_name === "string" ? stdin.tool_name : "";
19739
19814
  if (toolName !== "TodoWrite") return null;
19740
19815
  const { shouldSkipTodoWriteHint: shouldSkipTodoWriteHint2, buildHintMessage: buildHintMessage3 } = await Promise.resolve().then(() => (init_todowrite_hint(), todowrite_hint_exports));
19741
- const sessionId = typeof stdin.session_id === "string" && stdin.session_id || process.env.CLAUDE_SESSION_ID || `pid-${process.ppid ?? process.pid}`;
19742
- if (shouldSkipTodoWriteHint2(sessionId)) return null;
19816
+ if (shouldSkipTodoWriteHint2(deriveSessionId(stdin))) return null;
19743
19817
  return JSON.stringify({
19744
19818
  hookSpecificOutput: {
19745
19819
  hookEventName: "PostToolUse",
@@ -19801,6 +19875,672 @@ var init_hook_dispatch_cli = __esm({
19801
19875
  }
19802
19876
  });
19803
19877
 
19878
+ // src/ci/mr-comment.ts
19879
+ function parseMrUrl(url) {
19880
+ const ghMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
19881
+ if (ghMatch) {
19882
+ return { provider: "github", owner: ghMatch[1], repo: ghMatch[2], number: ghMatch[3] };
19883
+ }
19884
+ const tgitMatch = url.match(/git\.woa\.com\/(.+)\/([^/]+)\/merge_requests\/(\d+)/);
19885
+ if (tgitMatch) {
19886
+ return { provider: "tgit", owner: tgitMatch[1], repo: tgitMatch[2], number: tgitMatch[3] };
19887
+ }
19888
+ throw new Error(`\u65E0\u6CD5\u89E3\u6790 MR URL: ${url}\uFF0C\u4EC5\u652F\u6301 GitHub \u548C TGit`);
19889
+ }
19890
+ function formatComment(learning, suggestions, marker) {
19891
+ const lines = [marker, "", "## TeamAI \u77E5\u8BC6\u63D0\u70BC", ""];
19892
+ if (learning) {
19893
+ lines.push("### Learning", "");
19894
+ lines.push(`**${learning.title}**`, "");
19895
+ lines.push("<details>", "<summary>\u5C55\u5F00\u5B8C\u6574\u5185\u5BB9</summary>", "");
19896
+ lines.push("```markdown");
19897
+ lines.push(learning.content);
19898
+ lines.push("```");
19899
+ lines.push("", "</details>", "");
19900
+ }
19901
+ if (suggestions && suggestions.length > 0) {
19902
+ lines.push("### Codebase.md \u66F4\u65B0\u5EFA\u8BAE", "");
19903
+ lines.push("| \u7AE0\u8282 | \u64CD\u4F5C | \u5185\u5BB9\u9884\u89C8 |");
19904
+ lines.push("|------|------|---------|");
19905
+ for (const s of suggestions) {
19906
+ const preview = s.content.replace(/\n/g, " ").slice(0, 80);
19907
+ lines.push(`| ${s.section} | ${s.action} | ${preview} |`);
19908
+ }
19909
+ lines.push("");
19910
+ }
19911
+ if (!learning && (!suggestions || suggestions.length === 0)) {
19912
+ lines.push("> \u672C\u6B21 MR \u65E0\u53EF\u63D0\u70BC\u7684\u77E5\u8BC6\u5185\u5BB9\u3002", "");
19913
+ }
19914
+ lines.push("---");
19915
+ lines.push("> MR \u5408\u5165\u540E\u5C06\u81EA\u52A8\u5E94\u7528\u4EE5\u4E0A\u5EFA\u8BAE\u5230\u56E2\u961F\u77E5\u8BC6\u5E93\u3002");
19916
+ lines.push("> _Auto-generated by `teamai ci extract-mr`_");
19917
+ return lines.join("\n");
19918
+ }
19919
+ async function githubRequest(path69, method, body) {
19920
+ const token = process.env["GITHUB_TOKEN"];
19921
+ if (!token) throw new Error("\u672A\u8BBE\u7F6E GITHUB_TOKEN \u73AF\u5883\u53D8\u91CF");
19922
+ const url = `https://api.github.com${path69}`;
19923
+ const headers = {
19924
+ Authorization: `Bearer ${token}`,
19925
+ Accept: "application/vnd.github+json",
19926
+ "User-Agent": "teamai-cli"
19927
+ };
19928
+ if (body) headers["Content-Type"] = "application/json";
19929
+ return fetch(url, {
19930
+ method,
19931
+ headers,
19932
+ body: body ? JSON.stringify(body) : void 0,
19933
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
19934
+ });
19935
+ }
19936
+ async function findGitHubComment(owner, repo, prNumber, marker) {
19937
+ const resp = await githubRequest(
19938
+ `/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`,
19939
+ "GET"
19940
+ );
19941
+ if (!resp.ok) return null;
19942
+ const comments = await resp.json();
19943
+ return comments.find((c) => c.body.includes(marker)) ?? null;
19944
+ }
19945
+ async function postGitHubComment(owner, repo, prNumber, body) {
19946
+ const resp = await githubRequest(
19947
+ `/repos/${owner}/${repo}/issues/${prNumber}/comments`,
19948
+ "POST",
19949
+ { body }
19950
+ );
19951
+ if (!resp.ok) {
19952
+ const errText = await resp.text();
19953
+ throw new Error(`GitHub comment \u521B\u5EFA\u5931\u8D25 (${resp.status}): ${errText}`);
19954
+ }
19955
+ const data = await resp.json();
19956
+ return { created: true, url: data.html_url };
19957
+ }
19958
+ async function updateGitHubComment(owner, repo, commentId, body) {
19959
+ const resp = await githubRequest(
19960
+ `/repos/${owner}/${repo}/issues/comments/${commentId}`,
19961
+ "PATCH",
19962
+ { body }
19963
+ );
19964
+ if (!resp.ok) {
19965
+ const errText = await resp.text();
19966
+ throw new Error(`GitHub comment \u66F4\u65B0\u5931\u8D25 (${resp.status}): ${errText}`);
19967
+ }
19968
+ const data = await resp.json();
19969
+ return { created: false, url: data.html_url };
19970
+ }
19971
+ function getTGitToken() {
19972
+ const envToken = process.env["TAI_PAT_TOKEN"];
19973
+ if (envToken) return envToken;
19974
+ const oauthToken = gfGetOAuthToken();
19975
+ if (oauthToken) return oauthToken;
19976
+ throw new Error("\u672A\u8BBE\u7F6E TAI_PAT_TOKEN \u73AF\u5883\u53D8\u91CF\uFF0C\u4E14\u65E0\u6CD5\u4ECE gf credential \u83B7\u53D6 token");
19977
+ }
19978
+ async function tgitRequest(path69, method, body) {
19979
+ const token = getTGitToken();
19980
+ const url = `https://git.woa.com/api/v3${path69}`;
19981
+ const headers = {
19982
+ Authorization: `Bearer ${token}`,
19983
+ "Content-Type": "application/json"
19984
+ };
19985
+ return fetch(url, {
19986
+ method,
19987
+ headers,
19988
+ body: body ? JSON.stringify(body) : void 0,
19989
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
19990
+ });
19991
+ }
19992
+ async function getMrGlobalId(projectId, mrIid) {
19993
+ const resp = await tgitRequest(
19994
+ `/projects/${projectId}/merge_requests?iid=${mrIid}`,
19995
+ "GET"
19996
+ );
19997
+ if (!resp.ok) {
19998
+ throw new Error(`TGit \u67E5\u8BE2 MR \u5931\u8D25 (${resp.status})`);
19999
+ }
20000
+ const mrs = await resp.json();
20001
+ const mr = mrs.find((m) => String(m.iid) === mrIid);
20002
+ if (!mr) {
20003
+ throw new Error(`TGit MR !${mrIid} \u4E0D\u5B58\u5728`);
20004
+ }
20005
+ return mr.id;
20006
+ }
20007
+ async function findTGitComment(projectId, mrGlobalId, marker) {
20008
+ const resp = await tgitRequest(
20009
+ `/projects/${projectId}/merge_requests/${mrGlobalId}/notes?per_page=100`,
20010
+ "GET"
20011
+ );
20012
+ if (!resp.ok) return null;
20013
+ const notes = await resp.json();
20014
+ return notes.find((n) => n.body.includes(marker)) ?? null;
20015
+ }
20016
+ async function postTGitComment(projectId, mrGlobalId, body) {
20017
+ const resp = await tgitRequest(
20018
+ `/projects/${projectId}/merge_requests/${mrGlobalId}/notes`,
20019
+ "POST",
20020
+ { body }
20021
+ );
20022
+ if (!resp.ok) {
20023
+ const errText = await resp.text();
20024
+ throw new Error(`TGit comment \u521B\u5EFA\u5931\u8D25 (${resp.status}): ${errText}`);
20025
+ }
20026
+ return { created: true };
20027
+ }
20028
+ async function updateTGitComment(projectId, mrGlobalId, noteId, body) {
20029
+ const resp = await tgitRequest(
20030
+ `/projects/${projectId}/merge_requests/${mrGlobalId}/notes/${noteId}`,
20031
+ "PUT",
20032
+ { body }
20033
+ );
20034
+ if (!resp.ok) {
20035
+ const errText = await resp.text();
20036
+ throw new Error(`TGit comment \u66F4\u65B0\u5931\u8D25 (${resp.status}): ${errText}`);
20037
+ }
20038
+ return { created: false };
20039
+ }
20040
+ async function postOrUpdateMrComment(mrUrl, learning, suggestions, marker, dryRun) {
20041
+ const effectiveMarker = marker ?? DEFAULT_MARKER;
20042
+ const body = formatComment(learning, suggestions, effectiveMarker);
20043
+ const parsed = parseMrUrl(mrUrl);
20044
+ if (dryRun) {
20045
+ log.info("dry-run: \u4EE5\u4E0B\u4E3A\u5C06\u53D1\u5E03\u7684 comment \u5185\u5BB9:");
20046
+ console.log(body);
20047
+ return { created: true };
20048
+ }
20049
+ if (parsed.provider === "github") {
20050
+ const existing2 = await findGitHubComment(
20051
+ parsed.owner,
20052
+ parsed.repo,
20053
+ parsed.number,
20054
+ effectiveMarker
20055
+ );
20056
+ if (existing2) {
20057
+ log.debug(`\u53D1\u73B0\u5DF2\u6709 comment #${existing2.id}\uFF0C\u66F4\u65B0\u4E2D...`);
20058
+ return updateGitHubComment(parsed.owner, parsed.repo, existing2.id, body);
20059
+ }
20060
+ return postGitHubComment(parsed.owner, parsed.repo, parsed.number, body);
20061
+ }
20062
+ const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`);
20063
+ const mrGlobalId = await getMrGlobalId(projectId, parsed.number);
20064
+ const existing = await findTGitComment(projectId, mrGlobalId, effectiveMarker);
20065
+ if (existing) {
20066
+ log.debug(`\u53D1\u73B0\u5DF2\u6709 note #${existing.id}\uFF0C\u66F4\u65B0\u4E2D...`);
20067
+ return updateTGitComment(projectId, mrGlobalId, existing.id, body);
20068
+ }
20069
+ return postTGitComment(projectId, mrGlobalId, body);
20070
+ }
20071
+ function formatIndividualComment(type, index, content, provider) {
20072
+ const markerId = type === "learning" ? "learning" : `suggestion:${index}`;
20073
+ const marker = `<!-- teamai:ci-extract:${markerId} -->`;
20074
+ const lines = [marker, ""];
20075
+ if (type === "learning") {
20076
+ lines.push(`## TeamAI Learning`, "");
20077
+ lines.push(`**${content.title}**`, "");
20078
+ if (content.body) {
20079
+ lines.push("<details>", "<summary>\u5C55\u5F00\u5B8C\u6574\u5185\u5BB9</summary>", "");
20080
+ lines.push("```markdown");
20081
+ lines.push(content.body);
20082
+ lines.push("```");
20083
+ lines.push("", "</details>");
20084
+ }
20085
+ } else {
20086
+ lines.push(`## TeamAI Codebase \u5EFA\u8BAE #${index}`, "");
20087
+ lines.push(`**\u7AE0\u8282**: ${content.section} | **\u64CD\u4F5C**: ${content.action}`, "");
20088
+ if (content.preview) {
20089
+ lines.push("```");
20090
+ lines.push(content.preview.slice(0, 300));
20091
+ lines.push("```");
20092
+ }
20093
+ }
20094
+ lines.push("");
20095
+ if (provider === "github") {
20096
+ lines.push("> \u{1F4A1} \u4E0D\u5199\u5165\u6B64\u6761\uFF1F\u8BF7\u5BF9\u672C\u6761 comment \u52A0 \u{1F44E} reaction");
20097
+ } else {
20098
+ lines.push("> \u{1F4A1} \u4E0D\u5199\u5165\u6B64\u6761\uFF1F\u8BF7\u5BF9\u672C\u6761\u8BC4\u8BBA\u52A0 \u261D\uFE0F emoji reaction");
20099
+ }
20100
+ lines.push("");
20101
+ lines.push("_Auto-generated by `teamai ci extract-mr`_");
20102
+ return lines.join("\n");
20103
+ }
20104
+ async function postIndividualComments(mrUrl, learning, suggestions, dryRun) {
20105
+ const parsed = parseMrUrl(mrUrl);
20106
+ let posted = 0;
20107
+ const items = [];
20108
+ if (learning) {
20109
+ const body = formatIndividualComment(
20110
+ "learning",
20111
+ 0,
20112
+ { title: learning.title, body: learning.content },
20113
+ parsed.provider
20114
+ );
20115
+ items.push({ markerId: "learning", body });
20116
+ }
20117
+ if (suggestions) {
20118
+ for (let i = 0; i < suggestions.length; i++) {
20119
+ const s = suggestions[i];
20120
+ const body = formatIndividualComment(
20121
+ "suggestion",
20122
+ i + 1,
20123
+ { section: s.section, action: s.action, preview: s.content },
20124
+ parsed.provider
20125
+ );
20126
+ items.push({ markerId: `suggestion:${i + 1}`, body });
20127
+ }
20128
+ }
20129
+ if (items.length === 0) return { posted: 0 };
20130
+ if (dryRun) {
20131
+ for (const item of items) {
20132
+ log.info(`dry-run [${item.markerId}]:`);
20133
+ console.log(item.body);
20134
+ console.log("---");
20135
+ }
20136
+ return { posted: items.length };
20137
+ }
20138
+ if (parsed.provider === "github") {
20139
+ for (const item of items) {
20140
+ const marker = `<!-- teamai:ci-extract:${item.markerId} -->`;
20141
+ const existing = await findGitHubComment(parsed.owner, parsed.repo, parsed.number, marker);
20142
+ if (existing) {
20143
+ await updateGitHubComment(parsed.owner, parsed.repo, existing.id, item.body);
20144
+ } else {
20145
+ await postGitHubComment(parsed.owner, parsed.repo, parsed.number, item.body);
20146
+ }
20147
+ posted++;
20148
+ }
20149
+ } else {
20150
+ const projectId = encodeURIComponent(`${parsed.owner}/${parsed.repo}`);
20151
+ const mrGlobalId = await getMrGlobalId(projectId, parsed.number);
20152
+ const mrListResp = await tgitRequest(
20153
+ `/projects/${projectId}/merge_requests?iid=${parsed.number}`,
20154
+ "GET"
20155
+ );
20156
+ let anchorFile = "README.md";
20157
+ if (mrListResp.ok) {
20158
+ const mrList = await mrListResp.json();
20159
+ if (mrList.length > 0) {
20160
+ const { source_branch, target_branch } = mrList[0];
20161
+ const compareResp = await tgitRequest(
20162
+ `/projects/${projectId}/repository/compare?from=${target_branch}&to=${source_branch}`,
20163
+ "GET"
20164
+ );
20165
+ if (compareResp.ok) {
20166
+ const compareData = await compareResp.json();
20167
+ if (compareData.diffs && compareData.diffs.length > 0) {
20168
+ anchorFile = compareData.diffs[0].new_path;
20169
+ }
20170
+ }
20171
+ }
20172
+ }
20173
+ for (const item of items) {
20174
+ const marker = `<!-- teamai:ci-extract:${item.markerId} -->`;
20175
+ const existingNote = await findTGitComment(projectId, mrGlobalId, marker);
20176
+ if (existingNote) {
20177
+ await updateTGitComment(projectId, mrGlobalId, existingNote.id, item.body);
20178
+ } else {
20179
+ const resp = await tgitRequest(
20180
+ `/projects/${projectId}/merge_requests/${mrGlobalId}/notes`,
20181
+ "POST",
20182
+ { body: item.body, path: anchorFile, line: 1, line_type: "new" }
20183
+ );
20184
+ if (!resp.ok) {
20185
+ await postTGitComment(projectId, mrGlobalId, item.body);
20186
+ }
20187
+ }
20188
+ posted++;
20189
+ }
20190
+ }
20191
+ log.success(`\u5DF2\u53D1\u5E03 ${posted} \u6761\u72EC\u7ACB\u5EFA\u8BAE`);
20192
+ return { posted };
20193
+ }
20194
+ var DEFAULT_MARKER, API_TIMEOUT_MS;
20195
+ var init_mr_comment = __esm({
20196
+ "src/ci/mr-comment.ts"() {
20197
+ "use strict";
20198
+ init_gf_cli();
20199
+ init_logger();
20200
+ DEFAULT_MARKER = "<!-- teamai:ci-extract -->";
20201
+ API_TIMEOUT_MS = 15e3;
20202
+ }
20203
+ });
20204
+
20205
+ // src/ci/read-rejections.ts
20206
+ function extractMarkerId(body) {
20207
+ const match = body.match(MARKER_REGEX);
20208
+ return match ? match[1] : null;
20209
+ }
20210
+ async function githubRequest2(path69) {
20211
+ const token = process.env["GITHUB_TOKEN"];
20212
+ if (!token) throw new Error("\u672A\u8BBE\u7F6E GITHUB_TOKEN");
20213
+ return fetch(`https://api.github.com${path69}`, {
20214
+ headers: {
20215
+ Authorization: `Bearer ${token}`,
20216
+ Accept: "application/vnd.github+json",
20217
+ "User-Agent": "teamai-cli"
20218
+ },
20219
+ signal: AbortSignal.timeout(API_TIMEOUT_MS2)
20220
+ });
20221
+ }
20222
+ async function readGitHubRejections(owner, repo, prNumber) {
20223
+ const result = { rejectedIds: /* @__PURE__ */ new Set(), approvedIds: /* @__PURE__ */ new Set(), allIds: /* @__PURE__ */ new Set() };
20224
+ const resp = await githubRequest2(`/repos/${owner}/${repo}/issues/${prNumber}/comments?per_page=100`);
20225
+ if (!resp.ok) return result;
20226
+ const comments = await resp.json();
20227
+ for (const comment of comments) {
20228
+ const markerId = extractMarkerId(comment.body);
20229
+ if (!markerId) continue;
20230
+ result.allIds.add(markerId);
20231
+ const reactResp = await githubRequest2(
20232
+ `/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions?per_page=100`
20233
+ );
20234
+ if (!reactResp.ok) {
20235
+ result.approvedIds.add(markerId);
20236
+ continue;
20237
+ }
20238
+ const reactions = await reactResp.json();
20239
+ const hasThumbsDown = reactions.some((r) => r.content === "-1");
20240
+ if (hasThumbsDown) {
20241
+ result.rejectedIds.add(markerId);
20242
+ } else {
20243
+ result.approvedIds.add(markerId);
20244
+ }
20245
+ }
20246
+ return result;
20247
+ }
20248
+ function getTGitToken2() {
20249
+ const envToken = process.env["TAI_PAT_TOKEN"];
20250
+ if (envToken) return envToken;
20251
+ const oauthToken = gfGetOAuthToken();
20252
+ if (oauthToken) return oauthToken;
20253
+ throw new Error("\u672A\u8BBE\u7F6E TAI_PAT_TOKEN \u4E14\u65E0\u6CD5\u83B7\u53D6 OAuth token");
20254
+ }
20255
+ async function tgitRequest2(path69) {
20256
+ const token = getTGitToken2();
20257
+ return fetch(`https://git.woa.com/api/v3${path69}`, {
20258
+ headers: {
20259
+ Authorization: `Bearer ${token}`,
20260
+ "Content-Type": "application/json"
20261
+ },
20262
+ signal: AbortSignal.timeout(API_TIMEOUT_MS2)
20263
+ });
20264
+ }
20265
+ async function getMrGlobalId2(projectId, mrIid) {
20266
+ const resp = await tgitRequest2(`/projects/${projectId}/merge_requests?iid=${mrIid}`);
20267
+ if (!resp.ok) throw new Error(`TGit \u67E5\u8BE2 MR \u5931\u8D25 (${resp.status})`);
20268
+ const mrs = await resp.json();
20269
+ const mr = mrs.find((m) => String(m.iid) === mrIid);
20270
+ if (!mr) throw new Error(`TGit MR !${mrIid} \u4E0D\u5B58\u5728`);
20271
+ return mr.id;
20272
+ }
20273
+ async function readTGitRejections(owner, repo, mrIid) {
20274
+ const result = { rejectedIds: /* @__PURE__ */ new Set(), approvedIds: /* @__PURE__ */ new Set(), allIds: /* @__PURE__ */ new Set() };
20275
+ const projectId = encodeURIComponent(`${owner}/${repo}`);
20276
+ const mrGlobalId = await getMrGlobalId2(projectId, mrIid);
20277
+ const resp = await tgitRequest2(`/projects/${projectId}/merge_requests/${mrGlobalId}/notes?per_page=100`);
20278
+ if (!resp.ok) return result;
20279
+ const notes = await resp.json();
20280
+ for (const note of notes) {
20281
+ const markerId = extractMarkerId(note.body);
20282
+ if (!markerId) continue;
20283
+ result.allIds.add(markerId);
20284
+ const hasRejectEmoji = (note.comments ?? []).some((c) => c.comment === TGIT_REJECT_EMOJI);
20285
+ if (hasRejectEmoji) {
20286
+ result.rejectedIds.add(markerId);
20287
+ } else {
20288
+ result.approvedIds.add(markerId);
20289
+ }
20290
+ }
20291
+ return result;
20292
+ }
20293
+ async function readRejections(mrUrl) {
20294
+ const parsed = parseMrUrl(mrUrl);
20295
+ if (parsed.provider === "github") {
20296
+ log.debug("\u8BFB\u53D6 GitHub reactions...");
20297
+ return readGitHubRejections(parsed.owner, parsed.repo, parsed.number);
20298
+ }
20299
+ log.debug("\u8BFB\u53D6 TGit emoji reactions...");
20300
+ return readTGitRejections(parsed.owner, parsed.repo, parsed.number);
20301
+ }
20302
+ function shouldWrite(markerId, rejections, _provider) {
20303
+ return !rejections.rejectedIds.has(markerId);
20304
+ }
20305
+ var API_TIMEOUT_MS2, MARKER_REGEX, TGIT_REJECT_EMOJI;
20306
+ var init_read_rejections = __esm({
20307
+ "src/ci/read-rejections.ts"() {
20308
+ "use strict";
20309
+ init_mr_comment();
20310
+ init_gf_cli();
20311
+ init_logger();
20312
+ API_TIMEOUT_MS2 = 15e3;
20313
+ MARKER_REGEX = /<!-- teamai:ci-extract:(\S+) -->/;
20314
+ TGIT_REJECT_EMOJI = 8;
20315
+ }
20316
+ });
20317
+
20318
+ // src/ci/extract-mr.ts
20319
+ var extract_mr_exports = {};
20320
+ __export(extract_mr_exports, {
20321
+ ciExtractMr: () => ciExtractMr
20322
+ });
20323
+ import fs36 from "fs/promises";
20324
+ import path68 from "path";
20325
+ import os6 from "os";
20326
+ async function configureGitUser2(repoPath, provider) {
20327
+ const { execFileSync: execFileSync4 } = await import("child_process");
20328
+ let name = "teamai-ci";
20329
+ let email = "teamai-ci@noreply";
20330
+ try {
20331
+ if (provider === "github") {
20332
+ const token = process.env["GITHUB_TOKEN"];
20333
+ if (token) {
20334
+ const resp = await fetch("https://api.github.com/user", {
20335
+ headers: { Authorization: `Bearer ${token}`, "User-Agent": "teamai-cli" },
20336
+ signal: AbortSignal.timeout(8e3)
20337
+ });
20338
+ if (resp.ok) {
20339
+ const user = await resp.json();
20340
+ name = user.login;
20341
+ email = user.email ?? `${user.login}@users.noreply.github.com`;
20342
+ }
20343
+ }
20344
+ } else {
20345
+ const token = process.env["TAI_PAT_TOKEN"];
20346
+ if (token) {
20347
+ const resp = await fetch("https://git.woa.com/api/v3/user", {
20348
+ headers: { Authorization: `Bearer ${token}` },
20349
+ signal: AbortSignal.timeout(8e3)
20350
+ });
20351
+ if (resp.ok) {
20352
+ const user = await resp.json();
20353
+ name = user.username;
20354
+ email = user.email;
20355
+ }
20356
+ }
20357
+ }
20358
+ } catch {
20359
+ log.debug("\u65E0\u6CD5\u83B7\u53D6\u7528\u6237\u4FE1\u606F\uFF0C\u4F7F\u7528\u9ED8\u8BA4 git user");
20360
+ }
20361
+ try {
20362
+ execFileSync4("git", ["config", "user.name", name], { cwd: repoPath, stdio: "ignore" });
20363
+ execFileSync4("git", ["config", "user.email", email], { cwd: repoPath, stdio: "ignore" });
20364
+ log.debug(`Git user: ${name} <${email}>`);
20365
+ } catch {
20366
+ log.debug("git config \u5931\u8D25\uFF08\u975E git \u4ED3\u5E93\uFF09\uFF0C\u8DF3\u8FC7");
20367
+ }
20368
+ }
20369
+ async function writeKnowledgeToRepo(teamRepo, learning, suggestions, writeMode, mrUrl, dryRun) {
20370
+ const changedFiles = [];
20371
+ if (learning) {
20372
+ const safeTitle = learning.title.replace(/[^a-zA-Z0-9一-鿿_-]/g, "-").replace(/-+/g, "-").slice(0, 50);
20373
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
20374
+ const filename = `${dateStr}-${safeTitle}.md`;
20375
+ const learningsDir = path68.join(teamRepo, "learnings");
20376
+ const learningPath = path68.join(learningsDir, filename);
20377
+ if (!dryRun) {
20378
+ await fs36.mkdir(learningsDir, { recursive: true });
20379
+ await fs36.writeFile(learningPath, learning.content, "utf-8");
20380
+ }
20381
+ log.success(`Learning \u5199\u5165: learnings/${filename}`);
20382
+ changedFiles.push(`learnings/${filename}`);
20383
+ }
20384
+ if (suggestions && suggestions.length > 0) {
20385
+ if (writeMode === "direct") {
20386
+ const codebasePath = path68.join(teamRepo, "docs", "codebase.md");
20387
+ try {
20388
+ const existing = await fs36.readFile(codebasePath, "utf-8");
20389
+ const updated = await applyCodebaseSuggestions(existing, suggestions);
20390
+ if (!dryRun) {
20391
+ await fs36.writeFile(codebasePath, updated, "utf-8");
20392
+ }
20393
+ log.success("Codebase.md \u5DF2\u66F4\u65B0");
20394
+ changedFiles.push("docs/codebase.md");
20395
+ } catch {
20396
+ log.warn("docs/codebase.md \u4E0D\u5B58\u5728\u6216\u8BFB\u53D6\u5931\u8D25\uFF0C\u8DF3\u8FC7 codebase \u66F4\u65B0");
20397
+ }
20398
+ } else {
20399
+ for (const s of suggestions) {
20400
+ if (!dryRun) {
20401
+ await appendPendingReview(teamRepo, {
20402
+ kind: "codebase-section",
20403
+ target: { file: "docs/codebase.md", section: s.section },
20404
+ payload: { content: s.content, action: s.action },
20405
+ source: `ci:extract-mr:${mrUrl}`
20406
+ });
20407
+ }
20408
+ }
20409
+ log.success(`${suggestions.length} \u6761\u5EFA\u8BAE\u5DF2\u52A0\u5165 pending-review \u961F\u5217`);
20410
+ changedFiles.push(".teamai/pending-review.jsonl");
20411
+ }
20412
+ }
20413
+ if (!dryRun && changedFiles.length > 0) {
20414
+ try {
20415
+ const provider = mrUrl.includes("github.com") ? "github" : "tgit";
20416
+ await configureGitUser2(teamRepo, provider);
20417
+ await pushRepoDirectly(teamRepo, `[teamai] CI extract knowledge from MR`, changedFiles);
20418
+ log.success("\u5DF2\u63A8\u9001\u5230\u56E2\u961F\u4ED3\u5E93");
20419
+ } catch (err) {
20420
+ log.warn(`\u63A8\u9001\u5931\u8D25: ${err.message}\uFF0C\u6587\u4EF6\u5DF2\u5199\u5165\u672C\u5730`);
20421
+ }
20422
+ }
20423
+ }
20424
+ async function writeArtifacts(outputDir, learning, suggestions) {
20425
+ await fs36.mkdir(outputDir, { recursive: true });
20426
+ if (learning) {
20427
+ await fs36.writeFile(path68.join(outputDir, "learning.md"), learning.content, "utf-8");
20428
+ }
20429
+ if (suggestions && suggestions.length > 0) {
20430
+ await fs36.writeFile(
20431
+ path68.join(outputDir, "codebase-suggestions.json"),
20432
+ JSON.stringify(suggestions, null, 2),
20433
+ "utf-8"
20434
+ );
20435
+ }
20436
+ }
20437
+ async function ciExtractMr(opts) {
20438
+ if ((opts.mode === "write" || opts.mode === "both") && !opts.teamRepo) {
20439
+ throw new Error("write \u6A21\u5F0F\u9700\u8981 --team-repo \u53C2\u6570");
20440
+ }
20441
+ let existingCodebaseMd;
20442
+ if (opts.existingCodebase) {
20443
+ try {
20444
+ existingCodebaseMd = await fs36.readFile(opts.existingCodebase, "utf-8");
20445
+ } catch {
20446
+ log.warn(`\u65E0\u6CD5\u8BFB\u53D6 --existing-codebase: ${opts.existingCodebase}`);
20447
+ }
20448
+ } else if (opts.teamRepo) {
20449
+ const codebasePath = path68.join(opts.teamRepo, "docs", "codebase.md");
20450
+ try {
20451
+ existingCodebaseMd = await fs36.readFile(codebasePath, "utf-8");
20452
+ } catch {
20453
+ }
20454
+ }
20455
+ const tmpDir = await fs36.mkdtemp(path68.join(os6.tmpdir(), "teamai-ci-extract-"));
20456
+ let learning;
20457
+ let suggestions;
20458
+ try {
20459
+ const result = await importFromMR({
20460
+ url: opts.url,
20461
+ all: true,
20462
+ outputDir: tmpDir,
20463
+ existingCodebaseMd,
20464
+ dryRun: true
20465
+ // 不让 importFromMR 自己写文件,我们自己控制写入
20466
+ });
20467
+ learning = result.learning;
20468
+ suggestions = result.codebaseSuggestions;
20469
+ } finally {
20470
+ await fs36.rm(tmpDir, { recursive: true, force: true }).catch(() => {
20471
+ });
20472
+ }
20473
+ if (opts.mode === "comment" || opts.mode === "both") {
20474
+ if (opts.individualComments) {
20475
+ const { posted } = await postIndividualComments(opts.url, learning, suggestions, opts.dryRun);
20476
+ log.success(`\u5DF2\u53D1\u5E03 ${posted} \u6761\u72EC\u7ACB\u5EFA\u8BAE comment`);
20477
+ } else {
20478
+ const result = await postOrUpdateMrComment(
20479
+ opts.url,
20480
+ learning,
20481
+ suggestions,
20482
+ opts.commentMarker,
20483
+ opts.dryRun
20484
+ );
20485
+ if (result.created) {
20486
+ log.success("MR comment \u5DF2\u53D1\u5E03");
20487
+ } else {
20488
+ log.success("MR comment \u5DF2\u66F4\u65B0");
20489
+ }
20490
+ if (result.url) {
20491
+ log.info(`Comment URL: ${result.url}`);
20492
+ }
20493
+ }
20494
+ }
20495
+ if (opts.mode === "write" || opts.mode === "both") {
20496
+ let filteredLearning = learning;
20497
+ let filteredSuggestions = suggestions;
20498
+ if (opts.individualComments && !opts.dryRun) {
20499
+ const parsed = parseMrUrl(opts.url);
20500
+ const rejections = await readRejections(opts.url);
20501
+ if (rejections.allIds.size > 0) {
20502
+ if (learning && !shouldWrite("learning", rejections, parsed.provider)) {
20503
+ log.info("Learning \u88AB reject\uFF0C\u8DF3\u8FC7\u5199\u5165");
20504
+ filteredLearning = void 0;
20505
+ }
20506
+ if (suggestions) {
20507
+ filteredSuggestions = suggestions.filter(
20508
+ (_, i) => shouldWrite(`suggestion:${i + 1}`, rejections, parsed.provider)
20509
+ );
20510
+ const rejected = suggestions.length - filteredSuggestions.length;
20511
+ if (rejected > 0) {
20512
+ log.info(`${rejected} \u6761 codebase \u5EFA\u8BAE\u88AB reject\uFF0C\u5DF2\u6392\u9664`);
20513
+ }
20514
+ }
20515
+ }
20516
+ }
20517
+ await writeKnowledgeToRepo(
20518
+ opts.teamRepo,
20519
+ filteredLearning,
20520
+ filteredSuggestions,
20521
+ opts.writeMode ?? "direct",
20522
+ opts.url,
20523
+ opts.dryRun
20524
+ );
20525
+ }
20526
+ if (opts.output) {
20527
+ await writeArtifacts(opts.output, learning, suggestions);
20528
+ log.success(`Artifacts \u5DF2\u8F93\u51FA\u5230: ${opts.output}`);
20529
+ }
20530
+ }
20531
+ var init_extract_mr = __esm({
20532
+ "src/ci/extract-mr.ts"() {
20533
+ "use strict";
20534
+ init_import_mr();
20535
+ init_codebase();
20536
+ init_review_store();
20537
+ init_git();
20538
+ init_logger();
20539
+ init_mr_comment();
20540
+ init_read_rejections();
20541
+ }
20542
+ });
20543
+
19804
20544
  // src/index.ts
19805
20545
  init_logger();
19806
20546
  import { createRequire as createRequire2 } from "module";
@@ -20123,5 +20863,11 @@ program.command("hook-dispatch <event>").description("Unified hook dispatcher \u
20123
20863
  const { hookDispatchCli: hookDispatchCli2 } = await Promise.resolve().then(() => (init_hook_dispatch_cli(), hook_dispatch_cli_exports));
20124
20864
  await hookDispatchCli2(event, cmdOpts.tool ?? "claude", cmdOpts.matcher ?? "*");
20125
20865
  });
20866
+ var ciCmd = program.command("ci").description("CI pipeline integration commands");
20867
+ ciCmd.command("extract-mr").description("Extract knowledge from MR/PR and post as comment or write to team repo").requiredOption("--url <url>", "MR/PR web URL").option("--mode <mode>", "Operation mode: comment | write | both", "comment").option("--team-repo <path>", "Team knowledge repo path (required for write mode)").option("--existing-codebase <path>", "Existing codebase.md for style consistency").option("--comment-marker <marker>", "HTML comment anchor for idempotent updates", "<!-- teamai:ci-extract -->").option("--write-mode <mode>", "Write strategy: direct | pending-review", "direct").option("--output <dir>", "Write artifacts to directory").option("--individual-comments", "Post each suggestion as separate comment with reaction/resolve support").action(async (cmdOpts) => {
20868
+ const globalOpts = program.opts();
20869
+ const { ciExtractMr: ciExtractMr2 } = await Promise.resolve().then(() => (init_extract_mr(), extract_mr_exports));
20870
+ await ciExtractMr2({ ...globalOpts, ...cmdOpts });
20871
+ });
20126
20872
  program.parse();
20127
20873
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamai-cli",
3
- "version": "0.16.8",
3
+ "version": "0.16.9",
4
4
  "description": "TeamAI — the team harness for AI agents (skill sync + shared knowledge base, powered by Git)",
5
5
  "type": "module",
6
6
  "bin": {