Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe <tag>` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)).
- `teamai pull` keeps the `teamai tags subscribe <tag>` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)).
- `teamai pull` keeps a skill, rule or agent you changed since teamai delivered it instead of overwriting it, and names it: `Kept <path>: you changed it since teamai delivered it`, with a warning when the team version has changed since, which `teamai push` repeats for that copy, since the SessionStart pull is silent. Pull records the sha256 of what it writes at each path in the checkout's record, and the pre-push sync records its writes too, so a copy counts as changed only against that record; a skill counts as one copy, and files only you added do not count. The copies of other tools still update. `--force` keeps these copies, `--dry-run` prints `Would keep <path>`, and a copy of an item the team removed stays when you changed it. To take the team version, delete your copy and run `teamai pull --force`. There is no record before the first full pull on this version, or in a new worktree, so that pull overwrites as before. A file you added to a skill at a path another team version of it has is no longer named on every pull, and `teamai doctor` no longer fails a rules or agents check, or points at `teamai pull --force`, for a kept copy: it lists one as `changed by you (kept by pull)` beside any other problem (for [#822](https://github.com/Tencent/teamai-cli/issues/822)).
- On macOS, the git commands behind pull, push, reports and learnings publishing no longer wait on a PATH search each time they run. teamai spawned `git` by name, and on macOS that lookup costs a few milliseconds for each PATH entry ahead of git's directory: 30-67 ms per call with a typical PATH, while git itself takes about 8 ms. teamai now runs the `git` that lookup would pick by its absolute path, found once and again whenever PATH changes or that `git` is gone or no longer executable, and only once a `git --version` by that path starts. On macOS 26 an up-to-date `teamai pull` drops from 1.3 s to 0.4 s with a 42-entry PATH, and from 2.0 s to 0.4 s with the PATH an npm script gives; macOS 27 no longer shows the lookup cost. Windows, a PATH with an empty or relative entry, a first `git` on PATH that does not start (a missing interpreter, no execute permission), and the few one-off git calls outside these paths (provider clone, version and user probes) keep the bare-name spawn (for [#868](https://github.com/Tencent/teamai-cli/issues/868)).
- On Node 24, `teamai codebase --extract`, and the `teamai import` and CI extract paths that run it, no longer abort with `Fatal process out of memory: Zone` on a repository with `.swift` files. V8's optimizing Wasm compiler (nodejs/node#63421) ran out of memory on the tree-sitter grammars, so the AST track now keeps them on V8's baseline tier on Node 24 and later. That also cuts the TypeScript grammar's peak memory there from about 1.4 GB to about 0.1 GB, for a parse about 1.6x slower; Node 20 and 22 are unchanged (for [#860](https://github.com/Tencent/teamai-cli/issues/860)).
Expand Down
2 changes: 1 addition & 1 deletion docs/designs/multi-project-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ experience) both need it, without affecting the single-project main path.
Every resource type uses one layout and one rule:

```text
<type>/ root, shared with everyone
<type>/ root, shared with everyone (skills: through a tag, see below)
<type>/<ns>/ delivered only where <ns> is active in resources.<type>
namespace vs root the namespace item replaces the root item, whole, no merge
namespace vs namespace conflict when both would take one slot (see below)
Expand Down
9 changes: 7 additions & 2 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,13 @@ learnings. Key points:
only surface in `teamai recall` for members of that project. A directory with
no active project sees the shared root only.
- **Not auto-activated.** Unlike a lone role, a lone project is not auto-selected
— a member may legitimately belong to no project (they still get `common` and
the shared learnings root).
— a member may legitimately belong to no project (they get the shared
learnings root and the namespaces their role lists, such as `common`; with no
role, no namespace skills).
- **Root skills arrive through a tag.** While the team uses roles or projects,
the root `skills/` is the tag catalog: `teamai tags subscribe <tag>` delivers
a root skill. When a pull removes skills that are no longer delivered, for
example after picking a role or project, it names them in one line.
- **Activate everything at once.** `--project all` is a reserved value: it
expands to every id the manifest declares and persists that snapshot, so a
monorepo's onboarding docs carry one line instead of a list that drifts
Expand Down
6 changes: 5 additions & 1 deletion docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,11 @@ cd ~/work/billing && teamai init <team-repo> --project billing
`learnings/<project-id>/` 子目录下,只对该项目成员的 `teamai recall` 可见。
未激活任何项目的目录只能看到共享的根目录。
- **不自动激活。** 与「唯一 role 会被自动选中」不同,唯一的 project 不会自动选中
—— 成员可以不属于任何项目(仍能获得 `common` 与共享的 learnings 根)。
—— 成员可以不属于任何项目(能获得共享的 learnings 根,以及其 role 列出的
namespace,例如 `common`;没有 role 时不会通过 namespace 收到任何 skill)。
- **根 skill 通过 tag 获取。** 团队启用 roles 或 projects 后,根目录 `skills/`
是 tag 目录:用 `teamai tags subscribe <tag>` 获取根 skill。pull 删除不再下发的
skill 时(例如选择 role 或 project 之后),会用一行输出列出它们的名字。
- **一次激活全部。** `--project all` 是保留值:展开为 manifest 声明的全部 id
并落盘为快照,于是 monorepo 的接入文档只写一行,而不必维护一份「新增项目就会
漂移」的清单。它是对全部项目(含项目私有 learnings)的显式选择,重跑 `init`
Expand Down
67 changes: 66 additions & 1 deletion src/__tests__/pull-namespace-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('pull: an active namespace item replaces the root item of the same name
const read = (rel: string): Promise<string> => fse.readFile(path.join(homeDir, rel), 'utf8');
const exists = (rel: string): Promise<boolean> => fse.pathExists(path.join(homeDir, rel));
const team = (rel: string, content: string): Promise<void> => fse.outputFile(path.join(repoPath, rel), content);
const logged = (level: 'warn' | 'error', pattern: RegExp): boolean => (
const logged = (level: 'info' | 'warn' | 'error', pattern: RegExp): boolean => (
vi.mocked(log[level]).mock.calls.some((args) => pattern.test(args.map(String).join(' ')))
);

Expand Down Expand Up @@ -411,6 +411,71 @@ describe('pull: an active namespace item replaces the root item of the same name
expect(await read('.claude/skills/review/my-notes.md')).toBe('mine\n');
});

// #911: a root skill that stops arriving is removed, and pull says so
// instead of leaving only a debug line and "No resources to sync".
it('names a root skill it removes once it is no longer delivered, and how to get it back', async () => {
as(['devops'], { subscribedTags: ['ui'] });
await pull({});
expect(await read('.claude/skills/review/SKILL.md')).toContain('Shared review');

vi.mocked(log.info).mockClear();
as(['devops']);
await pull({ force: true });

expect(await exists('.claude/skills/review')).toBe(false);
expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: review\. .*`teamai tags subscribe <tag>`/)).toBe(true);
});

