fix(import): publish the learning import --from-mr extracts (#823) (#825)

`import --from-mr` wrote its learning into the teamai-learnings worktree,
then pushed with autoPushViaMR, which commits `.` in repo.localPath: the
knowledge clone, another checkout. That found nothing to commit, so the
learning stayed untracked on this machine and never reached the team,
while the command still reported the push step as done.

The draft now goes into the contribution queue, and a "Publish learning"
step calls publishQueuedLearnings, the path `teamai contribute` uses: it
commits and pushes the queue on teamai-learnings and drops an entry once
it is on origin. When publishing fails the learning stays queued, the step
says so, and the next `teamai pull` publishes it. As in contribute, the
queued file takes contribute's name (a random suffix keeps two learnings
with the same title and day apart), the recall index is rebuilt after the
publish attempt, the supersede check also reads the queue, and a read-only
(HTTP) source is refused up front instead of queueing a learning nothing
can publish; --dry-run and --output still work there. "Push changes via
MR" is left for the teamwiki update it was also for.

The learning also lands where contribute puts it: resolveLearningsSubdir
(now exported) picks learnings/<namespace>/ when exactly one active project
declares a learnings namespace, else the shared root. It used to go to the
root, where every project's members recall it.

Also, from the same follow-up issue:
- wiki slug: the main checkout's root takes its repo's name too, so one
  opened through a differently named symlink writes the same evidence as
  its worktrees. Subdirectories keep their own name.
- repo labels: a path is not qualified into a label a remote-form key
  already has (github.com/acme/api vs /x/acme/api), so the two no longer
  merge in `stats --by-repo`. The fallback is the repo's directory, so a
  bare repo's keys still share one row.
- local-agent tests use a session id unique per run: the hint markers are
  machine-wide files in os.tmpdir() keyed by session id, and overlapping
  runs deleted each other's.
