From 1e406b0d72ad7063d95bd0095f01414023587370 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Tue, 29 Sep 2026 22:29:40 +0100 Subject: [PATCH 1/2] fix(pull): name the skills a pull removes because they are no longer delivered (#911) --- CHANGELOG.md | 1 + docs/designs/multi-project-management.md | 2 +- docs/usage-guide.md | 9 ++++-- docs/usage-guide.zh-CN.md | 6 +++- src/__tests__/pull-namespace-override.test.ts | 29 ++++++++++++++++++- src/pull.ts | 28 ++++++++++++++++-- 6 files changed, 67 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d4dfa3f..d98e6a105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ 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 ` 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 a skill, rule or agent you changed since teamai delivered it instead of overwriting it, and names it: `Kept : 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 `, 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)). diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index c7aec32d3..32db66ea6 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -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 -/ root, shared with everyone +/ root, shared with everyone (skills: through a tag, see below) // delivered only where is active in resources. 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) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 53a109f8c..6159ce9c1 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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 skills). +- **Root skills arrive through a tag.** While the team uses roles or projects, + the root `skills/` is the tag catalog: `teamai tags subscribe ` 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 diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 961809ff2..33682d306 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -220,7 +220,11 @@ cd ~/work/billing && teamai init --project billing `learnings//` 子目录下,只对该项目成员的 `teamai recall` 可见。 未激活任何项目的目录只能看到共享的根目录。 - **不自动激活。** 与「唯一 role 会被自动选中」不同,唯一的 project 不会自动选中 - —— 成员可以不属于任何项目(仍能获得 `common` 与共享的 learnings 根)。 + —— 成员可以不属于任何项目(能获得共享的 learnings 根,以及其 role 列出的 + namespace,例如 `common`;没有 role 时不会收到任何 skill)。 +- **根 skill 通过 tag 获取。** 团队启用 roles 或 projects 后,根目录 `skills/` + 是 tag 目录:用 `teamai tags subscribe ` 获取根 skill。pull 删除不再下发的 + skill 时(例如选择 role 或 project 之后),会用一行输出列出它们的名字。 - **一次激活全部。** `--project all` 是保留值:展开为 manifest 声明的全部 id 并落盘为快照,于是 monorepo 的接入文档只写一行,而不必维护一份「新增项目就会 漂移」的清单。它是对全部项目(含项目私有 learnings)的显式选择,重跑 `init` diff --git a/src/__tests__/pull-namespace-override.test.ts b/src/__tests__/pull-namespace-override.test.ts index 6080dec68..ef17d435b 100644 --- a/src/__tests__/pull-namespace-override.test.ts +++ b/src/__tests__/pull-namespace-override.test.ts @@ -104,7 +104,7 @@ describe('pull: an active namespace item replaces the root item of the same name const read = (rel: string): Promise => fse.readFile(path.join(homeDir, rel), 'utf8'); const exists = (rel: string): Promise => fse.pathExists(path.join(homeDir, rel)); const team = (rel: string, content: string): Promise => 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(' '))) ); @@ -411,6 +411,33 @@ 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 `/)).toBe(true); + }); + + it('names a namespace skill it removes when the namespace deactivates, without the tag hint', 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\.$/)).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 () => { diff --git a/src/pull.ts b/src/pull.ts index 6604a59f4..9c11824aa 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -241,7 +241,8 @@ export async function cleanupInactiveNamespaceSkills( retainedSkillNames: Set, inactiveSkillNames: Set, inactiveSkillSources?: Map, -): Promise { +): Promise> { + const removed = new Set(); 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 @@ -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; } /** @@ -1269,19 +1272,25 @@ 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(); + 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, ); + for (const name of removed) undeliveredSkills.add(name); } // Same revocation for agents: a role change must remove the previous // role's agents, not just stop deploying them. @@ -1318,7 +1327,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); + rootSkillUndelivered = true; + log.debug(`Removed skill ${dir} from ${tool}: no longer delivered here`); + } } // Old releases could leave namespace-nested copies behind. Pull now @@ -1340,6 +1355,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 `.' + : ''; + 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`); } From bb14e67cc018976713ca1a89f949960b7c34c743 Mon Sep 17 00:00:00 2001 From: rubenmarcus Date: Wed, 30 Sep 2026 07:47:45 +0100 Subject: [PATCH 2/2] fix(pull): keep the tags subscribe hint when the removed skill copy is byte-identical to its inactive namespace source (#917 review) --- CHANGELOG.md | 1 + docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/pull-namespace-override.test.ts | 42 ++++++++++++++++++- src/pull.ts | 15 ++++++- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d98e6a105..792512c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ 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 ` 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 ` 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 : 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 `, 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)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 6159ce9c1..0d38be03e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -238,7 +238,7 @@ learnings. Key points: - **Not auto-activated.** Unlike a lone role, a lone project is not auto-selected — 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 skills). + 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 ` delivers a root skill. When a pull removes skills that are no longer delivered, for diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 33682d306..686da3cf3 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -221,7 +221,7 @@ cd ~/work/billing && teamai init --project billing 未激活任何项目的目录只能看到共享的根目录。 - **不自动激活。** 与「唯一 role 会被自动选中」不同,唯一的 project 不会自动选中 —— 成员可以不属于任何项目(能获得共享的 learnings 根,以及其 role 列出的 - namespace,例如 `common`;没有 role 时不会收到任何 skill)。 + namespace,例如 `common`;没有 role 时不会通过 namespace 收到任何 skill)。 - **根 skill 通过 tag 获取。** 团队启用 roles 或 projects 后,根目录 `skills/` 是 tag 目录:用 `teamai tags subscribe ` 获取根 skill。pull 删除不再下发的 skill 时(例如选择 role 或 project 之后),会用一行输出列出它们的名字。 diff --git a/src/__tests__/pull-namespace-override.test.ts b/src/__tests__/pull-namespace-override.test.ts index ef17d435b..6540af054 100644 --- a/src/__tests__/pull-namespace-override.test.ts +++ b/src/__tests__/pull-namespace-override.test.ts @@ -426,7 +426,10 @@ describe('pull: an active namespace item replaces the root item of the same name expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: review\. .*`teamai tags subscribe `/)).toBe(true); }); - it('names a namespace skill it removes when the namespace deactivates, without the tag hint', async () => { + // #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'); @@ -435,7 +438,42 @@ describe('pull: an active namespace item replaces the root item of the same name await pull({ force: true }); expect(await exists('.claude/skills/review')).toBe(false); - expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: review\.$/)).toBe(true); + expect(logged('info', /Removed 1 skill\(s\) no longer delivered here: review\. .*`teamai tags subscribe `/)).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 `/)).toBe(true); }); // A path another team version has is not enough to call a file a leftover: diff --git a/src/pull.ts b/src/pull.ts index 9c11824aa..3e49d664e 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1115,6 +1115,9 @@ async function pullForScope( let knownRepoSkillNames: Set | null = null; // name → team-repo source dir, for the data-safety check in Step 3b cleanup. let knownRepoSkillSources: Map | 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 | null = null; for (const type of resourceTypes) { const handler = getHandler(type); @@ -1209,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') { @@ -1290,7 +1294,14 @@ async function pullForScope( roleContext.inactiveSkillNames, roleContext.inactiveSkillSources, ); - for (const name of removed) undeliveredSkills.add(name); + // 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. @@ -1331,7 +1342,7 @@ async function pullForScope( log.debug(`Removed excluded skill ${dir} from ${tool}`); } else { undeliveredSkills.add(dir); - rootSkillUndelivered = true; + if (rootRepoSkillNames?.has(dir)) rootSkillUndelivered = true; log.debug(`Removed skill ${dir} from ${tool}: no longer delivered here`); } }