// #917 follow-up: the hint follows the repo, not the cleanup phase. Root
// `review` exists and is tagged `ui`, so even a namespace copy removed on
// deactivation leaves `tags subscribe` a way to bring the name back.
it('names a namespace skill it removes when the namespace deactivates, with the tag hint when the root copy is tag-recoverable', async () => {
await pull({});
expect(await read('.claude/skills/review/SKILL.md')).toContain('Front review');

vi.mocked(log.info).mockClear();
as(['devops']);
await pull({ force: true });

expect(await exists('.claude/skills/review')).toBe(false);
expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: review\. .*`teamai tags subscribe <tag>`/)).toBe(true);
});

it('names a namespace-only skill it removes without the tag hint: no root copy exists to subscribe to', async () => {
await team('skills/frontend/only-front/SKILL.md', skillMd('only-front', 'Front only'));

as(['frontend'], { excludedSkills: ['review'] });
await pull({});
expect(await read('.claude/skills/only-front/SKILL.md')).toContain('Front only');

vi.mocked(log.info).mockClear();
as(['devops'], { excludedSkills: ['review'] });
await pull({ force: true });

expect(await exists('.claude/skills/only-front')).toBe(false);
expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: only-front\.$/)).toBe(true);
});

// The inactive namespace copy is byte-identical to the root skill, so the
// namespace cleanup phase removes the directory before the desired-union
// sweep can. The tag hint must survive that routing (#917 review).
it('keeps the tag hint when the removed root skill is byte-identical to its inactive namespace copy', async () => {
await team('tags.yaml', 'skills:\n twin: [ui]\n');
await team('skills/twin/SKILL.md', skillMd('twin', 'Twin review'));
await team('skills/frontend/twin/SKILL.md', skillMd('twin', 'Twin review'));

as(['devops'], { subscribedTags: ['ui'] });
await pull({});
expect(await read('.claude/skills/twin/SKILL.md')).toContain('Twin review');

vi.mocked(log.info).mockClear();
as(['devops']);
await pull({ force: true });

expect(await exists('.claude/skills/twin')).toBe(false);
expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: twin\. .*`teamai tags subscribe <tag>`/)).toBe(true);
});