This commit is contained in:
Saul Moro
2026-09-25 11:50:20 +08:00
committed by GitHub
parent c73d22147d
commit 4a65e3f676
13 changed files with 405 additions and 44 deletions
+4 -2
View File
@@ -486,7 +486,7 @@ For users or agents that don't need git access and only consume skills/rules:
teamai init --http https://your-team-host/api --token <api-key>
```
- Read-only mode: `push` / `contribute` / `remove` are not available.
- Read-only mode: `push` / `contribute` / `remove` are not available, and `import --from-mr` cannot publish its learning (`--dry-run` and `--output` still work).
- No git clone required — skills/rules are delivered via a report/sync/ack lifecycle on a per-session basis.
- Supported agents automatically report their installed skill state at session start, and pull install/update/uninstall commands managed by the server.
- The API key is stored with `0600` permissions, or can be passed via the `TEAMAI_API_TOKEN` environment variable.
@@ -1391,6 +1391,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich
If core graph extraction or writing fails, the import reports an error without marking the commit as synced. The next incremental run retries that commit.
`--from-mr` publishes its learning the way `teamai contribute` does, on the `teamai-learnings` branch: under `learnings/<namespace>/` when exactly one active project declares a learnings namespace, otherwise at the shared `learnings/` root. If that fails, the learning stays queued on this machine and the next `teamai pull` publishes it.
AI-backed steps (`--deep-enrich`, knowledge enrichment) shell out to an AI coding CLI already installed on the machine instead of calling a model API directly. teamai probes `claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw` and uses the first one it finds. On macOS and Linux the probe runs through a login shell, so a CLI installed under `~/.nvm/` is found too. On Windows it uses the native `where`, which returns the npm shim (`%APPDATA%\npm\claude.cmd`) that Windows can actually launch — a Git Bash or WSL `bash` only reports MSYS paths such as `/c/Users/...`, which Windows cannot start.
For GitLab behind an API gateway, set `GITLAB_URL` and `GITLAB_API_PREFIX=api/gitlab` before running `teamai import --from-org https://gitlab.example.com/myorg`. Organization listing uses the configured prefix on every page; an unset or blank prefix defaults to `api/v4`.
@@ -1418,7 +1420,7 @@ teamai codebase --lint --output /path/to/repo
When extract finds components, it writes `teamwiki/evidence/code/<project>/_manifest.json` even if AI enrichment is skipped or produces nothing, so `--deep-enrich` can start.
Without `--project`, `<project>` is the directory's name. At the root of a linked git worktree it is the repo's name: the main checkout's, or a bare repo's (`repo/.bare` or `repo.git` → `repo`). Every worktree of a repo writes the same entry. `teamai import --dir` picks its slug the same way.
Without `--project`, `<project>` is the directory's name. At the root of a checkout, the main one or a linked git worktree, it is the repo's name: the main checkout's real name (also when opened through a symlink), or a bare repo's (`repo/.bare` or `repo.git` → `repo`). Every checkout of a repo writes the same entry. `teamai import --dir` picks its slug the same way.
### Dashboard
+4 -2
View File
@@ -451,7 +451,7 @@ teamai init https://github.com/yourorg/yourrepo --scope user
teamai init --http https://your-team-host/api --token <api-key>
```
- 只读模式:`push` / `contribute` / `remove` 不可用。
- 只读模式:`push` / `contribute` / `remove` 不可用,`import --from-mr` 无法发布其 learning(`--dry-run` 和 `--output` 仍可用)。
- 无需 git clone——skills/rules 通过 report/sync/ack 生命周期按 session 下发。
- 支持的 agent 在 session 启动时自动上报已安装 skill 状态,并拉取服务端管理的安装/更新/卸载指令。
- API key 存储为 `0600` 权限,也可通过 `TEAMAI_API_TOKEN` 环境变量传入。
@@ -1332,6 +1332,8 @@ teamai import --from-repo https://github.com/org/repo --skip-enrich
如果核心知识图谱提取或写入失败,导入会报错,且不会将该提交标记为已同步。下次增量导入会重试该提交。
`--from-mr` 与 `teamai contribute` 一样,把提取的经验发布到 `teamai-learnings` 分支:恰好一个激活项目声明了 learnings namespace 时放在 `learnings/<namespace>/` 下,否则放在共享的 `learnings/` 根目录。发布失败时,经验留在本机队列中,下次 `teamai pull` 会发布它。
需要 AI 的步骤(`--deep-enrich`、知识增强)复用本机已安装的 AI 编码 CLI,而不是直接调用模型 API。teamai 按 `claude` → `claude-internal` → `codex` → `codex-internal` → `codebuddy` → `workbuddy` → `openclaw` 的顺序探测,取第一个可用者。macOS / Linux 上探测经由 login shell,因此装在 `~/.nvm/` 下的 CLI 也能找到;Windows 上改用原生命令 `where`,拿到的是 Windows 真正能启动的 npm shim(`%APPDATA%\npm\claude.cmd`)——Git Bash 或 WSL 的 `bash` 只会返回 `/c/Users/...` 这类 MSYS 路径,Windows 无法启动。
对于 API 网关后的 GitLab,先设置 `GITLAB_URL` 和 `GITLAB_API_PREFIX=api/gitlab`,再运行 `teamai import --from-org https://gitlab.example.com/myorg`。组织仓库列表的每一页请求都会使用配置的前缀;未设置或为空时默认使用 `api/v4`。
@@ -1359,7 +1361,7 @@ teamai codebase --lint --output /path/to/repo
只要 extract 发现了组件,就会写入 `teamwiki/evidence/code/<project>/_manifest.json`(包括跳过 AI 增强或增强没有产出的情况),因此 `--deep-enrich` 可以接着跑。
不传 `--project` 时,`<project>` 取目录名;在 git 链接 worktree 的根目录下取仓库名:主检出的目录名,或 bare 仓库的名称(`repo/.bare` 或 `repo.git` → `repo`)。同一仓库的所有 worktree 写入同一个条目。`teamai import --dir` 用同样的方式确定 slug。
不传 `--project` 时,`<project>` 取目录名;在检出的根目录下(主检出或 git 链接 worktree)取仓库名:主检出的真实目录名(经符号链接打开时也是如此),或 bare 仓库的名称(`repo/.bare` 或 `repo.git` → `repo`)。同一仓库的所有检出写入同一个条目。`teamai import --dir` 用同样的方式确定 slug。
### Dashboard
+1 -1
View File
@@ -342,7 +342,7 @@ Generated: do not edit by hand. Regenerate with
- `teamai codebase` — Inspect and maintain team-codebase outputs
- `--extract [path]` — Extract code knowledge and build graph from source
- `--incremental` (hidden) — Only re-extract changed files (requires prior manifest)
- `--project <name>` (hidden) — Project slug for --extract (defaults to the directory name; a linked worktree's root uses the repo's name) and required for --deep-enrich
- `--project <name>` (hidden) — Project slug for --extract (defaults to the directory name; a checkout's root uses the repo's name) and required for --deep-enrich
- `--max-files <n>` (hidden) — Max source files to scan (default: 200)
- `--upgrade-wiki` (hidden) — Migrate docs/team-codebase/ to teamwiki/ graph format
- `--lint` — Run global consistency lint over the teamwiki knowledge graph
+10
View File
@@ -169,6 +169,16 @@ describe('defaultProjectSlug (#809)', () => {
expect(await defaultProjectSlug(worktreeRoot)).toBe('main-repo');
});
it('names the main checkout after itself when it is opened through a differently named symlink (#823)', async () => {
const alias = path.join(base, 'alias-to-main');
fs.symlinkSync(repoRoot, alias);
// The same slug its worktrees get, so the repo's evidence is not split.
expect(await defaultProjectSlug(alias)).toBe('main-repo');
// A subdirectory keeps its own name, whatever path leads to it.
fs.mkdirSync(path.join(repoRoot, 'pkg', 'cli'), { recursive: true });
expect(await defaultProjectSlug(path.join(alias, 'pkg', 'cli'))).toBe('cli');
});
it('keeps the directory\'s own name everywhere else', async () => {
const mainSub = path.join(repoRoot, 'pkg', 'api');
const worktreeSub = path.join(worktreeRoot, 'pkg', 'web');
@@ -0,0 +1,288 @@
/**
* E2E (#823, item 1): `teamai import --from-mr` publishes the learning it
* extracts.
*
* The learning was written into the `teamai-learnings` worktree, but the only
* push was `autoPushViaMR` committing `.` in `repo.localPath`, another
* checkout. That found nothing to commit, so the learning stayed untracked on
* this machine and never reached the team. It now goes through the same queue
* and publish as `teamai contribute`.
*
* `gh` (the MR) and `claude` (the extraction) are stand-ins on PATH.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..', '..');
const CLI = path.join(ROOT, 'dist', 'index.js');
const GIT_ENV = {
GIT_AUTHOR_NAME: 'TeamAI CI',
GIT_AUTHOR_EMAIL: 'ci@teamai.test',
GIT_COMMITTER_NAME: 'TeamAI CI',
GIT_COMMITTER_EMAIL: 'ci@teamai.test',
};
const FAKE_GH = `#!/usr/bin/env bash
case "$1 $2" in
"pr view") echo '{"title":"Retry flaky upload","body":"Retries S3 uploads.","author":{"login":"dev"},"mergedAt":"2026-09-20T10:00:00Z","commits":[{"oid":"abc123","messageHeadline":"retry upload"}]}' ;;
"pr diff") printf 'diff --git a/up.ts b/up.ts\\n+retry(3)\\n' ;;
"pr create") echo "https://github.com/acme/team/pull/99" ;;
*) echo "fake gh: $*" >&2; exit 1 ;;
esac
`;
const FAKE_CLAUDE = `#!/usr/bin/env bash
cat <<'MD'
---
title: Retry flaky S3 uploads
tags: [s3, retry]
---
# Retry flaky S3 uploads
Wrap uploads in retry(3); the bucket throttles bursts.
MD
`;
// contribute's naming, under `learnings/` or one namespace below it:
// <title-slug>-<date>-<random>.md
const LEARNING = /^learnings\/(?:[^/]+\/)?retry-flaky-s3-uploads-\d{4}-\d{2}-\d{2}-[a-z0-9]+\.md$/;
interface RunResult {
code: number | null;
output: string;
}
function git(args: string[], cwd: string): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } });
}
/**
* A user-scope install in git mode, on a local bare team repo (provider
* github), that has run `teamai pull`. With `project`, the team declares that
* project owning the learnings namespace of the same name, and the install
* has it active.
*/
function scenario(opts: { project?: string } = {}) {
const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-issue823-e2e-')));
const home = path.join(sandbox, 'home');
const bin = path.join(sandbox, 'bin');
const remote = path.join(sandbox, 'team.git');
const run = (args: string[]): Promise<RunResult> => new Promise((resolve) => {
const { CLAUDE_SESSION_ID: _s, GITHUB_TOKEN: _t, GH_TOKEN: _g, ...env } = process.env;
const child = spawn('node', [CLI, ...args], {
cwd: home,
env: {
...env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', NO_COLOR: '1',
PATH: `${bin}${path.delimiter}${env.PATH ?? ''}`, TEAMAI_CONTRIBUTE_HINT_DISABLED: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', (d: Buffer) => { output += d.toString(); });
child.stderr.on('data', (d: Buffer) => { output += d.toString(); });
child.on('close', (code) => resolve({ code, output }));
});
const setup = async () => {
fs.mkdirSync(path.join(home, '.teamai'), { recursive: true });
fs.mkdirSync(path.join(home, '.claude')); // the enabled agent's root, so pull has somewhere to deliver
fs.mkdirSync(bin);
fs.writeFileSync(path.join(bin, 'gh'), FAKE_GH, { mode: 0o755 });
fs.writeFileSync(path.join(bin, 'claude'), FAKE_CLAUDE, { mode: 0o755 });
const seed = path.join(sandbox, 'seed');
fs.mkdirSync(seed);
fs.writeFileSync(path.join(seed, 'teamai.yaml'), 'team: issue-823\nrepo: https://github.com/acme/team\nprovider: github\n');
if (opts.project) {
fs.mkdirSync(path.join(seed, 'manifest'));
fs.writeFileSync(path.join(seed, 'manifest', 'projects.yaml'), [
'version: 1',
'projects:',
` - id: ${opts.project}`,
` name: ${opts.project}`,
' resources:',
` learnings: [${opts.project}]`,
'',
].join('\n'));
}
git(['init', '-q', '-b', 'main'], seed);
git(['add', '-A'], seed);
git(['commit', '-q', '-m', 'seed'], seed);
git(['clone', '-q', '--bare', seed, remote], sandbox);
git(['clone', '-q', remote, path.join(home, '.teamai', 'team-repo')], sandbox);
fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [
'repo:',
` localPath: ${path.join(home, '.teamai', 'team-repo')}`,
` remote: ${remote}`,
' kind: git',
'username: ci-823',
'updatePolicy: auto',
'scope: user',
'enabledAgents: [claude]',
...(opts.project ? [`projects: [${opts.project}]`] : []),
'',
].join('\n'));
await run(['pull']);
};
/** The learnings on the team's `teamai-learnings` branch. */
const published = (): string[] => {
try {
return git(['ls-tree', '-r', '--name-only', 'teamai-learnings'], remote).split('\n').filter((f) => LEARNING.test(f));
} catch {
return []; // no branch: nothing was published
}
};
const queued = (): string[] => {
const queue = path.join(home, '.teamai', 'pending-learnings');
return fs.existsSync(queue) ? fs.readdirSync(queue) : [];
};
const untracked = (): string => {
const worktree = path.join(home, '.teamai', 'learnings-wt');
return fs.existsSync(path.join(worktree, '.git')) ? git(['status', '--porcelain'], worktree) : '';
};
const cleanup = () => fs.rmSync(sandbox, { recursive: true, force: true });
return { home, remote, run, setup, published, queued, untracked, cleanup };
}
const importMr = ['import', '--from-mr', 'https://github.com/acme/app/pull/7', '--all'];
describe('import --from-mr publishes its learning (#823)', () => {
describe('with the team repo reachable', () => {
const s = scenario();
let imported: RunResult = { code: null, output: '' };
beforeAll(async () => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
await s.setup();
imported = await s.run(importMr);
}, 120_000);
afterAll(() => s.cleanup());
it('pushes the learning to the team\'s teamai-learnings branch', () => {
expect(imported.code, imported.output).toBe(0);
expect(s.published(), imported.output).toHaveLength(1);
expect(imported.output).toContain('Push changes via MR [SKIPPED: No teamwiki changes to push]');
});
it('leaves nothing unpublished behind on this machine', () => {
expect(s.untracked(), imported.output).toBe('');
expect(s.queued(), imported.output).toEqual([]);
});
it('points recall at the published copy, not the queue it left', () => {
const index = JSON.parse(fs.readFileSync(path.join(s.home, '.teamai', 'search-index.json'), 'utf8')) as unknown;
const paths = JSON.stringify(index).match(/"[^"]*retry-flaky-s3-uploads-[^"]*\.md"/g) ?? [];
expect(paths.length, imported.output).toBeGreaterThan(0);
expect(paths.filter((p) => p.includes('pending-learnings'))).toEqual([]);
});
it('keeps a second learning with the same title and day as its own file', async () => {
const again = await s.run(importMr);
expect(again.code, again.output).toBe(0);
expect(s.published(), again.output).toHaveLength(2);
});
it('publishes it at the shared root when no project is active', () => {
expect(s.published().map((f) => path.posix.dirname(f))).toEqual(['learnings', 'learnings']);
});
});
describe('with one active project that owns a learnings namespace', () => {
const s = scenario({ project: 'alpha' });
let imported: RunResult = { code: null, output: '' };
beforeAll(async () => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
await s.setup();
imported = await s.run(importMr);
}, 120_000);
afterAll(() => s.cleanup());
it('publishes the learning in that namespace, as contribute does', () => {
expect(imported.code, imported.output).toBe(0);
expect(s.published().map((f) => path.posix.dirname(f)), imported.output).toEqual(['learnings/alpha']);
expect(imported.output).not.toContain('Learning saved locally');
});
});
describe('with another contribution stuck in the queue', () => {
const s = scenario();
let imported: RunResult = { code: null, output: '' };
beforeAll(async () => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
await s.setup();
// An entry nobody can read stays queued on every run.
const queue = path.join(s.home, '.teamai', 'pending-learnings');
fs.mkdirSync(queue, { recursive: true });
fs.writeFileSync(path.join(queue, 'stuck.md'), '# stuck\n', { mode: 0o000 });
imported = await s.run(importMr);
}, 120_000);
afterAll(() => s.cleanup());
it('publishes this learning and does not say it stayed local', () => {
expect(s.published(), imported.output).toHaveLength(1);
expect(imported.output).not.toContain('Learning saved locally');
});
});
describe('when publishing fails', () => {
const s = scenario();
let imported: RunResult = { code: null, output: '' };
let pulled: RunResult = { code: null, output: '' };
const state = { queuedAfterImport: [] as string[], publishedAfterImport: [] as string[] };
beforeAll(async () => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
await s.setup();
// The team repo is unreachable during the import.
fs.renameSync(s.remote, `${s.remote}.away`);
imported = await s.run(importMr);
fs.renameSync(`${s.remote}.away`, s.remote);
state.queuedAfterImport = s.queued();
state.publishedAfterImport = s.published();
pulled = await s.run(['pull']);
}, 120_000);
afterAll(() => s.cleanup());
it('keeps the learning queued and says the next pull publishes it', () => {
expect(imported.code, imported.output).toBe(0);
expect(imported.output).toContain('Learning saved locally');
expect(state.publishedAfterImport).toEqual([]);
expect(state.queuedAfterImport.filter((f) => /^retry-flaky-s3-uploads-.*\.md$/.test(f)), imported.output).toHaveLength(1);
});
it('publishes it on the next pull', () => {
expect(s.published(), pulled.output).toHaveLength(1);
expect(s.queued(), pulled.output).toEqual([]);
});
});
describe('on a read-only HTTP source', () => {
const s = scenario();
let imported: RunResult = { code: null, output: '' };
beforeAll(async () => {
if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`);
await s.setup();
// The same install, switched to an HTTP source, which has no learnings branch.
const config = path.join(s.home, '.teamai', 'config.yaml');
fs.writeFileSync(config, fs.readFileSync(config, 'utf8').replace(' kind: git\n', ' kind: http\n'));
imported = await s.run(importMr);
}, 120_000);
afterAll(() => s.cleanup());
it('refuses, as contribute does, instead of queueing a learning nothing can publish', () => {
expect(imported.output).toContain('read-only HTTP source');
expect(s.queued(), imported.output).toEqual([]);
});
it('still previews with --dry-run, which publishes nothing', async () => {
const preview = await s.run([...importMr, '--dry-run']);
expect(preview.output).not.toContain('read-only HTTP source');
expect(preview.output).toContain('Extract learning from MR');
});
});
});
+10 -7
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -20,7 +21,9 @@ let origHome: string | undefined;
let origCopilotHome: string | undefined;
let origPpid: number;
const TEST_SESSION_ID = 'test-session';
// The hint markers are machine-wide files in os.tmpdir() keyed by session id,
// so a fixed id let overlapping runs of this file delete each other's (#823).
const TEST_SESSION_ID = `test-session-${randomUUID()}`;
beforeEach(async () => {
tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-la-test-'));
@@ -350,7 +353,7 @@ describe('local-agent: emitBindingHint via reportAndSyncLocalAgent', () => {
await reportAndSyncLocalAgent({
cwd: projectDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
@@ -439,7 +442,7 @@ describe('local-agent: emitBindingHint via reportAndSyncLocalAgent', () => {
await reportAndSyncLocalAgent({
cwd: projectDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
@@ -477,7 +480,7 @@ describe('local-agent: emitBindingHint via reportAndSyncLocalAgent', () => {
await reportAndSyncLocalAgent({
cwd: projectDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
@@ -513,7 +516,7 @@ describe('local-agent: emitBindingHint via reportAndSyncLocalAgent', () => {
await reportAndSyncLocalAgent({
cwd: projectDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
@@ -713,7 +716,7 @@ describe('local-agent: worktree binding inheritance', () => {
await reportAndSyncLocalAgent({
cwd: worktreeDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
@@ -1452,7 +1455,7 @@ describe('local-agent: CloudStudio sandbox suppression', () => {
result = await reportAndSyncLocalAgent({
cwd: projectDir,
tool: 'codebuddy',
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: 'test-session', tool: 'codebuddy' },
event: { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId: TEST_SESSION_ID, tool: 'codebuddy' },
});
} finally {
process.stdout.write = origWrite;
+17
View File
@@ -79,6 +79,12 @@ describe('repoLabel (#809)', () => {
const repos = attributeByRepo(keys.map((cwd, i) => ev({ type: 'tool_use', timestamp: 't1', sessionId: `s${i}`, cwd })));
expect(repos.map((r) => [r.repo, r.sessions])).toEqual([['Tencent/teamai-cli', 2]]);
});
it('does not give a local path the label of a remote key (#823)', () => {
const keys = ['github.com/acme/api', '/x/acme/api', '/y/other/api'];
expect(keys.map((k) => repoLabel(k, keys))).toEqual(['acme/api', '/x/acme/api', 'other/api']);
const repos = attributeByRepo(keys.map((cwd, i) => ev({ type: 'tool_use', timestamp: 't1', sessionId: `s${i}`, cwd })));
expect(repos.map((r) => [r.repo, r.sessions]).sort()).toEqual([['/x/acme/api', 1], ['acme/api', 1], ['other/api', 1]]);
});
});
describe('repoName (#809)', () => {
@@ -138,6 +144,17 @@ describe('repoName (#809)', () => {
expect(repoLabel(plain, [plain])).toBe('no_repo');
});
it('keeps a bare repo\'s keys in one row when its label falls back to a path (#823)', () => {
// A remote key takes `acme/api`, so the bare repo cannot be qualified to it.
const anchor = bare('g', 'acme', 'api', '.bare');
const container = path.join(base, 'g', 'acme', 'api');
const other = path.join(base, 'h', 'other', 'api');
fs.mkdirSync(other, { recursive: true });
const keys = ['github.com/acme/api', anchor, container, other];
expect(repoLabel(anchor, keys)).toBe(repoLabel(container, keys));
expect(repoLabel(anchor, keys)).not.toBe('acme/api');
});
it('counts a session in a bare layout\'s own directory as that repo', () => {
const anchor = bare('e', 'repo', '.bare');
const container = path.join(base, 'e', 'repo');
+6 -5
View File
@@ -537,15 +537,16 @@ function buildOverview(
}
/**
* The wiki slug of `dir` when no `--project` is given (#809): a linked
* worktree's root takes its repo's name (repoName: the main checkout's, or a
* bare repo's), so every worktree of a repo writes the repo's evidence; any
* other directory keeps its own name.
* The wiki slug of `dir` when no `--project` is given (#809): a checkout's
* root, the main one or a linked worktree, takes its repo's name (repoName:
* the main checkout's real name, or a bare repo's), so every checkout of a repo
* writes the repo's evidence, whatever path it was opened by (#823); any other
* directory keeps its own name.
*/
export async function defaultProjectSlug(dir: string): Promise<string> {
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return path.basename(dir);
const anchors = await resolveAnchors(dir);
if (anchors && anchors.workspaceRoot !== anchors.projectAnchor && await realpath(dir) === anchors.workspaceRoot) {
if (anchors && await realpath(dir) === anchors.workspaceRoot) {
return repoName(anchors.projectAnchor);
}
return path.basename(dir);
+3 -3
View File
@@ -27,7 +27,7 @@ import { getDataHome, getReportsDir, isSelfMode } from './types.js';
* target one explicitly by contributing from that project's directory. This
* favors the safe default (visible to all) over silently guessing a namespace.
*/
async function resolveLearningsSubdir(localConfig: LocalConfig): Promise<string> {
export async function resolveLearningsSubdir(localConfig: LocalConfig): Promise<string> {
const namespaces = await resolveActiveLearningsNamespaces(
localConfig.repo.localPath,
localConfig.projects ?? [],
@@ -51,7 +51,7 @@ async function resolveLearningsSubdir(localConfig: LocalConfig): Promise<string>
* recallable the moment it is written, whether or not it has reached origin, and
* a queued edit of a published learning is the copy recall serves.
*/
async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<void> {
export async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<void> {
const repoPath = localConfig.repo.localPath;
const docsRepoDir = path.join(repoPath, 'docs');
const rulesRepoDir = path.join(repoPath, 'rules');
@@ -105,7 +105,7 @@ async function rebuildIndexAfterContribute(localConfig: LocalConfig): Promise<vo
* The title is slugified (lowercase, hyphens, max 50 chars).
* A 6-char random suffix avoids collisions.
*/
function generateFilename(title?: string): string {
export function generateFilename(title?: string): string {
const slug = (title ?? 'session-notes')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') // Allow CJK characters
+14 -15
View File
@@ -187,7 +187,7 @@ export async function importFromMR(opts: {
outputDir?: string;
writeLearningsDir?: string;
dryRun?: boolean;
}): Promise<{ learning?: LearningDraft; repoUrl: string }> {
}): Promise<{ learning?: LearningDraft; repoUrl: string; learningFile?: string }> {
const learningsDirs = opts.learningsDirs ?? [DEFAULT_LEARNINGS_DIR];
// ── 步骤 1:获取 MR 数据 ────────────────────────────────
@@ -253,8 +253,9 @@ export async function importFromMR(opts: {
}
// ── 步骤 6:写文件 ─────────────────────────────────────
let learningFile: string | undefined;
if (!opts.dryRun && acceptLearning) {
await writeLearning(learning, opts.outputDir, opts.writeLearningsDir);
learningFile = await writeLearning(learning, opts.outputDir, opts.writeLearningsDir);
}
// 推断仓库 URL
@@ -263,13 +264,15 @@ export async function importFromMR(opts: {
return {
learning: acceptLearning ? learning : undefined,
repoUrl,
learningFile,
};
}
/**
* 将 learning 草稿写入磁盘。
*
* Writes to outputDir when given, else to learningsDir. With neither, it warns and skips.
* Writes to outputDir when given, else to learningsDir, and returns the file written.
* With neither, it warns and skips.
*
* @param draft The learning draft
* @param outputDir Output directory (optional)
@@ -279,30 +282,26 @@ async function writeLearning(
draft: LearningDraft,
outputDir?: string,
learningsDir?: string,
): Promise<void> {
): Promise<string | undefined> {
if (outputDir) {
await fs.mkdir(outputDir, { recursive: true });
const filePath = path.join(outputDir, 'learning.md');
await fs.writeFile(filePath, draft.content, 'utf-8');
log.info(`Learning written: ${filePath}`);
return;
return filePath;
}
if (learningsDir) {
await fs.mkdir(learningsDir, { recursive: true });
const datePrefix = new Date().toISOString().slice(0, 10);
// 将标题转为合法文件名:取前 40 字符,替换非法字符为连字符
const safeTitle = draft.title
.slice(0, 40)
.replace(/[^a-zA-Z0-9一-鿿_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const filename = `${datePrefix}-${safeTitle}.md`;
const filePath = path.join(learningsDir, filename);
// contribute's naming: the random suffix keeps two members' learnings with
// the same title and day apart once both are published (#823).
const { generateFilename } = await import('./contribute.js');
const filePath = path.join(learningsDir, generateFilename(draft.title));
await fs.writeFile(filePath, draft.content, 'utf-8');
log.info(`Learning written: ${filePath}`);
return;
return filePath;
}
log.warn('No outputDir or learnings directory specified, learning draft not saved to disk');
return undefined;
}
+41 -5
View File
@@ -11,7 +11,9 @@ import { importFromRepoList } from './import-repo-list.js';
import { importFromOrg } from './import-org.js';
import { importFromIWikiDual } from './iwiki-dual.js';
import { learningsRoots } from './utils/learnings-roots.js';
import { pendingLearningsDir } from './utils/pending-learnings.js';
import type { GlobalOptions, LearningDraft } from './types.js';
import { assertNotReadOnly } from './read-only.js';
import { Listr, PRESET_TIMER } from 'listr2';
import { log, setSilent } from './utils/logger.js';
import { autoPushTeamRepo } from './utils/git.js';
@@ -238,23 +240,50 @@ export async function importCmd(opts: ImportOptions): Promise<void> {
} else if (opts.fromMr) {
// 分支 1:--from-mr <url>,提取 learning + 增量更新 teamwiki
const { localConfig, teamConfig } = await autoDetectInit();
// Its learning is published the way `teamai contribute` publishes, which a
// read-only (HTTP) source refuses; a dry run or --output publishes nothing.
if (!opts.dryRun && !opts.output) assertNotReadOnly(localConfig, 'teamai import --from-mr');
// As contribute: into the active project's learnings namespace when there
// is exactly one, else the shared root.
const { resolveLearningsSubdir } = await import('./contribute.js');
const learningsSubdir = opts.dryRun || opts.output ? '' : await resolveLearningsSubdir(localConfig);
const tasks = new Listr([
{
title: 'Extract learning from MR',
task: async (ctx) => {
const { learning, repoUrl } = await importFromMR({
const { learning, repoUrl, learningFile } = await importFromMR({
url: opts.fromMr!,
learningsDirs: learningsRoots(localConfig).read,
learningsDirs: [pendingLearningsDir(localConfig), ...learningsRoots(localConfig).read],
all: opts.all,
outputDir: opts.output,
writeLearningsDir: opts.dryRun ? undefined : learningsRoots(localConfig).write,
// Into the contribution queue, which publishing drains (#823).
writeLearningsDir: opts.dryRun ? undefined : path.join(pendingLearningsDir(localConfig), learningsSubdir),
dryRun: opts.dryRun,
});
ctx.learning = learning;
ctx.repoUrl = repoUrl;
ctx.learningFile = learningFile;
},
},
{
title: 'Publish learning',
skip: (ctx) => !!opts.dryRun || !!opts.output || !ctx.learning,
task: async (ctx, task) => {
const { publishQueuedLearnings } = await import('./utils/learnings-publish.js');
const { rebuildIndexAfterContribute } = await import('./contribute.js');
const report = await publishQueuedLearnings(localConfig, localConfig.username);
// Recall finds it where it is now: published, or still queued.
await rebuildIndexAfterContribute(localConfig).catch((e: unknown) =>
log.debug(`import: index rebuild skipped: ${e instanceof Error ? e.message : String(e)}`));
const queued = ctx.learningFile ? path.posix.join(learningsSubdir, path.basename(ctx.learningFile)) : '';
if (!report.published.includes(queued)) {
task.title = `Learning saved locally (${report.lastError ?? 'not published yet'}); `
+ 'the next `teamai pull` publishes it';
}
},
rendererOptions: { persistentOutput: true },
},
{
title: 'Incremental teamwiki update',
skip: (ctx) => !ctx.repoUrl || !!opts.dryRun || !!opts.output,
@@ -296,8 +325,9 @@ export async function importCmd(opts: ImportOptions): Promise<void> {
rendererOptions: { persistentOutput: true },
},
{
// The teamwiki update only: the learning was published above.
title: 'Push changes via MR',
skip: (ctx) => !!opts.dryRun || !!opts.output || (!ctx.learning && !ctx.didUpdate),
skip: (ctx) => !!opts.dryRun || !!opts.output || (!ctx.didUpdate && 'No teamwiki changes to push'),
task: async () => {
const { autoPushViaMR } = await import('./utils/git.js');
await autoPushViaMR(
@@ -312,8 +342,14 @@ export async function importCmd(opts: ImportOptions): Promise<void> {
], {
rendererOptions: { timer: PRESET_TIMER, collapseErrors: false },
exitOnError: true,
ctx: { learning: undefined as LearningDraft | undefined, repoUrl: '', didUpdate: false },
ctx: { learning: undefined as LearningDraft | undefined, learningFile: undefined as string | undefined, repoUrl: '', didUpdate: false },
});
// Publishing creates a worktree under `.teamai/`; self-heal the ignore
// rule first, as contribute does, while its notice can still be seen.
if (!opts.dryRun && !opts.output) {
const { migrateSelfModeGitignore } = await import('./init.js');
await migrateSelfModeGitignore(localConfig);
}
setSilent(true);
try { await tasks.run(); } finally { setSilent(false); }
} else if (opts.dir) {
+1 -1
View File
@@ -1117,7 +1117,7 @@ program
.description('Inspect and maintain team-codebase outputs')
.option('--extract [path]', 'Extract code knowledge and build graph from source')
.addOption(new Option('--incremental', 'Only re-extract changed files (requires prior manifest)').hideHelp())
.addOption(new Option('--project <name>', 'Project slug for --extract (defaults to the directory name; a linked worktree\'s root uses the repo\'s name) and required for --deep-enrich').hideHelp())
.addOption(new Option('--project <name>', 'Project slug for --extract (defaults to the directory name; a checkout\'s root uses the repo\'s name) and required for --deep-enrich').hideHelp())
.addOption(new Option('--max-files <n>', 'Max source files to scan (default: 200)').hideHelp())
.addOption(new Option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format').hideHelp())
.option('--lint', 'Run global consistency lint over the teamwiki knowledge graph')
+6 -3
View File
@@ -124,7 +124,8 @@ export function repoName(anchor: string): string {
* when that still collides, so two repos never share a label. Keys named by the
* same directory (a bare repo's `repo/.bare` and a session in `repo/`) are one
* repo and share a label. A remote-form key keeps its canonical `owner/repo`,
* which it shares with the same repo on another host.
* which it shares with the same repo on another host; a path is never
* qualified into one of those labels (#823).
*/
export function repoLabel(key: string, allKeys: Iterable<string>): string {
// A repo keeps its name even when it is a word attributeRepo reserves for
@@ -137,10 +138,12 @@ export function repoLabel(key: string, allKeys: Iterable<string>): string {
const name = nameOf(key);
if (name === 'no_repo' || !path.isAbsolute(key)) return name;
const dir = naming(key).dir;
const others = [...new Set(allKeys)].filter((k) =>
const keys = [...new Set(allKeys)];
const others = keys.filter((k) =>
k !== key && path.isAbsolute(k) && nameOf(k) === name && naming(k).dir !== dir);
if (others.length === 0) return name;
const qualified = (k: string) => `${path.basename(path.dirname(naming(k).dir))}/${nameOf(k)}`;
const label = qualified(key);
return others.some((k) => qualified(k) === label) ? key : label;
const remoteLabels = new Set(keys.filter((k) => !path.isAbsolute(k)).map(nameOf));
return (others.some((k) => qualified(k) === label) || remoteLabels.has(label)) ? dir : label;
}