// A path another team version has is not enough to call a file a leftover:
// the member may have added a file of that name, in a namespace they never had.
it('keeps a file the member added at a path another version has, and names it', async () => {
Expand Down
39 changes: 36 additions & 3 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ export async function cleanupInactiveNamespaceSkills(
retainedSkillNames: Set<string>,
inactiveSkillNames: Set<string>,
inactiveSkillSources?: Map<string, string>,
): Promise<void> {
): Promise<Set<string>> {
const removed = new Set<string>();
for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) {
if (isAgentExcluded(localConfig, tool)) continue;
// Ask where delivery writes, not where the tool root sits: OpenClaw keeps
Expand Down Expand Up @@ -270,9 +271,11 @@ export async function cleanupInactiveNamespaceSkills(
}

await remove(localSkillDir);
removed.add(skillName);
log.debug(`[${localConfig.scope}] Removed inactive role-scoped skill ${skillName} from ${tool}`);
}
}
return removed;
}

/**
Expand Down Expand Up @@ -1112,6 +1115,9 @@ async function pullForScope(
let knownRepoSkillNames: Set<string> | null = null;
// name → team-repo source dir, for the data-safety check in Step 3b cleanup.
let knownRepoSkillSources: Map<string, string> | null = null;
// Root skills (no namespace) are the tag catalog: one of them removed by
// either cleanup phase still means `tags subscribe` brings it back (#911).
let rootRepoSkillNames: Set<string> | null = null;

for (const type of resourceTypes) {
const handler = getHandler(type);
Expand Down Expand Up @@ -1206,6 +1212,7 @@ async function pullForScope(
desiredSkillNames = new Set(items.map((i) => i.name));
knownRepoSkillNames = new Set(desired.teamItems.map((i) => i.name));
knownRepoSkillSources = new Map(desired.teamItems.map((i) => [i.name, i.sourcePath]));
rootRepoSkillNames = new Set(desired.teamItems.filter((i) => !i.namespace).map((i) => i.name));
} else if (type === 'agents') {
const desired = await resolveDesiredAgents(freshConfig, localConfig, roleContext);
if (desired.kind === 'conflict') {
Expand Down Expand Up @@ -1269,19 +1276,32 @@ async function pullForScope(
totalSynced += items.length;
}

// Skills this pull removes because they are no longer delivered here, named
// in one line at the end of Step 3b so a member learns where they went (#911).
const undeliveredSkills = new Set<string>();
let rootSkillUndelivered = false;

// Step 3: Clean up tombstoned resources
if (!options.dryRun) {
await cleanupTombstonedResources(freshConfig, localConfig, scopeLabel, ledger);

if (roleContext) {
if (!skillsHeld) {
await cleanupInactiveNamespaceSkills(
const removed = await cleanupInactiveNamespaceSkills(
freshConfig,
localConfig,
desiredSkillNames ?? roleContext.activeSkillNames,
roleContext.inactiveSkillNames,
roleContext.inactiveSkillSources,
);
// A directory byte-identical to its inactive-namespace source is
// removed here, before Step 3b can see it. When the repo also holds
// that skill at the root, the tag channel can bring it back, so the
// recovery hint must fire for this removal too, not only Step 3b's.
for (const name of removed) {
undeliveredSkills.add(name);
if (rootRepoSkillNames?.has(name)) rootSkillUndelivered = true;
}
}
// Same revocation for agents: a role change must remove the previous
// role's agents, not just stop deploying them.
Expand Down Expand Up @@ -1318,7 +1338,13 @@ async function pullForScope(
continue;
}
await remove(skillDir);
log.debug(`Removed excluded skill ${dir} from ${tool}`);
if (excludedSkills.has(dir)) {
log.debug(`Removed excluded skill ${dir} from ${tool}`);
} else {
undeliveredSkills.add(dir);
if (rootRepoSkillNames?.has(dir)) rootSkillUndelivered = true;
log.debug(`Removed skill ${dir} from ${tool}: no longer delivered here`);
}
}

// Old releases could leave namespace-nested copies behind. Pull now
Expand All @@ -1340,6 +1366,13 @@ async function pullForScope(
}
}

if (undeliveredSkills.size > 0) {
const hint = roleContext && rootSkillUndelivered
? ' While the team uses roles or projects, root skills arrive only through a tag: `teamai tags subscribe <tag>`.'
: '';
log.info(`[${scopeLabel}] Removed ${undeliveredSkills.size} skill(s) no longer delivered here: ${[...undeliveredSkills].join(', ')}.${hint}`);
}

if (totalSynced === 0 && !docsSyncFailed) {
log.info(`[${scopeLabel}] No resources to sync`);
}
Expand Down
Loading