From cb4f0427cd320428232a5bf9b53484235e048b32 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 21 Sep 2026 19:19:09 +0200 Subject: [PATCH 01/25] fix(push): namespace new rules and agents from --role/--project (#649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--role`/`--project` only ever placed new skills, so a rule pushed with `--project front-app` landed at `rules/.md` and a new agent at `agents/.yaml` — both of which `pull` ships to every member. The flag also collapsed into the project's `skills` namespace, which is the wrong directory for a rule: a rule is namespaced on the `knowledge` axis, and the manifest allows the two to differ. Each pushable type now resolves from its own axis (skills → `skills`, rules → `knowledge`, agents → `agents`), and the destination is printed rather than chosen silently. Where the named project declares no namespace for a type being pushed, the command fails and names it instead of writing to the shared root. Only new resources already at the shared root are placed; anything the scanner namespaced keeps its path (#654), and an open PR's recorded destination still wins so a force-push never moves a resource. Also resolves a root-level local rule against its namespaced team copy, so a rule that was placed on an earlier push is not re-pushed to the shared root once it merges. --- docs/designs/multi-project-management.md | 2 +- docs/usage-guide.md | 17 +- docs/usage-guide.zh-CN.md | 16 +- skills/teamai/references/contribute-member.md | 5 + src/__tests__/push-namespaces.test.ts | 122 ++++++ src/__tests__/push-role.test.ts | 389 ++++++++++++++++++ src/__tests__/rules.test.ts | 101 +++++ src/projects.ts | 7 +- src/push-namespaces.ts | 125 ++++++ src/push.ts | 343 +++++++++------ src/resources/rules.ts | 63 ++- 11 files changed, 1040 insertions(+), 150 deletions(-) create mode 100644 src/__tests__/push-namespaces.test.ts create mode 100644 src/push-namespaces.ts diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 308788a7..c893bd46 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -81,7 +81,7 @@ projects: agents: [hai-inference] # optional; agents// scoped to this project ``` -Agent push uses the same role/project namespace resolution as pull and skips ambiguous source destinations. On a role or project change, agent cleanup checks each tool destination independently, including YAML `targets` and legacy format support. Locally edited copies are preserved. +Agent push uses the same role/project namespace resolution as pull and skips ambiguous source destinations. Placement follows it: a new agent pushed with `--role`/`--project` lands under `agents//` (the project's `agents` axis), the same way a new rule resolves from `knowledge` and a new skill from `skills` (issue #649). On a role or project change, agent cleanup checks each tool destination independently, including YAML `targets` and legacy format support. Locally edited copies are preserved. Directory layout reuses the existing namespace convention, adding one learnings layer: diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 0718a285..036e5efc 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -254,8 +254,11 @@ teamai projects members hai-inference # Who is registered on a project Member registration is a **side-effect of `init`**: running `teamai init --project ` appends `` to your `members/.yaml` roster (append + dedupe across directories), so the team can answer "who is on project X". `teamai push --project ` -pushes skills into that project's skills namespace (resolved from the manifest), -mirroring `teamai push --role`. +pushes each new resource into that project's namespace for its own resource type +(resolved from the manifest): a skill into `resources.skills`, a rule into +`resources.knowledge`, an agent into `resources.agents`. If the project declares +no namespace for a type being pushed, the push stops and names that type rather +than writing to the shared root, where the resource would reach everyone. Example local config: @@ -575,10 +578,10 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ```bash teamai push # Scan for new/modified resources, create an MR teamai push --all # Skip confirmation, push directly -teamai push --role pm # Push this skill to skills/pm// +teamai push --role pm # Push into the pm namespace (skills/pm/, rules/pm/, agents/pm/) ``` -**Namespace selection (new skills):** When pushing a new skill, the CLI automatically detects available namespaces and offers an interactive choice: +**Namespace selection (new resources):** When pushing a new skill, rule or agent, the CLI automatically detects available namespaces and offers an interactive choice: ``` Which namespace should new skills be pushed to? @@ -588,10 +591,12 @@ Which namespace should new skills be pushed to? Choose namespace [1-3] (default: 1 = common): ``` +- Each resource type resolves from its own axis: skills from the `skills` namespaces, rules from `knowledge`, agents from `agents`. A push that carries several types asks once per axis - If `primaryRole` is set, the list of available namespaces is expanded from the manifest -- If `primaryRole` is not set, the team repo's directory structure is scanned automatically +- If `primaryRole` is not set, the team repo's directory structure is scanned automatically for skills; a new rule or agent stays at the shared root - A single namespace is auto-selected; use `--role ` to choose one explicitly -- Modifying an existing skill automatically keeps its original namespace +- Modifying an existing resource automatically keeps its original namespace +- The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 5aa56d40..86ae5fa7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -238,8 +238,10 @@ teamai projects members hai-inference # 查看某项目下注册了哪些成员 成员登记是 `init` 的**副作用**:执行 `teamai init --project ` 会把 `` 追加进你的 `members/.yaml` 名册(跨目录 append + 去重),于是团队侧可以回答 -「谁在项目 X」。`teamai push --project ` 会把 skill 推送到该项目的 skills -namespace(从 manifest 解析),对标 `teamai push --role`。 +「谁在项目 X」。`teamai push --project ` 会按资源类型各自的维度(从 manifest +解析)把新资源推送到该项目对应的 namespace:skill 走 `resources.skills`,rule 走 +`resources.knowledge`,agent 走 `resources.agents`。若该项目未为本次推送涉及的类型 +声明 namespace,命令会报错并指明该类型,而不会退回共享根目录(那会发给所有人)。 本地配置示例: @@ -553,10 +555,10 @@ excludedSkills: ```bash teamai push # 扫描新增/修改的资源,创建 MR teamai push --all # 跳过确认,直接推送 -teamai push --role pm # 将本次 skill 推送到 skills/pm// +teamai push --role pm # 推送到 pm namespace(skills/pm/、rules/pm/、agents/pm/) ``` -**命名空间选择(新 skill):** 推送新 skill 时,CLI 会自动检测可用的命名空间并提供交互式选择: +**命名空间选择(新资源):** 推送新的 skill、rule 或 agent 时,CLI 会自动检测可用的命名空间并提供交互式选择: ``` Which namespace should new skills be pushed to? @@ -566,10 +568,12 @@ Which namespace should new skills be pushed to? Choose namespace [1-3] (default: 1 = common): ``` +- 每种资源类型按各自维度解析:skill 用 `skills`,rule 用 `knowledge`,agent 用 `agents`。一次推送涉及多种类型时,每个维度各询问一次 - 有 `primaryRole` 时,从 manifest 展开可用 namespace 列表 -- 无 `primaryRole` 时,自动扫描团队仓库目录结构 +- 无 `primaryRole` 时,skill 自动扫描团队仓库目录结构;新的 rule / agent 保留在共享根目录 - 单一命名空间时自动选中;也可用 `--role ` 显式指定 -- 修改已有 skill 时自动保持原 namespace +- 修改已有资源时自动保持原 namespace +- 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/skills/teamai/references/contribute-member.md b/skills/teamai/references/contribute-member.md index a39daf08..ccf8bd9b 100644 --- a/skills/teamai/references/contribute-member.md +++ b/skills/teamai/references/contribute-member.md @@ -87,6 +87,11 @@ The doc lands in the team's `learnings/` and appears for teammates on their next ``` To publish into a specific role namespace: `teamai push --skill --role `. + `--role ` / `--project ` place every new resource, not only skills: a + new rule and a new agent land in that namespace too (a project resolves each + from its own axis — `knowledge` for rules, `agents` for agents). Without one, + a new resource whose namespace cannot be resolved stays at the shared root and + reaches the whole team. ## After contributing diff --git a/src/__tests__/push-namespaces.test.ts b/src/__tests__/push-namespaces.test.ts new file mode 100644 index 00000000..fa9f2757 --- /dev/null +++ b/src/__tests__/push-namespaces.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + isAtSharedRoot, isPlaceableType, resolveProjectNamespace, withNamespace, +} from '../push-namespaces.js'; +import type { ProjectsManifest } from '../projects.js'; +import type { ResourceItem } from '../types.js'; + +function item(type: ResourceItem['type'], name: string, relativePath: string): ResourceItem { + return { name, type, sourcePath: `/local/${name}`, relativePath }; +} + +describe('isAtSharedRoot', () => { + it('is true for a resource that carries no namespace directory', () => { + expect(isAtSharedRoot(item('rules', 'my-rule', 'rules/my-rule.md'))).toBe(true); + expect(isAtSharedRoot(item('agents', 'vr', 'agents/vr.yaml'))).toBe(true); + expect(isAtSharedRoot(item('skills', 'my-skill', 'skills/my-skill'))).toBe(true); + }); + + it('is false once a namespace directory is present', () => { + expect(isAtSharedRoot(item('rules', 'my-rule', 'rules/frontend/my-rule.md'))).toBe(false); + expect(isAtSharedRoot(item('agents', 'vr', 'agents/frontend/vr.yaml'))).toBe(false); + expect(isAtSharedRoot(item('skills', 'my-skill', 'skills/frontend/my-skill'))).toBe(false); + }); + + it('is false for a rule authored inside a subdirectory of the tool rules dir', () => { + // `rules` names include their subdirectory (src/resources/rules.ts), so a + // deeper path is already namespaced and must not be placed again. + expect(isAtSharedRoot(item('rules', 'a/b/c', 'rules/a/b/c.md'))).toBe(false); + }); +}); + +describe('isPlaceableType', () => { + it('accepts the namespaced types and rejects the rest', () => { + expect(isPlaceableType('skills')).toBe(true); + expect(isPlaceableType('rules')).toBe(true); + expect(isPlaceableType('agents')).toBe(true); + // env is pushable but never namespaced; docs and hooks are not placed either. + expect(isPlaceableType('env')).toBe(false); + expect(isPlaceableType('docs')).toBe(false); + expect(isPlaceableType('hooks')).toBe(false); + }); +}); + +describe('withNamespace', () => { + it('inserts the namespace after the resource root, keeping the basename', () => { + expect(withNamespace('rules/my-rule.md', 'knowledge-ns')).toBe('rules/knowledge-ns/my-rule.md'); + expect(withNamespace('skills/my-skill', 'skills-ns')).toBe('skills/skills-ns/my-skill'); + }); + + it('keeps an agent extension the caller does not know about', () => { + // Agents push as .yaml, or .md on the legacy fallback path. + expect(withNamespace('agents/vr.yaml', 'ns')).toBe('agents/ns/vr.yaml'); + expect(withNamespace('agents/vr.md', 'ns')).toBe('agents/ns/vr.md'); + }); +}); + +const manifest: ProjectsManifest = { + version: 1, + projects: [ + { + id: 'front-app', + name: 'Front App', + description: '', + // Deliberately all different: the axis a type resolves from is the point. + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }, + { + id: 'bare', + name: 'Bare', + description: '', + resources: { knowledge: [], skills: ['bare-skills'], learnings: [], agents: [] }, + }, + { + id: 'multi', + name: 'Multi', + description: '', + resources: { knowledge: ['k1', 'k2'], skills: [], learnings: [], agents: [] }, + }, + { + id: 'unsafe', + name: 'Unsafe', + description: '', + // The manifest schema only requires a non-empty string. + resources: { knowledge: ['../../evil'], skills: [], learnings: [], agents: [] }, + }, + ], +}; + +describe('resolveProjectNamespace', () => { + it('resolves each type from its own axis', () => { + expect(resolveProjectNamespace(manifest, 'front-app', 'rules')).toEqual({ ok: true, namespace: 'fe-know' }); + expect(resolveProjectNamespace(manifest, 'front-app', 'skills')).toEqual({ ok: true, namespace: 'fe-skills' }); + expect(resolveProjectNamespace(manifest, 'front-app', 'agents')).toEqual({ ok: true, namespace: 'fe-agents' }); + }); + + it('fails naming the type and the axis when the project declares none', () => { + const result = resolveProjectNamespace(manifest, 'bare', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('rules'); + expect(result.ok === false && result.message).toContain('knowledge'); + expect(result.ok === false && result.message).toContain('bare'); + }); + + it('fails rather than guessing when the axis is multi-valued', () => { + const result = resolveProjectNamespace(manifest, 'multi', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('k1, k2'); + expect(result.ok === false && result.message).toContain('--role'); + }); + + it('rejects a namespace that is not a single safe path segment', () => { + const result = resolveProjectNamespace(manifest, 'unsafe', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('../../evil'); + }); + + it('fails on an unknown project instead of throwing', () => { + const result = resolveProjectNamespace(manifest, 'nope', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toMatch(/unknown project/i); + }); +}); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 87fe1562..1d502bda 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -62,10 +62,22 @@ vi.mock('../utils/git.js', () => ({ generateBranchName: (...args: unknown[]) => mockGenerateBranchName(...args), resetToCleanMaster: (...args: unknown[]) => mockResetToCleanMaster(...args), isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), + // Without these two the PR step throws inside its own catch, which quietly + // leaves process.exitCode at 1 and makes exit-code assertions meaningless. getDefaultBranch: vi.fn().mockResolvedValue('main'), + remoteBranchExists: vi.fn().mockResolvedValue(true), getFileContentAtRev: vi.fn().mockResolvedValue(null), })); +const mockLoadProjectsManifest = vi.fn().mockResolvedValue(null); +vi.mock('../projects.js', async () => { + const actual = await vi.importActual('../projects.js'); + return { + ...actual, + loadProjectsManifest: (...args: unknown[]) => mockLoadProjectsManifest(...args), + }; +}); + vi.mock('../roles.js', async () => { const actual = await vi.importActual('../roles.js'); return { @@ -773,3 +785,380 @@ describe('push completion signal through pushGroup (#702 follow-up, finding 5)', expect(outcome.completed).toBe(false); }); }); + +/** + * Issue #649: `--role`/`--project` used to place new skills only, so a new rule + * or agent landed at the shared root and `pull` shipped it to the whole team. + */ +describe('push namespace routing for rules and agents', () => { + /** Scans one item per type, and records what reached each handler's pushItem. */ + function mockHandlers( + scanned: Partial>>>, + pushedItems: Array>, + ) { + mockGetHandler.mockImplementation((type: string) => ({ + scanLocalForPush: vi.fn().mockResolvedValue(scanned[type as keyof typeof scanned] ?? []), + pushItem: vi.fn().mockImplementation(async (item: Record) => { + pushedItems.push(item); + }), + })); + } + + const newRule = { + name: 'my-rule', type: 'rules', sourcePath: '/tmp/my-rule.md', + relativePath: 'rules/my-rule.md', status: 'new', + }; + const newAgent = { + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/vr.yaml', status: 'new', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPullRepo.mockResolvedValue('Already up to date.'); + mockPushRepoBranch.mockResolvedValue(true); + mockCheckoutMaster.mockResolvedValue(undefined); + mockGenerateBranchName.mockReturnValue('teamai/push/test/20260403-120000'); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + }); + mockSaveStateForScope.mockResolvedValue(undefined); + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'hai', description: 'HyperAI', resources: { knowledge: ['common', 'hai'], skills: ['common', 'hai'], agents: [] } }, + ], + }); + readlineAnswer = '1'; + mockScanTeamRepoNamespaces.mockResolvedValue([]); + process.exitCode = undefined; + }); + + it('--role places a new rule and a new agent in that namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + + await push({ all: true, role: 'pm' }); + + const rule = pushedItems.find((i) => i.type === 'rules'); + const agent = pushedItems.find((i) => i.type === 'agents'); + expect(rule?.relativePath).toBe('rules/pm/my-rule.md'); + expect(rule?.namespace).toBe('pm'); + // The agent keeps the extension its handler chose. + expect(agent?.relativePath).toBe('agents/pm/vr.yaml'); + expect(agent?.namespace).toBe('pm'); + }); + + it('rejects a path-traversal --role before placing a rule', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: '../outside' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + // The flag is the problem, and this push carries no skill at all. + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('--role'); + }); + + it('--project resolves each type from its own axis, not from skills', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', + name: 'Front App', + description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + const at = (type: string) => pushedItems.find((i) => i.type === type)?.relativePath; + expect(at('rules')).toBe('rules/fe-know/my-rule.md'); + expect(at('skills')).toBe('skills/fe-skills/skill-a'); + expect(at('agents')).toBe('agents/fe-agents/vr.yaml'); + }); + + it('refuses to push to the shared root when the project declares no namespace for the type', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: [], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + }); + + it('pushes a rules-only scan to a project that declares no skills namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('leaves an already-namespaced rule where it is', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + rules: [{ + name: 'frontend/scoped', type: 'rules', sourcePath: '/tmp/scoped.md', + relativePath: 'rules/frontend/scoped.md', status: 'modified', + }], + }, pushedItems); + + await push({ all: true, role: 'pm' }); + + // pushItem writes rather than moves (#654): relocating would leave a copy behind. + expect(pushedItems[0]?.relativePath).toBe('rules/frontend/scoped.md'); + }); + + it('places a new rule in the role knowledge namespace when no flag is given', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['solo-know'], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // The knowledge axis, not the skills one. + expect(pushedItems[0]?.relativePath).toBe('rules/solo-know/my-rule.md'); + }); + + it('keeps a new rule at the shared root when the role declares no knowledge namespace', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: [], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/my-rule.md'); + expect(pushedItems[0]?.namespace).toBeUndefined(); + }); + + it('pushes a selected rule when only the unselected skill lacks a project namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + // Deselect the skill, keep the rule (item order is skills then rules). + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); + + await push({ project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems).toHaveLength(1); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('still fails when the skill lacking a project namespace is selected', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('says so when a new rule stays at the shared root', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: [], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/my-rule.md'); + // Reaching the whole team is the outcome worth naming out loud. + const { log } = await import('../utils/logger.js'); + const said = [...vi.mocked(log.info).mock.calls, ...vi.mocked(log.warn).mock.calls] + .flat().join(' '); + expect(said).toContain('rules/my-rule.md'); + expect(said).toMatch(/everyone|whole team|shared/i); + }); + + it('rejects an unknown --project even when nothing needs placing', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + // Only a modified, already-namespaced rule: nothing reaches the per-axis + // resolver, so the typo would otherwise pass unnoticed. + mockHandlers({ + rules: [{ + name: 'fe-know/scoped', type: 'rules', sourcePath: '/tmp/scoped.md', + relativePath: 'rules/fe-know/scoped.md', status: 'modified', + }], + }, pushedItems); + + await push({ all: true, project: 'typo-id' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('keeps the namespace an open PR recorded for a skill even under --role', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/8', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'skills', name: 'skill-a', relativePath: 'skills/js/skill-a', namespace: 'js' }], + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + }, pushedItems); + + await push({ all: true, role: 'pm' }); + + // The branch is force-pushed, so honouring --role here would move the skill + // inside the open PR rather than leaving a copy behind. + expect(pushedItems[0]?.relativePath).toBe('skills/js/skill-a'); + expect(pushedItems[0]?.namespace).toBe('js'); + }); + + it('reuses the namespace recorded for a rule when updating its open PR', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/7', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + // No flag this time: the destination must come from the PR record, or the + // force-pushed branch would move the rule to the shared root. + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + expect(pushedItems[0]?.namespace).toBe('fe-know'); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index aade1ac8..362ec594 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -340,6 +340,107 @@ scope: 'user', const names = items.map((i) => i.name); expect(names).not.toContain('common/old-rule'); }); + + /** + * Once push places a new rule under rules//, the author's own copy stays + * at the tool's rules root. Matching by full path alone would read it as a + * brand-new rule on the next push and send a second copy to the shared root, + * where it would reach the whole team (issue #649). + */ + it('matches a root-level local rule against its namespaced team copy', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'edited locally'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.status).toBe('modified'); + expect(item?.relativePath).toBe('rules/fe-know/my-rule.md'); + // Recorded on the item too, so an open PR can reuse the destination. + expect(item?.namespace).toBe('fe-know'); + }); + + it('reports a subdirectory rule name as its namespace', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'common')); + await fse.writeFile(path.join(teamRulesDir, 'common/coding-standards.md'), 'team'); + + await fse.ensureDir(path.join(homeDir, '.claude/rules/common')); + await fse.writeFile(path.join(homeDir, '.claude/rules/common/coding-standards.md'), 'edited'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'common/coding-standards'); + expect(item?.namespace).toBe('common'); + }); + + it('leaves the namespace unset for a rule at the shared root', async () => { + await fse.writeFile(path.join(homeDir, '.claude/rules/shared.md'), 'everyone'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'shared'); + expect(item?.relativePath).toBe('rules/shared.md'); + expect(item?.namespace).toBeUndefined(); + }); + + it('does not re-push a root-level local rule that equals its namespaced team copy', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'same content'); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'same content'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.map((i) => i.name)).not.toContain('my-rule'); + }); + + it('leaves an ambiguous root-level rule alone when two namespaces hold that name', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'ns-a')); + await fse.ensureDir(path.join(teamRulesDir, 'ns-b')); + await fse.writeFile(path.join(teamRulesDir, 'ns-a/my-rule.md'), 'a'); + await fse.writeFile(path.join(teamRulesDir, 'ns-b/my-rule.md'), 'b'); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'local'); + // A second tool directory holds the same rule, which used to warn twice. + await fse.ensureDir(path.join(homeDir, '.codebuddy', 'rules')); + await fse.writeFile(path.join(homeDir, '.codebuddy/rules/my-rule.md'), 'local'); + teamConfig.toolPaths.codebuddy = { skills: '.codebuddy/skills', rules: '.codebuddy/rules' }; + + const { log } = await import('../utils/logger.js'); + vi.mocked(log.warn).mockClear(); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + // Guessing one would overwrite another namespace's rule, so the scan skips + // it and says so rather than picking. + expect(items.map((i) => i.name)).not.toContain('my-rule'); + // Once for the rule, not once per tool directory holding a copy. + const warnings = vi.mocked(log.warn).mock.calls + .filter((call) => String(call[0]).includes('my-rule')); + expect(warnings).toHaveLength(1); + }); + + it('still treats a root-level rule as new when the namespaced copy is not active', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'other-ns')); + await fse.writeFile(path.join(teamRulesDir, 'other-ns/my-rule.md'), 'team content'); + await fse.ensureDir(path.join(localConfig.repo.localPath, 'manifest')); + await fse.writeFile( + path.join(localConfig.repo.localPath, 'manifest', 'roles.yaml'), + 'version: 1\nroles:\n - id: mine\n description: Mine\n resources:\n knowledge: [mine]\n skills: [mine]\n', + ); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'local'); + + const items = await handler.scanLocalForPush( + teamConfig, + { ...localConfig, primaryRole: 'mine' }, + ); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.status).toBe('new'); + expect(item?.relativePath).toBe('rules/my-rule.md'); + }); }); describe('RulesHandler.scanTeamForPull — subdirectory support', () => { diff --git a/src/projects.ts b/src/projects.ts index 4f78f9fe..6506b607 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -147,10 +147,15 @@ export function describeProjects(projects: Array//`, `rules//.md`, `agents//.yaml`. + * `pull` filters each of those against the member's active namespaces, and a + * resource written to the shared root ships to everyone. + * + * Push therefore has to decide, per resource type, which namespace a brand-new + * resource lands in. That decision used to be inlined in `pushCore` for skills + * alone, which is why a rule or an agent pushed with `--role`/`--project` still + * reached the whole team (issue #649). It lives here so every type answers the + * same question the same way, and so the answer can be tested without a repo. + */ +import { + isSafeNamespaceSegment, findProject, unknownProjectMessage, + type ProjectResourceType, type ProjectsManifest, +} from './projects.js'; +import type { ResourceItem, ResourceType } from './types.js'; + +/** Resource types `pull` namespaces, and push therefore has to place. */ +export type PlaceableType = 'skills' | 'rules' | 'agents'; + +export const PLACEABLE_TYPES: readonly PlaceableType[] = ['skills', 'rules', 'agents']; + +export function isPlaceableType(type: ResourceType): type is PlaceableType { + return (PLACEABLE_TYPES as readonly ResourceType[]).includes(type); +} + +/** + * The manifest axis each type is namespaced by. A rule is knowledge, not + * skills: `ProjectResourceNamespacesSchema` lets a project declare different + * namespaces for the two, so resolving a rule from `resources.skills` would + * target the wrong directory (issue #649, comment 3). + */ +export const NAMESPACE_AXIS = { + skills: 'skills', + rules: 'knowledge', + agents: 'agents', +} as const satisfies Record; + +/** + * True when `item` would be written to the shared root — no namespace segment + * between the resource root and the resource itself. Those are the only items + * push places; anything already namespaced keeps the path it came with, since + * `pushItem` writes rather than moves and relocating would leave the original + * behind. + */ +export function isAtSharedRoot(item: ResourceItem): boolean { + return item.relativePath.split('/').length === 2; +} + +/** + * Insert `namespace` after the resource root. Only the directory changes, so + * the basename — `.yaml` or a legacy `.md` for agents, a bare directory name + * for skills — survives without the caller knowing which it has. + */ +export function withNamespace(relativePath: string, namespace: string): string { + const [root, ...rest] = relativePath.split('/'); + return [root, namespace, ...rest].join('/'); +} + +/** + * Where a skill lands in `namespace`. Built from the root rather than through + * `withNamespace`, because a skill's team path is derived from its name alone + * (`src/resources/skills.ts` keeps the leaf as the name): this REPLACES any + * namespace the item already carries, where `withNamespace` would insert a + * second one. + */ +export function skillNamespacePath(namespace: string, name: string): string { + return `skills/${namespace}/${name}`; +} + +/** A namespace, or why one could not be resolved — phrased for the CLI user. */ +export type NamespaceResolution = + | { ok: true; namespace: string } + | { ok: false; message: string }; + +/** + * The namespace `--project ` places `type` in, read from that type's own + * axis in `manifest/projects.yaml`. + * + * Returns a failure rather than throwing, and rather than falling back to the + * shared root: a resource written to the root reaches the whole team, which is + * the outcome the flag was used to avoid. The caller reports the message and + * exits without pushing anything. + */ +export function resolveProjectNamespace( + manifest: ProjectsManifest, + projectId: string, + type: PlaceableType, +): NamespaceResolution { + const project = findProject(manifest, projectId); + if (!project) { + return { ok: false, message: unknownProjectMessage(manifest, projectId) }; + } + + const axis = NAMESPACE_AXIS[type]; + const namespaces = project.resources[axis]; + if (namespaces.length === 0) { + return { + ok: false, + message: `Project "${projectId}" declares no ${axis} namespace, so there is nowhere to put the new ${type}. ` + + `Add one to manifest/projects.yaml, or use --role to target a namespace explicitly.`, + }; + } + if (namespaces.length > 1) { + return { + ok: false, + message: `Project "${projectId}" maps ${type} to multiple ${axis} namespaces (${namespaces.join(', ')}); ` + + 'use --role to pick one.', + }; + } + + const namespace = namespaces[0]; + if (!isSafeNamespaceSegment(namespace)) { + return { + ok: false, + message: `Project "${projectId}" declares an unusable ${axis} namespace "${namespace}": ` + + "it must be a single path segment (letters, digits, '.', '_', '-'; no '/', '\\', or '..').", + }; + } + + return { ok: true, namespace }; +} diff --git a/src/push.ts b/src/push.ts index 4934a72f..ab6344d2 100644 --- a/src/push.ts +++ b/src/push.ts @@ -22,6 +22,11 @@ import { getDataHome, SYNC_LOCK_FILENAME } from './types.js'; import { acquireLock, releaseLock } from './update.js'; import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; import { loadRolesManifest, resolveRoleResourceNamespaces } from './roles.js'; +import type { ProjectsManifest } from './projects.js'; +import { + isAtSharedRoot, isPlaceableType, NAMESPACE_AXIS, PLACEABLE_TYPES, resolveProjectNamespace, + skillNamespacePath, withNamespace, type PlaceableType, +} from './push-namespaces.js'; import { askQuestion, askSelection } from './utils/prompt.js'; import { pathExists, pruneEmptyDirs, readFileSafe, writeFile } from './utils/fs.js'; @@ -60,26 +65,102 @@ export async function filterExistingTopLevelPaths( } /** - * Resolve available skill namespaces for the current user. - * Returns the deduplicated list from the manifest (via role config), - * or falls back to [primaryRole] if no manifest exists. + * The namespaces this user could push a new `type` into, on that type's own + * axis: the deduplicated list from the roles manifest, or — for skills, which + * are the only type with a detector — the namespace directories the team repo + * already has. */ -async function resolveSkillNamespaces( - repoPath: string, - primaryRole: string, - additionalRoles: string[], -): Promise { +async function namespaceCandidates(type: PlaceableType, localConfig: LocalConfig): Promise { + if (localConfig.primaryRole) { + try { + const manifest = await loadRolesManifest(localConfig.repo.localPath); + const namespaces = resolveRoleResourceNamespaces({ + manifest, + primaryRole: localConfig.primaryRole, + additionalRoles: localConfig.additionalRoles ?? [], + }); + return namespaces[NAMESPACE_AXIS[type]]; + } catch { + // Legacy fallback: with no readable manifest a role id doubles as its + // skills namespace. That convention only ever existed for skills. + return type === 'skills' ? [localConfig.primaryRole] : []; + } + } + + // No role configured. Skills can still be placed by detecting the team repo's + // existing namespace directories; rules and agents have no such detector, so + // a new one stays at the shared root. + if (type !== 'skills') return []; try { - const manifest = await loadRolesManifest(repoPath); - const namespaces = resolveRoleResourceNamespaces({ - manifest, - primaryRole, - additionalRoles, - }); - return namespaces.skills; + return await scanTeamRepoNamespaces(localConfig.repo.localPath); } catch { - return [primaryRole]; + return []; + } +} + +/** + * Where a new root-level resource should land, or why push must stop. The two + * stop states are separate because they exit differently: a manifest that + * cannot answer is a failure (exit 2), while a selection the user typed wrong + * ends the run without an error code, as it did before. + */ +type NewResourceDestination = + | { kind: 'namespace'; namespace: string } + | { kind: 'shared-root' } + | { kind: 'unresolvable'; message: string } + | { kind: 'invalid-selection'; message: string }; + +/** + * Decide the namespace for the new root-level resources of one type. + * + * Every axis answers this the same way — that consistency is the fix for #649 — + * but each reads its own namespace list, so a project whose `knowledge` and + * `skills` namespaces differ sends a rule and a skill to different directories. + */ +async function resolveNamespaceForNew( + type: PlaceableType, + options: { role?: string; project?: string; silent?: boolean }, + localConfig: LocalConfig, + projectsManifest: ProjectsManifest | null, +): Promise { + if (options.project && projectsManifest) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, type); + return resolved.ok + ? { kind: 'namespace', namespace: resolved.namespace } + : { kind: 'unresolvable', message: resolved.message }; } + + // An explicit --role is a literal namespace on every axis (already checked + // for path traversal before selection). + if (options.role) return { kind: 'namespace', namespace: options.role }; + + const candidates = await namespaceCandidates(type, localConfig); + if (candidates.length === 0) return { kind: 'shared-root' }; + if (candidates.length === 1) return { kind: 'namespace', namespace: candidates[0] }; + if (options.silent) { + // Skills keep their historical silent default (the primary role id); no + // other axis ever had that convention, so they take the first candidate. + const skillsDefault = type === 'skills' ? localConfig.primaryRole : undefined; + return { kind: 'namespace', namespace: skillsDefault ?? candidates[0] }; + } + + console.log(''); + console.log(`Which namespace should new ${type} be pushed to?`); + candidates.forEach((ns, index) => { + console.log(` ${index + 1}. ${ns}`); + }); + console.log(''); + const answer = await askQuestion( + `Choose namespace [1-${candidates.length}] (default: 1 = ${candidates[0]}): `, + ); + const selection = answer ? Number.parseInt(answer, 10) : 1; + if (Number.isNaN(selection) || selection < 1 || selection > candidates.length) { + return { + kind: 'invalid-selection', + message: `Invalid selection. Choose a number between 1 and ${candidates.length}.`, + }; + } + return { kind: 'namespace', namespace: candidates[selection - 1] }; } /** @@ -332,39 +413,37 @@ export async function push( const { localConfig, teamConfig } = await autoDetectInit(); assertNotReadOnly(localConfig, 'teamai push'); - // --project is a destination override expressed as a logical project: resolve - // it to the project's skills namespace (from manifest/projects.yaml) and reuse - // the --role landing logic below. Deliberately manifest-resolved, not the raw - // project id, so it agrees with what pull syncs (issue #375 P2 lesson). + // --project is a destination override expressed as a logical project. Each + // resource type then resolves from its OWN axis in manifest/projects.yaml — + // skills from `skills`, rules from `knowledge`, agents from `agents` — because + // a project may declare different namespaces for each (issue #649). A missing + // namespace only blocks a push that actually selects that type: the skills + // axis resolves against the scan, because it also relocates modified skills + // and the listing has to show where they go, but a failure there is held + // until the selection proves a skill is going out. + // Deliberately manifest-resolved, not the raw project id, so it agrees with + // what pull syncs (issue #375 P2 lesson). + let projectsManifest: ProjectsManifest | null = null; if (options.project) { if (options.role) { log.error('Use either --role or --project, not both.'); process.exitCode = 2; return; } - const { loadProjectsManifest, resolveProjectResourceNamespaces } = await import('./projects.js'); - const manifest = await loadProjectsManifest(localConfig.repo.localPath); - if (!manifest) { + const { loadProjectsManifest, findProject, unknownProjectMessage } = await import('./projects.js'); + projectsManifest = await loadProjectsManifest(localConfig.repo.localPath); + if (!projectsManifest) { log.error('This team repo defines no projects (no manifest/projects.yaml).'); process.exitCode = 2; return; } - let skillNamespaces: string[]; - try { - skillNamespaces = resolveProjectResourceNamespaces({ manifest, activeProjects: [options.project] }).skills; - } catch (e) { - log.error((e as Error).message); - process.exitCode = 2; - return; - } - if (skillNamespaces.length !== 1) { - log.error(skillNamespaces.length === 0 - ? `Project "${options.project}" declares no skills namespace; use --role to target one explicitly.` - : `Project "${options.project}" maps to multiple skills namespaces (${skillNamespaces.join(', ')}); use --role to pick one.`); + // The id is checked here, not with the namespaces: a typo must fail even on + // a push where nothing needs placing, instead of being silently ignored. + if (!findProject(projectsManifest, options.project)) { + log.error(unknownProjectMessage(projectsManifest, options.project)); process.exitCode = 2; return; } - options.role = skillNamespaces[0]; } try { const configContent = await readFileSafe(path.join(localConfig.repo.localPath, 'teamai.yaml')); @@ -425,7 +504,7 @@ export async function push( if (pendingTeamConfig !== null) { await writeFile(path.join(wtConfig.repo.localPath, 'teamai.yaml'), pendingTeamConfig); } - await pushCore(wtConfig, teamConfig, options, pendingTeamConfig, result); + await pushCore(wtConfig, teamConfig, options, projectsManifest, pendingTeamConfig, result); }); } catch (e) { if (e instanceof EmptyRepoError) { @@ -453,7 +532,7 @@ export async function push( return; } try { - await pushCore(localConfig, teamConfig, options, null, result); + await pushCore(localConfig, teamConfig, options, projectsManifest, null, result); } finally { await releaseLock(syncLock); } @@ -462,7 +541,9 @@ export async function push( async function pushCore( localConfig: LocalConfig, teamConfig: TeamaiConfig, - options: GlobalOptions & { all?: boolean; role?: string }, + options: GlobalOptions & { all?: boolean; role?: string; project?: string }, + /** Loaded by `push` when --project is given; the namespace source for it. */ + projectsManifest: ProjectsManifest | null = null, initialPendingTeamConfig: string | null = null, result?: { completed: boolean }, ): Promise { @@ -710,26 +791,52 @@ async function pushCore( } } - // An explicit --role is a destination override for every selected skill, - // including modified skills. Keep relativePath aligned with pushItem's + // An explicit --role or --project is a destination override for every selected + // skill, including modified ones. Keep relativePath aligned with pushItem's // destination so git stages the files that were actually copied (#331). + // Rules and agents are deliberately NOT relocated here: pushItem writes rather + // than moves, so moving one that already lives in a namespace would leave the + // original behind (#654). They are placed in step 4, and only when new. + let skillsDestination: string | undefined; + // Held rather than reported: the skills axis resolves here so the listing can + // show where a relocated skill goes, but a project that cannot answer for + // skills must only stop the push if a skill is actually selected. + let skillsDestinationError: string | undefined; if (options.role) { try { + // --role now names a directory on every axis, so this message must not + // claim the problem is with a skill. assertSafeResourceName(options.role); + } catch (e) { + log.error(`Invalid --role value "${options.role}": ${(e as Error).message}`); + process.exitCode = 2; + return; + } + skillsDestination = options.role; + } else if (options.project && projectsManifest && allItems.some((i) => i.type === 'skills')) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, 'skills'); + if (resolved.ok) { + skillsDestination = resolved.namespace; + } else { + skillsDestinationError = resolved.message; + } + } + if (skillsDestination) { + try { for (const item of allItems) { if (item.type === 'skills') { assertSafeResourceName(item.name); } } } catch (e) { - log.error(`Invalid skill role or name: ${(e as Error).message}`); + log.error(`Invalid skill name: ${(e as Error).message}`); process.exitCode = 2; return; } for (const item of allItems) { if (item.type !== 'skills') continue; - item.namespace = options.role; - item.relativePath = `skills/${options.role}/${item.name}`; + item.namespace = skillsDestination; + item.relativePath = skillNamespacePath(skillsDestination, item.name); } } @@ -772,9 +879,10 @@ async function pushCore( const num = `${i + 1}.`.padStart(4); console.log(` ${num} [${item.type}] ${item.name}${statusLabel}`); console.log(` from: ${item.sourcePath}`); - // Show destination for modified skills that already have a namespace - if (item.type === 'skills' && item.namespace) { - console.log(` to: skills/${item.namespace}/${item.name}`); + // Show the destination whenever it is already namespaced — which one it is + // decides who receives the resource, so it is not obvious from the name. + if (!isAtSharedRoot(item)) { + console.log(` to: ${item.relativePath}`); } const openPrs = findPendingForItem(pendingPushes, item); if (openPrs.length > 0) { @@ -828,13 +936,23 @@ async function pushCore( `Updating existing PR instead of creating a new one: ${group.reuse.prUrl ?? group.reuse.branch}`, ); // Reuse the destination chosen when that PR was opened rather than asking - // again — a different answer would silently move the skill. + // again — a different answer would silently move the resource, and the + // branch is force-pushed, so the old copy would not even stay behind. for (const item of group.items) { - if (item.type !== 'skills' || item.status !== 'new') continue; + if (item.status !== 'new' || !isPlaceableType(item.type)) continue; const ns = pendingNamespaceFor(group.reuse, item); if (!ns) continue; - item.namespace = ns; - item.relativePath = `skills/${ns}/${item.name}`; + if (item.type === 'skills') { + // A skill's path is derived from its name, so the recorded namespace + // replaces whatever a --role/--project override wrote above. + item.namespace = ns; + item.relativePath = skillNamespacePath(ns, item.name); + } else if (isAtSharedRoot(item)) { + // A rule or agent carries its own path from the scanner, and that path + // is authoritative when it already names a namespace (#654). + item.namespace = ns; + item.relativePath = withNamespace(item.relativePath, ns); + } } } for (const entry of partiallySelectedEntries(selectedItems, pendingPushes)) { @@ -844,89 +962,54 @@ async function pushCore( ); } - // ── Step 4: Resolve namespace for NEW skills only (after selection) ─ - const newSkills = selectedItems.filter( - (i) => i.type === 'skills' && i.status === 'new' && !i.namespace, - ); - let resolvedNamespaceForNew: string | undefined; + // ── Step 4: Place NEW root-level resources in a namespace (after selection) ─ + // One decision per axis: skills from the `skills` namespaces, rules from + // `knowledge`, agents from `agents`. Before #649 only skills were placed, so a + // new rule or agent landed at the shared root and pull shipped it to every + // member. Only items that would otherwise land at the root are touched — + // anything the scanner already namespaced keeps the path it came with. + // A project that declares no skills namespace only blocks the push once a + // skill is actually selected, so a rule can still go out from a scan that + // happens to contain an unrelated skill. + if (skillsDestinationError && selectedItems.some((i) => i.type === 'skills')) { + log.error(skillsDestinationError); + process.exitCode = 2; + return; + } - if (newSkills.length > 0) { - if (options.role) { - // Explicit --role flag: use as namespace directly (backward compat) - resolvedNamespaceForNew = options.role; - } else if (localConfig.primaryRole) { - try { - const skillNamespaces = await resolveSkillNamespaces( - localConfig.repo.localPath, - localConfig.primaryRole, - localConfig.additionalRoles ?? [], - ); + for (const type of PLACEABLE_TYPES) { + const newAtRoot = selectedItems.filter( + (i) => i.type === type && i.status === 'new' && !i.namespace && isAtSharedRoot(i), + ); + if (newAtRoot.length === 0) continue; - if (skillNamespaces.length === 0) { - resolvedNamespaceForNew = undefined; - } else if (skillNamespaces.length === 1) { - resolvedNamespaceForNew = skillNamespaces[0]; - } else if (options.silent) { - resolvedNamespaceForNew = localConfig.primaryRole; - } else { - console.log(''); - console.log('Which namespace should new skills be pushed to?'); - skillNamespaces.forEach((ns, index) => { - console.log(` ${index + 1}. ${ns}`); - }); - console.log(''); - const answer = await askQuestion( - `Choose namespace [1-${skillNamespaces.length}] (default: 1 = ${skillNamespaces[0]}): `, - ); - const selection = answer ? Number.parseInt(answer, 10) : 1; - if (Number.isNaN(selection) || selection < 1 || selection > skillNamespaces.length) { - log.error(`Invalid selection. Choose a number between 1 and ${skillNamespaces.length}.`); - return; - } - resolvedNamespaceForNew = skillNamespaces[selection - 1]; - } - } catch (e) { - log.error((e as Error).message); + const destination = await resolveNamespaceForNew(type, options, localConfig, projectsManifest); + switch (destination.kind) { + case 'unresolvable': + log.error(destination.message); + process.exitCode = 2; return; - } - } else { - // No role configured — auto-detect namespaces from team repo structure - try { - const detectedNamespaces = await scanTeamRepoNamespaces(localConfig.repo.localPath); - - if (detectedNamespaces.length === 0) { - resolvedNamespaceForNew = undefined; - } else if (detectedNamespaces.length === 1) { - resolvedNamespaceForNew = detectedNamespaces[0]; - } else if (options.silent) { - resolvedNamespaceForNew = detectedNamespaces[0]; - } else { - console.log(''); - console.log('Which namespace should new skills be pushed to?'); - detectedNamespaces.forEach((ns, index) => { - console.log(` ${index + 1}. ${ns}`); - }); - console.log(''); - const answer = await askQuestion( - `Choose namespace [1-${detectedNamespaces.length}] (default: 1 = ${detectedNamespaces[0]}): `, - ); - const selection = answer ? Number.parseInt(answer, 10) : 1; - if (Number.isNaN(selection) || selection < 1 || selection > detectedNamespaces.length) { - log.error(`Invalid selection. Choose a number between 1 and ${detectedNamespaces.length}.`); - return; - } - resolvedNamespaceForNew = detectedNamespaces[selection - 1]; + case 'invalid-selection': + log.error(destination.message); + return; + case 'shared-root': + // The one destination that reaches the whole team is the one worth + // saying out loud, so it is never the result of a silent fallback. + for (const item of newAtRoot) { + log.warn(`[${type}] ${item.name} → ${item.relativePath} (shared with everyone: no namespace resolved)`); } - } catch { - resolvedNamespaceForNew = undefined; - } - } - - // Apply namespace to new skills - for (const item of newSkills) { - if (resolvedNamespaceForNew) { - item.namespace = resolvedNamespaceForNew; - item.relativePath = `skills/${resolvedNamespaceForNew}/${item.name}`; + continue; + case 'namespace': + for (const item of newAtRoot) { + item.namespace = destination.namespace; + item.relativePath = withNamespace(item.relativePath, destination.namespace); + // The silent widening in #649 was the real damage: say where it went. + log.info(`[${type}] ${item.name} → ${item.relativePath}`); + } + break; + default: { + const unhandled: never = destination; + throw new Error(`Unhandled namespace destination: ${JSON.stringify(unhandled)}`); } } } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index bd4a0fdf..1a82d101 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -12,6 +12,7 @@ import { teamRuleToCopilotInstructions, } from './copilot-instructions.js'; import { assertWithinRoot } from '../utils/path-safety.js'; +import { resolveResourceNamespaces } from '../resource-namespaces.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -41,8 +42,33 @@ export class RulesHandler extends ResourceHandler { // Read tombstones to skip previously deleted resources const tombstones = await this.readTombstones(localConfig); + // A rule placed under rules// on an earlier push is still authored at + // the tool's rules root, so matching on the full path alone would read it + // as brand new and send a second copy to the shared root — where it would + // reach the whole team (issue #649). Index the namespaced team rules by + // bare name so that local copy resolves back to the file it came from. + // Only namespaces the user actually has active count, mirroring the agents + // handler; `null` means nothing is filtering, so every namespace counts. + const resolved = await resolveResourceNamespaces(localConfig); + const activeKnowledge = resolved?.activeNamespaces.knowledge ?? null; + const namespacedTeamRules = new Map(); + for (const file of teamRules) { + const segments = file.split('/'); + if (segments.length !== 2) continue; // only one namespace level is scoped + const [namespace, basename] = segments; + if (activeKnowledge && !activeKnowledge.includes(namespace)) continue; + const stem = basename.slice(0, -'.md'.length); + const matches = namespacedTeamRules.get(stem) ?? []; + matches.push(file); + namespacedTeamRules.set(stem, matches); + } + + const ambiguousReported = new Set(); + // Collect the best candidate for each rule name across all tool directories - const candidates = new Map(); + const candidates = new Map(); // One read per team rule, shared across every tool dir that compares against it. const teamContentCache = new Map(); const readTeamRule = async (filePath: string): Promise => { @@ -75,7 +101,27 @@ export class RulesHandler extends ResourceHandler { const localFilePath = path.join(rulesDir, file); // Team repo always stores `.md`, keyed by rule name. - const teamFileName = `${name}.md`; + let teamFileName = `${name}.md`; + if (!teamRules.has(teamFileName) && !name.includes('/')) { + const namespaced = namespacedTeamRules.get(name) ?? []; + if (namespaced.length === 1) { + teamFileName = namespaced[0]; + } else if (namespaced.length > 1) { + // Picking one would overwrite another namespace's rule with content + // that was never reviewed against it. Warn once per rule, not once + // per tool directory that happens to hold a copy. + if (!ambiguousReported.has(name)) { + ambiguousReported.add(name); + log.warn( + `[rules] Skipped ${name}: the team repo has it in more than one active namespace ` + + `(${namespaced.join(', ')}). Rename one, or edit the namespaced copy directly.`, + ); + } + continue; + } + } + + const teamRelPath = `rules/${teamFileName}`; if (teamRules.has(teamFileName)) { // File exists in team repo — check if content differs @@ -98,7 +144,7 @@ export class RulesHandler extends ResourceHandler { const mtime = await getFileMtime(localFilePath); const existing = candidates.get(name); if (!existing || mtime > existing.mtime) { - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'modified' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'modified', teamRelPath }); } } else { // File does not exist in team repo — candidate for "new". @@ -110,12 +156,12 @@ export class RulesHandler extends ResourceHandler { const existing = candidates.get(name); if (!existing) { const mtime = await getFileMtime(localFilePath); - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new', teamRelPath }); } else if (existing.status === 'new') { // Multiple tool dirs have the same new file — pick latest mtime const mtime = await getFileMtime(localFilePath); if (mtime > existing.mtime) { - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new', teamRelPath }); } } } @@ -125,12 +171,17 @@ export class RulesHandler extends ResourceHandler { // Convert candidates map to items array const items: ResourceItem[] = []; for (const [name, candidate] of candidates) { + // `rules//.md` is namespaced; `rules/.md` is shared. State + // it on the item so an open PR can reuse the destination, the way skills do. + const segments = candidate.teamRelPath.split('/'); + const namespace = segments.length > 2 ? segments[1] : undefined; items.push({ name, type: 'rules', sourcePath: candidate.sourcePath, - relativePath: `rules/${name}.md`, + relativePath: candidate.teamRelPath, status: candidate.status, + ...(namespace ? { namespace } : {}), }); } From 5c9b0aab454cafddd8cc0c4bc383b3a7187c8484 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 07:48:16 +0200 Subject: [PATCH 02/25] fix(push): record rule placement in state instead of matching by basename RulesHandler.scanLocalForPush matched a root-level local rule against the sole active rules//.md by basename. A namespaced team rule is pulled into a namespaced local directory, so another member's unrelated root rule with the same name would have been read as a modification of the team rule and overwritten it under --all. push now records where it placed each root-level rule (state.placedRules, name -> team path). The scanner redirects a root-level local rule only when that record exists and its team file is still present; otherwise the rule is new. The ambiguity warning goes with the basename index. Review: https://github.com/Tencent/teamai-cli/pull/698#issuecomment-5770251793 --- src/__tests__/push-role.test.ts | 34 +++++++++ src/__tests__/remove.test.ts | 2 + src/__tests__/rules.test.ts | 118 +++++++++++++++++--------------- src/push.ts | 7 ++ src/remove.ts | 1 + src/resources/rules.ts | 47 +++---------- src/types.ts | 10 +++ 7 files changed, 127 insertions(+), 92 deletions(-) diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 1d502bda..94cb49f1 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1161,4 +1161,38 @@ describe('push namespace routing for rules and agents', () => { expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); expect(pushedItems[0]?.namespace).toBe('fe-know'); }); + + it('records where a root-level rule was placed so the next scan recognises it', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, []); + + await push({ all: true, role: 'pm' }); + + // The author's copy stays at the tool's rules root, so the scanner needs + // the record to map it back to rules/pm/ instead of reading it as new. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; + expect(saved.placedRules).toEqual({ 'my-rule': 'rules/pm/my-rule.md' }); + }); + + it('does not record a rule the scanner already found in a subdirectory', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + rules: [{ + name: 'fe-know/my-rule', type: 'rules', sourcePath: '/tmp/fe-know/my-rule.md', + relativePath: 'rules/fe-know/my-rule.md', status: 'modified', namespace: 'fe-know', + }], + }, []); + + await push({ all: true }); + + // Its local path already carries the namespace, so full-path matching works. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; + expect(saved.placedRules ?? {}).toEqual({}); + }); }); diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index f8b8d9e9..722471ac 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -9,6 +9,8 @@ vi.mock('../config.js', async (importOriginal) => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // The rules scanner reads push placements from state.json; none here. + loadStateForScope: vi.fn(async () => ({})), })); vi.mock('../utils/git.js', () => ({ diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 362ec594..95f90898 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -8,6 +8,9 @@ vi.mock('../config.js', async (importOriginal) => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // The rules scanner reads state.json for push placements; an empty state is + // the default, and tests that need a record override it for one call. + loadStateForScope: vi.fn(async () => ({})), })); vi.mock('../utils/git.js', () => ({ @@ -33,7 +36,8 @@ vi.mock('../utils/logger.js', () => ({ })); import { RulesHandler } from '../resources/rules.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { loadStateForScope } from '../config.js'; +import type { TeamaiConfig, LocalConfig, State } from '../types.js'; describe('RulesHandler.scanLocalForPush — modified rule detection', () => { let tmpDir: string; @@ -341,16 +345,30 @@ scope: 'user', expect(names).not.toContain('common/old-rule'); }); + /** * Once push places a new rule under rules//, the author's own copy stays * at the tool's rules root. Matching by full path alone would read it as a * brand-new rule on the next push and send a second copy to the shared root, - * where it would reach the whole team (issue #649). + * where it would reach the whole team (issue #649). push records the + * placement in state.json, and only that record maps a root-level local rule + * to a namespaced team file: a basename match alone proves nothing, because + * a namespaced team rule is pulled into a namespaced local directory. */ - it('matches a root-level local rule against its namespaced team copy', async () => { + function stateWithPlacedRules(placedRules: Record) { + const state: State = { + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules, + }; + vi.mocked(loadStateForScope).mockResolvedValueOnce(state); + } + + it('matches a root-level local rule against the namespaced copy push recorded for it', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'edited locally'); @@ -362,32 +380,11 @@ scope: 'user', expect(item?.namespace).toBe('fe-know'); }); - it('reports a subdirectory rule name as its namespace', async () => { - const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); - await fse.ensureDir(path.join(teamRulesDir, 'common')); - await fse.writeFile(path.join(teamRulesDir, 'common/coding-standards.md'), 'team'); - - await fse.ensureDir(path.join(homeDir, '.claude/rules/common')); - await fse.writeFile(path.join(homeDir, '.claude/rules/common/coding-standards.md'), 'edited'); - - const items = await handler.scanLocalForPush(teamConfig, localConfig); - const item = items.find((i) => i.name === 'common/coding-standards'); - expect(item?.namespace).toBe('common'); - }); - - it('leaves the namespace unset for a rule at the shared root', async () => { - await fse.writeFile(path.join(homeDir, '.claude/rules/shared.md'), 'everyone'); - - const items = await handler.scanLocalForPush(teamConfig, localConfig); - const item = items.find((i) => i.name === 'shared'); - expect(item?.relativePath).toBe('rules/shared.md'); - expect(item?.namespace).toBeUndefined(); - }); - - it('does not re-push a root-level local rule that equals its namespaced team copy', async () => { + it('does not re-push a root-level local rule that equals the namespaced copy it was placed at', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'same content'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'same content'); @@ -395,52 +392,61 @@ scope: 'user', expect(items.map((i) => i.name)).not.toContain('my-rule'); }); - it('leaves an ambiguous root-level rule alone when two namespaces hold that name', async () => { + it('keeps an unrelated root-level rule new when only its basename matches a namespaced team rule', async () => { + // Another member's machine: the team has rules/fe-know/foo.md, and this + // user wrote their own foo.md at the rules root. Nothing was pushed from + // here, so there is no record — and no grounds to overwrite the team rule. const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); - await fse.ensureDir(path.join(teamRulesDir, 'ns-a')); - await fse.ensureDir(path.join(teamRulesDir, 'ns-b')); - await fse.writeFile(path.join(teamRulesDir, 'ns-a/my-rule.md'), 'a'); - await fse.writeFile(path.join(teamRulesDir, 'ns-b/my-rule.md'), 'b'); - - await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'local'); - // A second tool directory holds the same rule, which used to warn twice. - await fse.ensureDir(path.join(homeDir, '.codebuddy', 'rules')); - await fse.writeFile(path.join(homeDir, '.codebuddy/rules/my-rule.md'), 'local'); - teamConfig.toolPaths.codebuddy = { skills: '.codebuddy/skills', rules: '.codebuddy/rules' }; + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/foo.md'), 'team rule'); + stateWithPlacedRules({}); - const { log } = await import('../utils/logger.js'); - vi.mocked(log.warn).mockClear(); + await fse.writeFile(path.join(homeDir, '.claude/rules/foo.md'), 'unrelated local rule'); const items = await handler.scanLocalForPush(teamConfig, localConfig); - // Guessing one would overwrite another namespace's rule, so the scan skips - // it and says so rather than picking. - expect(items.map((i) => i.name)).not.toContain('my-rule'); - // Once for the rule, not once per tool directory holding a copy. - const warnings = vi.mocked(log.warn).mock.calls - .filter((call) => String(call[0]).includes('my-rule')); - expect(warnings).toHaveLength(1); + const item = items.find((i) => i.name === 'foo'); + expect(item?.status).toBe('new'); + expect(item?.relativePath).toBe('rules/foo.md'); + expect(item?.namespace).toBeUndefined(); }); - it('still treats a root-level rule as new when the namespaced copy is not active', async () => { + it('treats a root-level rule as new again once its recorded team file is gone', async () => { + // The rule was removed from the team repo (or its namespace renamed): the + // record no longer points at anything and must not invent a destination. const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.ensureDir(path.join(teamRulesDir, 'other-ns')); await fse.writeFile(path.join(teamRulesDir, 'other-ns/my-rule.md'), 'team content'); - await fse.ensureDir(path.join(localConfig.repo.localPath, 'manifest')); - await fse.writeFile( - path.join(localConfig.repo.localPath, 'manifest', 'roles.yaml'), - 'version: 1\nroles:\n - id: mine\n description: Mine\n resources:\n knowledge: [mine]\n skills: [mine]\n', - ); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'local'); - const items = await handler.scanLocalForPush( - teamConfig, - { ...localConfig, primaryRole: 'mine' }, - ); + const items = await handler.scanLocalForPush(teamConfig, localConfig); const item = items.find((i) => i.name === 'my-rule'); expect(item?.status).toBe('new'); expect(item?.relativePath).toBe('rules/my-rule.md'); }); + + it('reports a subdirectory rule name as its namespace', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'common')); + await fse.writeFile(path.join(teamRulesDir, 'common/coding-standards.md'), 'team'); + + await fse.ensureDir(path.join(homeDir, '.claude/rules/common')); + await fse.writeFile(path.join(homeDir, '.claude/rules/common/coding-standards.md'), 'edited'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'common/coding-standards'); + expect(item?.namespace).toBe('common'); + }); + + it('leaves the namespace unset for a rule at the shared root', async () => { + await fse.writeFile(path.join(homeDir, '.claude/rules/shared.md'), 'everyone'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'shared'); + expect(item?.relativePath).toBe('rules/shared.md'); + expect(item?.namespace).toBeUndefined(); + }); }); describe('RulesHandler.scanTeamForPull — subdirectory support', () => { diff --git a/src/push.ts b/src/push.ts index ab6344d2..dd0ee892 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1051,6 +1051,13 @@ async function pushCore( if (item.type === 'rules' && !state.pushedRules.includes(item.name)) { state.pushedRules.push(item.name); } + // A root-level local rule that landed under rules// is still authored + // at the tool's rules root, so the scanner needs this record to recognise + // it next time (RulesHandler.scanLocalForPush). A rule the scanner already + // found in a subdirectory carries the namespace in its name and needs none. + if (item.type === 'rules' && item.namespace && !item.name.includes('/')) { + state.placedRules = { ...state.placedRules, [item.name]: item.relativePath }; + } if (item.type === 'env' && !state.pushedEnvVars.includes(item.name)) { state.pushedEnvVars.push(item.name); } diff --git a/src/remove.ts b/src/remove.ts index e5674227..4d9b818d 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -196,6 +196,7 @@ async function removeCore( } if (type === 'rules') { state.pushedRules = state.pushedRules.filter((r) => !found.includes(r)); + for (const name of found) delete state.placedRules?.[name]; } // `wiki` is not tracked in pushedX state; nothing to clean here. await saveStateForScope(state, localConfig); diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 1a82d101..853a990f 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -12,7 +12,7 @@ import { teamRuleToCopilotInstructions, } from './copilot-instructions.js'; import { assertWithinRoot } from '../utils/path-safety.js'; -import { resolveResourceNamespaces } from '../resource-namespaces.js'; +import { loadStateForScope } from '../config.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -45,25 +45,12 @@ export class RulesHandler extends ResourceHandler { // A rule placed under rules// on an earlier push is still authored at // the tool's rules root, so matching on the full path alone would read it // as brand new and send a second copy to the shared root — where it would - // reach the whole team (issue #649). Index the namespaced team rules by - // bare name so that local copy resolves back to the file it came from. - // Only namespaces the user actually has active count, mirroring the agents - // handler; `null` means nothing is filtering, so every namespace counts. - const resolved = await resolveResourceNamespaces(localConfig); - const activeKnowledge = resolved?.activeNamespaces.knowledge ?? null; - const namespacedTeamRules = new Map(); - for (const file of teamRules) { - const segments = file.split('/'); - if (segments.length !== 2) continue; // only one namespace level is scoped - const [namespace, basename] = segments; - if (activeKnowledge && !activeKnowledge.includes(namespace)) continue; - const stem = basename.slice(0, -'.md'.length); - const matches = namespacedTeamRules.get(stem) ?? []; - matches.push(file); - namespacedTeamRules.set(stem, matches); - } - - const ambiguousReported = new Set(); + // reach the whole team (issue #649). state.json records where this machine + // placed each root-level rule, and that record — not the basename — maps + // the local copy back to its team file. A namespaced team rule is pulled + // into a namespaced local directory, so a root-level local rule that only + // shares a basename with one, and has no record, is unrelated and stays new. + const placedRules = (await loadStateForScope(localConfig)).placedRules ?? {}; // Collect the best candidate for each rule name across all tool directories const candidates = new Map 1) { - // Picking one would overwrite another namespace's rule with content - // that was never reviewed against it. Warn once per rule, not once - // per tool directory that happens to hold a copy. - if (!ambiguousReported.has(name)) { - ambiguousReported.add(name); - log.warn( - `[rules] Skipped ${name}: the team repo has it in more than one active namespace ` - + `(${namespaced.join(', ')}). Rename one, or edit the namespaced copy directly.`, - ); - } - continue; - } + // A record whose team file is gone (rule removed, namespace renamed) + // no longer proves anything, so the rule is new again. + const placed = placedRules[name]?.replace(/^rules\//, ''); + if (placed && teamRules.has(placed)) teamFileName = placed; } const teamRelPath = `rules/${teamFileName}`; diff --git a/src/types.ts b/src/types.ts index 7e73adfa..0e64c49d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -625,6 +625,16 @@ export const StateSchema = z.object({ /** Tool targets that completed the last inherited user-resource pull. */ lastInheritedPullTargets: z.array(z.string()).optional(), pushedRules: z.array(z.string()).default([]), + /** + * Where push placed each root-level local rule inside the team repo, by rule + * name, e.g. `{ "my-rule": "rules/fe-know/my-rule.md" }`. The author's copy + * stays at the tool's rules root after push, so without this record the next + * scan would read it as a brand-new rule. Only a rule this machine pushed is + * recorded: an unrelated local rule that merely shares a basename with a + * namespaced team rule has no entry and is never matched to it. Optional + * for the same reason as `coAuthorManaged`; absent reads as an empty map. + */ + placedRules: z.record(z.string(), z.string()).optional(), pushedSkills: z.array(z.string()).default([]), pushedEnvVars: z.array(z.string()).default([]), /** Push branches whose PR is still open — see PendingPushSchema. */ From 099d8ffbd56805dd6b74e1706a4d1878e1a4ecaa Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 12:20:28 +0200 Subject: [PATCH 03/25] fix(push): stop on unreadable roles manifest, sync placed rules, resolve in dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on top of the #649 placement fix. A roles manifest that exists but cannot answer — unparseable, or missing the configured role — no longer falls back to an empty namespace list, which sent a new rule or agent to the shared root and therefore to the whole team. Only an absent manifest keeps the pre-manifest fallback, so `loadRolesManifest` now throws a tagged `RolesManifestNotFoundError` to tell the two apart. `syncTeamUpdatesToLocal` follows the same `placedRules` record the scanner does, so a root-authored rule placed under `rules//` takes part in the three-way sync. Without it a teammate's newer version was never synced down and the stale root copy was pushed over it. `--dry-run` now runs the recorded-destination and placement steps before it exits, so it reports where every new resource goes and fails on the same unresolvable project axis the real command refuses. --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/pre-push-sync.test.ts | 45 +++++ src/__tests__/push-role.test.ts | 111 ++++++++++++ src/push.ts | 251 ++++++++++++++++++---------- src/roles.ts | 16 +- src/utils/pre-push-sync.ts | 30 +++- 7 files changed, 367 insertions(+), 94 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 036e5efc..ac61a335 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -597,6 +597,8 @@ Choose namespace [1-3] (default: 1 = common): - A single namespace is auto-selected; use `--role ` to choose one explicitly - Modifying an existing resource automatically keeps its original namespace - The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` +- A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior +- `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. @@ -1484,7 +1486,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent lands at the root. Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 86ae5fa7..b56a14b4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -574,6 +574,8 @@ Choose namespace [1-3] (default: 1 = common): - 单一命名空间时自动选中;也可用 `--role ` 显式指定 - 修改已有资源时自动保持原 namespace - 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` +- 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 +- `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 @@ -1443,7 +1445,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 落在根目录。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 4bde47e0..df64f3e9 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -97,6 +97,51 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(content).toBe('v2 content'); }); + it('syncs a root-authored rule through its recorded rules// destination', async () => { + // push placed this rule under rules/fe-know/; the author's copy stayed at + // the tool's rules root, so there is no rules/my-rule.md to compare against. + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + // Without the redirect the stale root copy reads as a local modification and + // the next push sends it over the teammate's update. + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('teammate v2'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); + }); + + it('leaves a root rule alone when no record maps it to a namespaced team rule', async () => { + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'someone else v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'my own rule'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('my own rule')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', {}); + + // A shared basename is not evidence: this machine never pushed that rule. + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('my own rule'); + }); + + it('ignores a record whose team file is gone', async () => { + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('v1 content'); + expect(mockGetFileContentAtRev).not.toHaveBeenCalled(); + }); + it('should NOT sync local rule when user edited it', async () => { // Team repo has v2 await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), 'v2 content'); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 94cb49f1..ec85e48e 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { push } from '../push.js'; +import { RolesManifestNotFoundError } from '../roles.js'; const mockAutoDetectInit = vi.fn(); const mockPullRepo = vi.fn(); @@ -1195,4 +1196,114 @@ describe('push namespace routing for rules and agents', () => { const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; expect(saved.placedRules ?? {}).toEqual({}); }); + it('stops the push when the roles manifest exists but cannot be read', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockRejectedValue(new Error('Invalid roles manifest YAML: bad indentation')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // Falling back here would publish the rule to the whole team, which is the + // widening #649 is about — and nobody asked for it. + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Invalid roles manifest YAML'); + }); + + it('stops the push when the configured role is missing from the manifest', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'ghost' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['solo-know'], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('keeps the pre-manifest fallback when the team repo has no roles manifest', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockRejectedValue( + new RolesManifestNotFoundError('/tmp/team-repo/manifest/roles.yaml'), + ); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + await push({ all: true }); + + // No manifest at all is the team's actual layout, not a failure: the role id + // still doubles as the skills namespace and the rule stays shared, loudly. + expect(process.exitCode).toBeUndefined(); + const at = (type: string) => pushedItems.find((i) => i.type === type)?.relativePath; + expect(at('skills')).toBe('skills/solo/skill-a'); + expect(at('rules')).toBe('rules/my-rule.md'); + }); + + it('--dry-run reports the destination and pushes nothing', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + + await push({ dryRun: true, project: 'front-app' }); + + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.info).mock.calls.flat().join(' '); + expect(said).toContain('rules/fe-know/my-rule.md'); + expect(said).toContain('agents/fe-agents/vr.yaml'); + }); + + it('--dry-run fails on a project axis the real push would refuse', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: [], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, []); + + await push({ dryRun: true, project: 'front-app' }); + + // A dry run that called this viable would be worse than no dry run at all. + expect(process.exitCode).toBe(2); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('knowledge'); + }); }); diff --git a/src/push.ts b/src/push.ts index dd0ee892..76d3077d 100644 --- a/src/push.ts +++ b/src/push.ts @@ -21,7 +21,7 @@ import type { import { getDataHome, SYNC_LOCK_FILENAME } from './types.js'; import { acquireLock, releaseLock } from './update.js'; import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; -import { loadRolesManifest, resolveRoleResourceNamespaces } from './roles.js'; +import { loadRolesManifest, resolveRoleResourceNamespaces, RolesManifestNotFoundError } from './roles.js'; import type { ProjectsManifest } from './projects.js'; import { isAtSharedRoot, isPlaceableType, NAMESPACE_AXIS, PLACEABLE_TYPES, resolveProjectNamespace, @@ -64,13 +64,29 @@ export async function filterExistingTopLevelPaths( return result; } +/** Candidate namespaces, or why the question could not be answered. */ +type CandidateResolution = + | { ok: true; candidates: string[] } + | { ok: false; message: string }; + /** * The namespaces this user could push a new `type` into, on that type's own * axis: the deduplicated list from the roles manifest, or — for skills, which * are the only type with a detector — the namespace directories the team repo * already has. + * + * A manifest that EXISTS but cannot answer — unparseable, or missing the role + * this directory is configured with — is a failure, not an empty list. Falling + * back on it would place a new rule or agent at the shared root, which sends it + * to the whole team: the exact widening #649 is about, and one the user never + * asked for. A manifest that is ABSENT is the pre-manifest convention instead, + * where the shared root is the team's actual layout, so it keeps the legacy + * fallback and the loud warning that goes with it. */ -async function namespaceCandidates(type: PlaceableType, localConfig: LocalConfig): Promise { +async function namespaceCandidates( + type: PlaceableType, + localConfig: LocalConfig, +): Promise { if (localConfig.primaryRole) { try { const manifest = await loadRolesManifest(localConfig.repo.localPath); @@ -79,22 +95,34 @@ async function namespaceCandidates(type: PlaceableType, localConfig: LocalConfig primaryRole: localConfig.primaryRole, additionalRoles: localConfig.additionalRoles ?? [], }); - return namespaces[NAMESPACE_AXIS[type]]; - } catch { - // Legacy fallback: with no readable manifest a role id doubles as its + return { ok: true, candidates: namespaces[NAMESPACE_AXIS[type]] }; + } catch (e) { + if (!(e instanceof RolesManifestNotFoundError)) { + return { + ok: false, + message: `Cannot resolve where new ${type} should go: ${(e as Error).message}. ` + + 'Fix manifest/roles.yaml, run `teamai roles set `, or pass --role ' + + 'to name the namespace for this push.', + }; + } + // Legacy fallback: with no manifest at all a role id doubles as its // skills namespace. That convention only ever existed for skills. - return type === 'skills' ? [localConfig.primaryRole] : []; + return { ok: true, candidates: type === 'skills' ? [localConfig.primaryRole] : [] }; } } // No role configured. Skills can still be placed by detecting the team repo's // existing namespace directories; rules and agents have no such detector, so // a new one stays at the shared root. - if (type !== 'skills') return []; + if (type !== 'skills') return { ok: true, candidates: [] }; try { - return await scanTeamRepoNamespaces(localConfig.repo.localPath); - } catch { - return []; + return { ok: true, candidates: await scanTeamRepoNamespaces(localConfig.repo.localPath) }; + } catch (e) { + return { + ok: false, + message: `Cannot list the team repo's skills namespaces: ${(e as Error).message}. ` + + 'Pass --role to name the namespace for this push.', + }; } } @@ -134,7 +162,9 @@ async function resolveNamespaceForNew( // for path traversal before selection). if (options.role) return { kind: 'namespace', namespace: options.role }; - const candidates = await namespaceCandidates(type, localConfig); + const resolution = await namespaceCandidates(type, localConfig); + if (!resolution.ok) return { kind: 'unresolvable', message: resolution.message }; + const { candidates } = resolution; if (candidates.length === 0) return { kind: 'shared-root' }; if (candidates.length === 1) return { kind: 'namespace', namespace: candidates[0] }; if (options.silent) { @@ -233,6 +263,108 @@ export { createPrWithFallback }; */ type PushGroupOutcome = 'pushed' | 'nochange' | 'pr-failed' | 'failed'; +/** + * Give every resource waiting in an open PR back the destination that PR + * recorded, rather than asking again — a different answer would silently move + * the resource, and the branch is force-pushed, so the old copy would not even + * stay behind. Runs before placement, so a resource with a recorded namespace + * is no longer at the shared root and placement leaves it alone. + */ +function reuseRecordedDestinations(groups: PushGroup[]): void { + for (const group of groups) { + if (!group.reuse) continue; + log.info( + `Updating existing PR instead of creating a new one: ${group.reuse.prUrl ?? group.reuse.branch}`, + ); + for (const item of group.items) { + if (item.status !== 'new' || !isPlaceableType(item.type)) continue; + const ns = pendingNamespaceFor(group.reuse, item); + if (!ns) continue; + if (item.type === 'skills') { + // A skill's path is derived from its name, so the recorded namespace + // replaces whatever a --role/--project override wrote above. + item.namespace = ns; + item.relativePath = skillNamespacePath(ns, item.name); + } else if (isAtSharedRoot(item)) { + // A rule or agent carries its own path from the scanner, and that path + // is authoritative when it already names a namespace (#654). + item.namespace = ns; + item.relativePath = withNamespace(item.relativePath, ns); + } + } + } +} + +/** + * Place the NEW root-level resources of `items` in a namespace, printing where + * each one goes. One decision per axis: skills from the `skills` namespaces, + * rules from `knowledge`, agents from `agents`. Before #649 only skills were + * placed, so a new rule or agent landed at the shared root and pull shipped it + * to every member. Only items that would otherwise land at the root are + * touched — anything the scanner already namespaced keeps the path it came with. + * + * Returns false when the push must stop; it has already reported why and set + * `process.exitCode`. `--dry-run` runs this too, so it shows the destinations + * and fails on the same unresolvable axis the real command would. + */ +async function placeNewResources(args: { + items: ResourceItem[]; + options: { role?: string; project?: string; silent?: boolean }; + localConfig: LocalConfig; + projectsManifest: ProjectsManifest | null; + skillsDestinationError?: string; +}): Promise { + const { items, options, localConfig, projectsManifest, skillsDestinationError } = args; + + // A project that declares no skills namespace only blocks the push once a + // skill is actually selected, so a rule can still go out from a scan that + // happens to contain an unrelated skill. + if (skillsDestinationError && items.some((i) => i.type === 'skills')) { + log.error(skillsDestinationError); + process.exitCode = 2; + return false; + } + + for (const type of PLACEABLE_TYPES) { + const newAtRoot = items.filter( + (i) => i.type === type && i.status === 'new' && !i.namespace && isAtSharedRoot(i), + ); + if (newAtRoot.length === 0) continue; + + const destination = await resolveNamespaceForNew(type, options, localConfig, projectsManifest); + switch (destination.kind) { + case 'unresolvable': + log.error(destination.message); + process.exitCode = 2; + return false; + case 'invalid-selection': + log.error(destination.message); + return false; + case 'shared-root': + // The one destination that reaches the whole team is the one worth + // saying out loud, so it is never the result of a silent fallback. + for (const item of newAtRoot) { + log.warn(`[${type}] ${item.name} → ${item.relativePath} (shared with everyone: no namespace resolved)`); + } + continue; + case 'namespace': + for (const item of newAtRoot) { + item.namespace = destination.namespace; + item.relativePath = withNamespace(item.relativePath, destination.namespace); + // The silent widening in #649 was the real damage: say where it went. + log.info(`[${type}] ${item.name} → ${item.relativePath}`); + } + break; + default: { + const unhandled: never = destination; + throw new Error(`Unhandled namespace destination: ${JSON.stringify(unhandled)}`); + } + } + } + + return true; +} + /** * Push each selected resource into the team repo, commit it on a branch, and * open (or update) the matching PR. On a thrown failure it rolls back the copies @@ -605,7 +737,10 @@ async function pushCore( // This prevents files changed by teammates from being falsely flagged as "modified". try { const state = await loadStateForScope(localConfig); - await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev); + // placedRules redirects a root-authored rule to the rules// file push + // put it in, so a teammate's newer version syncs down instead of being + // overwritten by the stale root copy the scan would otherwise call modified. + await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev, state.placedRules ?? {}); } catch (e) { log.debug(`Pre-push sync skipped: ${(e as Error).message}`); } @@ -902,8 +1037,19 @@ async function pushCore( console.log(''); } - // ── Step 2: Dry run exits after display ──────────────────────────── + // ── Step 2: Dry run resolves placement, then exits ────────────── + // Everything is treated as selected, so the run reports the destination of + // every new resource and fails on a project axis that cannot answer. Exiting + // before this would let a dry run call a push viable that the real command + // refuses — and say nothing about who the new resources reach. if (options.dryRun) { + // Same two steps, same order as a real run: an open PR's recorded + // destination first, then placement for whatever is still at the root. + reuseRecordedDestinations(planPushGroups(allItems, pendingPushes)); + const placed = await placeNewResources({ + items: allItems, options, localConfig, projectsManifest, skillsDestinationError, + }); + if (!placed) return; log.info('Dry run — no changes made'); return; } @@ -930,31 +1076,7 @@ async function pushCore( // can happen in one run, so editing a resource under review updates its PR // without dragging unrelated resources into that review. const groups = planPushGroups(selectedItems, pendingPushes); - for (const group of groups) { - if (!group.reuse) continue; - log.info( - `Updating existing PR instead of creating a new one: ${group.reuse.prUrl ?? group.reuse.branch}`, - ); - // Reuse the destination chosen when that PR was opened rather than asking - // again — a different answer would silently move the resource, and the - // branch is force-pushed, so the old copy would not even stay behind. - for (const item of group.items) { - if (item.status !== 'new' || !isPlaceableType(item.type)) continue; - const ns = pendingNamespaceFor(group.reuse, item); - if (!ns) continue; - if (item.type === 'skills') { - // A skill's path is derived from its name, so the recorded namespace - // replaces whatever a --role/--project override wrote above. - item.namespace = ns; - item.relativePath = skillNamespacePath(ns, item.name); - } else if (isAtSharedRoot(item)) { - // A rule or agent carries its own path from the scanner, and that path - // is authoritative when it already names a namespace (#654). - item.namespace = ns; - item.relativePath = withNamespace(item.relativePath, ns); - } - } - } + reuseRecordedDestinations(groups); for (const entry of partiallySelectedEntries(selectedItems, pendingPushes)) { log.warn( `Only part of ${entry.prUrl ?? entry.branch} is selected, so the selected resources go into a ` @@ -963,56 +1085,9 @@ async function pushCore( } // ── Step 4: Place NEW root-level resources in a namespace (after selection) ─ - // One decision per axis: skills from the `skills` namespaces, rules from - // `knowledge`, agents from `agents`. Before #649 only skills were placed, so a - // new rule or agent landed at the shared root and pull shipped it to every - // member. Only items that would otherwise land at the root are touched — - // anything the scanner already namespaced keeps the path it came with. - // A project that declares no skills namespace only blocks the push once a - // skill is actually selected, so a rule can still go out from a scan that - // happens to contain an unrelated skill. - if (skillsDestinationError && selectedItems.some((i) => i.type === 'skills')) { - log.error(skillsDestinationError); - process.exitCode = 2; - return; - } - - for (const type of PLACEABLE_TYPES) { - const newAtRoot = selectedItems.filter( - (i) => i.type === type && i.status === 'new' && !i.namespace && isAtSharedRoot(i), - ); - if (newAtRoot.length === 0) continue; - - const destination = await resolveNamespaceForNew(type, options, localConfig, projectsManifest); - switch (destination.kind) { - case 'unresolvable': - log.error(destination.message); - process.exitCode = 2; - return; - case 'invalid-selection': - log.error(destination.message); - return; - case 'shared-root': - // The one destination that reaches the whole team is the one worth - // saying out loud, so it is never the result of a silent fallback. - for (const item of newAtRoot) { - log.warn(`[${type}] ${item.name} → ${item.relativePath} (shared with everyone: no namespace resolved)`); - } - continue; - case 'namespace': - for (const item of newAtRoot) { - item.namespace = destination.namespace; - item.relativePath = withNamespace(item.relativePath, destination.namespace); - // The silent widening in #649 was the real damage: say where it went. - log.info(`[${type}] ${item.name} → ${item.relativePath}`); - } - break; - default: { - const unhandled: never = destination; - throw new Error(`Unhandled namespace destination: ${JSON.stringify(unhandled)}`); - } - } - } + if (!await placeNewResources({ + items: selectedItems, options, localConfig, projectsManifest, skillsDestinationError, + })) return; // ── Step 5: Push each group — one branch/PR per group ────────────── // Config edits ride along with the first group so they land in a single PR. diff --git a/src/roles.ts b/src/roles.ts index 143a7ab9..9dab426c 100644 --- a/src/roles.ts +++ b/src/roles.ts @@ -91,11 +91,25 @@ function validateManifestShape(raw: unknown): RolesManifest { return manifest; } +/** + * The team repo has no `manifest/roles.yaml` at all — a distinct case from one + * that exists but cannot be parsed. `push` treats them differently: an absent + * manifest is the pre-manifest layout, where a role id doubles as its skills + * namespace, while an unreadable one is a failure that must stop the push + * rather than let a new rule or agent fall back to the shared root (#649). + */ +export class RolesManifestNotFoundError extends Error { + constructor(manifestPath: string) { + super(`Roles manifest not found: ${manifestPath}`); + this.name = 'RolesManifestNotFoundError'; + } +} + export async function loadRolesManifest(repoPath: string): Promise { const manifestPath = path.join(repoPath, 'manifest', 'roles.yaml'); const content = await readFileSafe(manifestPath); if (!content) { - throw new Error(`Roles manifest not found: ${manifestPath}`); + throw new RolesManifestNotFoundError(manifestPath); } let raw: unknown; diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index df5d3486..92aa9cbb 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -35,11 +35,19 @@ import { log } from './logger.js'; * genuine edits — leave it alone for scanLocalForPush to pick up. * * This is a no-op when `lastPullRev` is null (first run or after re-init). + * + * `placedRules` is `state.placedRules`: where push put each root-level local + * rule inside the team repo. A rule authored at the tool's rules root and + * placed under `rules//` has no `rules/.md` to compare against, so + * without this map the three-way check below would skip it and the scanner — + * which DOES follow the map — would then read the stale root copy as a local + * modification and push it over a teammate's newer version. */ export async function syncTeamUpdatesToLocal( teamConfig: TeamaiConfig, localConfig: LocalConfig, lastPullRev: string | null, + placedRules: Record = {}, ): Promise { if (!lastPullRev) { log.debug('No lastPullRev — skipping pre-push sync'); @@ -49,7 +57,7 @@ export async function syncTeamUpdatesToLocal( const repoPath = localConfig.repo.localPath; const baseDir = resolveBaseDir(localConfig); - await syncRulesToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev); + await syncRulesToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev, placedRules); await syncSkillsToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev); } @@ -63,6 +71,7 @@ async function syncRulesToLocal( repoPath: string, baseDir: string, lastPullRev: string, + placedRules: Record, ): Promise { const teamRulesDir = path.join(repoPath, 'rules'); if (!await pathExists(teamRulesDir)) return; @@ -89,8 +98,23 @@ async function syncRulesToLocal( const localFilePath = path.join(rulesDir, file); // The team repo always stores the tool-neutral `.md`. - const teamRelPath = `rules/${name}.md`; - const teamFilePath = path.join(teamRulesDir, `${name}.md`); + let teamRelPath = `rules/${name}.md`; + let teamFilePath = path.join(teamRulesDir, `${name}.md`); + + // Same redirect as RulesHandler.scanLocalForPush: a root-level rule this + // machine pushed lives under rules// in the team repo, and both sides + // must compare against that file or the scan reverts a teammate's update. + if (!await pathExists(teamFilePath) && !name.includes('/')) { + // The record comes from state.json on disk; keep it inside rules/. + const placed = placedRules[name]; + if (placed?.startsWith('rules/') && !placed.split('/').includes('..')) { + const placedPath = path.join(repoPath, placed); + if (await pathExists(placedPath)) { + teamRelPath = placed; + teamFilePath = placedPath; + } + } + } // Only process files that exist in both places but differ if (!await pathExists(teamFilePath)) continue; From 0db7219c0c9e1e8cf7f464366a9b19ed40f892a0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 12:32:40 +0200 Subject: [PATCH 04/25] test(push): cover #649 placement with the real CLI across agents and providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the built dist/index.js against real git remotes, a fake `gh` and a fake GitLab API, and asserts on the branch content that reached the remote. The four agents × three providers cover the placement itself; the remaining cases cover what review round 2 raised — the pre-push sync following placedRules, an unreadable roles manifest stopping the push, and --dry-run resolving the same destinations. Reverting any of those three fixes turns exactly its case red and leaves the rest green. --- src/__tests__/push-namespace-e2e.test.ts | 483 +++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 src/__tests__/push-namespace-e2e.test.ts diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts new file mode 100644 index 00000000..36ed6e9c --- /dev/null +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -0,0 +1,483 @@ +/** + * Issue #649 end to end, against the built CLI and real git remotes. + * + * `teamai push --role/--project` used to place new SKILLS only. A new rule was + * written to `rules/.md` and a new agent to `agents/.yaml`, neither + * of which carries a namespace segment, so `pull` delivered both to every member + * of the team. These tests drive `dist/index.js` and assert on the content of + * the branch that reached the remote, not on CLI output alone. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +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 PUSH_AGENTS = ['claude', 'codex', 'codebuddy', 'opencode'] as const; +type PushAgent = (typeof PUSH_AGENTS)[number]; + +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', +}; + +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 }, + }).trim(); +} + +function runCLI( + args: string[], + cwd: string, + home: string, + envOverrides: Record = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + cwd, + env: { ...process.env, ...GIT_ENV, HOME: home, FORCE_COLOR: '0', ...envOverrides }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + }); +} + +/** + * A new agent authored in the tool's OWN format, because push reverse-parses + * the local file before it can place the canonical `.yaml`. Codex reads TOML; + * the others read `.md` with YAML frontmatter. + */ +function localAgentFile(agent: PushAgent): { name: string; content: string } { + if (agent === 'codex') { + return { + name: 'vr.toml', + content: 'name = "vr"\ndescription = "reviews code"\ndeveloper_instructions = "You review."\n', + }; + } + return { + name: 'vr.md', + content: '---\nname: vr\ndescription: reviews code\n---\n\nYou review.\n', + }; +} + +const PROJECTS_MANIFEST = [ + 'version: 1', + 'projects:', + ' - id: front-app', + ' name: Front App', + ' description: Front end', + ' resources:', + ' knowledge: [fe-know]', + ' skills: [fe-skills]', + ' learnings: []', + ' agents: [fe-agents]', +].join('\n'); + +const ROLES_MANIFEST = [ + 'version: 1', + 'roles:', + ' - id: backend', + ' description: Backend', + ' resources:', + ' knowledge: [be-know]', + ' skills: [be-skills]', + ' agents: [be-agents]', +].join('\n'); + +interface Fixture { + sandbox: string; + home: string; + projectRoot: string; + remote: string; + teamRepo: string; + agent: PushAgent; + username: string; +} + +/** Seeded team repo + one member directory, with nothing pushed yet. */ +function makeFixture(options: { + agent: PushAgent; + provider: 'git' | 'github' | 'gitlab'; + repoUrl?: string; + username?: string; + rolesManifest?: string; +}): Fixture { + const { agent, provider } = options; + const username = options.username ?? `author-${provider}`; + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), `teamai-ns-649-${provider}-${agent}-`)); + const home = path.join(sandbox, 'home'); + const projectRoot = path.join(sandbox, 'project'); + const seed = path.join(sandbox, 'seed'); + const remote = path.join(sandbox, 'team.git'); + const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); + + fs.mkdirSync(home, { recursive: true }); + for (const dir of ['skills', 'rules', 'agents']) { + fs.mkdirSync(path.join(projectRoot, `.${agent}`, dir), { recursive: true }); + fs.mkdirSync(path.join(seed, dir), { recursive: true }); + fs.writeFileSync(path.join(seed, dir, '.gitkeep'), ''); + } + fs.mkdirSync(path.join(seed, 'manifest'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'manifest', 'projects.yaml'), PROJECTS_MANIFEST); + fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), options.rolesManifest ?? ROLES_MANIFEST); + fs.writeFileSync( + path.join(seed, 'teamai.yaml'), + [ + 'team: issue-649', + `repo: ${options.repoUrl ?? 'https://git.example.test/team/issue-649.git'}`, + `provider: ${provider}`, + 'reviewers: []', + 'toolPaths:', + ` ${agent}:`, + ` skills: .${agent}/skills`, + ` rules: .${agent}/rules`, + ` agents: .${agent}/agents`, + ].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, teamRepo], projectRoot); + + fs.writeFileSync( + path.join(projectRoot, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${teamRepo}`, + ` remote: ${remote}`, + `username: ${username}`, + 'updatePolicy: auto', + 'primaryRole: backend', + 'additionalRoles: []', + 'scope: project', + `projectRoot: ${projectRoot}`, + ].join('\n'), + ); + + return { sandbox, home, projectRoot, remote, teamRepo, agent, username }; +} + +/** Author a new rule, skill and agent at each tool directory's root. */ +function writeLocalResources(fixture: Fixture, ruleBody = '# Rule v1\n'): void { + const { projectRoot, agent } = fixture; + fs.writeFileSync(path.join(projectRoot, `.${agent}/rules`, 'my-rule.md'), ruleBody); + fs.mkdirSync(path.join(projectRoot, `.${agent}/skills`, 'my-skill'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `.${agent}/skills/my-skill`, 'SKILL.md'), + '---\nname: my-skill\ndescription: a skill\n---\n\n# Skill\n', + ); + const agentFile = localAgentFile(agent); + fs.writeFileSync(path.join(projectRoot, `.${agent}/agents`, agentFile.name), agentFile.content); +} + +/** Files on the single push branch this fixture's remote received. */ +function branchFiles(fixture: Fixture): { branch: string; files: string[] } { + const branch = git( + ['for-each-ref', '--format=%(refname:short)', `refs/heads/teamai/push/${fixture.username}/`], + fixture.remote, + ).split('\n').filter(Boolean).at(-1) ?? ''; + const files = branch + ? git(['ls-tree', '-r', '--name-only', branch], fixture.remote).split('\n').filter(Boolean) + : []; + return { branch, files }; +} + +function readState(fixture: Fixture): Record { + return JSON.parse(fs.readFileSync(path.join(fixture.projectRoot, '.teamai', 'state.json'), 'utf8')); +} + +/** Land a branch on the remote's default branch and drop it, as a merged PR does. */ +function mergeBranch(fixture: Fixture, branch: string): void { + const clone = path.join(fixture.sandbox, `merge-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + git(['merge', '--no-edit', '-q', `origin/${branch}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + git(['push', '-q', 'origin', '--delete', branch], clone); + fs.rmSync(clone, { recursive: true, force: true }); +} + +/** Commit a file straight onto the remote's default branch, as a teammate would. */ +function commitOnMain(fixture: Fixture, relPath: string, content: string): void { + const clone = path.join(fixture.sandbox, `mate-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + fs.mkdirSync(path.dirname(path.join(clone, relPath)), { recursive: true }); + fs.writeFileSync(path.join(clone, relPath), content); + git(['add', '-A'], clone); + git(['commit', '-q', '-m', `teammate: ${relPath}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + fs.rmSync(clone, { recursive: true, force: true }); +} + +const cleanups: string[] = []; +afterEach(() => { + while (cleanups.length) { + fs.rmSync(cleanups.pop()!, { recursive: true, force: true }); + } +}); + +function track(fixture: Fixture): Fixture { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + cleanups.push(fixture.sandbox); + return fixture; +} + +describe('push places new rules and agents in a namespace (issue #649)', () => { + it.each(PUSH_AGENTS)('--project resolves each type from its own axis for %s', async (agent) => { + const fixture = track(makeFixture({ agent, provider: 'git' })); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + // The generic git provider cannot open a PR; the branch is still pushed. + expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('skills/fe-skills/my-skill/SKILL.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + // The shared root is what shipped the rule to the whole team before #649. + expect(files).not.toContain('rules/my-rule.md'); + expect(files).not.toContain('agents/vr.yaml'); + + // The author's copy stays at the tool's rules root, so push records where + // it put it; without that the next scan reads it as a brand-new rule. + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + }, 60_000); + + it('sends an edit of the root copy back to the same namespace after the PR merges', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + const unchanged = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + expect(unchanged.output).toContain('No new or modified resources to push'); + + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v2\n'); + const edited = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(edited.output).toContain('[rules] my-rule (modified)'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).not.toContain('rules/my-rule.md'); + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)).toContain('Rule v2'); + }, 60_000); + + it('never matches another member\'s root rule to a namespaced team rule by basename', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git', username: 'member-b' })); + // The team already has the namespaced rule, but THIS machine never pushed it. + commitOnMain(fixture, 'rules/fe-know/my-rule.md', '# Author version\n'); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Unrelated\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(result.output).toContain('[rules] my-rule (new)'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('rules/be-know/my-rule.md'); + // The author's rule is untouched: a shared basename is not evidence. + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)) + .toContain('# Author version'); + }, 60_000); + + it('syncs a teammate\'s newer namespaced rule instead of pushing the stale root copy over it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + // A pull is what records lastPullRev, which the three-way check needs. + const pulled = await runCLI(['pull'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + commitOnMain(fixture, 'rules/fe-know/my-rule.md', '# Teammate v2\n'); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + // The root copy is the author's own, placed under rules/fe-know/. Without + // the placedRules redirect in the pre-push sync it stayed at v1, read as a + // local modification, and reverted the teammate's update. (The pull above + // also installs the CLI's built-in `teamai` skill, which this run does + // push — the assertion is about the rule.) + expect(result.output).not.toContain('[rules] my-rule'); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), 'utf8')) + .toContain('Teammate v2'); + const { branch } = branchFiles(fixture); + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)).toContain('Teammate v2'); + expect(git(['show', 'main:rules/fe-know/my-rule.md'], fixture.remote)).toContain('Teammate v2'); + }, 60_000); + + it('stops the push when the roles manifest exists but cannot be parsed', async () => { + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: 'version: 1\nroles:\n - id: backend\n bad indentation: [\n', + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + // Falling back here would publish the rule to the whole team. + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('Cannot resolve where new rules should go'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('--dry-run reports the destinations and pushes nothing', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--dry-run'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + expect(result.output).toContain('Dry run'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('--dry-run fails on a project axis the real push would refuse', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + fs.writeFileSync( + path.join(fixture.teamRepo, 'manifest', 'projects.yaml'), + PROJECTS_MANIFEST.replace('knowledge: [fe-know]', 'knowledge: []'), + ); + git(['commit', '-q', '-am', 'drop the knowledge axis'], fixture.teamRepo); + git(['push', '-q', 'origin', 'main'], fixture.teamRepo); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI( + ['push', '--project', 'front-app', '--dry-run'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('declares no knowledge namespace'); + }, 60_000); +}); + +describe('push namespace placement reaches the PR providers (issue #649)', () => { + it.each(PUSH_AGENTS)('creates a GitHub PR for the namespaced branch for %s', async (agent) => { + const fixture = track(makeFixture({ + agent, + provider: 'github', + repoUrl: 'https://github.com/team/issue-649.git', + })); + const binDir = path.join(fixture.sandbox, 'bin'); + const ghLog = path.join(fixture.sandbox, 'gh.log'); + fs.mkdirSync(binDir); + fs.writeFileSync( + path.join(binDir, 'gh'), + '#!/bin/sh\nprintf "%s\\n" "$*" > "$TEAMAI_FAKE_GH_LOG"\n' + + 'printf "%s\\n" "https://github.com/team/issue-649/pull/649"\n', + { mode: 0o755 }, + ); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + { PATH: `${binDir}:${process.env.PATH ?? ''}`, TEAMAI_FAKE_GH_LOG: ghLog }, + ); + + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Pull Request created: https://github.com/team/issue-649/pull/649'); + expect(fs.readFileSync(ghLog, 'utf8')).toContain('pr create -R team/issue-649'); + const { files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + expect(files).not.toContain('rules/my-rule.md'); + }, 60_000); + + it.each(PUSH_AGENTS)('creates a GitLab MR for the namespaced branch for %s', async (agent) => { + const requestPaths: string[] = []; + const server = http.createServer((request, response) => { + requestPaths.push(request.url ?? ''); + request.on('data', () => {}); + request.on('end', () => { + response.writeHead(201, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + iid: 649, + web_url: 'https://gitlab.example.test/team/issue-649/-/merge_requests/649', + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Failed to start the fake GitLab API server.'); + } + const gitlabUrl = `http://127.0.0.1:${address.port}`; + const fixture = track(makeFixture({ + agent, + provider: 'gitlab', + repoUrl: `${gitlabUrl}/team/issue-649.git`, + })); + writeLocalResources(fixture); + + try { + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + { GITLAB_URL: gitlabUrl, GITLAB_TOKEN: 'test-token' }, + ); + + expect(result.code, result.output).toBe(0); + expect(result.output).toContain( + 'Pull Request created: https://gitlab.example.test/team/issue-649/-/merge_requests/649', + ); + expect(requestPaths).toEqual(['/api/v4/projects/team%2Fissue-649/merge_requests']); + const { files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + expect(files).not.toContain('rules/my-rule.md'); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }, 60_000); +}); From d81c2046c2339d7ca7f7ae9a76bbca5e21feba84 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 12:57:00 +0200 Subject: [PATCH 05/25] fix(push): keep a placed resource maintainable and removable by its author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the placement this PR added. A placement record only ever meant "push put this here", and the two sides that read one disagreed about how much it was worth. `placedResourcePath` is now the single resolver: it validates the record (inside the resource root, namespaced, no traversal, named after the resource) and both the push scanner and the pre-push sync go through it, so they cannot drift apart again. The record also takes precedence over a shared-root file that appears later with the same basename — mapping the author's copy onto somebody else's rule would push their content over it. Agents gained the analogue, `placedAgents`. `AgentsHandler.scanLocalForPush` only accepts a team source whose namespace is ACTIVE here, so an agent published with --role/--project into a namespace this directory never activated was skipped as "no active source" on the author's very next edit: they could create the agent and then never maintain it. `teamai remove rules ` resolves the same record through the new `publishedNameFor` hook. The author's copy stays at the rules root, so the name they type is the bare one, and remove answered "not found" about a rule it had recorded publishing. It now reports which name it resolved to, deletes the namespaced team file, and takes the author's root copy with it — left behind, that copy re-publishes the rule on the next push. --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/agents.test.ts | 44 ++++++++++++++++- src/__tests__/pre-push-sync.test.ts | 19 ++++++++ src/__tests__/push-namespace-e2e.test.ts | 55 ++++++++++++++++++++++ src/__tests__/push-namespaces.test.ts | 45 +++++++++++++++++- src/__tests__/push-role.test.ts | 16 +++++++ src/__tests__/remove.test.ts | 60 +++++++++++++++++++++++- src/__tests__/rules.test.ts | 18 +++++++ src/push-namespaces.ts | 39 +++++++++++++++ src/push.ts | 8 +++- src/remove.ts | 44 ++++++++++++++++- src/resources/agents.ts | 12 ++++- src/resources/base.ts | 12 +++++ src/resources/rules.ts | 56 +++++++++++++++++----- src/types.ts | 10 ++++ src/utils/pre-push-sync.ts | 26 +++++----- 17 files changed, 433 insertions(+), 35 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ac61a335..c9a4bbe8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -599,6 +599,8 @@ Choose namespace [1-3] (default: 1 = common): - The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` - A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses +- A placed resource stays maintainable from the machine that published it. `state.json` records where push put each one, so a later edit of the author's own copy goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b56a14b4..ead53912 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -576,6 +576,8 @@ Choose namespace [1-3] (default: 1 = common): - 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` - 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 +- 已落点的资源在发布它的机器上仍可维护:`state.json` 会记录 push 的落点,因此作者修改自己的副本后仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index aecafb5f..aa520160 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -20,7 +20,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { AgentsHandler } from '../resources/agents.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { getDataHome, type TeamaiConfig, type LocalConfig } from '../types.js'; /** * Build a minimal TeamaiConfig with the given toolPaths. @@ -282,6 +282,48 @@ projects: expect(await fse.readFile(path.join(repoPath, 'agents/aaa/reviewer.md'), 'utf8')).toBe('# original'); }); + /** + * An agent published with `--role`/`--project` lands in a namespace the + * author's own directory need not have activated. Without the record push + * put in state.json, their very next edit is skipped as "no active source" + * and they can never maintain the agent they just created (#649 review). + */ + it('accepts the namespace push recorded for an agent even when it is inactive', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Published\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Published\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + }); + + it('still skips an inactive agent this machine never published', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Somebody else\'s\n---\n\nEdited locally.\n'); + // A record for a DIFFERENT agent must not widen this one. + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { other: 'agents/fe-agents/other.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('no active source'); + }); + it.each(['zzz', ''])('rejects ambiguous push destinations including root: %s', async (namespace) => { await fse.outputFile(path.join(repoPath, 'agents/aaa/reviewer.md'), '# aaa'); await fse.outputFile(path.join(repoPath, 'agents', namespace, 'reviewer.md'), '# zzz'); diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index df64f3e9..d4d13468 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -116,6 +116,25 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); }); + it('keeps following the record when a shared-root rule with the same name exists', async () => { + // Both sides must resolve the same file. If the sync compared against + // rules/my-rule.md while the scanner followed the record, the scan would + // read the local copy as modified and push it over the placed rule. + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), 'somebody else\'s shared rule'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + expect(await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8')) + .toBe('teammate v2'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); + }); + it('leaves a root rule alone when no record maps it to a namespaced team rule', async () => { await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'someone else v2'); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 36ed6e9c..61b75020 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -214,6 +214,12 @@ function mergeBranch(fixture: Fixture, branch: string): void { git(['push', '-q', 'origin', 'main'], clone); git(['push', '-q', 'origin', '--delete', branch], clone); fs.rmSync(clone, { recursive: true, force: true }); + // The member's own clone keeps the branch it pushed. Once the PR is merged + // and the remote branch is gone, that local ref is stale — and `push`/`remove` + // generate branch names at one-second resolution, so a run in the same second + // would collide with it. + git(['checkout', '-q', 'main'], fixture.teamRepo); + git(['branch', '-q', '-D', branch], fixture.teamRepo); } /** Commit a file straight onto the remote's default branch, as a teammate would. */ @@ -343,6 +349,55 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(git(['show', 'main:rules/fe-know/my-rule.md'], fixture.remote)).toContain('Teammate v2'); }, 60_000); + it('lets the author keep editing an agent they published into an inactive namespace', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // `front-app` is never activated in this directory, so `fe-agents` is not + // an active namespace: without the placedAgents record the edit below is + // skipped as "no active source" and the agent cannot be maintained. + fs.writeFileSync( + path.join(fixture.projectRoot, '.claude/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review twice.\n', + ); + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).not.toContain('no active source'); + expect(result.output).toContain('[agents] vr (modified)'); + const { branch } = branchFiles(fixture); + expect(git(['show', `${branch}:agents/fe-agents/vr.yaml`], fixture.remote)) + .toContain('You review twice.'); + }, 60_000); + + it('removes a rule by the bare name it was published under a namespace with', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // The author's copy is at the rules root, so `my-rule` is the name they know. + const result = await runCLI( + ['remove', 'rules', 'my-rule', '--force'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('my-rule was published as fe-know/my-rule'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + // The namespaced team file is gone from the branch, not just the local copy. + expect(files).not.toContain('rules/fe-know/my-rule.md'); + // And the author's own copy went with it, or the next push re-publishes it. + expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + }, 60_000); + it('stops the push when the roles manifest exists but cannot be parsed', async () => { const fixture = track(makeFixture({ agent: 'claude', diff --git a/src/__tests__/push-namespaces.test.ts b/src/__tests__/push-namespaces.test.ts index fa9f2757..29833655 100644 --- a/src/__tests__/push-namespaces.test.ts +++ b/src/__tests__/push-namespaces.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { - isAtSharedRoot, isPlaceableType, resolveProjectNamespace, withNamespace, + isAtSharedRoot, isPlaceableType, placedResourcePath, resolveProjectNamespace, withNamespace, } from '../push-namespaces.js'; import type { ProjectsManifest } from '../projects.js'; import type { ResourceItem } from '../types.js'; @@ -120,3 +120,46 @@ describe('resolveProjectNamespace', () => { expect(result.ok === false && result.message).toMatch(/unknown project/i); }); }); + +/** + * `state.json` is a file on disk, and both the push scanner and the pre-push + * sync resolve a placement record through this one function. When only one of + * them followed the record, the sync skipped a teammate's newer version and + * the scan pushed the stale local copy over it. + */ +describe('placedResourcePath', () => { + const rules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + it('returns the recorded destination', () => { + expect(placedResourcePath(rules, 'rules', 'my-rule')).toBe('rules/fe-know/my-rule.md'); + }); + + it('returns null with no record at all', () => { + expect(placedResourcePath(undefined, 'rules', 'my-rule')).toBeNull(); + expect(placedResourcePath({}, 'rules', 'my-rule')).toBeNull(); + }); + + it('ignores a name that already carries its namespace', () => { + // It matches its team file by full path and never needs a record. + expect(placedResourcePath({ 'fe-know/my-rule': 'rules/fe-know/my-rule.md' }, 'rules', 'fe-know/my-rule')) + .toBeNull(); + }); + + it('rejects a record that escapes the resource root', () => { + expect(placedResourcePath({ x: 'rules/../../etc/passwd' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: '../rules/ns/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'skills/ns/x.md' }, 'rules', 'x')).toBeNull(); + }); + + it('rejects a record that is not namespaced or not named after the resource', () => { + expect(placedResourcePath({ x: 'rules/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/sub/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/other.md' }, 'rules', 'x')).toBeNull(); + }); + + it('resolves an agent record, whose file keeps the canonical .yaml', () => { + expect(placedResourcePath({ vr: 'agents/fe-agents/vr.yaml' }, 'agents', 'vr')) + .toBe('agents/fe-agents/vr.yaml'); + expect(placedResourcePath({ vr: 'agents/fe-agents/vr.yaml' }, 'rules', 'vr')).toBeNull(); + }); +}); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index ec85e48e..cf17c20a 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1178,6 +1178,22 @@ describe('push namespace routing for rules and agents', () => { expect(saved.placedRules).toEqual({ 'my-rule': 'rules/pm/my-rule.md' }); }); + it('records where it placed a new agent, so the author can still edit it', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, []); + + await push({ all: true, role: 'pm' }); + + // AgentsHandler.scanLocalForPush only accepts a source whose namespace is + // ACTIVE here; without the record the author's next edit is skipped as + // "no active source" and the agent they just published is unmaintainable. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; + expect(saved.placedAgents).toEqual({ vr: 'agents/pm/vr.yaml' }); + }); + it('does not record a rule the scanner already found in a subdirectory', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig({ primaryRole: undefined }), diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index 722471ac..7ce2b140 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -4,13 +4,15 @@ import os from 'node:os'; import fse from 'fs-extra'; // Mock external dependencies before importing modules +const mockState: { placedRules?: Record } = {}; vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), - // The rules scanner reads push placements from state.json; none here. - loadStateForScope: vi.fn(async () => ({})), + // The rules scanner and `publishedNameFor` read push placements from + // state.json; tests set `mockState.placedRules` when they need one. + loadStateForScope: vi.fn(async () => mockState), })); vi.mock('../utils/git.js', () => ({ @@ -89,9 +91,63 @@ scope: 'user', afterEach(async () => { vi.unstubAllEnvs(); + delete mockState.placedRules; await fse.remove(tmpDir); }); + /** + * A rule push placed under rules// is published as `/`, but the + * author's own copy never left the rules root, so the name they type is the + * bare one. Answering "not found" about a rule we recorded putting there — or + * deleting rules/.md and leaving the namespaced one published — is the + * finding this covers (#649 review round 3). + */ + describe('a rule published into a namespace', () => { + it('resolves the bare name to the name it was published under', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBe('fe-know/my-rule'); + }); + + it('does not resolve a record whose team file is gone', async () => { + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + }); + + it('does not resolve a rule this machine never placed', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + }); + + it('removes the namespaced team file and the author\'s copy at the rules root', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + // The author's own copy, and the namespaced one a member would receive. + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'my-rule.md'), 'local'); + await fse.outputFile(path.join(homeDir, '.claude', 'rules', 'fe-know', 'my-rule.md'), 'local'); + + const removed = await handler.removeItem('fe-know/my-rule', teamConfig, localConfig); + + expect(await fse.pathExists( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), + )).toBe(false); + // Leaving the root copy behind re-publishes the rule on the next push. + expect(await fse.pathExists(path.join(homeDir, '.claude', 'rules', 'my-rule.md'))).toBe(false); + expect(await fse.pathExists( + path.join(homeDir, '.claude', 'rules', 'fe-know', 'my-rule.md'), + )).toBe(false); + expect(removed.length).toBeGreaterThanOrEqual(3); + }); + }); + it('should remove rule from team repo and all tool directories', async () => { // Create the rule in team repo await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'my-rule.md'), 'rule content'); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 95f90898..0b3a3831 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -410,6 +410,24 @@ scope: 'user', expect(item?.namespace).toBeUndefined(); }); + it('keeps following the record when a shared-root rule with the same name appears later', async () => { + // Another contributor adds rules/my-rule.md after this rule was placed. + // Mapping the author's copy onto it would push their content over an + // unrelated team rule, so the record wins (#649 review round 3). + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'the placed rule'); + await fse.writeFile(path.join(teamRulesDir, 'my-rule.md'), 'somebody else\'s shared rule'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'edited locally'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.relativePath).toBe('rules/fe-know/my-rule.md'); + expect(item?.namespace).toBe('fe-know'); + }); + it('treats a root-level rule as new again once its recorded team file is gone', async () => { // The rule was removed from the team repo (or its namespace renamed): the // record no longer points at anything and must not invent a destination. diff --git a/src/push-namespaces.ts b/src/push-namespaces.ts index 47009a64..7167941d 100644 --- a/src/push-namespaces.ts +++ b/src/push-namespaces.ts @@ -123,3 +123,42 @@ export function resolveProjectNamespace( return { ok: true, namespace }; } + +/** + * Where push put a root-level local resource, from the record it kept in + * `state.json` (`placedRules`, `placedAgents`). The author's copy stays at the + * tool's resource root after a push places it under `//`, so without + * this record the next scan reads it as a brand-new resource and sends a second + * copy to the shared root — where it reaches the whole team (#649). + * + * Returns null unless the record is one push could have written: inside `root`, + * namespaced, free of traversal, and named after `name`. `state.json` is a file + * on disk, so a record that fails any of those is treated as absent rather than + * followed. The caller still has to check that the file is there — a record + * pointing at a removed or renamed resource proves nothing. + * + * Both the push scanner and the pre-push sync resolve through this. They must + * agree: when only one of them followed the record, the sync skipped a + * teammate's newer version and the scan then pushed the stale copy over it. + */ +export function placedResourcePath( + placed: Record | undefined, + root: 'rules' | 'agents', + name: string, +): string | null { + // A name that already carries a namespace matches its team file by full path + // and never needs a record. + if (!placed || name.includes('/')) return null; + + const recorded = placed[name]; + if (!recorded) return null; + + const segments = recorded.split('/'); + if (segments.length !== 3) return null; + if (segments[0] !== root) return null; + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return null; + // `.md` for a rule, `.yaml` (or a legacy `.md`) for an agent. + if (!segments[2].startsWith(`${name}.`)) return null; + + return recorded; +} diff --git a/src/push.ts b/src/push.ts index 76d3077d..a72aee7f 100644 --- a/src/push.ts +++ b/src/push.ts @@ -740,7 +740,7 @@ async function pushCore( // placedRules redirects a root-authored rule to the rules// file push // put it in, so a teammate's newer version syncs down instead of being // overwritten by the stale root copy the scan would otherwise call modified. - await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev, state.placedRules ?? {}); + await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev, state.placedRules); } catch (e) { log.debug(`Pre-push sync skipped: ${(e as Error).message}`); } @@ -1133,6 +1133,12 @@ async function pushCore( if (item.type === 'rules' && item.namespace && !item.name.includes('/')) { state.placedRules = { ...state.placedRules, [item.name]: item.relativePath }; } + // An agent placed in a namespace this directory has not activated would be + // skipped as "no active source" on the author's very next edit. The record + // is what lets them keep maintaining the agent they just published. + if (item.type === 'agents' && item.namespace) { + state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; + } if (item.type === 'env' && !state.pushedEnvVars.includes(item.name)) { state.pushedEnvVars.push(item.name); } diff --git a/src/remove.ts b/src/remove.ts index 4d9b818d..208196dc 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; import { assertNotReadOnly } from './read-only.js'; import { pullRepo, pushRepoBranch, checkoutMaster, generateBranchName } from './utils/git.js'; @@ -47,6 +48,32 @@ export async function remove( await removeCore(type, names, options, localConfig, teamConfig); } +/** + * Drop the placement record of each removed resource. + * + * `remove` accepts both spellings — the published `/` and the bare + * `` the author's own copy carries — and the record is always keyed by + * the bare one, so the key comes from the basename. The recorded path still has + * to match the resource being removed, or removing `other-ns/my-rule` would + * drop the record of a `my-rule` that lives somewhere else entirely. + * + * Existence is deliberately NOT the test: the removal only exists on the push + * branch until its PR merges, and `removeCore` checks the default branch back + * out before this runs, so the file is still on disk at this point. + */ +function dropPlacementRecords( + records: Record | undefined, + removed: string[], + teamPathsFor: (publishedName: string) => string[], +): void { + if (!records) return; + for (const name of removed) { + const key = path.basename(name); + const recorded = records[key]; + if (recorded && teamPathsFor(name).includes(recorded)) delete records[key]; + } +} + async function removeCore( type: string, names: string[], @@ -76,6 +103,16 @@ async function removeCore( for (const name of names) { if (allNames.has(name)) { found.push(name); + continue; + } + // A resource this machine placed in a namespace is published as + // `/`, while the author's local copy — and so the name they type + // — is the bare one. Resolve it rather than answering "not found" about a + // resource we know we put there (#649 review). + const published = await handler.publishedNameFor(name, localConfig); + if (published && allNames.has(published)) { + log.info(`${name} was published as ${published}`); + found.push(published); } else { notFound.push(name); } @@ -196,7 +233,12 @@ async function removeCore( } if (type === 'rules') { state.pushedRules = state.pushedRules.filter((r) => !found.includes(r)); - for (const name of found) delete state.placedRules?.[name]; + // A record left behind would send the author's local copy back to a path + // that is about to stop existing. + dropPlacementRecords(state.placedRules, found, (n) => [`rules/${n}.md`]); + } + if (type === 'agents') { + dropPlacementRecords(state.placedAgents, found, (n) => [`agents/${n}.yaml`, `agents/${n}.md`]); } // `wiki` is not tracked in pushedX state; nothing to clean here. await saveStateForScope(state, localConfig); diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 9c1883ec..a23ffc21 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -10,6 +10,8 @@ import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; import { resolveResourceNamespaces } from '../resource-namespaces.js'; import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; +import { loadStateForScope } from '../config.js'; +import { placedResourcePath } from '../push-namespaces.js'; import { parseAgentYaml, serializeAgentYaml, @@ -141,13 +143,21 @@ export class AgentsHandler extends ResourceHandler { const resolved = await resolveResourceNamespaces(localConfig); const activeNamespaces = resolved?.activeNamespaces.agents ?? null; + // An agent this machine published with --role/--project lives in a + // namespace this directory need not have activated. Without the record it + // would read as "no active source" and the author could never edit the + // agent they just created (#649 review). + const placedAgents = (await loadStateForScope(localConfig)).placedAgents; for (const [stem, toolFiles] of grouped) { // Determine if this agent is already in the team repo (root or agents//). // A modified agent must be written back where it lives, so its namespace // directory is carried into `relativePath` below. const sources = await findTeamAgentFiles(teamAgentsDir, stem); + const placedNamespace = placedResourcePath(placedAgents, 'agents', stem)?.split('/')[1]; const candidates = sources.filter( - (file) => activeNamespaces === null || !file.namespace || activeNamespaces.includes(file.namespace), + (file) => activeNamespaces === null || !file.namespace + || activeNamespaces.includes(file.namespace) + || file.namespace === placedNamespace, ); if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, diff --git a/src/resources/base.ts b/src/resources/base.ts index 0280d997..a3ed1b97 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -94,6 +94,18 @@ export abstract class ResourceHandler { localConfig: LocalConfig, ): Promise; + /** + * The name this resource is published under, when the user typed a different + * one. `remove` matches what the user types against the team repo, where a + * placed resource lives at `//`; the author's local copy is + * still at the resource root, so they know it by its bare name and `remove` + * would answer "not found". Handlers that keep a placement record resolve it + * here. Returns null when there is nothing to translate. + */ + async publishedNameFor(_name: string, _localConfig: LocalConfig): Promise { + return null; + } + /** * Where `item` lands for each tool that can receive it on this machine. * diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 853a990f..0ba26482 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -13,6 +13,7 @@ import { } from './copilot-instructions.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; +import { placedResourcePath } from '../push-namespaces.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -50,7 +51,7 @@ export class RulesHandler extends ResourceHandler { // the local copy back to its team file. A namespaced team rule is pulled // into a namespaced local directory, so a root-level local rule that only // shares a basename with one, and has no record, is unrelated and stays new. - const placedRules = (await loadStateForScope(localConfig)).placedRules ?? {}; + const placedRules = (await loadStateForScope(localConfig)).placedRules; // Collect the best candidate for each rule name across all tool directories const candidates = new Map { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', name, + ); + if (!placed) return null; + if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; + return placed.slice('rules/'.length, -'.md'.length); + } + /** * Remove a rule from the team repo and all local AI tool rules/ directories. + * + * `name` may be the published one (`fe-know/my-rule`) or the bare one the + * author's own copy carries (`my-rule`) — `remove` resolves the first through + * `publishedNameFor`, so both reach the same team file. The local sweep below + * covers both spellings, because a rule placed in a namespace leaves the + * author's copy at the rules root while every other member receives it at + * `rules//`. */ async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const removed: string[] = []; @@ -286,6 +309,11 @@ export class RulesHandler extends ResourceHandler { removed.push(teamFile); } + // The author's own copy is at the rules root under the bare name, whatever + // namespace the team file ended up in. Leaving it behind re-publishes the + // rule on the next push. + const localNames = new Set([name, path.basename(name)]); + // Record tombstone so the resource won't be re-pushed await this.addTombstone(name, localConfig); @@ -299,12 +327,14 @@ export class RulesHandler extends ResourceHandler { if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); const extensions = new Set([ruleFileExtensionForTool(tool), '.md']); - for (const extension of extensions) { - const filePath = path.join(baseDir, toolPath.rules, `${name}${extension}`); - if (await pathExists(filePath)) { - await remove(filePath); - removed.push(filePath); - log.debug(`Removed rule ${name} from ${tool}`); + for (const localName of localNames) { + for (const extension of extensions) { + const filePath = path.join(baseDir, toolPath.rules, `${localName}${extension}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + log.debug(`Removed rule ${localName} from ${tool}`); + } } } } diff --git a/src/types.ts b/src/types.ts index 0e64c49d..ef77c19e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -635,6 +635,16 @@ export const StateSchema = z.object({ * for the same reason as `coAuthorManaged`; absent reads as an empty map. */ placedRules: z.record(z.string(), z.string()).optional(), + /** + * Where push placed each new agent inside the team repo, by agent name, e.g. + * `{ "vr": "agents/fe-agents/vr.yaml" }`. `AgentsHandler.scanLocalForPush` + * only accepts a team source whose namespace this directory has ACTIVE, so + * without this record an author who published an agent with `--role`/ + * `--project` could never edit it again: the file they created reads as + * inactive and the push is skipped. Same shape and same caveats as + * `placedRules`. + */ + placedAgents: z.record(z.string(), z.string()).optional(), pushedSkills: z.array(z.string()).default([]), pushedEnvVars: z.array(z.string()).default([]), /** Push branches whose PR is still open — see PendingPushSchema. */ diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index 92aa9cbb..04661b89 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -19,6 +19,7 @@ import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule- import { teamRuleToCursorMdc, cursorMdcBodyEqualsTeamMd } from '../resources/cursor-mdc.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { log } from './logger.js'; +import { placedResourcePath } from '../push-namespaces.js'; /** * Sync team repo updates to local tool directories BEFORE scanning for push. @@ -47,7 +48,7 @@ export async function syncTeamUpdatesToLocal( teamConfig: TeamaiConfig, localConfig: LocalConfig, lastPullRev: string | null, - placedRules: Record = {}, + placedRules: Record | undefined = undefined, ): Promise { if (!lastPullRev) { log.debug('No lastPullRev — skipping pre-push sync'); @@ -71,7 +72,7 @@ async function syncRulesToLocal( repoPath: string, baseDir: string, lastPullRev: string, - placedRules: Record, + placedRules: Record | undefined, ): Promise { const teamRulesDir = path.join(repoPath, 'rules'); if (!await pathExists(teamRulesDir)) return; @@ -101,19 +102,14 @@ async function syncRulesToLocal( let teamRelPath = `rules/${name}.md`; let teamFilePath = path.join(teamRulesDir, `${name}.md`); - // Same redirect as RulesHandler.scanLocalForPush: a root-level rule this - // machine pushed lives under rules// in the team repo, and both sides - // must compare against that file or the scan reverts a teammate's update. - if (!await pathExists(teamFilePath) && !name.includes('/')) { - // The record comes from state.json on disk; keep it inside rules/. - const placed = placedRules[name]; - if (placed?.startsWith('rules/') && !placed.split('/').includes('..')) { - const placedPath = path.join(repoPath, placed); - if (await pathExists(placedPath)) { - teamRelPath = placed; - teamFilePath = placedPath; - } - } + // Same redirect as RulesHandler.scanLocalForPush, through the same + // resolver: a root-level rule this machine pushed lives under rules// + // in the team repo, and both sides must compare against that file or the + // scan reverts a teammate's update. + const placed = placedResourcePath(placedRules, 'rules', name); + if (placed && await pathExists(path.join(repoPath, placed))) { + teamRelPath = placed; + teamFilePath = path.join(repoPath, placed); } // Only process files that exist in both places but differ From 0afa9dc1815cb371aadc58cac79efd895fdfba8d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 13:20:07 +0200 Subject: [PATCH 06/25] fix(push): narrow what a placement record grants, and when it is written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, each about the record rather than the placement. `remove` consulted it only after a bare-name match failed, but the LOCAL scan contributes the bare name whenever the author's own copy has edits — so `remove rules my-rule` deleted that copy, reported success, and left `rules//my-rule.md` published. The record is now resolved first. A record is written only for a resource push actually placed: `new`, and namespaced by this run. Recording a `modified` agent meant a namespace that happened to be active at edit time became standing permission to keep editing that agent long after the role or project granting it was dropped. Records are persisted per group, right after that group reaches the remote, instead of after every group completes. A failing later group returned early and took the earlier group's mapping with it, so a resource that WAS pushed came back misclassified once its PR merged. And the roles manifest is held to the same rule as `--role` and the projects manifest: a namespace is one path segment. `foo/bar` wrote an agent below the depth pull looks at, and read back as namespace `foo` for a rule. --- src/__tests__/push-namespace-e2e.test.ts | 40 ++++++++++++ src/__tests__/push-role.test.ts | 77 ++++++++++++++++++++++++ src/push.ts | 60 +++++++++++++++--- src/remove.ts | 18 +++--- 4 files changed, 177 insertions(+), 18 deletions(-) diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 61b75020..873cbf4a 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -375,6 +375,46 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { .toContain('You review twice.'); }, 60_000); + it('removes the published rule even when the author\'s root copy has local edits', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // With edits, the LOCAL scan contributes the bare name too. Taking that + // match deletes the local copy, reports success, and leaves the namespaced + // team file published — the author believes the rule is gone (#649 review). + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Edited\n'); + const result = await runCLI( + ['remove', 'rules', 'my-rule', '--force'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('my-rule was published as fe-know/my-rule'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).not.toContain('rules/fe-know/my-rule.md'); + expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); + }, 60_000); + + it('refuses a roles manifest namespace that is not a single path segment', async () => { + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: ROLES_MANIFEST.replace('knowledge: [be-know]', 'knowledge: [foo/bar]'), + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + // Two levels is a depth pull never looks at for agents, and reads back as + // the wrong namespace for a rule. + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('foo/bar'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + it('removes a rule by the bare name it was published under a namespace with', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); writeLocalResources(fixture); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index cf17c20a..d40b21f8 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1194,6 +1194,83 @@ describe('push namespace routing for rules and agents', () => { expect(saved.placedAgents).toEqual({ vr: 'agents/pm/vr.yaml' }); }); + it('does not record an agent it merely edited in an already-active namespace', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // The scanner found this agent in a namespace that is active HERE, so it + // will find it again. Recording it would turn a temporary activation into + // standing permission to keep editing it after the role or project that + // granted it is dropped (#649 review round 4). + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/hai/vr.yaml', status: 'modified', namespace: 'hai', + }], + }, []); + + await push({ all: true }); + + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; + expect(saved.placedAgents ?? {}).toEqual({}); + }); + + it('refuses a roles manifest whose namespace is not a single path segment', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['foo/bar'], skills: ['solo'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // `--role` and the projects manifest are both checked for this; two levels + // put an agent below the depth pull looks at, and read back as the wrong + // namespace for a rule. + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('foo/bar'); + }); + + it('keeps the placement of a group that pushed when a later group fails', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // Two groups: the rule belongs to an open PR, the agent does not. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/9', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + // First group pushes, second throws. + mockPushRepoBranch.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('remote rejected')); + + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(1); + // The rule is on the remote now. Losing where it went means the author's + // root copy is reclassified once that PR merges. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; + expect(saved.placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + }); + it('does not record a rule the scanner already found in a subdirectory', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig({ primaryRole: undefined }), diff --git a/src/push.ts b/src/push.ts index a72aee7f..f3d0b690 100644 --- a/src/push.ts +++ b/src/push.ts @@ -23,6 +23,7 @@ import { acquireLock, releaseLock } from './update.js'; import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; import { loadRolesManifest, resolveRoleResourceNamespaces, RolesManifestNotFoundError } from './roles.js'; import type { ProjectsManifest } from './projects.js'; +import { isSafeNamespaceSegment } from './projects.js'; import { isAtSharedRoot, isPlaceableType, NAMESPACE_AXIS, PLACEABLE_TYPES, resolveProjectNamespace, skillNamespacePath, withNamespace, type PlaceableType, @@ -95,7 +96,22 @@ async function namespaceCandidates( primaryRole: localConfig.primaryRole, additionalRoles: localConfig.additionalRoles ?? [], }); - return { ok: true, candidates: namespaces[NAMESPACE_AXIS[type]] }; + const axis = NAMESPACE_AXIS[type]; + const candidates = namespaces[axis]; + // A namespace is ONE directory under the resource root. `--role` and the + // projects manifest are both checked for that; the roles manifest was + // not, so `foo/bar` went through and pushed an agent to a depth `pull` + // never looks at, and a rule whose namespace then read back as `foo`. + const unsafe = candidates.find((namespace) => !isSafeNamespaceSegment(namespace)); + if (unsafe !== undefined) { + return { + ok: false, + message: `The roles manifest declares an unusable ${axis} namespace "${unsafe}": ` + + "it must be a single path segment (letters, digits, '.', '_', '-'; no '/', '\\', or '..'). " + + 'Fix manifest/roles.yaml, or pass --role to name the namespace for this push.', + }; + } + return { ok: true, candidates }; } catch (e) { if (!(e instanceof RolesManifestNotFoundError)) { return { @@ -263,6 +279,34 @@ export { createPrWithFallback }; */ type PushGroupOutcome = 'pushed' | 'nochange' | 'pr-failed' | 'failed'; +/** + * Remember where push put each resource it PLACED, so the author can keep + * maintaining it. Their own copy stays at the tool's resource root, and the + * team file is now under `//`: the scanner needs the record to + * recognise the two as the same resource (`RulesHandler.scanLocalForPush`), + * and `AgentsHandler.scanLocalForPush` needs it to accept a source whose + * namespace this directory has not activated. + * + * Only `new` items are recorded, and only ones that ended up namespaced. A + * `modified` resource was found in its namespace by the scanner, which means + * that namespace is active here and the scan will find it again; recording it + * would turn a temporary activation into standing permission to keep editing + * an agent long after the role or project that granted it was dropped. + */ +function recordPlacements(state: State, items: ResourceItem[]): void { + for (const item of items) { + if (item.status !== 'new' || !item.namespace) continue; + // A rule the scanner already found in a subdirectory carries the namespace + // in its name and matches by full path, so it needs no record. + if (item.type === 'rules' && !item.name.includes('/')) { + state.placedRules = { ...state.placedRules, [item.name]: item.relativePath }; + } + if (item.type === 'agents') { + state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; + } + } +} + /** * Give every resource waiting in an open PR back the destination that PR * recorded, rather than asking again — a different answer would silently move @@ -1111,6 +1155,11 @@ async function pushCore( process.exitCode = 1; return; } + // Per group, and before the early return above can skip it: this group's + // resources are on the remote now, so where they went has to be recorded + // even if a later group fails. Recorded after the push rather than before, + // because a rolled-back group placed nothing. + recordPlacements(pushState, group.items); if (outcome === 'pushed') anyPushed = true; if (outcome === 'pr-failed') anyPrFailed = true; configRider = false; @@ -1130,15 +1179,6 @@ async function pushCore( // at the tool's rules root, so the scanner needs this record to recognise // it next time (RulesHandler.scanLocalForPush). A rule the scanner already // found in a subdirectory carries the namespace in its name and needs none. - if (item.type === 'rules' && item.namespace && !item.name.includes('/')) { - state.placedRules = { ...state.placedRules, [item.name]: item.relativePath }; - } - // An agent placed in a namespace this directory has not activated would be - // skipped as "no active source" on the author's very next edit. The record - // is what lets them keep maintaining the agent they just published. - if (item.type === 'agents' && item.namespace) { - state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; - } if (item.type === 'env' && !state.pushedEnvVars.includes(item.name)) { state.pushedEnvVars.push(item.name); } diff --git a/src/remove.ts b/src/remove.ts index 208196dc..20c74190 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -101,18 +101,20 @@ async function removeCore( const found: string[] = []; const notFound: string[] = []; for (const name of names) { - if (allNames.has(name)) { - found.push(name); - continue; - } - // A resource this machine placed in a namespace is published as - // `/`, while the author's local copy — and so the name they type - // — is the bare one. Resolve it rather than answering "not found" about a - // resource we know we put there (#649 review). + // The placement record is consulted FIRST. A resource this machine placed + // in a namespace is published as `/`, while the author's local + // copy — and so the name they type — is the bare one; and the LOCAL scan + // contributes that bare name whenever their copy has edits. Taking the + // bare match would delete the local copy, report success, and leave the + // namespaced team file published (#649 review). const published = await handler.publishedNameFor(name, localConfig); if (published && allNames.has(published)) { log.info(`${name} was published as ${published}`); found.push(published); + continue; + } + if (allNames.has(name)) { + found.push(name); } else { notFound.push(name); } From 9d1295b22dcabf0f9106e6954bbdfb525efd4255 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 13:39:48 +0200 Subject: [PATCH 07/25] fix(push): let --role/--project decide which team agent a local edit belongs to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AgentsHandler.scanLocalForPush` picked the team file to edit by activity alone, and it runs before the destination is resolved. So `agents/other-ns/vr.yaml` — an agent this directory never activates — made `push --project front-app` report "no active source" and push nothing, even though the same stem is allowed to exist in several namespaces and the flag had named a different one. The scan now takes the requested namespace, through a new optional `ScanForPushOptions`; with one named, sources in other namespaces are other agents, and an absent one means this agent is new there. A shared-root copy still blocks, and now says why: both would be active at once, which is the collision pull reports. `RulesHandler.removeItem` swept the bare basename unconditionally, so `remove rules fe/foo` deleted an unrelated personal .claude/rules/foo.md. The bare copy is only ours to delete when this machine's placement record says the two are the same rule. `teamai push --help` said both flags target skills. --- docs/usage-guide.md | 1 + docs/usage-guide.zh-CN.md | 1 + src/__tests__/agents.test.ts | 49 ++++++++++++++++++++++++ src/__tests__/push-namespace-e2e.test.ts | 27 +++++++++++++ src/__tests__/remove.test.ts | 26 +++++++++++++ src/index.ts | 5 ++- src/push.ts | 19 ++++++++- src/resources/agents.ts | 44 +++++++++++++++++---- src/resources/base.ts | 13 +++++++ src/resources/rules.ts | 13 ++++++- 10 files changed, 185 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c9a4bbe8..cb14eff6 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -601,6 +601,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses - A placed resource stays maintainable from the machine that published it. `state.json` records where push put each one, so a later edit of the author's own copy goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root +- With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index ead53912..bf4cf220 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -578,6 +578,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 - 已落点的资源在发布它的机器上仍可维护:`state.json` 会记录 push 的落点,因此作者修改自己的副本后仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 +- 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index aa520160..1e82de95 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -306,6 +306,55 @@ projects: expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); }); + /** + * The layout allows the same stem in several namespaces. An explicit + * --role/--project names the destination, so a copy in some OTHER namespace + * is a different agent and must not block publishing this one — which is + * what filtering on activity alone did (#649 review). + */ + it('publishes into the requested namespace despite a stem in an inactive one', async () => { + await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nYou review the front end.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + // New at the shared root; placement then writes it to the requested namespace. + expect(items[0]?.status).toBe('new'); + }); + + it('edits the copy in the requested namespace rather than treating it as new', async () => { + await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); + const requested = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(requested, 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + }); + + it('refuses to publish a namespaced second copy beside a shared-root agent', async () => { + // The root copy reaches every member, so both would be active at once — + // the collision pull reports and skips. + await fse.outputFile(path.join(repoPath, 'agents/reviewer.yaml'), + 'name: reviewer\ndescription: Shared\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Shared\n---\n\nEdited locally.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('shared root'); + }); + it('still skips an inactive agent this machine never published', async () => { await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 873cbf4a..b8625342 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -398,6 +398,33 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); }, 60_000); + it('publishes an agent into the requested namespace despite the same stem elsewhere', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // A team agent of the same name in a namespace this directory never + // activates. Without the requested destination taking part in candidate + // selection, it blocks the push entirely with "no active source". + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s reviewer\ninstructions: Read other-ns.\n'); + fs.writeFileSync( + path.join(fixture.projectRoot, '.claude/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review the front end.\n', + ); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).not.toContain('no active source'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('agents/fe-agents/vr.yaml'); + // The other namespace's agent is a different agent, and stays untouched. + expect(git(['show', `${branch}:agents/other-ns/vr.yaml`], fixture.remote)) + .toContain('Read other-ns.'); + }, 60_000); + it('refuses a roles manifest namespace that is not a single path segment', async () => { const fixture = track(makeFixture({ agent: 'claude', diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index 7ce2b140..2598626a 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -126,10 +126,36 @@ scope: 'user', expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); }); + it('leaves an unrelated root rule that only shares the basename', async () => { + // `remove rules fe/foo` must not take a personal .claude/rules/foo.md + // with it. Nothing on this machine says the two are the same rule. + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'my own foo'); + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'))).toBe(false); + expect(await fse.readFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'utf-8')) + .toBe('my own foo'); + }); + + it('ignores a record that points somewhere else', async () => { + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'my own foo'); + mockState.placedRules = { foo: 'rules/other-ns/foo.md' }; + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + expect(await fse.readFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'utf-8')) + .toBe('my own foo'); + }); + it('removes the namespaced team file and the author\'s copy at the rules root', async () => { await fse.outputFile( path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', ); + // The record is what makes the root copy this rule's, and ours to delete. + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; // The author's own copy, and the namespaced one a member would receive. await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'my-rule.md'), 'local'); await fse.outputFile(path.join(homeDir, '.claude', 'rules', 'fe-know', 'my-rule.md'), 'local'); diff --git a/src/index.ts b/src/index.ts index 55659204..87ef267d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,8 +99,9 @@ program .description('Push local resources to team repo') .option('--all', 'Push all without confirmation') .option('--skill ', 'Push a specific skill by path (e.g., ~/.claude/skills/hai/my-skill or skills/hai_dev/my-skill)') - .option('--role ', 'Target role namespace for pushed project skills') - .option('--project ', 'Target a project: push skills into the project\'s skills namespace (from manifest/projects.yaml)') + .option('--role ', 'Namespace for new skills, rules and agents (skills//, rules//, agents//)') + .option('--project ', "Target a project: each new resource goes to that project's namespace for its own type " + + '— skills, knowledge for rules, agents (from manifest/projects.yaml)') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { push } = await import('./push.js'); diff --git a/src/push.ts b/src/push.ts index f3d0b690..331f9dc8 100644 --- a/src/push.ts +++ b/src/push.ts @@ -820,9 +820,26 @@ async function pushCore( const pushableTypes: ResourceType[] = ['skills', 'rules', 'env', 'agents']; const fullScan: ResourceItem[] = []; + // Agents are the one type whose SCAN needs the destination: it has to tell + // "an edit of the team's copy" from "a new agent for this namespace", and an + // explicit --role/--project is what answers that. Rules and skills are placed + // after selection, so their scan needs nothing. An unsafe --role resolves to + // no candidate here and is rejected with exit 2 before anything is pushed. + let requestedAgentsNamespace: string | undefined; + if (options.role) { + requestedAgentsNamespace = options.role; + } else if (options.project && projectsManifest) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, 'agents'); + if (resolved.ok) requestedAgentsNamespace = resolved.namespace; + } + for (const type of pushableTypes) { const handler = getHandler(type); - const items = await handler.scanLocalForPush(scanTeamConfig, localConfig); + const items = await handler.scanLocalForPush( + scanTeamConfig, + localConfig, + type === 'agents' ? { namespace: requestedAgentsNamespace } : undefined, + ); fullScan.push(...items); } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index a23ffc21..83a19d85 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { parse as parseYaml } from 'yaml'; -import { isToolInstalledForConfig, ResourceHandler } from './base.js'; +import { isToolInstalledForConfig, ResourceHandler, type ScanForPushOptions } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFiles, listDirs, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, writeFile, readFileSafe } from '../utils/fs.js'; import { log } from '../utils/logger.js'; @@ -63,7 +63,12 @@ export class AgentsHandler extends ResourceHandler { * New format (.yaml in team repo): attempts multi-tool reverse + merge. * Built-in CLI agents are excluded from push. */ - async scanLocalForPush(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + async scanLocalForPush( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + options?: ScanForPushOptions, + ): Promise { + const requestedNamespace = options?.namespace; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); const tombstones = await this.readTombstones(localConfig); // Single-repo mode: users drop canonical agent files straight into the repo's @@ -154,18 +159,41 @@ export class AgentsHandler extends ResourceHandler { // directory is carried into `relativePath` below. const sources = await findTeamAgentFiles(teamAgentsDir, stem); const placedNamespace = placedResourcePath(placedAgents, 'agents', stem)?.split('/')[1]; - const candidates = sources.filter( - (file) => activeNamespaces === null || !file.namespace - || activeNamespaces.includes(file.namespace) - || file.namespace === placedNamespace, - ); + // An explicit --role/--project names the destination, so IT decides which + // team file this local agent is an edit of. A copy of the same stem in + // another namespace is a different agent — the layout allows that — and + // must not block publishing this one, which is what activity filtering + // alone did (#649 review). + const candidates = requestedNamespace + ? sources.filter((file) => file.namespace === requestedNamespace) + : sources.filter( + (file) => activeNamespaces === null || !file.namespace + || activeNamespaces.includes(file.namespace) + || file.namespace === placedNamespace, + ); if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', skipReason: `Ambiguous agent "${stem}": multiple active sources (${candidates.map((file) => file.path).join(', ')}). Give active agents unique names before pushing.` }); continue; } - if (sources.length > 0 && candidates.length === 0) { + // A shared-root copy reaches every member, so a namespaced second copy + // would leave two active agents answering to the same name — exactly the + // collision pull reports and skips. Only reachable with an explicit + // destination; without one the root copy is always a candidate. + const sharedRoot = candidates.length === 0 && sources.find((file) => !file.namespace); + if (sharedRoot) { + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: `agents/${stem}.yaml`, status: 'modified', + skipReason: `Agent "${stem}" already exists at the shared root (agents/${stem}${sharedRoot.ext}), ` + + `which every member receives. Edit that copy, or rename this agent, rather than publishing ` + + `a second one into "${requestedNamespace}".` }); + continue; + } + // With no destination named, a stem that exists only in namespaces this + // directory has not activated is not ours to edit. With one named, the + // agent is simply new there, and placement writes it to that namespace. + if (!requestedNamespace && sources.length > 0 && candidates.length === 0) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', skipReason: `Agent "${stem}" has no active source. Activate its role or project before pushing local edits.` }); diff --git a/src/resources/base.ts b/src/resources/base.ts index a3ed1b97..dec681ae 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -45,6 +45,18 @@ export async function isToolInstalledForConfig( * Abstract base class for resource handlers. * Each resource type (skills, rules, docs, env, agents, hooks, mcp) implements this. */ +/** + * What `push` knows before it scans. Only the destination an explicit + * `--role`/`--project` names, and only agents read it: their scan has to + * decide which team file a local edit is an edit OF, and that answer changes + * when the user has named a namespace (see `AgentsHandler.scanLocalForPush`). + * Rules and skills are placed after selection, so their scan needs nothing. + */ +export interface ScanForPushOptions { + /** The namespace `--role ` / `--project ` resolved to, if any. */ + namespace?: string; +} + export abstract class ResourceHandler { abstract readonly type: ResourceType; @@ -55,6 +67,7 @@ export abstract class ResourceHandler { abstract scanLocalForPush( teamConfig: TeamaiConfig, localConfig: LocalConfig, + options?: ScanForPushOptions, ): Promise; /** diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 0ba26482..c4909e21 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -311,8 +311,17 @@ export class RulesHandler extends ResourceHandler { // The author's own copy is at the rules root under the bare name, whatever // namespace the team file ended up in. Leaving it behind re-publishes the - // rule on the next push. - const localNames = new Set([name, path.basename(name)]); + // rule on the next push — but only THIS machine's placement record makes + // that copy ours to delete. Without it, `remove rules fe/foo` would take an + // unrelated personal .claude/rules/foo.md with it (#649 review). + const localNames = new Set([name]); + const bareName = path.basename(name); + if (bareName !== name) { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, + ); + if (placed === `rules/${name}.md`) localNames.add(bareName); + } // Record tombstone so the resource won't be re-pushed await this.addTombstone(name, localConfig); From dcb6291732051b31ac5efa65f2eda7dd885a63fd Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 13:54:31 +0200 Subject: [PATCH 08/25] fix(push): never place a new resource onto one that is already there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement rewrote a new root-level resource to the resolved namespace without looking at what was at that path. An unrelated local `foo.md` — which the scanner rightly calls new, since no record maps it anywhere — landed on `rules//foo.md` and replaced somebody else's rule, silently, in a run they never reviewed. Push now stops and names the file. The same guard covers the `--role`/`--project` skills override, for new skills only: a modified one is meant to land on its own directory. `loadRolesManifest` read through `readFileSafe`, which answers null for every failure, so a manifest that exists but cannot be read arrived looking exactly like a missing one — and a missing one is the pre-manifest layout, which sends new rules and agents to the shared root. The two are told apart now. `RulesHandler.removeItem` tombstoned only the name it was given. Removing through a placement record means the author's source is named `` while the published file is `/`, and the local sweep skips excluded tools, so a root copy could outlive the removal there and come back on the next push. Both names are tombstoned when the record vouches for the bare one. --- docs/usage-guide.md | 1 + docs/usage-guide.zh-CN.md | 1 + src/__tests__/push-namespace-e2e.test.ts | 16 +++++ src/__tests__/push-role.test.ts | 86 ++++++++++++++++++++++++ src/__tests__/remove.test.ts | 31 +++++++++ src/__tests__/roles.test.ts | 35 ++++++++++ src/push.ts | 30 ++++++++- src/resources/rules.ts | 10 ++- src/roles.ts | 13 +++- 9 files changed, 217 insertions(+), 6 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cb14eff6..17a3690e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -602,6 +602,7 @@ Choose namespace [1-3] (default: 1 = common): - A placed resource stays maintainable from the machine that published it. `state.json` records where push put each one, so a later edit of the author's own copy goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once +- A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index bf4cf220..44160df4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -579,6 +579,7 @@ Choose namespace [1-3] (default: 1 = common): - 已落点的资源在发布它的机器上仍可维护:`state.json` 会记录 push 的落点,因此作者修改自己的副本后仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 +- 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index b8625342..4b881ca5 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -425,6 +425,22 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { .toContain('Read other-ns.'); }, 60_000); + it('refuses to place a new rule onto a team rule that already holds the name', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // The destination the author's role resolves to is already taken by + // somebody else's rule. Theirs is NEW here — no record maps it to anything + // — so placing it there would replace that file in a run nobody reviewed. + commitOnMain(fixture, 'rules/be-know/foo.md', '# The team rule\n'); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'foo.md'), '# My own foo\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('rules/be-know/foo.md already exists'); + expect(branchFiles(fixture).branch).toBe(''); + expect(git(['show', 'main:rules/be-know/foo.md'], fixture.remote)).toContain('The team rule'); + }, 60_000); + it('refuses a roles manifest namespace that is not a single path segment', async () => { const fixture = track(makeFixture({ agent: 'claude', diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index d40b21f8..68e6c698 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { push } from '../push.js'; import { RolesManifestNotFoundError } from '../roles.js'; @@ -1271,6 +1274,89 @@ describe('push namespace routing for rules and agents', () => { expect(saved.placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); }); + it('refuses to place a new rule onto an existing team file', async () => { + // The scanner correctly calls an unrelated root rule NEW — no record maps + // it to anything. Placing it on a namespace that already holds that name + // replaces somebody else's rule, silently, in a run they never reviewed. + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'rules', 'pm'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'rules/pm', 'my-rule.md'), 'the team rule'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(repoDir, 'rules/pm', 'my-rule.md'), 'utf-8')) + .toBe('the team rule'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('rules/pm/my-rule.md'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('refuses to place a new skill onto an existing team skill', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'skills', 'pm', 'skill-a'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'the team skill'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(fs.readFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'utf-8')) + .toBe('the team skill'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('still lets a MODIFIED skill land on its own existing directory', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'skills', 'pm', 'skill-a'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'the team skill'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/pm/skill-a', status: 'modified', namespace: 'pm' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('skills/pm/skill-a'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it('does not record a rule the scanner already found in a subdirectory', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig({ primaryRole: undefined }), diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index 2598626a..c990627f 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -126,6 +126,37 @@ scope: 'user', expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); }); + it('tombstones the bare name too, so a copy the sweep skipped cannot come back', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + await handler.removeItem('fe-know/my-rule', teamConfig, localConfig); + + // The local sweep skips excluded tools, so a root copy can outlive the + // removal there — and the scan calls it `my-rule`, which the published + // tombstone would not match. + const tombstones = await fse.readFile( + path.join(localConfig.repo.localPath, 'rules', '.removed'), 'utf-8', + ); + expect(tombstones.split('\n')).toContain('fe-know/my-rule'); + expect(tombstones.split('\n')).toContain('my-rule'); + }); + + it('tombstones only the name given when no record vouches for the bare one', async () => { + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + const tombstones = await fse.readFile( + path.join(localConfig.repo.localPath, 'rules', '.removed'), 'utf-8', + ); + expect(tombstones.split('\n')).toContain('fe/foo'); + // Tombstoning `foo` would suppress an unrelated personal rule of that name. + expect(tombstones.split('\n')).not.toContain('foo'); + }); + it('leaves an unrelated root rule that only shares the basename', async () => { // `remove rules fe/foo` must not take a personal .claude/rules/foo.md // with it. Nothing on this machine says the two are the same rule. diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts index f6b33a2c..a17d5411 100644 --- a/src/__tests__/roles.test.ts +++ b/src/__tests__/roles.test.ts @@ -11,6 +11,7 @@ import { resolveRoleResourceNamespaces, activeRoleIds, loadRolesManifestIfPresent, + RolesManifestNotFoundError, } from '../roles.js'; import type { RolesManifest } from '../roles.js'; @@ -23,6 +24,40 @@ describe('loadRolesManifest', () => { return repoDir; } + /** + * `push` treats a MISSING manifest as the pre-manifest layout, where a role + * id doubles as its skills namespace and a new rule or agent stays at the + * shared root. `readFileSafe` answers null for every failure, so an + * unreadable manifest arrived looking exactly like a missing one — and sent + * those resources to the whole team (#649 review). + */ + it('reports an existing manifest it cannot read, rather than a missing one', async () => { + const repoDir = writeManifest('version: 1\nroles: []\n'); + const manifestPath = path.join(repoDir, 'manifest', 'roles.yaml'); + chmodSync(manifestPath, 0o000); + let unreadable = true; + try { + readFileSync(manifestPath, 'utf-8'); + unreadable = false; // running as root: the mode does not stop the read + } catch { /* expected */ } + + if (unreadable) { + await expect(loadRolesManifest(repoDir)).rejects.toThrow(/could not be read/); + await expect(loadRolesManifest(repoDir)).rejects.not.toBeInstanceOf(RolesManifestNotFoundError); + } + + chmodSync(manifestPath, 0o600); + rmSync(repoDir, { recursive: true, force: true }); + }); + + it('reports a genuinely missing manifest as not found', async () => { + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-roles-')); + + await expect(loadRolesManifest(repoDir)).rejects.toBeInstanceOf(RolesManifestNotFoundError); + + rmSync(repoDir, { recursive: true, force: true }); + }); + it('parses a valid manifest (with legacy learnings + shareTarget)', async () => { // Old manifests with learnings and shareTarget should still parse without error const repoDir = writeManifest(` diff --git a/src/push.ts b/src/push.ts index 331f9dc8..4855a372 100644 --- a/src/push.ts +++ b/src/push.ts @@ -393,8 +393,22 @@ async function placeNewResources(args: { continue; case 'namespace': for (const item of newAtRoot) { + const placedAt = withNamespace(item.relativePath, destination.namespace); + // This resource is NEW here, so nothing of ours is at that path yet. + // Anything already there is somebody else's, and `pushItem` writes + // rather than merges: placing on top of it would replace their work + // with ours, silently, in a run they never reviewed. + if (await pathExists(path.join(localConfig.repo.localPath, placedAt))) { + log.error( + `[${type}] ${item.name} cannot be placed: ${placedAt} already exists in the team repo, ` + + `and this is a new ${type.slice(0, -1)}, so pushing it there would overwrite that copy. ` + + 'Pull and edit the existing one, rename yours, or pass --role to choose another namespace.', + ); + process.exitCode = 2; + return false; + } item.namespace = destination.namespace; - item.relativePath = withNamespace(item.relativePath, destination.namespace); + item.relativePath = placedAt; // The silent widening in #649 was the real damage: say where it went. log.info(`[${type}] ${item.name} → ${item.relativePath}`); } @@ -1031,8 +1045,20 @@ async function pushCore( } for (const item of allItems) { if (item.type !== 'skills') continue; + const placedAt = skillNamespacePath(skillsDestination, item.name); + // Same rule as step 4: a MODIFIED skill is meant to land on its own + // existing directory, a new one must never land on somebody else's. + if (item.status === 'new' && await pathExists(path.join(localConfig.repo.localPath, placedAt))) { + log.error( + `[skills] ${item.name} cannot be placed: ${placedAt} already exists in the team repo, ` + + 'and this is a new skill, so pushing it there would overwrite that copy. ' + + 'Pull and edit the existing one, rename yours, or pass --role to choose another namespace.', + ); + process.exitCode = 2; + return; + } item.namespace = skillsDestination; - item.relativePath = skillNamespacePath(skillsDestination, item.name); + item.relativePath = placedAt; } } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index c4909e21..4f8f4e66 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -323,8 +323,14 @@ export class RulesHandler extends ResourceHandler { if (placed === `rules/${name}.md`) localNames.add(bareName); } - // Record tombstone so the resource won't be re-pushed - await this.addTombstone(name, localConfig); + // Record a tombstone so the resource won't be re-pushed. The bare name gets + // one too whenever the record above proved it is this rule: the local sweep + // below skips excluded tools, so a root copy can outlive the removal there, + // and the scan names it `` — which the published tombstone would not + // match (#649 review). + for (const tombstoned of localNames) { + await this.addTombstone(tombstoned, localConfig); + } // Remove from each tool's rules directory. `.mdc` tools may have an older // teamai layout wrote `.md` there, so both are removed — otherwise `remove` diff --git a/src/roles.ts b/src/roles.ts index 9dab426c..d7406e7e 100644 --- a/src/roles.ts +++ b/src/roles.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import YAML from 'yaml'; import { z } from 'zod'; -import { readFileSafe, readFileIfExists, ensureDir, writeFile } from './utils/fs.js'; +import { readFileSafe, readFileIfExists, ensureDir, pathExists, writeFile } from './utils/fs.js'; const ROLE_RESOURCE_TYPES = ['knowledge', 'skills', 'agents'] as const; @@ -108,7 +108,16 @@ export class RolesManifestNotFoundError extends Error { export async function loadRolesManifest(repoPath: string): Promise { const manifestPath = path.join(repoPath, 'manifest', 'roles.yaml'); const content = await readFileSafe(manifestPath); - if (!content) { + if (content === null) { + // `readFileSafe` answers null for EVERY failure, so "no such file" and + // "cannot read it" arrive identically. Only the first is the pre-manifest + // layout; treating a permission error as that one sends new rules and + // agents to the shared root, which is the whole team (#649). + if (await pathExists(manifestPath)) { + throw new Error( + `Roles manifest exists but could not be read: ${manifestPath}. Check its permissions.`, + ); + } throw new RolesManifestNotFoundError(manifestPath); } From 9df289897fb2ebfb8ad6bcc35bcfa91af9211b21 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 14:17:11 +0200 Subject: [PATCH 09/25] fix(push): resolve a placed agent on removal, and collide on either extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `remove` asked every handler for the published name, but only rules answered. So `teamai remove agents vr` matched the bare stem and deleted every `vr` in every namespace — other people's agents included — while the namespaced `placedAgents` record, keyed by a path the removal never named, survived. `AgentsHandler.publishedNameFor` resolves it now; the existing sweep already narrows a `/` to one file, since the root directory is one of the directories it probes. The bare stem is tombstoned alongside the published one and swept from the tool directories, the same way rules are. The placement collision check tested the proposed path alone. `pull` reads a legacy `.md` as the same agent as `.yaml`, so a new `.md` landing beside an existing `.yaml` passed the check and left two copies answering to one name. Agents are now checked under both canonical extensions. --- src/__tests__/agents.test.ts | 50 ++++++++++++++++++++++++++++++++ src/__tests__/push-role.test.ts | 30 +++++++++++++++++++ src/__tests__/roles.test.ts | 17 +++++------ src/push.ts | 27 +++++++++++++++-- src/resources/agents.ts | 51 +++++++++++++++++++++++++++------ 5 files changed, 154 insertions(+), 21 deletions(-) diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 1e82de95..795c1493 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -312,6 +312,56 @@ projects: * is a different agent and must not block publishing this one — which is * what filtering on activity alone did (#649 review). */ + /** + * `remove agents vr` matched the bare stem and deleted every `vr` in every + * namespace, other people's agents included, and left the namespaced record + * behind (#649 review). + */ + it('resolves the bare agent name to the namespace push recorded for it', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), + 'name: vr\ndescription: Mine\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.publishedNameFor('vr', localConfig)).toBe('fe/vr'); + }); + + it('does not resolve an agent record whose team file is gone', async () => { + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.publishedNameFor('vr', localConfig)).toBeNull(); + }); + + it('removes only the named namespace, leaving the same stem elsewhere', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Mine\ninstructions: A.\n'); + await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: Theirs\ninstructions: B.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '# the author copy'); + + await handler.removeItem('fe/vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + // Somebody else's agent of the same name is not ours to delete. + expect(await fse.readFile(path.join(repoPath, 'agents/other/vr.yaml'), 'utf-8')).toContain('Theirs'); + // The author's own copy is at the agents root under the bare stem. + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/vr.md'))).toBe(false); + const tombstones = await fse.readFile(path.join(repoPath, 'agents', '.removed'), 'utf-8'); + expect(tombstones.split('\n')).toContain('fe/vr'); + expect(tombstones.split('\n')).toContain('vr'); + }); + + it('still removes a bare stem from every namespace', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: B\ninstructions: B.\n'); + + await handler.removeItem('vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + expect(await fse.pathExists(path.join(repoPath, 'agents/other/vr.yaml'))).toBe(false); + }); + it('publishes into the requested namespace despite a stem in an inactive one', async () => { await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 68e6c698..3c3dd27b 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1305,6 +1305,36 @@ describe('push namespace routing for rules and agents', () => { } }); + it('refuses to place a new .md agent beside an existing .yaml of the same stem', async () => { + // pull reads a legacy .md as the same agent as its .yaml, so both copies + // would be active at once — the ambiguity pull reports and skips. + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'agents', 'pm'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'agents/pm', 'vr.yaml'), 'name: vr\ndescription: team\ninstructions: x\n'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + agents: [{ name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', relativePath: 'agents/vr.md', status: 'new' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(fs.existsSync(path.join(repoDir, 'agents/pm', 'vr.md'))).toBe(false); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents/pm/vr.yaml'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + it('refuses to place a new skill onto an existing team skill', async () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); fs.mkdirSync(path.join(repoDir, 'skills', 'pm', 'skill-a'), { recursive: true }); diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts index a17d5411..afac3a57 100644 --- a/src/__tests__/roles.test.ts +++ b/src/__tests__/roles.test.ts @@ -31,23 +31,20 @@ describe('loadRolesManifest', () => { * unreadable manifest arrived looking exactly like a missing one — and sent * those resources to the whole team (#649 review). */ - it('reports an existing manifest it cannot read, rather than a missing one', async () => { + // chmod 0o000 has no effect when running as root (CI), so skip — same gate + // as `src/__tests__/git-kind-learnings.test.ts` (#727). + it.skipIf(process.getuid?.() === 0)('reports an existing manifest it cannot read, rather than a missing one', async () => { const repoDir = writeManifest('version: 1\nroles: []\n'); const manifestPath = path.join(repoDir, 'manifest', 'roles.yaml'); chmodSync(manifestPath, 0o000); - let unreadable = true; - try { - readFileSync(manifestPath, 'utf-8'); - unreadable = false; // running as root: the mode does not stop the read - } catch { /* expected */ } - if (unreadable) { + try { await expect(loadRolesManifest(repoDir)).rejects.toThrow(/could not be read/); await expect(loadRolesManifest(repoDir)).rejects.not.toBeInstanceOf(RolesManifestNotFoundError); + } finally { + if (existsSync(manifestPath)) chmodSync(manifestPath, 0o600); + rmSync(repoDir, { recursive: true, force: true }); } - - chmodSync(manifestPath, 0o600); - rmSync(repoDir, { recursive: true, force: true }); }); it('reports a genuinely missing manifest as not found', async () => { diff --git a/src/push.ts b/src/push.ts index 4855a372..be9ee14c 100644 --- a/src/push.ts +++ b/src/push.ts @@ -279,6 +279,19 @@ export { createPrWithFallback }; */ type PushGroupOutcome = 'pushed' | 'nochange' | 'pr-failed' | 'failed'; +/** + * The paths that would make `placedAt` a second copy of the same resource. + * An agent is canonically `.yaml`, but `pull` reads a legacy `.md` + * as the same agent, so a new `.md` landing beside an existing `.yaml` (or the + * reverse) produces exactly the ambiguity pull reports and skips. Checking the + * proposed path alone misses that. + */ +function collisionPaths(type: PlaceableType, placedAt: string): string[] { + if (type !== 'agents') return [placedAt]; + const stem = placedAt.replace(/\.(yaml|md)$/, ''); + return [`${stem}.yaml`, `${stem}.md`]; +} + /** * Remember where push put each resource it PLACED, so the author can keep * maintaining it. Their own copy stays at the tool's resource root, and the @@ -398,10 +411,18 @@ async function placeNewResources(args: { // Anything already there is somebody else's, and `pushItem` writes // rather than merges: placing on top of it would replace their work // with ours, silently, in a run they never reviewed. - if (await pathExists(path.join(localConfig.repo.localPath, placedAt))) { + let taken: string | undefined; + for (const candidate of collisionPaths(type, placedAt)) { + if (await pathExists(path.join(localConfig.repo.localPath, candidate))) { + taken = candidate; + break; + } + } + if (taken) { log.error( - `[${type}] ${item.name} cannot be placed: ${placedAt} already exists in the team repo, ` - + `and this is a new ${type.slice(0, -1)}, so pushing it there would overwrite that copy. ` + `[${type}] ${item.name} cannot be placed: ${taken} already exists in the team repo, ` + + `and this is a new ${type.slice(0, -1)}, so pushing it there would ` + + (taken === placedAt ? 'overwrite that copy. ' : 'leave two copies of the same agent. ') + 'Pull and edit the existing one, rename yours, or pass --role to choose another namespace.', ); process.exitCode = 2; diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 83a19d85..7cda113b 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -464,18 +464,51 @@ export class AgentsHandler extends ResourceHandler { * Tries both .yaml and .md extensions in the team repo. * Records a tombstone to prevent re-push. */ + /** + * `vr` when push placed it at `agents/fe/vr.yaml`: the author's local copy is + * at the tool's agents root, so the name they type is the bare one. Without + * this, `remove` matched that bare name and deleted every `vr` in every + * namespace — other people's agents included (#649 review). + */ + async publishedNameFor(name: string, localConfig: LocalConfig): Promise { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedAgents, 'agents', name, + ); + if (!placed) return null; + if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; + return placed.slice('agents/'.length).replace(/\.(yaml|md)$/, ''); + } + + /** + * Remove an agent from the team repo and all local AI tool agents/ directories. + * + * `name` is either a bare stem, which still means "this agent wherever it + * lives", or the published `/` that `publishedNameFor` resolved — + * and that one names exactly one file, so only it is removed. The local sweep + * covers both spellings: a placed agent leaves the author's copy at the + * agents root while every other member receives it under `/`. + */ async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const removed: string[] = []; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + const stem = path.basename(name); - // Root or agents//, both extensions, every namespace the stem lives in. + // Both extensions, and every namespace a BARE stem lives in. A published + // `/` resolves to exactly one file, because the root directory is + // one of the directories probed and `/.yaml` sits under it — so + // naming a namespace leaves the same stem in other namespaces alone. for (const located of await findTeamAgentFiles(teamAgentsDir, name)) { await remove(located.path); removed.push(located.path); } - await this.addTombstone(name, localConfig); + // The bare stem gets a tombstone too: the local sweep below skips excluded + // tools, so a root copy can outlive the removal there, and the scan names + // it `` — which the published tombstone would not match. + for (const tombstoned of new Set([name, stem])) { + await this.addTombstone(tombstoned, localConfig); + } for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; @@ -484,12 +517,14 @@ export class AgentsHandler extends ResourceHandler { if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); // Try every native agent extension: the render format varies per tool. - for (const ext of AGENT_FILE_EXTENSIONS) { - const filePath = path.join(baseDir, toolPath.agents, `${name}${ext}`); - if (await pathExists(filePath)) { - await remove(filePath); - removed.push(filePath); - log.debug(`Removed agent ${name} from ${tool}`); + for (const localName of new Set([name, stem])) { + for (const ext of AGENT_FILE_EXTENSIONS) { + const filePath = path.join(baseDir, toolPath.agents, `${localName}${ext}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + log.debug(`Removed agent ${localName} from ${tool}`); + } } } } From 7a313f1f3ab0c695df2cb4596833bacdda0517ba Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 14:37:32 +0200 Subject: [PATCH 10/25] fix(remove): make the placed-agent resolution actually reach the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 7 added `AgentsHandler.publishedNameFor` but `remove` only used its answer when `allNames` also carried that spelling — and `scanTeamForPull` reports an agent by its bare stem, never `/`. So the resolution was inert on the real command path: `teamai remove agents vr` fell back to the bare stem and deleted every `vr` in every namespace, exactly as before. The cross check is gone; `publishedNameFor` has already proved the file is in the team repo, which is stronger evidence than membership in a list the scans spell differently per type. The bare-stem tombstone that round went with it. Agents deploy FLATTENED, so both the push scan and the post-pull cleanup read a bare tombstone globally: removing `fe/vr` suppressed and deleted `be/vr` the moment that namespace became active. Only the published name is tombstoned now. The author's own flattened copy is still swept, but only where this machine's record says the file just removed is where push put it — without that, the copy on disk may be another namespace's deployment. Covered end to end this time: the new case drives `teamai remove agents vr` through the built CLI, which is the join the round-7 unit tests skipped. --- src/__tests__/agents.test.ts | 22 +++++++++++++++- src/__tests__/push-namespace-e2e.test.ts | 32 ++++++++++++++++++++++++ src/remove.ts | 7 +++++- src/resources/agents.ts | 24 +++++++++++++----- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 795c1493..1427fba9 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -339,6 +339,11 @@ projects: await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Mine\ninstructions: A.\n'); await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: Theirs\ninstructions: B.\n'); await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '# the author copy'); + // Push recorded where it put this agent; that record is what makes the + // flattened local copy this agent's rather than another namespace's. + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); await handler.removeItem('fe/vr', teamConfig, localConfig); @@ -349,7 +354,22 @@ projects: expect(await fse.pathExists(path.join(homeDir, '.claude/agents/vr.md'))).toBe(false); const tombstones = await fse.readFile(path.join(repoPath, 'agents', '.removed'), 'utf-8'); expect(tombstones.split('\n')).toContain('fe/vr'); - expect(tombstones.split('\n')).toContain('vr'); + // Agents deploy flattened, so a bare `vr` tombstone would suppress and + // delete be/vr the moment that namespace became active. + expect(tombstones.split('\n')).not.toContain('vr'); + }); + + it('keeps the flattened local copy when no record proves it is this agent\'s', async () => { + // Agents deploy flattened, so ~/.claude/agents/vr.md could be be/vr's + // deployment. Without a record, removing fe/vr must not take it. + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '# some other namespace copy'); + + await handler.removeItem('fe/vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + expect(await fse.readFile(path.join(homeDir, '.claude/agents/vr.md'), 'utf-8')) + .toBe('# some other namespace copy'); }); it('still removes a bare stem from every namespace', async () => { diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 4b881ca5..918ce22a 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -441,6 +441,38 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(git(['show', 'main:rules/be-know/foo.md'], fixture.remote)).toContain('The team rule'); }, 60_000); + it('removes only the published agent, through the real remove command', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // A second agent of the same name in another namespace, and the author's + // own, published through --project. + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + const result = await runCLI( + ['remove', 'agents', 'vr', '--force'], + fixture.projectRoot, + fixture.home, + ); + + // The bare stem is what the author knows; it has to resolve to what push + // published, or removal falls back to the stem and takes every namespace. + expect(result.output).toContain('vr was published as fe-agents/vr'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).not.toContain('agents/fe-agents/vr.yaml'); + // Somebody else's agent of the same name survives. + expect(git(['show', `${branch}:agents/other-ns/vr.yaml`], fixture.remote)) + .toContain('Read other-ns.'); + // And the tombstone names only the published agent: a bare `vr` would + // suppress other-ns/vr for anyone who activates that namespace. + const tombstones = git(['show', `${branch}:agents/.removed`], fixture.remote).split('\n'); + expect(tombstones).toContain('fe-agents/vr'); + expect(tombstones).not.toContain('vr'); + }, 60_000); + it('refuses a roles manifest namespace that is not a single path segment', async () => { const fixture = track(makeFixture({ agent: 'claude', diff --git a/src/remove.ts b/src/remove.ts index 20c74190..4d22311e 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -108,7 +108,12 @@ async function removeCore( // bare match would delete the local copy, report success, and leave the // namespaced team file published (#649 review). const published = await handler.publishedNameFor(name, localConfig); - if (published && allNames.has(published)) { + if (published) { + // Not cross-checked against `allNames`: `publishedNameFor` has already + // proved the file is in the team repo, and the scans do not all spell a + // namespaced resource the same way — `scanTeamForPull` reports an agent + // by its bare stem, so requiring membership here silently fell back to + // the bare name and removed that agent from EVERY namespace (#649 review). log.info(`${name} was published as ${published}`); found.push(published); continue; diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 7cda113b..52d569e1 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -503,11 +503,23 @@ export class AgentsHandler extends ResourceHandler { removed.push(located.path); } - // The bare stem gets a tombstone too: the local sweep below skips excluded - // tools, so a root copy can outlive the removal there, and the scan names - // it `` — which the published tombstone would not match. - for (const tombstoned of new Set([name, stem])) { - await this.addTombstone(tombstoned, localConfig); + // Only the name given. Agents deploy FLATTENED — `~/.claude/agents/` — + // so a bare-stem tombstone is read globally by both the push scan and the + // post-pull cleanup: removing `fe/vr` would suppress and delete `be/vr` the + // moment that namespace became active (#649 review). Rules can afford the + // bare spelling because they keep their namespace directory locally. + await this.addTombstone(name, localConfig); + + // The author's own copy IS flattened, though, and it is theirs only when + // this machine's record says the file just removed is where push put it. + const localNames = new Set([name]); + if (stem !== name) { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedAgents, 'agents', stem, + ); + if (placed === `agents/${name}.yaml` || placed === `agents/${name}.md`) { + localNames.add(stem); + } } for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { @@ -517,7 +529,7 @@ export class AgentsHandler extends ResourceHandler { if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); // Try every native agent extension: the render format varies per tool. - for (const localName of new Set([name, stem])) { + for (const localName of localNames) { for (const ext of AGENT_FILE_EXTENSIONS) { const filePath = path.join(baseDir, toolPath.agents, `${localName}${ext}`); if (await pathExists(filePath)) { From ccc13ecbb55ce8ad29089696e1724f1f4e418a0e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 14:49:22 +0200 Subject: [PATCH 11/25] fix(push): raise a project agents-axis failure the scan would otherwise swallow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--project ` resolved the agents destination before scanning and dropped the failure on the floor. A project with no agents namespace then looked identical to a run with no flag at all: the scan skipped the agent as "no active source", the item never reached placement, and the command exited 0 with "No new or modified resources" — on a flag it could not honour. The error is carried forward and raised as soon as the scan contains an agent. It cannot wait for the selection the way the skills axis does, because the item that would prove the axis is needed is exactly the one the scan removes. `placedResourcePath` matched the recorded filename by prefix, so a record pointing at `rules//foo.backup.md` was trusted whenever that file existed, and scanning, the pre-push sync and removal would all follow it onto somebody else's file. The filename must now be exactly the resource's own. --- src/__tests__/push-namespaces.test.ts | 10 +++++++++ src/__tests__/push-role.test.ts | 32 +++++++++++++++++++++++++++ src/push-namespaces.ts | 8 +++++-- src/push.ts | 18 ++++++++++++++- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/__tests__/push-namespaces.test.ts b/src/__tests__/push-namespaces.test.ts index 29833655..a212b505 100644 --- a/src/__tests__/push-namespaces.test.ts +++ b/src/__tests__/push-namespaces.test.ts @@ -157,6 +157,16 @@ describe('placedResourcePath', () => { expect(placedResourcePath({ x: 'rules/ns/other.md' }, 'rules', 'x')).toBeNull(); }); + it('requires the exact filename, not merely the resource name as a prefix', () => { + // `x.backup.md` is somebody else's file. Trusting it would point scanning, + // the pre-push sync and removal at an unrelated resource (#649 review). + expect(placedResourcePath({ x: 'rules/ns/x.backup.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/x.yaml' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ vr: 'agents/ns/vr.old.yaml' }, 'agents', 'vr')).toBeNull(); + // A legacy `.md` agent is still the agent itself. + expect(placedResourcePath({ vr: 'agents/ns/vr.md' }, 'agents', 'vr')).toBe('agents/ns/vr.md'); + }); + it('resolves an agent record, whose file keeps the canonical .yaml', () => { expect(placedResourcePath({ vr: 'agents/fe-agents/vr.yaml' }, 'agents', 'vr')) .toBe('agents/fe-agents/vr.yaml'); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 3c3dd27b..e1adc151 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1006,6 +1006,38 @@ describe('push namespace routing for rules and agents', () => { expect(pushedItems[0]?.namespace).toBeUndefined(); }); + it('fails on a project with no agents namespace even when the scan skipped the agent', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // The scan drops this one itself, so an error deferred to selection would + // never be raised and the run would end "No new or modified resources". + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/agents', + relativePath: 'agents/vr.yaml', status: 'modified', + skipReason: 'Agent "vr" has no active source. Activate its role or project before pushing local edits.', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + it('pushes a selected rule when only the unselected skill lacks a project namespace', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ diff --git a/src/push-namespaces.ts b/src/push-namespaces.ts index 7167941d..6572c7ae 100644 --- a/src/push-namespaces.ts +++ b/src/push-namespaces.ts @@ -157,8 +157,12 @@ export function placedResourcePath( if (segments.length !== 3) return null; if (segments[0] !== root) return null; if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return null; - // `.md` for a rule, `.yaml` (or a legacy `.md`) for an agent. - if (!segments[2].startsWith(`${name}.`)) return null; + // Exactly the resource's own file: `.md` for a rule, `.yaml` or a + // legacy `.md` for an agent. A prefix test would accept + // `.backup.md` and redirect scanning, syncing and removal onto an + // unrelated file that happens to sit there. + const allowed = root === 'rules' ? [`${name}.md`] : [`${name}.yaml`, `${name}.md`]; + if (!allowed.includes(segments[2])) return null; return recorded; } diff --git a/src/push.ts b/src/push.ts index be9ee14c..6f5c63dc 100644 --- a/src/push.ts +++ b/src/push.ts @@ -861,11 +861,16 @@ async function pushCore( // after selection, so their scan needs nothing. An unsafe --role resolves to // no candidate here and is rejected with exit 2 before anything is pushed. let requestedAgentsNamespace: string | undefined; + let agentsDestinationError: string | undefined; if (options.role) { requestedAgentsNamespace = options.role; } else if (options.project && projectsManifest) { const resolved = resolveProjectNamespace(projectsManifest, options.project, 'agents'); - if (resolved.ok) requestedAgentsNamespace = resolved.namespace; + if (resolved.ok) { + requestedAgentsNamespace = resolved.namespace; + } else { + agentsDestinationError = resolved.message; + } } for (const type of pushableTypes) { @@ -878,6 +883,17 @@ async function pushCore( fullScan.push(...items); } + // A project that cannot answer for agents has to fail HERE, not at step 4. + // Unlike skills, an agent with no resolvable destination is dropped by the + // scan itself — skipped as "no active source" — so deferring the error until + // the selection proves one is going out means never raising it, and the run + // ends "No new or modified resources" on a flag that could not be honoured. + if (agentsDestinationError && fullScan.some((item) => item.type === 'agents')) { + log.error(agentsDestinationError); + process.exitCode = 2; + return; + } + // Preserve blocked items in the full scan so their pending PR records survive. // Exclude them before selection and grouping: pushItem cannot write their paths. const allItems = fullScan.filter((item) => { From feb0533b8e60c7bed5769ee021fbab7498fc0e2d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 15:13:19 +0200 Subject: [PATCH 12/25] fix(agents): reach the canonical source, and hold the record to what it proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, all on the agent side of placement. The single-repo canonical source in `.teamai/agents/` is picked up directly, never reverse-parsed, and that branch ignored the placement record: a root `vr.yaml` placed at `agents/fe/vr.yaml` read as new on the next push, and the collision guard then refused the very agent this machine published. Removal missed the same directory, so the agent republished itself on the next push — which a bare-stem tombstone cannot prevent without suppressing that stem in every other namespace, since agents deploy flattened. The project agents-axis error now counts only agents that actually need a destination. A modified agent already in a namespace is written in place, so an empty agents axis is none of its business; blocking it contradicted the rule that only new shared-root resources are placed. And the record is no longer taken as licence to overwrite. It admits a namespace this directory never activates, which also means `pull` never refreshed a copy and the pre-push sync does not cover agents — so if the canonical file moved on since the last pull, push now says so and asks for a pull instead of writing a stale rendering over whoever changed it. --- docs/usage-guide.md | 1 + docs/usage-guide.zh-CN.md | 1 + src/__tests__/agents.test.ts | 102 ++++++++++++++++++++++++++++++++ src/__tests__/push-role.test.ts | 30 +++++++++- src/push.ts | 19 ++++-- src/resources/agents.ts | 86 ++++++++++++++++++++++++--- 6 files changed, 224 insertions(+), 15 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 17a3690e..4893e354 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -603,6 +603,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` +- An agent whose namespace is not active here is editable through its placement record, but only while your copy is in step with the team: if the team file changed since your last pull, push says so and asks you to pull first rather than write over it **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 44160df4..8c762fbb 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -580,6 +580,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace +- 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,但仅限于本地副本与团队保持一致:若自上次 pull 以来团队文件已变更,push 会提示先执行 pull,而不会直接覆盖 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 1427fba9..89ecfd0f 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -19,6 +19,14 @@ vi.mock('../utils/logger.js', () => ({ })), })); +const mockGetFileContentAtRev = vi.fn< + (repoPath: string, rev: string, filePath: string) => Promise +>().mockResolvedValue(null); +vi.mock('../utils/git.js', async () => ({ + ...(await vi.importActual('../utils/git.js')), + getFileContentAtRev: (...args: [string, string, string]) => mockGetFileContentAtRev(...args), +})); + import { AgentsHandler } from '../resources/agents.js'; import { getDataHome, type TeamaiConfig, type LocalConfig } from '../types.js'; @@ -84,6 +92,7 @@ describe('AgentsHandler — Phase 1 push/pull/remove', () => { }); afterEach(async () => { + mockGetFileContentAtRev.mockResolvedValue(null); vi.unstubAllEnvs(); await fse.remove(tmpDir); }); @@ -372,6 +381,51 @@ projects: .toBe('# some other namespace copy'); }); + /** + * Single-repo mode: the canonical source is the repo's own .teamai/agents/, + * picked up directly rather than reverse-parsed. Both the placement record + * and removal have to reach it (#649 review). + */ + describe('single-repo mode canonical sources', () => { + function selfConfig(): LocalConfig { + return { ...localConfig, repo: { ...localConfig.repo, kind: 'self' }, projectRoot: tmpDir }; + } + + it('follows the record for a root canonical source placed in a namespace', async () => { + const self = selfConfig(); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), + 'name: vr\ndescription: Published\ninstructions: Read it.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), + 'name: vr\ndescription: Published\ninstructions: Edited locally.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + // Without the record this reads as new, and the collision check then + // refuses the very agent this machine published. + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); + }); + + it('removes the canonical source so the agent cannot republish itself', async () => { + const self = selfConfig(); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + await handler.removeItem('fe/vr', teamConfig, self); + + // A bare-stem tombstone cannot cover this without suppressing the same + // stem in every other namespace, because agents deploy flattened. + expect(await fse.pathExists(path.join(tmpDir, '.teamai/agents/vr.yaml'))).toBe(false); + }); + }); + it('still removes a bare stem from every namespace', async () => { await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: B\ninstructions: B.\n'); @@ -425,6 +479,54 @@ projects: expect(items[0]?.skipReason).toContain('shared root'); }); + /** + * The record lets the author edit an agent in a namespace this directory + * never activates — which also means `pull` never refreshed a copy of it, and + * the pre-push sync covers rules and skills but not agents. Pushing a stale + * rendering over a teammate's newer canonical file is the risk (#649 review). + */ + it('refuses to push over a recorded agent that moved on since the last pull', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Teammate v2\ninstructions: Newer.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + lastPullRev: 'abc1234', + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + // At the last pull the canonical file said something else. + mockGetFileContentAtRev.mockResolvedValue( + Buffer.from('name: reviewer\ndescription: v1\ninstructions: Older.\n'), + ); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('since your last pull'); + }); + + it('pushes a recorded agent whose canonical file has not moved', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + const canonical = 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'; + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), canonical); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + lastPullRev: 'abc1234', + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from(canonical)); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + }); + it('still skips an inactive agent this machine never published', async () => { await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index e1adc151..d047173a 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1024,7 +1024,7 @@ describe('push namespace routing for rules and agents', () => { mockHandlers({ agents: [{ name: 'vr', type: 'agents', sourcePath: '/tmp/agents', - relativePath: 'agents/vr.yaml', status: 'modified', + relativePath: 'agents/vr.yaml', status: 'modified', needsDestination: true, skipReason: 'Agent "vr" has no active source. Activate its role or project before pushing local edits.', }], }, pushedItems); @@ -1038,6 +1038,34 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); }); + it('lets a modified namespaced agent through a project whose agents axis is empty', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // Already in a namespace, so it is modified in place and needs no + // placement — an empty agents axis is none of its business. + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/hai/vr.yaml', status: 'modified', namespace: 'hai', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('agents/hai/vr.yaml'); + }); + it('pushes a selected rule when only the unselected skill lacks a project namespace', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ diff --git a/src/push.ts b/src/push.ts index 6f5c63dc..c0f2b02a 100644 --- a/src/push.ts +++ b/src/push.ts @@ -883,12 +883,19 @@ async function pushCore( fullScan.push(...items); } - // A project that cannot answer for agents has to fail HERE, not at step 4. - // Unlike skills, an agent with no resolvable destination is dropped by the - // scan itself — skipped as "no active source" — so deferring the error until - // the selection proves one is going out means never raising it, and the run - // ends "No new or modified resources" on a flag that could not be honoured. - if (agentsDestinationError && fullScan.some((item) => item.type === 'agents')) { + // A project that cannot answer for agents has to fail HERE, not at step 4: + // an agent with no resolvable destination is dropped by the scan itself — + // skipped as "no active source" — so deferring the error until the selection + // proves one is going out means never raising it, and the run ends "No new or + // modified resources" on a flag that could not be honoured. + // + // Only agents that actually need a destination count. One already in a + // namespace is being modified in place and needs no placement, so an empty + // agents axis is none of its business (#649 review). + const needsAgentsDestination = (item: ResourceItem): boolean => item.type === 'agents' + && (item.status === 'new' + || ('needsDestination' in item && item.needsDestination === true)); + if (agentsDestinationError && fullScan.some(needsAgentsDestination)) { log.error(agentsDestinationError); process.exitCode = 2; return; diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 52d569e1..253ab2e9 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -10,6 +10,7 @@ import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; import { resolveResourceNamespaces } from '../resource-namespaces.js'; import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; +import { getFileContentAtRev } from '../utils/git.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; import { @@ -40,6 +41,14 @@ export interface AgentResourceItem extends ResourceItem { mergedSpec?: AgentSpec; /** Human-readable reason to skip this item during pushItem (merge failed). */ skipReason?: string; + /** + * Set when the scan could not find a team source this directory may write to, + * so the agent needs a destination named before it can go anywhere. `push` + * reads it to decide whether a `--project` whose agents axis is empty is a + * problem for THIS run: a modified agent already in a namespace needs no + * placement and must not be blocked by it (#649 review). + */ + needsDestination?: boolean; /** True when item came from a legacy .md team-repo file (older format). */ legacy?: boolean; } @@ -78,6 +87,13 @@ export class AgentsHandler extends ResourceHandler { // origin/ checkout so only genuine additions/edits surface. These win // over the reverse-parse path below on name conflicts (explicit canonical is // authoritative). Active tree = projectRoot (kept intact by withKnowledgeWorktree). + // An agent this machine published with --role/--project lives in a + // namespace this directory need not have activated. Without the record it + // would read as "no active source" and the author could never edit the + // agent they just created (#649 review). + const pushState = await loadStateForScope(localConfig); + const placedAgents = pushState.placedAgents; + const directItems: AgentResourceItem[] = []; const directStems = new Set(); if (isSelfMode(localConfig) && localConfig.projectRoot) { @@ -94,17 +110,33 @@ export class AgentsHandler extends ResourceHandler { if (BUILTIN_AGENT_NAMES.has(stem)) continue; const activePath = path.join(dir, file); - const basePath = path.join(localConfig.repo.localPath, relDir, file); - const baseExists = await pathExists(basePath); + let teamRelPath = `${relDir}/${file}`; + let basePath = path.join(localConfig.repo.localPath, teamRelPath); + let baseExists = await pathExists(basePath); + // A canonical source authored at .teamai/agents/ root and placed + // under agents// has nothing at agents/.yaml, so without + // the record it reads as brand new — and the collision check then + // refuses the very agent this machine published (#649 review). + if (!baseExists && !namespace) { + const placed = placedResourcePath(placedAgents, 'agents', stem); + if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { + teamRelPath = placed; + basePath = path.join(localConfig.repo.localPath, placed); + baseExists = true; + } + } if (baseExists && await fileContentEqual(activePath, basePath)) continue; // unchanged directItems.push({ name: stem, type: 'agents', sourcePath: activePath, - relativePath: `${relDir}/${file}`, + relativePath: teamRelPath, status: (baseExists ? 'modified' : 'new') as ResourceItemStatus, legacy: isMd, + ...(baseExists && teamRelPath !== `${relDir}/${file}` + ? { namespace: teamRelPath.split('/')[1] } + : {}), }); directStems.add(stem); } @@ -148,11 +180,6 @@ export class AgentsHandler extends ResourceHandler { const resolved = await resolveResourceNamespaces(localConfig); const activeNamespaces = resolved?.activeNamespaces.agents ?? null; - // An agent this machine published with --role/--project lives in a - // namespace this directory need not have activated. Without the record it - // would read as "no active source" and the author could never edit the - // agent they just created (#649 review). - const placedAgents = (await loadStateForScope(localConfig)).placedAgents; for (const [stem, toolFiles] of grouped) { // Determine if this agent is already in the team repo (root or agents//). // A modified agent must be written back where it lives, so its namespace @@ -196,10 +223,35 @@ export class AgentsHandler extends ResourceHandler { if (!requestedNamespace && sources.length > 0 && candidates.length === 0) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', + needsDestination: true, skipReason: `Agent "${stem}" has no active source. Activate its role or project before pushing local edits.` }); continue; } const located = candidates[0]; + + // Accepted only because of the placement record: this namespace is NOT + // active here, so `pull` never refreshed a local copy of it, and the + // pre-push sync covers rules and skills but not agents. If the canonical + // file moved on since the last pull, the local rendering is stale and + // pushing it would revert whoever changed it (#649 review). + if (located?.namespace && located.namespace === placedNamespace + && !(activeNamespaces ?? []).includes(located.namespace) + && pushState.lastPullRev) { + const relFromRepo = path.relative(localConfig.repo.localPath, located.path); + const atLastPull = await getFileContentAtRev( + localConfig.repo.localPath, pushState.lastPullRev, relFromRepo, + ); + const current = await readFileSafe(located.path); + if (atLastPull !== null && current !== null && atLastPull.toString('utf-8') !== current) { + items.push({ name: stem, type: 'agents', sourcePath: located.path, + relativePath: relFromRepo, status: 'modified', + skipReason: `Agent "${stem}" changed in the team repo (${relFromRepo}) since your last pull, ` + + 'and its namespace is not active here, so your copy cannot be compared against it. ' + + 'Run `teamai pull` first, then push again.' }); + continue; + } + } + const teamYamlPath = located?.ext === '.yaml' ? located.path : path.join(teamAgentsDir, `${stem}.yaml`); const teamMdPath = located?.ext === '.md' ? located.path : path.join(teamAgentsDir, `${stem}.md`); const hasTeamYaml = located?.ext === '.yaml'; @@ -522,6 +574,24 @@ export class AgentsHandler extends ResourceHandler { } } + // Single-repo mode: the canonical source lives in the repo's own + // .teamai/agents/, and the scan picks it up directly. Leaving it behind + // republishes the agent on the next push, and a bare-stem tombstone cannot + // stop that without suppressing the same stem in every other namespace, + // because agents deploy flattened (#649 review). + if (isSelfMode(localConfig) && localConfig.projectRoot) { + const activeAgentsDir = path.join(localConfig.projectRoot, '.teamai', 'agents'); + for (const localName of localNames) { + for (const ext of ['.yaml', '.md'] as const) { + const filePath = path.join(activeAgentsDir, `${localName}${ext}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + } + } + } + } + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; // A tool the member excluded is not ours to write to, so it is not ours From ec46f9a744ada0167e41171f4963b6596b491152 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 15:48:24 +0200 Subject: [PATCH 13/25] fix(agents): deliver recorded agents on pull, and let a named destination win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staleness guard added last round was defeated by the pull it recommended: `pull` advances lastPullRev without deploying an inactive namespace, so the next push saw an unchanged canonical and wrote the stale rendering anyway. It also never fired right after the first PR merged, when the file did not exist at lastPullRev. The guard is gone, and the cause with it. `pull` now delivers an agent whose placement record names it, so the local copy tracks the team file and the ordinary comparison is valid — the inactive case stops being special instead of needing its own machinery. A stem an ACTIVE namespace already claims is left alone, since agents deploy flattened and the active one is what is deployed here; the scan follows the same order, treating the record as a fallback rather than an extra candidate. Pending-PR reuse matched on type and name alone, so an open PR for a different resource of the same name captured a push that named another namespace and force-pushed into that review. Neither silent answer is safe, so the flag the user typed decides, the open PR is left untouched, and the collision is reported. This supersedes the original #331/#654 rule that a PR's destination always won; that rule still holds whenever no destination is named. --- docs/usage-guide.md | 3 +- docs/usage-guide.zh-CN.md | 3 +- src/__tests__/agents.test.ts | 23 +++++----- src/__tests__/doctor-agents-delivery.test.ts | 2 + src/__tests__/doctor-rules-delivery.test.ts | 2 + src/__tests__/doctor.test.ts | 2 + src/__tests__/pull-agents-role-filter.test.ts | 37 ++++++++++++++++ src/__tests__/push-role.test.ts | 21 +++++++--- src/pull.ts | 27 +++++++++++- src/push.ts | 33 ++++++++++++++- src/resources/agents.ts | 42 +++++-------------- 11 files changed, 142 insertions(+), 53 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4893e354..728730d6 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -603,7 +603,8 @@ Choose namespace [1-3] (default: 1 = common): - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` -- An agent whose namespace is not active here is editable through its placement record, but only while your copy is in step with the team: if the team file changed since your last pull, push says so and asks you to pull first rather than write over it +- An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here +- A resource awaiting review in an open PR keeps that PR's destination — unless this push names a different namespace, in which case the flag decides, the open PR is left untouched, and the collision is reported **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 8c762fbb..35ccb759 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -580,7 +580,8 @@ Choose namespace [1-3] (default: 1 = common): - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace -- 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,但仅限于本地副本与团队保持一致:若自上次 pull 以来团队文件已变更,push 会提示先执行 pull,而不会直接覆盖 +- 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 +- 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定了另一个 namespace,则以命令行为准,原 PR 保持不动,并提示该冲突 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 89ecfd0f..69ef1192 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -485,26 +485,27 @@ projects: * the pre-push sync covers rules and skills but not agents. Pushing a stale * rendering over a teammate's newer canonical file is the risk (#649 review). */ - it('refuses to push over a recorded agent that moved on since the last pull', async () => { + it('prefers an active source over the placement record', async () => { + // The record is a FALLBACK. When an active namespace holds this stem, that + // is the agent deployed here — and the one pull delivers. await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), - 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); - const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); - await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Teammate v2\ninstructions: Newer.\n'); + 'version: 1\nprojects:\n - id: active\n resources:\n agents: [common]\n'); + localConfig.projects = ['active']; + await fse.outputFile(path.join(repoPath, 'agents/common/reviewer.yaml'), + 'name: reviewer\ndescription: Active\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Recorded\ninstructions: Read fe.\n'); await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), - '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + '---\nname: reviewer\ndescription: Active\n---\n\nEdited locally.\n'); await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { - lastPullRev: 'abc1234', placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, }); - // At the last pull the canonical file said something else. - mockGetFileContentAtRev.mockResolvedValue( - Buffer.from('name: reviewer\ndescription: v1\ninstructions: Older.\n'), - ); const items = await handler.scanLocalForPush(teamConfig, localConfig); expect(items).toHaveLength(1); - expect(items[0]?.skipReason).toContain('since your last pull'); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/common/reviewer.yaml'); }); it('pushes a recorded agent whose canonical file has not moved', async () => { diff --git a/src/__tests__/doctor-agents-delivery.test.ts b/src/__tests__/doctor-agents-delivery.test.ts index 354cb9fb..9f337e93 100644 --- a/src/__tests__/doctor-agents-delivery.test.ts +++ b/src/__tests__/doctor-agents-delivery.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ detectProjectConfig: vi.fn().mockResolvedValue(null), loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/logger.js', () => ({ diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 72bf26af..411f5b75 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ detectProjectConfig: vi.fn().mockResolvedValue(null), loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/logger.js', () => ({ diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index f68ae101..7168ff61 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), detectProjectConfig: vi.fn().mockResolvedValue(null), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/fs.js', () => ({ diff --git a/src/__tests__/pull-agents-role-filter.test.ts b/src/__tests__/pull-agents-role-filter.test.ts index 0f74a121..2c30a032 100644 --- a/src/__tests__/pull-agents-role-filter.test.ts +++ b/src/__tests__/pull-agents-role-filter.test.ts @@ -86,4 +86,41 @@ describe('filterAgentsByNamespaces', () => { expect(() => filterAgentsByNamespaces(agents, null)).toThrow(/Duplicate agent "reviewer"/); }); + + /** + * An agent published with --role/--project lives in a namespace this + * directory need not activate, and push lets the author keep editing it + * through the placement record. Pull has to deliver it for the same reason: + * otherwise the local copy never tracks the team file and the next push + * writes a stale rendering over whoever changed it (#649 review). + */ + it('delivers an agent this machine published into an inactive namespace', () => { + const agents = [makeAgent('vr', 'fe-agents'), makeAgent('other', 'devops')]; + + const result = filterAgentsByNamespaces(agents, ['common'], { + vr: 'agents/fe-agents/vr.yaml', + }); + + expect(result.map((a) => a.name)).toEqual(['vr']); + }); + + it('leaves the record alone when an active namespace claims that stem', () => { + // Agents deploy flattened, so two of one stem would collide on the same + // filename — and the active one is the agent deployed here. + const agents = [makeAgent('vr', 'common'), makeAgent('vr', 'fe-agents')]; + + const result = filterAgentsByNamespaces(agents, ['common'], { + vr: 'agents/fe-agents/vr.yaml', + }); + + expect(result).toHaveLength(1); + expect(result[0]?.namespace).toBe('common'); + }); + + it('ignores a record that does not match the agent it names', () => { + const agents = [makeAgent('vr', 'fe-agents')]; + + expect(filterAgentsByNamespaces(agents, ['common'], { vr: 'agents/other/vr.yaml' })) + .toEqual([]); + }); }); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index d047173a..91f796c5 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1194,10 +1194,17 @@ describe('push namespace routing for rules and agents', () => { await push({ all: true, role: 'pm' }); - // The branch is force-pushed, so honouring --role here would move the skill - // inside the open PR rather than leaving a copy behind. - expect(pushedItems[0]?.relativePath).toBe('skills/js/skill-a'); - expect(pushedItems[0]?.namespace).toBe('js'); + // Supersedes the original #331/#654 rule that the PR's destination always + // won. Matching is by type and name only, so an open PR for a DIFFERENT + // resource of the same name would capture this push and force-push into a + // review it has nothing to do with (#649 review). The flag the user typed + // decides, the open PR is left alone, and the collision is reported. + expect(pushedItems[0]?.relativePath).toBe('skills/pm/skill-a'); + expect(pushedItems[0]?.namespace).toBe('pm'); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.warn).mock.calls.flat().join(' '); + expect(said).toContain('awaiting review at skills/js/skill-a'); + expect(said).toContain('separate PR'); }); it('reuses the namespace recorded for a rule when updating its open PR', async () => { @@ -1318,20 +1325,22 @@ describe('push namespace routing for rules and agents', () => { branch: 'teamai/push/test/20260101-000000', prUrl: 'https://git.woa.com/mr/9', createdAt: '2026-01-01T00:00:00.000Z', - items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', namespace: 'pm' }], }], }); mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); // First group pushes, second throws. mockPushRepoBranch.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('remote rejected')); + // The PR's namespace matches what --role asks for, so its branch is reused + // and the run really does have two groups. await push({ all: true, role: 'pm' }); expect(process.exitCode).toBe(1); // The rule is on the remote now. Losing where it went means the author's // root copy is reclassified once that PR merges. const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; - expect(saved.placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + expect(saved.placedRules).toEqual({ 'my-rule': 'rules/pm/my-rule.md' }); }); it('refuses to place a new rule onto an existing team file', async () => { diff --git a/src/pull.ts b/src/pull.ts index 732d3295..2768cf8b 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import matter from 'gray-matter'; +import { placedResourcePath } from './push-namespaces.js'; import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js'; import { pullRepo, getHeadRev, createGit } from './utils/git.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; @@ -272,11 +273,28 @@ export function filterRulesByKnowledgeNamespaces( export function filterAgentsByNamespaces( agents: ResourceItem[], agentNamespaces: string[] | null, + placedAgents?: Record, ): ResourceItem[] { - const kept = agentNamespaces + const active = agentNamespaces ? agents.filter((agent) => !agent.namespace || agentNamespaces.includes(agent.namespace)) : agents; + // An agent this machine published with --role/--project lives in a namespace + // this directory need not activate, and push lets the author keep editing it + // through that record. Pull has to deliver it for the same reason: otherwise + // the local copy never tracks the team file, and the next push writes a stale + // rendering over whoever changed it (#649 review). A stem an ACTIVE namespace + // already claims is left alone — that agent is the one deployed here, and two + // would collide on the same flattened filename. + const claimed = new Set(active.map((agent) => agent.name)); + const recovered = placedAgents + ? agents.filter((agent) => agent.namespace + && !claimed.has(agent.name) + && placedResourcePath(placedAgents, 'agents', agent.name) + === `agents/${agent.namespace}/${path.basename(agent.relativePath)}`) + : []; + const kept = recovered.length > 0 ? [...active, ...recovered] : active; + const seen = new Map(); for (const agent of kept) { const existing = seen.get(agent.name); @@ -422,7 +440,12 @@ export async function resolveDesiredAgents( roleContext: RolePullContext | null, ): Promise { const items = await getHandler('agents').scanTeamForPull(teamConfig, localConfig); - return filterAgentsByNamespaces(items, roleContext ? roleContext.activeNamespaces.agents : null); + const { placedAgents } = await loadStateForScope(localConfig); + return filterAgentsByNamespaces( + items, + roleContext ? roleContext.activeNamespaces.agents : null, + placedAgents, + ); } // Deployment adds a CONTRIBUTORS file that the team source may not have; ignore it diff --git a/src/push.ts b/src/push.ts index c0f2b02a..2980080f 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1206,7 +1206,38 @@ async function pushCore( // branch, which updates it in place; everything else goes into a new PR. Both // can happen in one run, so editing a resource under review updates its PR // without dragging unrelated resources into that review. - const groups = planPushGroups(selectedItems, pendingPushes); + // An open PR is matched by type and name alone. When the user has NAMED a + // destination, a pending entry that put the same-named resource somewhere + // else is a different resource: reusing its branch would force-push this + // content into that PR and move it to the wrong namespace (#649 review). + const requestedNamespaceFor = (type: PlaceableType): string | undefined => { + if (options.role) return options.role; + if (!options.project || !projectsManifest) return undefined; + const resolved = resolveProjectNamespace(projectsManifest, options.project, type); + return resolved.ok ? resolved.namespace : undefined; + }; + const conflictsWithRequest = (recorded: { type: string; namespace?: string }): boolean => { + if (!recorded.namespace) return false; + if (!isPlaceableType(recorded.type as ResourceType)) return false; + const requested = requestedNamespaceFor(recorded.type as PlaceableType); + return requested !== undefined && requested !== recorded.namespace; + }; + const reusablePending = pendingPushes.filter((entry) => { + const conflicting = entry.items.filter(conflictsWithRequest); + if (conflicting.length === 0) return true; + // Neither silent answer is safe: honouring the PR ignores the flag the user + // typed, and reusing the branch force-pushes this content into a review it + // may have nothing to do with. Say what is happening and open a new PR. + for (const recorded of conflicting) { + log.warn( + `[${recorded.type}] ${recorded.name} is awaiting review at ${recorded.relativePath} ` + + `(${entry.prUrl ?? entry.branch}). This push names a different namespace, so it goes to a ` + + 'separate PR and that one is left untouched.', + ); + } + return false; + }); + const groups = planPushGroups(selectedItems, reusablePending); reuseRecordedDestinations(groups); for (const entry of partiallySelectedEntries(selectedItems, pendingPushes)) { log.warn( diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 253ab2e9..99131394 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -10,7 +10,6 @@ import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; import { resolveResourceNamespaces } from '../resource-namespaces.js'; import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; -import { getFileContentAtRev } from '../utils/git.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; import { @@ -91,8 +90,7 @@ export class AgentsHandler extends ResourceHandler { // namespace this directory need not have activated. Without the record it // would read as "no active source" and the author could never edit the // agent they just created (#649 review). - const pushState = await loadStateForScope(localConfig); - const placedAgents = pushState.placedAgents; + const placedAgents = (await loadStateForScope(localConfig)).placedAgents; const directItems: AgentResourceItem[] = []; const directStems = new Set(); @@ -191,13 +189,18 @@ export class AgentsHandler extends ResourceHandler { // another namespace is a different agent — the layout allows that — and // must not block publishing this one, which is what activity filtering // alone did (#649 review). + const active = sources.filter( + (file) => activeNamespaces === null || !file.namespace + || activeNamespaces.includes(file.namespace), + ); + // The record is a FALLBACK, not an additional candidate: when an active + // namespace already holds this stem, that is the agent deployed here, and + // `pull` leaves the recorded one undelivered for exactly that reason. const candidates = requestedNamespace ? sources.filter((file) => file.namespace === requestedNamespace) - : sources.filter( - (file) => activeNamespaces === null || !file.namespace - || activeNamespaces.includes(file.namespace) - || file.namespace === placedNamespace, - ); + : active.length > 0 + ? active + : sources.filter((file) => file.namespace === placedNamespace); if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', @@ -229,29 +232,6 @@ export class AgentsHandler extends ResourceHandler { } const located = candidates[0]; - // Accepted only because of the placement record: this namespace is NOT - // active here, so `pull` never refreshed a local copy of it, and the - // pre-push sync covers rules and skills but not agents. If the canonical - // file moved on since the last pull, the local rendering is stale and - // pushing it would revert whoever changed it (#649 review). - if (located?.namespace && located.namespace === placedNamespace - && !(activeNamespaces ?? []).includes(located.namespace) - && pushState.lastPullRev) { - const relFromRepo = path.relative(localConfig.repo.localPath, located.path); - const atLastPull = await getFileContentAtRev( - localConfig.repo.localPath, pushState.lastPullRev, relFromRepo, - ); - const current = await readFileSafe(located.path); - if (atLastPull !== null && current !== null && atLastPull.toString('utf-8') !== current) { - items.push({ name: stem, type: 'agents', sourcePath: located.path, - relativePath: relFromRepo, status: 'modified', - skipReason: `Agent "${stem}" changed in the team repo (${relFromRepo}) since your last pull, ` - + 'and its namespace is not active here, so your copy cannot be compared against it. ' - + 'Run `teamai pull` first, then push again.' }); - continue; - } - } - const teamYamlPath = located?.ext === '.yaml' ? located.path : path.join(teamAgentsDir, `${stem}.yaml`); const teamMdPath = located?.ext === '.md' ? located.path : path.join(teamAgentsDir, `${stem}.md`); const hasTeamYaml = located?.ext === '.yaml'; From 9488cd947e6d3a1ef306ab7b49309ded4b56a117 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 16:07:15 +0200 Subject: [PATCH 14/25] fix(pull): stop revoking the agent pull had just delivered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the branch, before the next review round. Round 11 taught delivery about placement records but not revocation, and both run in the same pull: `filterAgentsByNamespaces` wrote the agent and `cleanupInactiveNamespaces` deleted it again, byte-equal to the render so the data-safety gate passed it straight through. The record-based delivery was inert and the file churned on every pull. Both halves now resolve through one exported `selectAgentsForDirectory`, so they cannot disagree — the same treatment `placedResourcePath` already gives the push scanner and the pre-push sync. Two smaller ones from the same pass. `--dry-run` grouped against the unfiltered pending list, so it reported a destination the real push no longer uses, which breaks the property that a dry run matches the run it describes. And the partial-selection warning counted entries the run had already declined to reuse, contradicting the warning given for them; both now share the filtered list, which is computed once there is actually something to push. --- src/__tests__/agents.test.ts | 41 ++++++++++++++ src/__tests__/push-namespace-e2e.test.ts | 21 +++++++ src/__tests__/push-role.test.ts | 28 ++++++++++ src/pull.ts | 22 +------- src/push.ts | 70 +++++++++++++----------- src/resources/agents.ts | 47 +++++++++++++++- 6 files changed, 173 insertions(+), 56 deletions(-) diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 69ef1192..18493be7 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -485,6 +485,47 @@ projects: * the pre-push sync covers rules and skills but not agents. Pushing a stale * rendering over a teammate's newer canonical file is the risk (#649 review). */ + /** + * Delivery and revocation are two halves of the same decision. When only + * delivery knew about the placement record, `pull` wrote the agent and the + * revocation pass deleted it again in the same run — so the record-based + * delivery was inert and the file churned on every pull. + */ + it('does not revoke an agent this machine published into an inactive namespace', async () => { + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + // Deploy it the way pull would. + await handler.pullItem( + { name: 'reviewer', type: 'agents', sourcePath, relativePath: 'agents/fe-agents/reviewer.yaml', namespace: 'fe-agents' }, + teamConfig, localConfig, + ); + const deployed = path.join(homeDir, '.claude/agents/reviewer.md'); + expect(await fse.pathExists(deployed)).toBe(true); + + // `fe-agents` is not active; only the record keeps this agent here. + await handler.cleanupInactiveNamespaces(teamConfig, localConfig, ['common']); + + expect(await fse.pathExists(deployed)).toBe(true); + }); + + it('still revokes an agent whose namespace went inactive with no record', async () => { + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Theirs\ninstructions: Read it.\n'); + await handler.pullItem( + { name: 'reviewer', type: 'agents', sourcePath, relativePath: 'agents/fe-agents/reviewer.yaml', namespace: 'fe-agents' }, + teamConfig, localConfig, + ); + const deployed = path.join(homeDir, '.claude/agents/reviewer.md'); + expect(await fse.pathExists(deployed)).toBe(true); + + await handler.cleanupInactiveNamespaces(teamConfig, localConfig, ['common']); + + expect(await fse.pathExists(deployed)).toBe(false); + }); + it('prefers an active source over the placement record', async () => { // The record is a FALLBACK. When an active namespace holds this stem, that // is the agent deployed here — and the one pull delivers. diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 918ce22a..412835c8 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -441,6 +441,27 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(git(['show', 'main:rules/be-know/foo.md'], fixture.remote)).toContain('The team rule'); }, 60_000); + it('pull delivers a published agent from an inactive namespace and keeps it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // `front-app` is never activated here: only the placement record keeps this + // agent. Delivery and revocation run in the SAME pull, so if only one of + // them knows about the record, the file is written and deleted again. + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + const deployed = path.join(fixture.projectRoot, '.claude/agents', 'vr.md'); + expect(fs.existsSync(deployed), pulled.output).toBe(true); + + // And it survives a second pull, which is when a revoke would show up. + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + expect(fs.existsSync(deployed), again.output).toBe(true); + }, 60_000); + it('removes only the published agent, through the real remove command', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); // A second agent of the same name in another namespace, and the author's diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 91f796c5..28d15e10 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1311,6 +1311,34 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('foo/bar'); }); + it('--dry-run reports the same destination the real push would use', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // An open PR holds the same name at a different namespace. The real push + // ignores it under --role, so the dry run must not report its destination. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/11', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, []); + + await push({ dryRun: true, role: 'pm' }); + + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.info).mock.calls.flat().join(' '); + expect(said).toContain('rules/pm/my-rule.md'); + expect(said).not.toContain('rules/fe-know/my-rule.md'); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + }); + it('keeps the placement of a group that pushed when a later group fails', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ diff --git a/src/pull.ts b/src/pull.ts index 2768cf8b..c57a40d2 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import matter from 'gray-matter'; -import { placedResourcePath } from './push-namespaces.js'; +import { selectAgentsForDirectory } from './resources/agents.js'; import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js'; import { pullRepo, getHeadRev, createGit } from './utils/git.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; @@ -275,25 +275,7 @@ export function filterAgentsByNamespaces( agentNamespaces: string[] | null, placedAgents?: Record, ): ResourceItem[] { - const active = agentNamespaces - ? agents.filter((agent) => !agent.namespace || agentNamespaces.includes(agent.namespace)) - : agents; - - // An agent this machine published with --role/--project lives in a namespace - // this directory need not activate, and push lets the author keep editing it - // through that record. Pull has to deliver it for the same reason: otherwise - // the local copy never tracks the team file, and the next push writes a stale - // rendering over whoever changed it (#649 review). A stem an ACTIVE namespace - // already claims is left alone — that agent is the one deployed here, and two - // would collide on the same flattened filename. - const claimed = new Set(active.map((agent) => agent.name)); - const recovered = placedAgents - ? agents.filter((agent) => agent.namespace - && !claimed.has(agent.name) - && placedResourcePath(placedAgents, 'agents', agent.name) - === `agents/${agent.namespace}/${path.basename(agent.relativePath)}`) - : []; - const kept = recovered.length > 0 ? [...active, ...recovered] : active; + const kept = selectAgentsForDirectory(agents, agentNamespaces, placedAgents); const seen = new Map(); for (const agent of kept) { diff --git a/src/push.ts b/src/push.ts index 2980080f..235f217e 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1134,6 +1134,38 @@ async function pushCore( return; } + // An open PR is matched by type and name alone. When the user has NAMED a + // destination, a pending entry that put the same-named resource somewhere + // else is a different resource: reusing its branch would force-push this + // content into that PR and move it to the wrong namespace (#649 review). + const requestedNamespaceFor = (type: PlaceableType): string | undefined => { + if (options.role) return options.role; + if (!options.project || !projectsManifest) return undefined; + const resolved = resolveProjectNamespace(projectsManifest, options.project, type); + return resolved.ok ? resolved.namespace : undefined; + }; + const conflictsWithRequest = (recorded: { type: string; namespace?: string }): boolean => { + if (!recorded.namespace) return false; + if (!isPlaceableType(recorded.type as ResourceType)) return false; + const requested = requestedNamespaceFor(recorded.type as PlaceableType); + return requested !== undefined && requested !== recorded.namespace; + }; + const reusablePending = pendingPushes.filter((entry) => { + const conflicting = entry.items.filter(conflictsWithRequest); + if (conflicting.length === 0) return true; + // Neither silent answer is safe: honouring the PR ignores the flag the user + // typed, and reusing the branch force-pushes this content into a review it + // may have nothing to do with. Say what is happening and open a new PR. + for (const recorded of conflicting) { + log.warn( + `[${recorded.type}] ${recorded.name} is awaiting review at ${recorded.relativePath} ` + + `(${entry.prUrl ?? entry.branch}). This push names a different namespace, so it goes to a ` + + 'separate PR and that one is left untouched.', + ); + } + return false; + }); + // ── Step 1: Display ALL scanned items with numbers ───────────────── console.log(''); console.log(`Found ${allItems.length} resource(s) to push:`); @@ -1176,7 +1208,7 @@ async function pushCore( if (options.dryRun) { // Same two steps, same order as a real run: an open PR's recorded // destination first, then placement for whatever is still at the root. - reuseRecordedDestinations(planPushGroups(allItems, pendingPushes)); + reuseRecordedDestinations(planPushGroups(allItems, reusablePending)); const placed = await placeNewResources({ items: allItems, options, localConfig, projectsManifest, skillsDestinationError, }); @@ -1206,40 +1238,12 @@ async function pushCore( // branch, which updates it in place; everything else goes into a new PR. Both // can happen in one run, so editing a resource under review updates its PR // without dragging unrelated resources into that review. - // An open PR is matched by type and name alone. When the user has NAMED a - // destination, a pending entry that put the same-named resource somewhere - // else is a different resource: reusing its branch would force-push this - // content into that PR and move it to the wrong namespace (#649 review). - const requestedNamespaceFor = (type: PlaceableType): string | undefined => { - if (options.role) return options.role; - if (!options.project || !projectsManifest) return undefined; - const resolved = resolveProjectNamespace(projectsManifest, options.project, type); - return resolved.ok ? resolved.namespace : undefined; - }; - const conflictsWithRequest = (recorded: { type: string; namespace?: string }): boolean => { - if (!recorded.namespace) return false; - if (!isPlaceableType(recorded.type as ResourceType)) return false; - const requested = requestedNamespaceFor(recorded.type as PlaceableType); - return requested !== undefined && requested !== recorded.namespace; - }; - const reusablePending = pendingPushes.filter((entry) => { - const conflicting = entry.items.filter(conflictsWithRequest); - if (conflicting.length === 0) return true; - // Neither silent answer is safe: honouring the PR ignores the flag the user - // typed, and reusing the branch force-pushes this content into a review it - // may have nothing to do with. Say what is happening and open a new PR. - for (const recorded of conflicting) { - log.warn( - `[${recorded.type}] ${recorded.name} is awaiting review at ${recorded.relativePath} ` - + `(${entry.prUrl ?? entry.branch}). This push names a different namespace, so it goes to a ` - + 'separate PR and that one is left untouched.', - ); - } - return false; - }); const groups = planPushGroups(selectedItems, reusablePending); reuseRecordedDestinations(groups); - for (const entry of partiallySelectedEntries(selectedItems, pendingPushes)) { + // The conflicting entries dropped above are deliberately not reused, so they + // are not "partly selected" either — warning about them would contradict the + // warning already given. + for (const entry of partiallySelectedEntries(selectedItems, reusablePending)) { log.warn( `Only part of ${entry.prUrl ?? entry.branch} is selected, so the selected resources go into a ` + 'new PR and will exist in both. Select all of its resources to update it in place instead.', diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 99131394..8b0684c6 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -61,6 +61,40 @@ export interface AgentResourceItem extends ResourceItem { * * Tools without an `agents` path in toolPaths are silently skipped. */ +/** + * The agents this directory should hold: the ones in an active namespace, plus + * any this machine published into a namespace it does not activate — push lets + * the author keep editing those through the placement record, so pull has to + * deliver them or the local copy never tracks the team file (#649). + * + * A stem an ACTIVE namespace already claims is left alone: agents deploy + * flattened, so two would collide on one filename, and the active one is the + * agent deployed here. + * + * Delivery and revocation both resolve through this. They must agree — when + * only delivery knew about the record, `pull` wrote the agent and the + * revocation pass deleted it again in the same run. + */ +export function selectAgentsForDirectory( + agents: ResourceItem[], + activeNamespaces: string[] | null, + placedAgents?: Record, +): ResourceItem[] { + if (activeNamespaces === null) return agents; + + const active = agents.filter( + (agent) => !agent.namespace || activeNamespaces.includes(agent.namespace), + ); + if (!placedAgents) return active; + + const claimed = new Set(active.map((agent) => agent.name)); + const recovered = agents.filter((agent) => agent.namespace + && !claimed.has(agent.name) + && placedResourcePath(placedAgents, 'agents', agent.name) + === `agents/${agent.namespace}/${path.basename(agent.relativePath)}`); + return recovered.length > 0 ? [...active, ...recovered] : active; +} + export class AgentsHandler extends ResourceHandler { readonly type = 'agents' as const; @@ -609,9 +643,16 @@ export class AgentsHandler extends ResourceHandler { activeNamespaces: string[], ): Promise { const items = await this.scanTeamForPull(teamConfig, localConfig); - const isActive = (item: AgentResourceItem): boolean => !item.namespace || activeNamespaces.includes(item.namespace); - const active = items.filter(isActive); - const inactive = items.filter((item) => !isActive(item) && !BUILTIN_AGENT_NAMES.has(item.name)); + // The same selection `pull` delivers with, records included: revoking an + // agent this machine published would delete the copy pull had just written. + const { placedAgents } = await loadStateForScope(localConfig); + const kept = new Set( + selectAgentsForDirectory(items, activeNamespaces, placedAgents).map((item) => item.relativePath), + ); + const active = items.filter((item) => kept.has(item.relativePath)); + const inactive = items.filter( + (item) => !kept.has(item.relativePath) && !BUILTIN_AGENT_NAMES.has(item.name), + ); if (inactive.length === 0) return; for (const { tool, dir: destDir } of await this.agentToolDirs(teamConfig, localConfig)) { From 18ebc340a7808404478eebc9ca908c421155155a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 16:12:40 +0200 Subject: [PATCH 15/25] fix(pull): stop the stale sweep from deleting the author's own placed rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pullAllRules` sweeps a local rule whose name is absent from the desired set. A rule published into a namespace keeps the author's copy at the rules ROOT under its bare name, while the desired set holds `/` — or nothing at all when that namespace is not active here — so the sweep deleted their own file, local edits included. The placement record marks it as theirs, and only while the team file it points at still exists. Pending-PR conflict detection trusted `PendingPushItem.namespace`, but the agent scan records a namespaced destination without setting that field, so those entries slipped past the check and a push naming another namespace could force-push into the PR under review. The namespace is derived from the recorded path when the field is absent, and the agent scan now sets it too — the field was the only thing telling `pendingNamespaceFor` where to put the resource. --- docs/usage-guide.md | 1 + docs/usage-guide.zh-CN.md | 1 + src/__tests__/push-role.test.ts | 29 ++++++++++++++ src/__tests__/rules.test.ts | 41 ++++++++++++++++++++ src/__tests__/skip-uninstalled-tools.test.ts | 3 ++ src/push.ts | 14 +++++-- src/resources/agents.ts | 5 +++ src/resources/rules.ts | 13 +++++++ 8 files changed, 104 insertions(+), 3 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 728730d6..4d0c6135 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -605,6 +605,7 @@ Choose namespace [1-3] (default: 1 = common): - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a different namespace, in which case the flag decides, the open PR is left untouched, and the collision is reported +- Your own copy of a rule you published into a namespace stays at the rules root and `pull` leaves it alone; it is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 35ccb759..1476c6f3 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -582,6 +582,7 @@ Choose namespace [1-3] (default: 1 = common): - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定了另一个 namespace,则以命令行为准,原 PR 保持不动,并提示该冲突 +- 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录,`pull` 不会删除它;只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 28d15e10..b7c8b163 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1311,6 +1311,35 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('foo/bar'); }); + it('detects a pending namespace recorded only in the path', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // The agent scan records a namespaced destination without setting the + // `namespace` field, so trusting that field let the entry slip past the + // conflict check and be force-pushed into (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/12', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml' }], + }], + }); + mockHandlers({ agents: [{ ...newAgent }] }, pushedItems); + + await push({ all: true, role: 'be-agents' }); + + expect(pushedItems[0]?.relativePath).toBe('agents/be-agents/vr.yaml'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')) + .toContain('awaiting review at agents/fe-agents/vr.yaml'); + }); + it('--dry-run reports the same destination the real push would use', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(), diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 0b3a3831..2176e5c1 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -608,6 +608,47 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'hooks.md'))).toBe(false); }); + /** + * A rule published into a namespace keeps the author's copy at the rules + * ROOT under its bare name, while the desired set holds `/` — or + * nothing at all when the namespace is not active here. Sweeping by name + * alone therefore deleted the author's own file, local edits included + * (#649 review). + */ + it("spares the author's root copy of a rule published into a namespace", async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'my local edits'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')) + .toBe('my local edits'); + }); + + it('still sweeps a root rule whose record points at a file that is gone', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'orphaned'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localRulesDir, 'my-rule.md'))).toBe(false); + }); + it('should remove stale files in subdirectories', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index f34ddf86..f4ac0fc8 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -8,6 +8,9 @@ vi.mock('../config.js', async (importOriginal) => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // pullAllRules reads placement records so its stale sweep spares the + // author's own copy of a rule published into a namespace. + loadStateForScope: vi.fn(async () => ({})), })); vi.mock('../utils/git.js', () => ({ diff --git a/src/push.ts b/src/push.ts index 235f217e..b06171c7 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1144,11 +1144,19 @@ async function pushCore( const resolved = resolveProjectNamespace(projectsManifest, options.project, type); return resolved.ok ? resolved.namespace : undefined; }; - const conflictsWithRequest = (recorded: { type: string; namespace?: string }): boolean => { - if (!recorded.namespace) return false; + const conflictsWithRequest = ( + recorded: { type: string; namespace?: string; relativePath: string }, + ): boolean => { if (!isPlaceableType(recorded.type as ResourceType)) return false; + // The path, not the field: a scan can record an item whose destination is + // namespaced while leaving `namespace` unset, and trusting the field let + // those entries slip past the check and be force-pushed into (#649 review). + const segments = recorded.relativePath.split('/'); + const recordedNamespace = recorded.namespace + ?? (segments.length === 3 ? segments[1] : undefined); + if (!recordedNamespace) return false; const requested = requestedNamespaceFor(recorded.type as PlaceableType); - return requested !== undefined && requested !== recorded.namespace; + return requested !== undefined && requested !== recordedNamespace; }; const reusablePending = pendingPushes.filter((entry) => { const conflicting = entry.items.filter(conflictsWithRequest); diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 8b0684c6..358d912a 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -377,6 +377,10 @@ export class AgentsHandler extends ResourceHandler { relativePath: `${teamDir}/${stem}.yaml`, status, mergedSpec: mergeResult.spec, + // Carried explicitly: an open PR records this item, and a record + // with no namespace reads as "shared root" to everything that + // later compares destinations (#649 review). + ...(located?.namespace ? { namespace: located.namespace } : {}), }); continue; } @@ -390,6 +394,7 @@ export class AgentsHandler extends ResourceHandler { relativePath: `${teamDir}/${stem}.md`, status, skipReason, + ...(located?.namespace ? { namespace: located.namespace } : {}), }); } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 4f8f4e66..7a04ddae 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -403,6 +403,19 @@ export class RulesHandler extends ResourceHandler { // 1.5. Clean up stale local rule files not present in team repo const teamRuleNames = new Set(rules.map((r) => r.name)); + // A rule this machine published into a namespace keeps the author's copy at + // the rules ROOT under its bare name. The desired set never contains that + // name — it is `/` there, or absent when the namespace is not + // active here — so the sweep below would delete the author's own file, + // local edits and all (#649 review). The record is what marks it as ours, + // and only while the team file it points at still exists. + const placedRules = (await loadStateForScope(localConfig)).placedRules; + for (const name of Object.keys(placedRules ?? {})) { + const placed = placedResourcePath(placedRules, 'rules', name); + if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { + teamRuleNames.add(name); + } + } const tombstones = await this.readTombstones(localConfig); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; From 7034a518e569139ef9c36aa79fc33269e2f0454a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 17:31:51 +0200 Subject: [PATCH 16/25] fix(push): defer the agents-axis failure to selection, reload projects.yaml after the pull, and stop a named namespace reusing a shared-root PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project with no agents namespace failed before the listing whenever any new agent was present locally, so a rules-only push under `--project` was blocked on an agent the user was never given the chance to deselect. Only an agent the scan itself skipped (`needsDestination`) fails early now — that one never reaches the listing, so deferring its error means never raising it. A new agent is listed, and step 4 raises the same error if it stays selected. `manifest/projects.yaml` was read in `push`, before `pushCore` pulled the team clone, so a project whose namespaces changed on the remote placed this run's new rules and agents by the previous pull's mapping. It is read inside `pushCore` now, after the pull, and in self mode from the fresh worktree. Pending-PR conflict detection treated a recorded path with no namespace as non-conflicting, so an explicit `--role`/`--project` reused a shared-root PR's branch and rebuilt it with the namespaced path, moving a review the user did not name from "everyone" to one namespace. The shared root is a destination like any other: it conflicts with any namespace the flag names. `pull` delivered a rule this machine placed at `/rules//` beside the author's copy at the rules root, so a tool that loads rules recursively applied the same rule twice, disagreeing as soon as the team file moved on. The placement record names the root copy as this rule's local file, so delivery updates it and removes the namespaced duplicate an earlier pull wrote. A namespace another member placed is untouched. --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/push-namespace-e2e.test.ts | 77 ++++++++++++++ src/__tests__/push-role.test.ts | 122 +++++++++++++++++++++++ src/__tests__/rules.test.ts | 47 ++++++++- src/push.ts | 95 +++++++++++------- src/resources/rules.ts | 32 +++++- src/types.ts | 7 ++ 8 files changed, 342 insertions(+), 46 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4d0c6135..32e1cb63 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -604,8 +604,8 @@ Choose namespace [1-3] (default: 1 = common): - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here -- A resource awaiting review in an open PR keeps that PR's destination — unless this push names a different namespace, in which case the flag decides, the open PR is left untouched, and the collision is reported -- Your own copy of a rule you published into a namespace stays at the rules root and `pull` leaves it alone; it is swept only once the team file it was placed at is gone +- A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported +- Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1476c6f3..595a304c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -581,8 +581,8 @@ Choose namespace [1-3] (default: 1 = common): - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 -- 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定了另一个 namespace,则以命令行为准,原 PR 保持不动,并提示该冲突 -- 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录,`pull` 不会删除它;只有当它对应的团队文件不存在时才会被清理 +- 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 +- 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 412835c8..e8542db9 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -190,6 +190,15 @@ function writeLocalResources(fixture: Fixture, ruleBody = '# Rule v1\n'): void { fs.writeFileSync(path.join(projectRoot, `.${agent}/agents`, agentFile.name), agentFile.content); } +/** Resolve once the wall clock has moved to the next second. */ +function nextSecond(): Promise { + const started = Math.floor(Date.now() / 1000); + return new Promise((resolve) => { + const tick = () => (Math.floor(Date.now() / 1000) > started ? resolve() : setTimeout(tick, 50)); + tick(); + }); +} + /** Files on the single push branch this fixture's remote received. */ function branchFiles(fixture: Fixture): { branch: string; files: string[] } { const branch = git( @@ -462,6 +471,74 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(fs.existsSync(deployed), again.output).toBe(true); }, 60_000); + it('pull updates the author\'s root copy of a placed rule instead of writing a second one', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // be-know is the backend role's knowledge namespace, so it IS active here + // and pull delivers the rule — which used to land at rules/be-know/ beside + // the author's own copy at the root, the same rule twice for a tool that + // loads rules recursively. + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Rule v2, edited by a teammate\n'); + + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + const rulesDir = path.join(fixture.projectRoot, '.claude/rules'); + expect(fs.readFileSync(path.join(rulesDir, 'my-rule.md'), 'utf8')).toContain('Rule v2'); + expect(fs.existsSync(path.join(rulesDir, 'be-know', 'my-rule.md')), pulled.output).toBe(false); + }, 60_000); + + it('leaves a shared-root PR untouched when the next push names a namespace', async () => { + // No knowledge namespace on the role, so the first push goes to the shared root. + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: ROLES_MANIFEST.replace('knowledge: [be-know]', 'knowledge: []'), + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v1\n'); + const first = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + const { branch: sharedBranch, files: sharedFiles } = branchFiles(fixture); + expect(sharedFiles, first.output).toContain('rules/my-rule.md'); + + // The PR is still open. Naming a namespace now must not rebuild it. Branch + // names carry a one-second timestamp, so a second push inside the same + // second would be given the pending branch's name and land on it for that + // reason alone; wait the second out so the test sees the conflict check. + await nextSecond(); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v2\n'); + const second = await runCLI(['push', '--role', 'fe-know', '--all'], fixture.projectRoot, fixture.home); + + expect(second.output).toContain('awaiting review at rules/my-rule.md'); + expect(second.output).toContain('separate PR'); + const branches = git( + ['for-each-ref', '--format=%(refname:short)', `refs/heads/teamai/push/${fixture.username}/`], + fixture.remote, + ).split('\n').filter(Boolean); + expect(branches, second.output).toHaveLength(2); + const other = branches.find((name) => name !== sharedBranch) ?? ''; + expect(git(['ls-tree', '-r', '--name-only', other], fixture.remote)).toContain('rules/fe-know/my-rule.md'); + // The review at the shared root still holds exactly what it did. + expect(git(['ls-tree', '-r', '--name-only', sharedBranch], fixture.remote)).toContain('rules/my-rule.md'); + expect(git(['show', `${sharedBranch}:rules/my-rule.md`], fixture.remote)).toContain('Rule v1'); + }, 60_000); + + it('places by the projects manifest the pull just fetched, not the one from the last pull', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // The remote renames the project's knowledge namespace after this clone + // was made. Read before the pull, the manifest still says fe-know. + commitOnMain(fixture, 'manifest/projects.yaml', PROJECTS_MANIFEST.replace('knowledge: [fe-know]', 'knowledge: [fe-know-v2]')); + + const result = await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + + expect(result.output).toContain('[rules] my-rule → rules/fe-know-v2/my-rule.md'); + const { files } = branchFiles(fixture); + expect(files, result.output).toContain('rules/fe-know-v2/my-rule.md'); + expect(files).not.toContain('rules/fe-know/my-rule.md'); + }, 60_000); + it('removes only the published agent, through the real remove command', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); // A second agent of the same name in another namespace, and the author's diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index b7c8b163..d00ffbc1 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1095,6 +1095,66 @@ describe('push namespace routing for rules and agents', () => { expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); }); + it('pushes a selected rule when only the unselected new agent lacks a project agents namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // A NEW agent reaches the listing, so the user can deselect it. Failing + // before the selection blocked a rules-only push on an agent that was + // never going out (#649 review). The scan-skipped case is different — see + // the test above — because that agent never reaches the listing at all. + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + // Deselect the agent, keep the rule (item order is rules then agents). + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([0]); + + await push({ project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems).toHaveLength(1); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('still fails when the new agent lacking a project agents namespace is selected', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + it('still fails when the skill lacking a project namespace is selected', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ @@ -1144,6 +1204,34 @@ describe('push namespace routing for rules and agents', () => { expect(said).toMatch(/everyone|whole team|shared/i); }); + it('reads the projects manifest only after the team clone has been pulled', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + // Read before the pull, the manifest is the previous pull's copy, and a + // namespace the remote has since changed places this run's new rules by + // the stale mapping (#649 review). + expect(mockPullRepo).toHaveBeenCalled(); + expect(mockLoadProjectsManifest).toHaveBeenCalled(); + const pullOrder = mockPullRepo.mock.invocationCallOrder[0]; + const manifestOrder = mockLoadProjectsManifest.mock.invocationCallOrder[0]; + expect(manifestOrder).toBeGreaterThan(pullOrder); + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + }); + it('rejects an unknown --project even when nothing needs placing', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ @@ -1340,6 +1428,40 @@ describe('push namespace routing for rules and agents', () => { .toContain('awaiting review at agents/fe-agents/vr.yaml'); }); + it('treats a pending shared-root resource as conflicting with an explicit namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // The open PR holds this rule at the shared root. Reusing its branch would + // force-rebuild it with the namespaced path and silently change the scope + // of a review the user did not name (#649 review). A recorded path with no + // namespace is as much a destination as a namespaced one. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/13', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/my-rule.md' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: 'fe-know' }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + // A new branch, never the pending one. + const branches = mockPushRepoBranch.mock.calls.map((call) => call[3]); + expect(branches).not.toContain('teamai/push/test/20260101-000000'); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.warn).mock.calls.flat().join(' '); + expect(said).toContain('awaiting review at rules/my-rule.md'); + expect(said).toContain('separate PR'); + }); + it('--dry-run reports the same destination the real push would use', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(), diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 2176e5c1..d0ba7584 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -627,12 +627,57 @@ scope: 'user', placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, } as State); - await handler.pullAllRules(teamConfig, localConfig); + await handler.pullAllRules(teamConfig, localConfig, [ + { name: 'other', type: 'rules', sourcePath: path.join(teamRulesDir, 'other.md'), relativePath: 'rules/other.md', status: 'new' }, + ]); expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')) .toBe('my local edits'); }); + /** + * When that namespace IS active here, the team file is delivered — and it + * used to land at `/rules//` beside the author's root copy, + * so a tool that loads rules recursively applied both, and they disagreed as + * soon as the team file moved on. The record names the root copy as this + * rule's local file, so delivery updates it and takes the duplicate with it + * (#649 review). + */ + it("delivers a rule this machine placed onto the author's root copy, not beside it", async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'stale root copy'); + await fse.outputFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'duplicate from an earlier pull'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('team content'); + expect(await fse.pathExists(path.join(localRulesDir, 'fe-know/my-rule.md'))).toBe(false); + }); + + it('delivers a namespaced rule another member placed to its namespace directory', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + // A record for a DIFFERENT namespace is not this rule's. + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/be-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'utf-8')).toBe('team content'); + expect(await fse.pathExists(path.join(localRulesDir, 'my-rule.md'))).toBe(false); + }); + it('still sweeps a root rule whose record points at a file that is gone', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); diff --git a/src/push.ts b/src/push.ts index b06171c7..913bb7b2 100644 --- a/src/push.ts +++ b/src/push.ts @@ -634,27 +634,14 @@ export async function push( // until the selection proves a skill is going out. // Deliberately manifest-resolved, not the raw project id, so it agrees with // what pull syncs (issue #375 P2 lesson). - let projectsManifest: ProjectsManifest | null = null; - if (options.project) { - if (options.role) { - log.error('Use either --role or --project, not both.'); - process.exitCode = 2; - return; - } - const { loadProjectsManifest, findProject, unknownProjectMessage } = await import('./projects.js'); - projectsManifest = await loadProjectsManifest(localConfig.repo.localPath); - if (!projectsManifest) { - log.error('This team repo defines no projects (no manifest/projects.yaml).'); - process.exitCode = 2; - return; - } - // The id is checked here, not with the namespaces: a typo must fail even on - // a push where nothing needs placing, instead of being silently ignored. - if (!findProject(projectsManifest, options.project)) { - log.error(unknownProjectMessage(projectsManifest, options.project)); - process.exitCode = 2; - return; - } + // The manifest itself is read in `pushCore`, AFTER the team clone is pulled: + // read here it would be the previous pull's copy, and a project whose + // namespaces changed on the remote would place this run's new rules and + // agents by the stale mapping (#649 review). + if (options.project && options.role) { + log.error('Use either --role or --project, not both.'); + process.exitCode = 2; + return; } try { const configContent = await readFileSafe(path.join(localConfig.repo.localPath, 'teamai.yaml')); @@ -715,7 +702,7 @@ export async function push( if (pendingTeamConfig !== null) { await writeFile(path.join(wtConfig.repo.localPath, 'teamai.yaml'), pendingTeamConfig); } - await pushCore(wtConfig, teamConfig, options, projectsManifest, pendingTeamConfig, result); + await pushCore(wtConfig, teamConfig, options, pendingTeamConfig, result); }); } catch (e) { if (e instanceof EmptyRepoError) { @@ -743,7 +730,7 @@ export async function push( return; } try { - await pushCore(localConfig, teamConfig, options, projectsManifest, null, result); + await pushCore(localConfig, teamConfig, options, null, result); } finally { await releaseLock(syncLock); } @@ -753,8 +740,6 @@ async function pushCore( localConfig: LocalConfig, teamConfig: TeamaiConfig, options: GlobalOptions & { all?: boolean; role?: string; project?: string }, - /** Loaded by `push` when --project is given; the namespace source for it. */ - projectsManifest: ProjectsManifest | null = null, initialPendingTeamConfig: string | null = null, result?: { completed: boolean }, ): Promise { @@ -812,6 +797,36 @@ async function pushCore( } } + // --project is a destination override expressed as a logical project. Each + // resource type then resolves from its OWN axis in manifest/projects.yaml — + // skills from `skills`, rules from `knowledge`, agents from `agents` — because + // a project may declare different namespaces for each (issue #649). A missing + // namespace only blocks a push that actually selects that type: the skills + // axis resolves against the scan, because it also relocates modified skills + // and the listing has to show where they go, but a failure there is held + // until the selection proves a skill is going out. + // Deliberately manifest-resolved, not the raw project id, so it agrees with + // what pull syncs (issue #375 P2 lesson). Read from the clone the pull above + // just refreshed (or the fresh worktree, in self mode), never from an earlier + // state of it. + let projectsManifest: ProjectsManifest | null = null; + if (options.project) { + const { loadProjectsManifest, findProject, unknownProjectMessage } = await import('./projects.js'); + projectsManifest = await loadProjectsManifest(localConfig.repo.localPath); + if (!projectsManifest) { + log.error('This team repo defines no projects (no manifest/projects.yaml).'); + process.exitCode = 2; + return; + } + // The id is checked here, not with the namespaces: a typo must fail even on + // a push where nothing needs placing, instead of being silently ignored. + if (!findProject(projectsManifest, options.project)) { + log.error(unknownProjectMessage(projectsManifest, options.project)); + process.exitCode = 2; + return; + } + } + // Sync team repo updates to local tool directories before scanning. // This prevents files changed by teammates from being falsely flagged as "modified". try { @@ -883,19 +898,20 @@ async function pushCore( fullScan.push(...items); } - // A project that cannot answer for agents has to fail HERE, not at step 4: - // an agent with no resolvable destination is dropped by the scan itself — - // skipped as "no active source" — so deferring the error until the selection - // proves one is going out means never raising it, and the run ends "No new or - // modified resources" on a flag that could not be honoured. + // A project that cannot answer for agents fails HERE only for an agent the + // scan itself dropped — skipped as "no active source" with `needsDestination` + // set. That one never reaches the listing, so deferring its error until the + // selection proves it is going out means never raising it, and the run would + // end "No new or modified resources" on a flag that could not be honoured. // - // Only agents that actually need a destination count. One already in a - // namespace is being modified in place and needs no placement, so an empty - // agents axis is none of its business (#649 review). - const needsAgentsDestination = (item: ResourceItem): boolean => item.type === 'agents' - && (item.status === 'new' - || ('needsDestination' in item && item.needsDestination === true)); - if (agentsDestinationError && fullScan.some(needsAgentsDestination)) { + // A NEW agent is different: it is listed, so the user can deselect it, and + // step 4 raises the same error if it stays selected. Failing for it here + // blocked a rules-only push on an agent that was never going out (#649 + // review). An agent already in a namespace is modified in place and needs no + // placement, so an empty agents axis is none of its business either. + const skippedForWantOfDestination = (item: ResourceItem): boolean => item.type === 'agents' + && 'needsDestination' in item && item.needsDestination === true; + if (agentsDestinationError && fullScan.some(skippedForWantOfDestination)) { log.error(agentsDestinationError); process.exitCode = 2; return; @@ -1154,7 +1170,10 @@ async function pushCore( const segments = recorded.relativePath.split('/'); const recordedNamespace = recorded.namespace ?? (segments.length === 3 ? segments[1] : undefined); - if (!recordedNamespace) return false; + // A recorded path with no namespace is the shared root — as much a + // destination as any namespace. Letting it through would reuse that PR's + // branch and rebuild it with the namespaced path, moving a review the + // user did not name from "everyone" to one namespace (#649 review). const requested = requestedNamespaceFor(recorded.type as PlaceableType); return requested !== undefined && requested !== recordedNamespace; }; diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 7a04ddae..55e21d67 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -231,6 +231,7 @@ export class RulesHandler extends ResourceHandler { // missing file is. Only a comparison against the render can see that, and // the render belongs here rather than in a second copy inside `doctor`. const source = await readFileSafe(item.sourcePath); + const localName = await this.localNameFor(item.name, localConfig); const targets: DeliveryTarget[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentExcluded(localConfig, tool)) continue; @@ -243,20 +244,42 @@ export class RulesHandler extends ResourceHandler { } const destDir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); + const ext = ruleFileExtensionForTool(tool); targets.push({ tool, - dest: path.join(destDir, `${item.name}${ruleFileExtensionForTool(tool)}`), + dest: path.join(destDir, `${localName}${ext}`), content: source === null ? undefined : renderRuleForTool(tool, source), + ...(localName !== item.name + ? { supersedes: path.join(destDir, `${item.name}${ext}`) } + : {}), }); } return targets; } + /** + * The name a delivered rule has in a tool's rules directory. It is the + * team name — `fe-know/my-rule` lands at `rules/fe-know/my-rule.*` — except + * for a rule THIS machine placed: push left the author's copy at the rules + * root under the bare name, and that copy is the one the scanner and the + * pre-push sync read, so delivery updates it rather than writing a second + * copy beside it that a tool loading rules recursively would apply as well + * (#649 review). + */ + private async localNameFor(teamName: string, localConfig: LocalConfig): Promise { + const bareName = path.basename(teamName); + if (bareName === teamName) return teamName; + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, + ); + return placed === `rules/${teamName}.md` ? bareName : teamName; + } + /** * Pull a single rule file to all configured AI tool rules/ directories. */ async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { - for (const { tool, dest, content } of await this.deliveryTargets(teamConfig, localConfig, item)) { + for (const { tool, dest, content, supersedes } of await this.deliveryTargets(teamConfig, localConfig, item)) { const destDir = path.dirname(dest); try { if (content === undefined) { @@ -267,8 +290,11 @@ export class RulesHandler extends ResourceHandler { await writeFile(dest, content); // Drop the `.md` copy left by an older layout; a tool that reads a // derived extension does not read it, and it would outlive the rule. - const legacyCopy = path.join(destDir, `${item.name}.md`); + const legacyCopy = path.join(destDir, `${path.basename(dest, path.extname(dest))}.md`); if (dest !== legacyCopy) await remove(legacyCopy); + // The namespaced copy an earlier pull wrote beside the author's root + // copy: the same rule twice, for a tool that loads rules recursively. + if (supersedes) await remove(supersedes); log.debug(`Synced rule ${item.name} → ${tool}`); } catch (e) { log.warn(`Failed to sync rule ${item.name} to ${tool}: ${(e as Error).message}`); diff --git a/src/types.ts b/src/types.ts index ef77c19e..3fed1afe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -710,6 +710,13 @@ export interface ResourceDiff { export interface DeliveryTarget { tool: string; dest: string; + /** + * A path this delivery makes redundant, removed once `dest` is written: a + * rule this machine placed in a namespace is delivered onto the author's + * root copy, and the `/` copy an earlier pull wrote is the same + * rule twice. + */ + supersedes?: string; /** * The exact bytes `pullItem` writes at `dest`, for a handler that renders * its destination rather than copying a tree there. It is what tells a copy From 7979af426090dd5d44279b959686871f2d7ba73f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 18:21:42 +0200 Subject: [PATCH 17/25] fix(push): stop on a stale clone under --project, retire a renamed canonical agent's recorded file, and prune placement records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed refresh of the team clone was only warned about, after which `--project` resolved every destination from the previous pull's `manifest/projects.yaml`. A namespace the remote had changed sent this run's new rules and agents to the members of the old one. `--project` stops now, and so does a new resource that would resolve from `manifest/roles.yaml`; a team with no roles manifest resolves from nothing that can go stale and keeps its behaviour. `--role` names the namespace itself and is unaffected. In self mode the placement record redirected a root canonical agent to the recorded file, extension included. `pushItem` writes by the source's extension, so an author who rewrote `vr.md` as `vr.yaml` had the `.yaml` written and the `.md` staged: the change never reached the branch and the `.md` stayed. The destination now keeps the record's directory and the source's extension, `pushItem` deletes the file it retires, `push` stages that deletion and moves the record to the new path. Placement records were written when a branch reached the remote and never removed. Once the PR was closed unmerged, or the file deleted upstream, the record pointed at nothing — until another member created the same path, at which point it came true again and their unrelated resource read as this author's. `push` and `pull` now drop a record whose target is neither on the default branch nor awaiting review on a branch origin still has, before anything reads the records. A record is kept when origin cannot be asked. --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/agents.test.ts | 25 ++++++ src/__tests__/placement-records.test.ts | 102 +++++++++++++++++++++++ src/__tests__/push-namespace-e2e.test.ts | 40 +++++++++ src/__tests__/push-role.test.ts | 95 +++++++++++++++++++++ src/pull.ts | 18 ++++ src/push.ts | 74 +++++++++++++++- src/resources/agents.ts | 25 +++++- src/utils/pending-push.ts | 54 ++++++++++++ 10 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/placement-records.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 32e1cb63..5ea6f5f7 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -605,6 +605,8 @@ Choose namespace [1-3] (default: 1 = common): - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported +- If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does a new resource that would resolve from `manifest/roles.yaml`. Fix the pull and retry, or name the namespace with `--role ` +- A placement record is dropped once its team file is neither on the default branch nor awaiting review in an open PR (the PR was closed unmerged, or the file was deleted later). `push` and `pull` both settle this before they read the records - Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 595a304c..0f9513ed 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -582,6 +582,8 @@ Choose namespace [1-3] (default: 1 = common): - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 +- push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;需要从 `manifest/roles.yaml` 解析落点的新资源同样如此。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace +- 落点记录会在其对应的团队文件既不在默认分支、也不在任何待评审 PR 中时被清除(PR 未合并即关闭,或文件之后被删除)。`push` 和 `pull` 都会在读取记录前先做这一步 - 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 18493be7..509056a4 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -410,6 +410,31 @@ projects: expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); }); + it('follows a renamed canonical source to its new extension and retires the recorded file', async () => { + const self = selfConfig(); + // Recorded and published as legacy .md; the author has since rewritten it as .yaml. + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.md'), '# vr\nLegacy body.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), + 'name: vr\ndescription: Rewritten\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.md' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + // Keeping the recorded .md as relativePath made pushGroup stage a path + // pushItem never wrote — the .yaml went unstaged and the .md stayed. + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); + expect(items[0]).toMatchObject({ supersedes: 'agents/fe/vr.md' }); + + await handler.pushItem(items[0]!, teamConfig, self); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(true); + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.md'))).toBe(false); + }); + it('removes the canonical source so the agent cannot republish itself', async () => { const self = selfConfig(); await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts new file mode 100644 index 00000000..6c5df191 --- /dev/null +++ b/src/__tests__/placement-records.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../utils/logger.js', () => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, +})); +const mockRemoteBranchExists = vi.fn(); +vi.mock('../utils/git.js', () => ({ + remoteBranchExists: (...args: unknown[]) => mockRemoteBranchExists(...args), +})); + +import { prunePlacementRecords } from '../utils/pending-push.js'; +import type { PendingPush } from '../types.js'; + +/** + * A placement record says "push put this resource of ours at that path". It is + * written when the branch reaches the remote, which is before the PR merges, + * so the target's absence from the default branch is normal while the PR is + * open — and permanent once the PR is closed unmerged or the file is deleted + * upstream. A record kept past that point comes true again the day another + * member creates the same path, and the author's unrelated copy is then + * treated as that resource (#649 review). + */ +describe('prunePlacementRecords', () => { + let repoPath: string; + const awaiting = (relativePath: string, branch = 'teamai/push/me/1'): PendingPush => ({ + branch, prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath }], + }); + + beforeEach(async () => { + repoPath = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-placed-')); + mockRemoteBranchExists.mockReset(); + }); + afterEach(async () => { await fse.remove(repoPath); }); + + it('keeps a record whose target is on the default branch', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await prunePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('keeps a record whose target is still awaiting review on an existing branch', async () => { + mockRemoteBranchExists.mockResolvedValue(true); + const state = { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, + pendingPushes: [awaiting('rules/fe/my-rule.md')], + }; + + expect(await prunePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('drops a record whose PR branch is gone and whose target never landed', async () => { + mockRemoteBranchExists.mockResolvedValue(false); + const state = { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, + pendingPushes: [awaiting('rules/fe/my-rule.md')], + }; + + expect(await prunePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + }); + + it('drops a record whose target was deleted upstream with nothing awaiting review', async () => { + const state = { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, + placedAgents: { vr: 'agents/fe/vr.yaml' }, + pendingPushes: [], + }; + + expect(await prunePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(state.placedAgents).toEqual({}); + expect(mockRemoteBranchExists).not.toHaveBeenCalled(); + }); + + it('keeps a record when the remote cannot be asked about its branch', async () => { + mockRemoteBranchExists.mockResolvedValue(null); + const state = { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, + pendingPushes: [awaiting('rules/fe/my-rule.md')], + }; + + expect(await prunePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('trusts a pending entry without asking the remote when told not to', async () => { + const state = { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, + pendingPushes: [awaiting('rules/fe/my-rule.md')], + }; + + expect(await prunePlacementRecords(repoPath, state, { verifyBranches: false })).toBe(false); + expect(mockRemoteBranchExists).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index e8542db9..75816cda 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -243,6 +243,16 @@ function commitOnMain(fixture: Fixture, relPath: string, content: string): void fs.rmSync(clone, { recursive: true, force: true }); } +/** Delete a file straight off the remote's default branch, as a teammate would. */ +function deleteOnMain(fixture: Fixture, relPath: string): void { + const clone = path.join(fixture.sandbox, `mate-rm-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + git(['rm', '-q', relPath], clone); + git(['commit', '-q', '-m', `teammate: delete ${relPath}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + fs.rmSync(clone, { recursive: true, force: true }); +} + const cleanups: string[] = []; afterEach(() => { while (cleanups.length) { @@ -539,6 +549,36 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(files).not.toContain('rules/fe-know/my-rule.md'); }, 60_000); + it('refuses a --project push when the team clone cannot be refreshed', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // The clone's manifest is whatever the last pull left. Point origin + // somewhere unreachable so this run cannot refresh it. + git(['remote', 'set-url', 'origin', path.join(fixture.sandbox, 'nowhere.git')], fixture.teamRepo); + + const result = await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('could not be refreshed'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('pull drops the placement record of a rule the team has since deleted', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); + deleteOnMain(fixture, 'rules/be-know/my-rule.md'); + + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + // Left in place, the record would claim the next `rules/be-know/my-rule.md` + // anybody creates as this author's, and their root copy would push over it. + expect(readState(fixture).placedRules ?? {}).toEqual({}); + }, 60_000); + it('removes only the published agent, through the real remove command', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); // A second agent of the same name in another namespace, and the author's diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index d00ffbc1..537fb0e9 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1232,6 +1232,101 @@ describe('push namespace routing for rules and agents', () => { expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); }); + it('stops a --project push when the team clone could not be refreshed', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockPullRepo.mockRejectedValueOnce(new Error('could not resolve host')); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + // The manifest in the clone is the previous pull's. A namespace the remote + // has since changed would route this rule to the wrong members, and a + // warning does not stop that (#649 review). + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); + }); + + it('drops a placement record whose PR was closed without merging, before scanning', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // The recorded target is not on the default branch and the branch that + // carried it is gone: the PR was closed, not merged. Kept, the record would + // come true again the day another member creates that very path, and the + // author's unrelated root copy would then be pushed over it (#649 review). + const { remoteBranchExists } = await import('../utils/git.js'); + vi.mocked(remoteBranchExists).mockResolvedValue(false); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/14', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({}, []); + + try { + await push({ all: true }); + + const saved = mockSaveStateForScope.mock.calls + .map((call) => call[0] as { placedRules?: Record }) + .find((state) => state.placedRules !== undefined && !('my-rule' in state.placedRules)); + expect(saved, 'no saved state dropped the record').toBeDefined(); + } finally { + vi.mocked(remoteBranchExists).mockResolvedValue(true); + } + }); + + it('moves the placement record to the extension a renamed canonical agent now has', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, pendingPushes: [], + placedAgents: { vr: 'agents/fe-agents/vr.md' }, + }); + // The scan followed the record to the legacy .md, but the source is a + // .yaml now: the write goes to one path, so the record and the staged + // file must both follow it, and the .md it replaces must go too. + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/.teamai/agents/vr.yaml', + relativePath: 'agents/fe-agents/vr.yaml', status: 'modified', namespace: 'fe-agents', + supersedes: 'agents/fe-agents/vr.md', + }], + }, pushedItems); + + await push({ all: true }); + + const staged = mockPushRepoBranch.mock.calls[0]?.[2] as string[]; + expect(staged).toContain('agents/fe-agents/vr.yaml'); + expect(staged).toContain('agents/fe-agents/vr.md'); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; + expect(saved.placedAgents).toEqual({ vr: 'agents/fe-agents/vr.yaml' }); + }); + it('rejects an unknown --project even when nothing needs placing', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ diff --git a/src/pull.ts b/src/pull.ts index c57a40d2..d2055476 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -9,6 +9,7 @@ import { pendingLearningsDir } from './utils/pending-learnings.js'; import { learningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; +import { prunePlacementRecords } from './utils/pending-push.js'; import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; @@ -774,6 +775,23 @@ async function pullForScope( return; } + // A placement record whose team file is gone — deleted upstream, or never + // merged — must not survive to claim the next same-named file somebody + // creates. Settle it against the tree just refreshed, before delivery reads + // the records (#649 review). A record whose PR is still open is kept; one + // whose branch is gone from origin is not, since the merge would have put + // the file on the default branch just refreshed. + if (!options.dryRun) { + try { + const recordsState = await loadStateForScope(localConfig); + if (await prunePlacementRecords(localConfig.repo.localPath, recordsState)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.debug(`[${scopeLabel}] Placement record cleanup skipped: ${(e as Error).message}`); + } + } + // Publish what contribute queued. Here rather than inside the refresh, which // returns early for single-repo and HTTP: contribute tells the member the next // pull will retry, and that has to hold in every mode. pull() holds the diff --git a/src/push.ts b/src/push.ts index 913bb7b2..3026bce4 100644 --- a/src/push.ts +++ b/src/push.ts @@ -6,7 +6,7 @@ import { createGit, pullRepo, pushRepoBranch, checkoutMaster, generateBranchName, resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, } from './utils/git.js'; -import { +import { prunePlacementRecords, findPendingForItem, partiallySelectedEntries, pendingNamespaceFor, planPushGroups, prunePendingPushes, recordPendingPush, toPendingItems, type PushGroup, } from './utils/pending-push.js'; @@ -308,6 +308,14 @@ function collisionPaths(type: PlaceableType, placedAt: string): string[] { */ function recordPlacements(state: State, items: ResourceItem[]): void { for (const item of items) { + // A recorded agent whose canonical source changed extension was written + // to a new path this run; the record has to follow it or the next scan + // finds nothing at the recorded one and reads the agent as new. The scan + // only sets `supersedes` when it reached the file through the record. + if (item.type === 'agents' && supersededPathOf(item)) { + state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; + continue; + } if (item.status !== 'new' || !item.namespace) continue; // A rule the scanner already found in a subdirectory carries the namespace // in its name and matches by full path, so it needs no record. @@ -320,6 +328,11 @@ function recordPlacements(state: State, items: ResourceItem[]): void { } } +/** The team-relative path `pushItem` retired while writing `item`, if any. */ +function supersededPathOf(item: ResourceItem): string | undefined { + return 'supersedes' in item && typeof item.supersedes === 'string' ? item.supersedes : undefined; +} + /** * Give every resource waiting in an open PR back the destination that PR * recorded, rather than asking again — a different answer would silently move @@ -370,8 +383,10 @@ async function placeNewResources(args: { localConfig: LocalConfig; projectsManifest: ProjectsManifest | null; skillsDestinationError?: string; + /** The clone could not be refreshed this run; its manifests may be stale. */ + teamRepoStale?: boolean; }): Promise { - const { items, options, localConfig, projectsManifest, skillsDestinationError } = args; + const { items, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale } = args; // A project that declares no skills namespace only blocks the push once a // skill is actually selected, so a rule can still go out from a scan that @@ -388,6 +403,22 @@ async function placeNewResources(args: { ); if (newAtRoot.length === 0) continue; + // Same reasoning as --project in pushCore, for the roles manifest: a + // namespace resolved from an unrefreshed clone may name the wrong members. + // A team with no roles manifest at all resolves from nothing that can go + // stale, and keeps its pre-manifest behaviour. + if ( + teamRepoStale && !options.role + && await pathExists(path.join(localConfig.repo.localPath, 'manifest', 'roles.yaml')) + ) { + log.error( + `Cannot place new ${type}: the team repo could not be refreshed, so manifest/roles.yaml may be ` + + 'stale. Fix the pull and retry, or name the namespace with --role .', + ); + process.exitCode = 1; + return false; + } + const destination = await resolveNamespaceForNew(type, options, localConfig, projectsManifest); switch (destination.kind) { case 'unresolvable': @@ -473,6 +504,10 @@ async function pushGroup(args: { await handler.pushItem(item, teamConfig, localConfig); workingTreeDirtied = true; pushedFiles.push(item.relativePath); + // A file this write retired (a canonical agent renamed .md ↔ .yaml) is + // tracked on the default branch, so staging its path stages the removal. + const supersedes = supersededPathOf(item); + if (supersedes) pushedFiles.push(supersedes); } // Refresh marketplace.json if it exists and skills were pushed @@ -761,6 +796,9 @@ async function pushCore( // working-tree content before the reset and restore it after pull, so config edits // survive and get committed alongside resources (see gitFiles construction below). let pendingTeamConfig: string | null = initialPendingTeamConfig; + // Set when the pull below failed: everything read from the clone after this + // point is the previous pull's, manifests included. + let teamRepoStale = false; if (!selfMode) { const pullSpin = spinner('Pulling latest changes...').start(); try { @@ -793,6 +831,7 @@ async function pushCore( } pullSpin.succeed('Up to date'); } catch (e) { + teamRepoStale = true; pullSpin.warn(`Pull failed: ${(e as Error).message}`); } } @@ -811,6 +850,17 @@ async function pushCore( // state of it. let projectsManifest: ProjectsManifest | null = null; if (options.project) { + // A warning is not enough here: with the clone unrefreshed, a namespace + // the remote has changed would send this run's new rules and agents to + // the members of the OLD one, and nothing later in the run can tell. + if (teamRepoStale) { + log.error( + 'Cannot resolve --project destinations: the team repo could not be refreshed, so ' + + 'manifest/projects.yaml may be stale. Fix the pull and retry, or name the namespace with --role .', + ); + process.exitCode = 1; + return; + } const { loadProjectsManifest, findProject, unknownProjectMessage } = await import('./projects.js'); projectsManifest = await loadProjectsManifest(localConfig.repo.localPath); if (!projectsManifest) { @@ -827,6 +877,22 @@ async function pushCore( } } + // Placement records outlive their purpose when the PR they were written for + // is closed unmerged, or the team file is later deleted. Settle that against + // the clone just pulled, BEFORE the scan reads the records: a stale one would + // match the next same-named file anybody creates (#649 review). Not when the + // clone is stale itself — a file missing from an unrefreshed tree proves nothing. + if (!teamRepoStale) { + try { + const recordsState = await loadStateForScope(localConfig); + if (await prunePlacementRecords(localConfig.repo.localPath, recordsState)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.debug(`Placement record cleanup skipped: ${(e as Error).message}`); + } + } + // Sync team repo updates to local tool directories before scanning. // This prevents files changed by teammates from being falsely flagged as "modified". try { @@ -1237,7 +1303,7 @@ async function pushCore( // destination first, then placement for whatever is still at the root. reuseRecordedDestinations(planPushGroups(allItems, reusablePending)); const placed = await placeNewResources({ - items: allItems, options, localConfig, projectsManifest, skillsDestinationError, + items: allItems, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale, }); if (!placed) return; log.info('Dry run — no changes made'); @@ -1279,7 +1345,7 @@ async function pushCore( // ── Step 4: Place NEW root-level resources in a namespace (after selection) ─ if (!await placeNewResources({ - items: selectedItems, options, localConfig, projectsManifest, skillsDestinationError, + items: selectedItems, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale, })) return; // ── Step 5: Push each group — one branch/PR per group ────────────── diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 358d912a..90e75e13 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -50,6 +50,12 @@ export interface AgentResourceItem extends ResourceItem { needsDestination?: boolean; /** True when item came from a legacy .md team-repo file (older format). */ legacy?: boolean; + /** + * Team-relative path of the file this push retires: the recorded canonical + * file when the author renamed their source from `.md` to `.yaml` or back. + * `pushItem` deletes it and `push` stages the deletion and moves the record. + */ + supersedes?: string; } /** @@ -145,6 +151,7 @@ export class AgentsHandler extends ResourceHandler { let teamRelPath = `${relDir}/${file}`; let basePath = path.join(localConfig.repo.localPath, teamRelPath); let baseExists = await pathExists(basePath); + let supersedes: string | undefined; // A canonical source authored at .teamai/agents/ root and placed // under agents// has nothing at agents/.yaml, so without // the record it reads as brand new — and the collision check then @@ -152,12 +159,17 @@ export class AgentsHandler extends ResourceHandler { if (!baseExists && !namespace) { const placed = placedResourcePath(placedAgents, 'agents', stem); if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { - teamRelPath = placed; + // The destination keeps the record's directory but THIS file's + // extension: `pushItem` writes by the source's extension, so a + // relativePath still naming the recorded `.md` would stage a + // path nothing was written to, and leave that `.md` behind. + teamRelPath = `${path.posix.dirname(placed)}/${file}`; + if (teamRelPath !== placed) supersedes = placed; basePath = path.join(localConfig.repo.localPath, placed); baseExists = true; } } - if (baseExists && await fileContentEqual(activePath, basePath)) continue; // unchanged + if (baseExists && !supersedes && await fileContentEqual(activePath, basePath)) continue; // unchanged directItems.push({ name: stem, @@ -169,6 +181,7 @@ export class AgentsHandler extends ResourceHandler { ...(baseExists && teamRelPath !== `${relDir}/${file}` ? { namespace: teamRelPath.split('/')[1] } : {}), + ...(supersedes ? { supersedes } : {}), }); directStems.add(stem); } @@ -481,6 +494,14 @@ export class AgentsHandler extends ResourceHandler { await ensureDir(path.dirname(dest)); await copyFile(item.sourcePath, dest); } + // The recorded file under the other extension is the same agent; two + // canonical files for one stem is what pull reports as a collision. + if (agentItem.supersedes) { + const retired = path.resolve(localConfig.repo.localPath, agentItem.supersedes); + assertWithinRoot(path.join(localConfig.repo.localPath, 'agents'), retired, + `Invalid superseded agent path outside team repo agents directory: ${agentItem.supersedes}`); + if (retired !== dest) await remove(retired); + } log.debug(`Copied agent ${item.name} → team repo (${ext} verbatim)`); } diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 8344c03e..86b9ba4e 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -12,6 +12,8 @@ * and force-push the recorded branch — updating the existing PR in place — * instead of opening a duplicate. */ +import path from 'node:path'; +import { pathExists } from './fs.js'; import { remoteBranchExists } from './git.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -143,3 +145,55 @@ export function toPendingItems(items: ResourceItem[]): PendingPush['items'] { namespace: i.namespace, })); } + +/** + * Drop placement records (`placedRules`, `placedAgents`) whose target is + * neither on the default branch nor awaiting review in an open PR. + * + * A record is written when the branch reaches the remote, before the PR + * merges, so a target missing from the default branch is normal while that + * PR is open. It is permanent once the PR was closed unmerged or the file was + * deleted upstream — and a record kept past that point comes true again the + * day another member creates the same path: the scan, the pre-push sync and + * removal would then treat their unrelated resource as this author's, and the + * author's own root copy would be pushed over it (#649 review). + * + * A referencing PR branch is looked up on origin, the way `prunePendingPushes` + * does, and a record is kept when the remote cannot answer. `verifyBranches: + * false` trusts the pending entries as they stand instead. + */ +export async function prunePlacementRecords( + repoPath: string, + state: Pick, + options: { verifyBranches?: boolean } = {}, +): Promise { + const verifyBranches = options.verifyBranches ?? true; + const branchAlive = new Map(); + const awaitingReview = async (relativePath: string): Promise => { + for (const entry of state.pendingPushes ?? []) { + if (!entry.items.some((item) => item.relativePath === relativePath)) continue; + if (!verifyBranches) return true; + if (!branchAlive.has(entry.branch)) { + branchAlive.set(entry.branch, await remoteBranchExists(repoPath, entry.branch)); + } + // `null` = could not ask; keep the record rather than guess. + if (branchAlive.get(entry.branch) !== false) return true; + } + return false; + }; + + let changed = false; + for (const field of ['placedRules', 'placedAgents'] as const) { + const records = state[field]; + if (!records) continue; + for (const [name, recorded] of Object.entries(records)) { + if (await pathExists(path.join(repoPath, recorded))) continue; + if (await awaitingReview(recorded)) continue; + log.debug(`Dropping placement record ${field}.${name} → ${recorded}: not on the default branch and not awaiting review`); + const { [name]: _dropped, ...rest } = records; + state[field] = rest; + changed = true; + } + } + return changed; +} From 708238dc08a788ea0c8d87479e80e319874199eb Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 18:52:17 +0200 Subject: [PATCH 18/25] fix(push): record a placement only once it has landed, withdraw it when a shared-root file takes the name, and drop every stale record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement records were written when the branch reached the remote, so a PR closed without merging left one behind for as long as its branch stayed — and no provider here can say whether a PR is open. The record now travels on the pending PR entry (`PendingPushItem.placed`, with the blob push wrote) and becomes a `placedRules`/`placedAgents` record only when that blob is in the default branch's history for the path: the PR merged, however the platform merged it. A path that merely exists is not enough, since another member may have created it after the PR was closed. `push`, `pull` and `remove` settle this before reading the records; the stale sweep spares a root copy whose placement is still awaiting review. `localNameFor` redirected a recorded rule onto the bare root path without asking whether a shared-root rule of the same name was being delivered too, so both landed on the one file in loop order, and the next push could follow the record and carry the shared rule over the namespaced one. Delivery keeps the namespaced path when the shared root holds that name, and the reconcile pass withdraws the record with a warning: the root copy follows the shared rule from then on. Dropping stale records destructured from the ORIGINAL map each iteration, so a later deletion put back what an earlier one had removed and only the last stale record went. The kept entries are rebuilt in one pass. --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/placement-records.test.ts | 169 ++++++++++++++++------- src/__tests__/push-namespace-e2e.test.ts | 63 ++++++++- src/__tests__/push-role.test.ts | 113 +++++++++------ src/__tests__/rules.test.ts | 40 ++++++ src/pull.ts | 14 +- src/push.ts | 59 ++------ src/remove.ts | 13 ++ src/resources/rules.ts | 19 ++- src/types.ts | 15 ++ src/utils/git.ts | 25 ++++ src/utils/pending-push.ts | 160 +++++++++++++++------ 13 files changed, 495 insertions(+), 203 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5ea6f5f7..06b5d569 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -599,14 +599,14 @@ Choose namespace [1-3] (default: 1 = common): - The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` - A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses -- A placed resource stays maintainable from the machine that published it. `state.json` records where push put each one, so a later edit of the author's own copy goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source +- A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported - If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does a new resource that would resolve from `manifest/roles.yaml`. Fix the pull and retry, or name the namespace with `--role ` -- A placement record is dropped once its team file is neither on the default branch nor awaiting review in an open PR (the PR was closed unmerged, or the file was deleted later). `push` and `pull` both settle this before they read the records +- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records - Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 0f9513ed..2ca571a1 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -576,14 +576,14 @@ Choose namespace [1-3] (default: 1 = common): - 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` - 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 -- 已落点的资源在发布它的机器上仍可维护:`state.json` 会记录 push 的落点,因此作者修改自己的副本后仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 +- 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 - push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;需要从 `manifest/roles.yaml` 解析落点的新资源同样如此。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace -- 落点记录会在其对应的团队文件既不在默认分支、也不在任何待评审 PR 中时被清除(PR 未合并即关闭,或文件之后被删除)。`push` 和 `pull` 都会在读取记录前先做这一步 +- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步 - 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index 6c5df191..357351c7 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -3,100 +3,163 @@ import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +const mockWarn = vi.fn(); vi.mock('../utils/logger.js', () => ({ - log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + log: { debug: vi.fn(), info: vi.fn(), warn: (...args: unknown[]) => mockWarn(...args), error: vi.fn(), success: vi.fn() }, })); const mockRemoteBranchExists = vi.fn(); -vi.mock('../utils/git.js', () => ({ +vi.mock('../utils/git.js', async () => ({ + ...(await vi.importActual('../utils/git.js')), remoteBranchExists: (...args: unknown[]) => mockRemoteBranchExists(...args), })); +import { execFileSync } from 'node:child_process'; -import { prunePlacementRecords } from '../utils/pending-push.js'; -import type { PendingPush } from '../types.js'; +import { reconcilePlacementRecords, isPlacement } from '../utils/pending-push.js'; +import type { PendingPush, ResourceItem } from '../types.js'; /** - * A placement record says "push put this resource of ours at that path". It is - * written when the branch reaches the remote, which is before the PR merges, - * so the target's absence from the default branch is normal while the PR is - * open — and permanent once the PR is closed unmerged or the file is deleted - * upstream. A record kept past that point comes true again the day another - * member creates the same path, and the author's unrelated copy is then - * treated as that resource (#649 review). + * A placement record says "the author's root copy of IS the team file + * at //". Push marks the placement on the pending PR entry; + * only once that file is on the default branch does it become a record — so a + * PR closed unmerged, branch kept or not, never leaves one behind, and no + * provider has to be asked whether a PR is open. A record is withdrawn again + * when its file is gone, or when a shared-root file of the same name appears + * and takes over the root path in every tool directory (#649 review). */ -describe('prunePlacementRecords', () => { +describe('reconcilePlacementRecords', () => { let repoPath: string; - const awaiting = (relativePath: string, branch = 'teamai/push/me/1'): PendingPush => ({ - branch, prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', - items: [{ type: 'rules', name: 'my-rule', relativePath }], + const pending = (items: PendingPush['items'], branch = 'teamai/push/me/1'): PendingPush => ({ + branch, prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', items, }); + const placedRule = { type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe', placed: true }; beforeEach(async () => { repoPath = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-placed-')); mockRemoteBranchExists.mockReset(); + mockWarn.mockReset(); }); afterEach(async () => { await fse.remove(repoPath); }); - it('keeps a record whose target is on the default branch', async () => { + it('records a placement once its file is on the default branch', async () => { await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); - const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; - expect(await prunePlacementRecords(repoPath, state)).toBe(false); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); }); - it('keeps a record whose target is still awaiting review on an existing branch', async () => { - mockRemoteBranchExists.mockResolvedValue(true); - const state = { - placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, - pendingPushes: [awaiting('rules/fe/my-rule.md')], - }; + it('records nothing while the placement is not on the default branch, whatever its branch is doing', async () => { + // Open PR, or closed unmerged with the branch kept: the same from here, + // and neither may leave a record. + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; - expect(await prunePlacementRecords(repoPath, state)).toBe(false); - expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({}); + expect(mockRemoteBranchExists).not.toHaveBeenCalled(); }); - it('drops a record whose PR branch is gone and whose target never landed', async () => { - mockRemoteBranchExists.mockResolvedValue(false); - const state = { - placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, - pendingPushes: [awaiting('rules/fe/my-rule.md')], - }; + it('records a placement only when the blob it pushed is in the default branch history for that path', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'ours, as pushed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'merge ours']); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + // A teammate edits it afterwards: the path still exists, the blob differs now. + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'edited after the merge\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate edit']); + await fse.outputFile(path.join(repoPath, 'never-committed.md'), 'never pushed anywhere\n'); + const theirs = git(['hash-object', 'never-committed.md']); + + const landed = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, blob: ours }])] }; + expect(await reconcilePlacementRecords(repoPath, landed)).toBe(true); + expect(landed.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + + // Same path, but what is there was never what we pushed: somebody else's file. + const shadow = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, blob: theirs }])] }; + expect(await reconcilePlacementRecords(repoPath, shadow)).toBe(false); + expect(shadow.placedRules).toEqual({}); + }); - expect(await prunePlacementRecords(repoPath, state)).toBe(true); + it('does not record a pending item that was not a placement', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, placed: undefined }])] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); expect(state.placedRules).toEqual({}); }); - it('drops a record whose target was deleted upstream with nothing awaiting review', async () => { + it('keeps a record whose file is on the default branch', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('drops every record whose file the team has deleted, not just the last one', async () => { + // Deleting by destructuring from the ORIGINAL map put back what an earlier + // iteration had removed, so only the last stale record actually went. + await fse.outputFile(path.join(repoPath, 'rules/fe/kept.md'), 'x'); const state = { - placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, - placedAgents: { vr: 'agents/fe/vr.yaml' }, + placedRules: { gone1: 'rules/fe/gone1.md', kept: 'rules/fe/kept.md', gone2: 'rules/fe/gone2.md' }, + placedAgents: { vr: 'agents/fe/vr.yaml', qa: 'agents/fe/qa.yaml' }, pendingPushes: [], }; - expect(await prunePlacementRecords(repoPath, state)).toBe(true); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({ kept: 'rules/fe/kept.md' }); + expect(state.placedAgents).toEqual({}); + }); + + it('withdraws a record once a shared-root file of the same name exists, and says so', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'the author\'s'); + await fse.outputFile(path.join(repoPath, 'rules/my-rule.md'), 'somebody else\'s, for everyone'); + const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({}); + expect(mockWarn.mock.calls.flat().join(' ')).toContain('rules/my-rule.md now exists at the shared root'); + }); + + it('withdraws an agent record shadowed by a legacy shared-root .md of the same stem', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\n'); + await fse.outputFile(path.join(repoPath, 'agents/vr.md'), '# vr\n'); + const state = { placedRules: {}, placedAgents: { vr: 'agents/fe/vr.yaml' }, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedAgents).toEqual({}); - expect(mockRemoteBranchExists).not.toHaveBeenCalled(); }); - it('keeps a record when the remote cannot be asked about its branch', async () => { - mockRemoteBranchExists.mockResolvedValue(null); - const state = { - placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, - pendingPushes: [awaiting('rules/fe/my-rule.md')], - }; + it('does not record a landed placement that a shared-root file already shadows', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + await fse.outputFile(path.join(repoPath, 'rules/my-rule.md'), 'y'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; - expect(await prunePlacementRecords(repoPath, state)).toBe(false); - expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({}); }); +}); - it('trusts a pending entry without asking the remote when told not to', async () => { - const state = { - placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, - pendingPushes: [awaiting('rules/fe/my-rule.md')], +describe('isPlacement', () => { + const base = { sourcePath: '/tmp/x', status: 'new' as const }; + it('is a new root rule or agent that ended up namespaced', () => { + expect(isPlacement({ ...base, type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe' })).toBe(true); + expect(isPlacement({ ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe' })).toBe(true); + }); + it('is not a rule the scanner found in a subdirectory, a modified item, a skill, or an unplaced one', () => { + expect(isPlacement({ ...base, type: 'rules', name: 'fe/my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe' })).toBe(false); + expect(isPlacement({ ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', status: 'modified' })).toBe(false); + expect(isPlacement({ ...base, type: 'skills', name: 's', relativePath: 'skills/fe/s', namespace: 'fe' })).toBe(false); + expect(isPlacement({ ...base, type: 'rules', name: 'my-rule', relativePath: 'rules/my-rule.md' })).toBe(false); + }); + it('is an agent rewritten under another extension through its record', () => { + const item: ResourceItem & { supersedes: string } = { + ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', + status: 'modified', supersedes: 'agents/fe/vr.md', }; - - expect(await prunePlacementRecords(repoPath, state, { verifyBranches: false })).toBe(false); - expect(mockRemoteBranchExists).not.toHaveBeenCalled(); + expect(isPlacement(item)).toBe(true); }); }); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 75816cda..1ec9b8f2 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -290,9 +290,15 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(files).not.toContain('rules/my-rule.md'); expect(files).not.toContain('agents/vr.yaml'); - // The author's copy stays at the tool's rules root, so push records where - // it put it; without that the next scan reads it as a brand-new rule. - expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + // The author's copy stays at the tool's rules root, so push marks where it + // put it on the pending PR entry; the record itself is written once the + // PR merges (see the next case), so a PR closed unmerged leaves none. + const state = readState(fixture) as { placedRules?: unknown; pendingPushes: Array<{ items: Array> }> }; + expect(state.placedRules ?? {}).toEqual({}); + expect(state.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true }), + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), + ])); }, 60_000); it('sends an edit of the root copy back to the same namespace after the PR merges', async () => { @@ -307,6 +313,8 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { fixture.home, ); expect(unchanged.output).toContain('No new or modified resources to push'); + // That run found the file on the default branch, so the placement is a record now. + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v2\n'); const edited = await runCLI( @@ -568,7 +576,8 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { writeLocalResources(fixture); await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); mergeBranch(fixture, branchFiles(fixture).branch); - expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); + const landed = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(readState(fixture).placedRules, landed.output).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); deleteOnMain(fixture, 'rules/be-know/my-rule.md'); const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); @@ -579,6 +588,52 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(readState(fixture).placedRules ?? {}).toEqual({}); }, 60_000); + it('never records a placement whose PR was closed without merging, even with the branch kept', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + expect(branchFiles(fixture).files).toContain('rules/be-know/my-rule.md'); + + // Nothing merges. The branch stays on the remote, as a closed PR's often does. + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + + // A teammate later publishes an unrelated rule at that very path. + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Somebody else\'s rule\n'); + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + // Delivered to its namespace directory; the author's root copy is untouched. + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), 'utf8')).toBe('# Rule v1\n'); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules/be-know', 'my-rule.md'), 'utf8')) + .toContain("Somebody else's rule"); + }, 60_000); + + it('withdraws a placement record when a shared-root rule takes the name', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); + + commitOnMain(fixture, 'rules/my-rule.md', '# Shared rule for everyone\n'); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + expect(pulled.output).toContain('rules/my-rule.md now exists at the shared root'); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + const rulesDir = path.join(fixture.projectRoot, '.claude/rules'); + expect(fs.readFileSync(path.join(rulesDir, 'my-rule.md'), 'utf8')).toContain('Shared rule'); + expect(fs.readFileSync(path.join(rulesDir, 'be-know', 'my-rule.md'), 'utf8')).toContain('Rule v1'); + // And the next push does not follow the withdrawn record onto the namespaced file. + fs.writeFileSync(path.join(rulesDir, 'my-rule.md'), '# Shared rule, edited here\n'); + await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + const { branch, files } = branchFiles(fixture); + expect(files, branch).toContain('rules/my-rule.md'); + expect(git(['show', `${branch}:rules/be-know/my-rule.md`], fixture.remote)).toContain('Rule v1'); + }, 60_000); + it('removes only the published agent, through the real remove command', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); // A second agent of the same name in another namespace, and the author's diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 537fb0e9..6b1e9590 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -71,6 +71,8 @@ vi.mock('../utils/git.js', () => ({ getDefaultBranch: vi.fn().mockResolvedValue('main'), remoteBranchExists: vi.fn().mockResolvedValue(true), getFileContentAtRev: vi.fn().mockResolvedValue(null), + hashObject: vi.fn().mockResolvedValue(null), + blobInHistory: vi.fn().mockResolvedValue(null), })); const mockLoadProjectsManifest = vi.fn().mockResolvedValue(null); @@ -1260,39 +1262,50 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); }); - it('drops a placement record whose PR was closed without merging, before scanning', async () => { - mockAutoDetectInit.mockResolvedValue({ - localConfig: makeLocalConfig(), - teamConfig: makeTeamConfig(), - }); - // The recorded target is not on the default branch and the branch that - // carried it is gone: the PR was closed, not merged. Kept, the record would - // come true again the day another member creates that very path, and the - // author's unrelated root copy would then be pushed over it (#649 review). - const { remoteBranchExists } = await import('../utils/git.js'); - vi.mocked(remoteBranchExists).mockResolvedValue(false); - mockLoadStateForScope.mockResolvedValue({ - lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], - pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, - placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, - pendingPushes: [{ - branch: 'teamai/push/test/20260101-000000', - prUrl: 'https://git.woa.com/mr/14', - createdAt: '2026-01-01T00:00:00.000Z', - items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], - }], - }); - mockHandlers({}, []); - + it('turns a placement into a record only once its file is on the default branch, before scanning', async () => { + // Pushed and awaiting review: nothing on the default branch yet, so no + // record — a PR closed unmerged, branch kept or not, looks exactly like + // this and must not leave one behind (#649 review). + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-landed-')); try { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + const awaiting = { + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/14', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true }], + }], + }; + mockLoadStateForScope.mockImplementation(async () => structuredClone(awaiting)); + mockHandlers({}, []); + + await push({ all: true }); + const recordedEarly = mockSaveStateForScope.mock.calls + .map((call) => call[0] as { placedRules?: Record }) + .some((state) => 'my-rule' in (state.placedRules ?? {})); + expect(recordedEarly).toBe(false); + + // The PR merged: the file is on the default branch now. + fs.mkdirSync(path.join(repoDir, 'rules', 'fe-know'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'rules/fe-know', 'my-rule.md'), 'landed'); + mockSaveStateForScope.mockClear(); + await push({ all: true }); const saved = mockSaveStateForScope.mock.calls .map((call) => call[0] as { placedRules?: Record }) - .find((state) => state.placedRules !== undefined && !('my-rule' in state.placedRules)); - expect(saved, 'no saved state dropped the record').toBeDefined(); + .find((state) => 'my-rule' in (state.placedRules ?? {})); + expect(saved?.placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); } finally { - vi.mocked(remoteBranchExists).mockResolvedValue(true); + fs.rmSync(repoDir, { recursive: true, force: true }); } }); @@ -1323,8 +1336,12 @@ describe('push namespace routing for rules and agents', () => { const staged = mockPushRepoBranch.mock.calls[0]?.[2] as string[]; expect(staged).toContain('agents/fe-agents/vr.yaml'); expect(staged).toContain('agents/fe-agents/vr.md'); - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; - expect(saved.placedAgents).toEqual({ vr: 'agents/fe-agents/vr.yaml' }); + // The move is a placement of the new path: it becomes the record once the + // PR merges, and the recorded .md is dropped then, when it is gone. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), + ]); }); it('rejects an unknown --project even when nothing needs placing', async () => { @@ -1426,9 +1443,16 @@ describe('push namespace routing for rules and agents', () => { await push({ all: true, role: 'pm' }); // The author's copy stays at the tool's rules root, so the scanner needs - // the record to map it back to rules/pm/ instead of reading it as new. - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; - expect(saved.placedRules).toEqual({ 'my-rule': 'rules/pm/my-rule.md' }); + // the record to map it back to rules/pm/ instead of reading it as new. It + // is marked on the pending PR entry and becomes a record when that merges. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { + placedRules?: Record; + pendingPushes: Array<{ items: Array> }>; + }; + expect(saved.placedRules ?? {}).toEqual({}); + expect(saved.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', placed: true }), + ])); }); it('records where it placed a new agent, so the author can still edit it', async () => { @@ -1443,8 +1467,10 @@ describe('push namespace routing for rules and agents', () => { // AgentsHandler.scanLocalForPush only accepts a source whose namespace is // ACTIVE here; without the record the author's next edit is skipped as // "no active source" and the agent they just published is unmaintainable. - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; - expect(saved.placedAgents).toEqual({ vr: 'agents/pm/vr.yaml' }); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/pm/vr.yaml', placed: true }), + ])); }); it('does not record an agent it merely edited in an already-active namespace', async () => { @@ -1465,8 +1491,10 @@ describe('push namespace routing for rules and agents', () => { await push({ all: true }); - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedAgents?: Record }; - expect(saved.placedAgents ?? {}).toEqual({}); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.not.objectContaining({ placed: true }), + ]); }); it('refuses a roles manifest whose namespace is not a single path segment', async () => { @@ -1613,8 +1641,11 @@ describe('push namespace routing for rules and agents', () => { expect(process.exitCode).toBe(1); // The rule is on the remote now. Losing where it went means the author's // root copy is reclassified once that PR merges. - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; - expect(saved.placedRules).toEqual({ 'my-rule': 'rules/pm/my-rule.md' }); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ branch: string; items: Array> }> }; + const reused = saved.pendingPushes.find((entry) => entry.branch === 'teamai/push/test/20260101-000000'); + expect(reused?.items).toEqual([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', placed: true }), + ]); }); it('refuses to place a new rule onto an existing team file', async () => { @@ -1745,8 +1776,10 @@ describe('push namespace routing for rules and agents', () => { await push({ all: true }); // Its local path already carries the namespace, so full-path matching works. - const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record }; - expect(saved.placedRules ?? {}).toEqual({}); + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.not.objectContaining({ placed: true }), + ]); }); it('stops the push when the roles manifest exists but cannot be read', async () => { const pushedItems: Array> = []; diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index d0ba7584..3245bccb 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -661,6 +661,25 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'fe-know/my-rule.md'))).toBe(false); }); + it('does not redirect a placed rule onto a root path a shared-root rule of the same name owns', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'the author\'s namespaced rule'); + await fse.writeFile(path.join(teamRulesDir, 'my-rule.md'), 'an unrelated rule for everyone'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + // Both would otherwise land on my-rule.md, in whichever order the loop + // ran; the shared-root rule owns that path and the namespaced one keeps its own. + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('an unrelated rule for everyone'); + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'utf-8')).toBe('the author\'s namespaced rule'); + }); + it('delivers a namespaced rule another member placed to its namespace directory', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); @@ -678,6 +697,27 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'my-rule.md'))).toBe(false); }); + it("spares the author's root copy while its placement is still awaiting review", async () => { + // Pushed, not merged: no team file, no record yet. The pending entry is + // what says this copy is ours. + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'awaiting review'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true }], + }], + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('awaiting review'); + }); + it('still sweeps a root rule whose record points at a file that is gone', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); diff --git a/src/pull.ts b/src/pull.ts index d2055476..3aa11760 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -9,7 +9,7 @@ import { pendingLearningsDir } from './utils/pending-learnings.js'; import { learningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; -import { prunePlacementRecords } from './utils/pending-push.js'; +import { reconcilePlacementRecords } from './utils/pending-push.js'; import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; @@ -775,16 +775,14 @@ async function pullForScope( return; } - // A placement record whose team file is gone — deleted upstream, or never - // merged — must not survive to claim the next same-named file somebody - // creates. Settle it against the tree just refreshed, before delivery reads - // the records (#649 review). A record whose PR is still open is kept; one - // whose branch is gone from origin is not, since the merge would have put - // the file on the default branch just refreshed. + // Settle the placement records against the tree just refreshed, before + // delivery reads them: a placement whose PR has merged becomes a record, one + // whose file the team deleted stops being one, and one shadowed by a new + // shared-root file of the same name is withdrawn (#649 review). if (!options.dryRun) { try { const recordsState = await loadStateForScope(localConfig); - if (await prunePlacementRecords(localConfig.repo.localPath, recordsState)) { + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { await saveStateForScope(recordsState, localConfig); } } catch (e) { diff --git a/src/push.ts b/src/push.ts index 3026bce4..60905dbc 100644 --- a/src/push.ts +++ b/src/push.ts @@ -6,7 +6,7 @@ import { createGit, pullRepo, pushRepoBranch, checkoutMaster, generateBranchName, resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, } from './utils/git.js'; -import { prunePlacementRecords, +import { reconcilePlacementRecords, findPendingForItem, partiallySelectedEntries, pendingNamespaceFor, planPushGroups, prunePendingPushes, recordPendingPush, toPendingItems, type PushGroup, } from './utils/pending-push.js'; @@ -292,42 +292,6 @@ function collisionPaths(type: PlaceableType, placedAt: string): string[] { return [`${stem}.yaml`, `${stem}.md`]; } -/** - * Remember where push put each resource it PLACED, so the author can keep - * maintaining it. Their own copy stays at the tool's resource root, and the - * team file is now under `//`: the scanner needs the record to - * recognise the two as the same resource (`RulesHandler.scanLocalForPush`), - * and `AgentsHandler.scanLocalForPush` needs it to accept a source whose - * namespace this directory has not activated. - * - * Only `new` items are recorded, and only ones that ended up namespaced. A - * `modified` resource was found in its namespace by the scanner, which means - * that namespace is active here and the scan will find it again; recording it - * would turn a temporary activation into standing permission to keep editing - * an agent long after the role or project that granted it was dropped. - */ -function recordPlacements(state: State, items: ResourceItem[]): void { - for (const item of items) { - // A recorded agent whose canonical source changed extension was written - // to a new path this run; the record has to follow it or the next scan - // finds nothing at the recorded one and reads the agent as new. The scan - // only sets `supersedes` when it reached the file through the record. - if (item.type === 'agents' && supersededPathOf(item)) { - state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; - continue; - } - if (item.status !== 'new' || !item.namespace) continue; - // A rule the scanner already found in a subdirectory carries the namespace - // in its name and matches by full path, so it needs no record. - if (item.type === 'rules' && !item.name.includes('/')) { - state.placedRules = { ...state.placedRules, [item.name]: item.relativePath }; - } - if (item.type === 'agents') { - state.placedAgents = { ...state.placedAgents, [item.name]: item.relativePath }; - } - } -} - /** The team-relative path `pushItem` retired while writing `item`, if any. */ function supersededPathOf(item: ResourceItem): string | undefined { return 'supersedes' in item && typeof item.supersedes === 'string' ? item.supersedes : undefined; @@ -611,7 +575,7 @@ async function pushGroup(args: { branch: branchName, prUrl, createdAt: new Date().toISOString(), - items: toPendingItems(items), + items: await toPendingItems(items, localConfig.repo.localPath), }); // Switch back to the default branch so the next group starts clean @@ -877,15 +841,15 @@ async function pushCore( } } - // Placement records outlive their purpose when the PR they were written for - // is closed unmerged, or the team file is later deleted. Settle that against - // the clone just pulled, BEFORE the scan reads the records: a stale one would - // match the next same-named file anybody creates (#649 review). Not when the + // Settle the placement records against the clone just pulled, BEFORE the + // scan reads them: a placement whose PR has merged becomes a record, one + // whose file the team deleted stops being one, and one shadowed by a new + // shared-root file of the same name is withdrawn (#649 review). Not when the // clone is stale itself — a file missing from an unrefreshed tree proves nothing. if (!teamRepoStale) { try { const recordsState = await loadStateForScope(localConfig); - if (await prunePlacementRecords(localConfig.repo.localPath, recordsState)) { + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { await saveStateForScope(recordsState, localConfig); } } catch (e) { @@ -1370,11 +1334,10 @@ async function pushCore( process.exitCode = 1; return; } - // Per group, and before the early return above can skip it: this group's - // resources are on the remote now, so where they went has to be recorded - // even if a later group fails. Recorded after the push rather than before, - // because a rolled-back group placed nothing. - recordPlacements(pushState, group.items); + // Where this group's placed resources went travels on its pending entry + // (`toPendingItems`), written by `pushGroup` when the branch reaches the + // remote; it becomes a record once the file lands on the default branch + // (`reconcilePlacementRecords`). if (outcome === 'pushed') anyPushed = true; if (outcome === 'pr-failed') anyPrFailed = true; configRider = false; diff --git a/src/remove.ts b/src/remove.ts index 4d22311e..5d7c36be 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; +import { reconcilePlacementRecords } from './utils/pending-push.js'; import { assertNotReadOnly } from './read-only.js'; import { pullRepo, pushRepoBranch, checkoutMaster, generateBranchName } from './utils/git.js'; import { createPrWithFallback, filterExistingTopLevelPaths } from './push.js'; @@ -91,6 +92,18 @@ async function removeCore( } catch { /* continue even if pull fails */ } } + // `publishedNameFor` below resolves the bare name the author types through + // the placement record, and a placement becomes a record only once it has + // landed on the default branch — which this may be the first command to see. + try { + const recordsState = await loadStateForScope(localConfig); + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.debug(`Placement record cleanup skipped: ${(e as Error).message}`); + } + const handler = getHandler(type as ResourceType); // Verify which resources exist diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 55e21d67..8a315892 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -272,7 +272,13 @@ export class RulesHandler extends ResourceHandler { const placed = placedResourcePath( (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, ); - return placed === `rules/${teamName}.md` ? bareName : teamName; + if (placed !== `rules/${teamName}.md`) return teamName; + // A shared-root rule of the same name owns the root path in every tool + // dir; delivering both there would leave whichever wrote last. The + // reconcile pass withdraws the record for this case, but delivery must + // not depend on having run after it. + if (await pathExists(path.join(localConfig.repo.localPath, 'rules', `${bareName}.md`))) return teamName; + return bareName; } /** @@ -434,14 +440,21 @@ export class RulesHandler extends ResourceHandler { // name — it is `/` there, or absent when the namespace is not // active here — so the sweep below would delete the author's own file, // local edits and all (#649 review). The record is what marks it as ours, - // and only while the team file it points at still exists. - const placedRules = (await loadStateForScope(localConfig)).placedRules; + // and only while the team file it points at still exists. Before the PR + // merges there is no record yet — the placement is on the pending entry — + // and the copy is just as much ours then. + const { placedRules, pendingPushes } = await loadStateForScope(localConfig); for (const name of Object.keys(placedRules ?? {})) { const placed = placedResourcePath(placedRules, 'rules', name); if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { teamRuleNames.add(name); } } + for (const entry of pendingPushes ?? []) { + for (const item of entry.items) { + if (item.placed && item.type === 'rules') teamRuleNames.add(item.name); + } + } const tombstones = await this.readTombstones(localConfig); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; diff --git a/src/types.ts b/src/types.ts index 3fed1afe..bde2833c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -593,6 +593,21 @@ export const PendingPushItemSchema = z.object({ relativePath: z.string(), /** Skill namespace chosen at push time, reapplied when the PR is updated. */ namespace: z.string().optional(), + /** + * True when this push PLACED the resource: a root-authored rule or agent + * written under `//`. Once `relativePath` is on the default + * branch — the PR merged — it becomes a `placedRules`/`placedAgents` record + * (`reconcilePlacementRecords`). Until then nothing records it, so a PR + * closed unmerged leaves no record behind, branch deleted or not. + */ + placed: z.boolean().optional(), + /** + * Git blob id of the file this push wrote at `relativePath`, for a placed + * item. Landing is proven by that blob appearing in the default branch's + * history for the path — not by the path merely existing, which another + * member's unrelated file would also satisfy. + */ + blob: z.string().optional(), }); /** diff --git a/src/utils/git.ts b/src/utils/git.ts index 84a6a49e..1d3dc50e 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -847,6 +847,31 @@ export async function resetToCleanMaster(git: SimpleGit, localPath?: string): Pr * Uses `git show :` to retrieve historical file content. * Returns null if the file doesn't exist at that revision or if the rev is invalid. */ +/** The git blob id of a working-tree file, as `git hash-object` reports it; null if unreadable. */ +export async function hashObject(repoPath: string, filePath: string): Promise { + try { + return (await createGit(repoPath).raw(['hash-object', '--', filePath])).trim() || null; + } catch { + return null; + } +} + +/** + * Whether `blob` was ever the content of `filePath` on the current branch. + * Squash- and rebase-merges rewrite commits but keep the blob, so this is what + * tells "our push landed here" from "somebody else created this path" when + * the branch itself is no longer around to ask. Null when git cannot say. + */ +export async function blobInHistory(repoPath: string, blob: string, filePath: string): Promise { + try { + const out = await createGit(repoPath).raw(['log', 'HEAD', `--find-object=${blob}`, '--format=%H', '--', filePath]); + return out.trim().length > 0; + } catch (e) { + log.debug(`git log --find-object failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + export async function getFileContentAtRev( repoPath: string, rev: string, diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 86b9ba4e..26502707 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -14,7 +14,8 @@ */ import path from 'node:path'; import { pathExists } from './fs.js'; -import { remoteBranchExists } from './git.js'; +import { remoteBranchExists, hashObject, blobInHistory } from './git.js'; +import { placedResourcePath } from '../push-namespaces.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -137,63 +138,136 @@ export function recordPendingPush(state: State, entry: PendingPush): void { ]; } -export function toPendingItems(items: ResourceItem[]): PendingPush['items'] { - return items.map((i) => ({ - type: i.type, - name: i.name, - relativePath: i.relativePath, - namespace: i.namespace, - })); +/** + * The pending-entry view of the pushed items. Called after `pushItem` and + * before the branch is switched away, while `relativePath` is the file this + * run wrote: a placed item records its blob so landing can later be proven. + */ +export async function toPendingItems(items: ResourceItem[], repoPath: string): Promise { + const out: PendingPush['items'] = []; + for (const i of items) { + const placed = isPlacement(i); + const blob = placed ? await hashObject(repoPath, i.relativePath) : null; + out.push({ + type: i.type, + name: i.name, + relativePath: i.relativePath, + namespace: i.namespace, + ...(placed ? { placed: true } : {}), + ...(blob ? { blob } : {}), + }); + } + return out; } /** - * Drop placement records (`placedRules`, `placedAgents`) whose target is - * neither on the default branch nor awaiting review in an open PR. + * Whether pushing `item` PLACED it: a root-authored rule or agent that this + * run wrote under `//`, which is what a placement record is for. + * The author's copy stays at the tool's resource root, so the scanner needs + * the record to recognise the two as one resource (`RulesHandler`), and + * `AgentsHandler` needs it to accept a source whose namespace this directory + * has not activated. * - * A record is written when the branch reaches the remote, before the PR - * merges, so a target missing from the default branch is normal while that - * PR is open. It is permanent once the PR was closed unmerged or the file was - * deleted upstream — and a record kept past that point comes true again the - * day another member creates the same path: the scan, the pre-push sync and - * removal would then treat their unrelated resource as this author's, and the - * author's own root copy would be pushed over it (#649 review). + * Only a `new` item that ended up namespaced counts. A `modified` one was + * found in its namespace by the scanner, which means that namespace is active + * here and the scan will find it again; recording it would turn a temporary + * activation into standing permission to keep editing an agent long after the + * role or project that granted it was dropped. The one exception is an agent + * the scan reached THROUGH its record and rewrote under another extension + * (`supersedes`): the record has to follow it to the new path. + */ +export function isPlacement(item: ResourceItem): boolean { + if (item.type === 'agents' && 'supersedes' in item && typeof item.supersedes === 'string') return true; + if (item.status !== 'new' || !item.namespace) return false; + // A rule the scanner already found in a subdirectory carries the namespace + // in its name and matches by full path, so it needs no record. + if (item.type === 'rules') return !item.name.includes('/'); + return item.type === 'agents'; +} + +/** The shared-root file(s) that, if present, mean `name` is not ours to redirect. */ +function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { + return root === 'rules' ? [`rules/${name}.md`] : [`agents/${name}.yaml`, `agents/${name}.md`]; +} + +/** + * Bring the placement records (`placedRules`, `placedAgents`) in line with + * the default branch as just pulled. Three moves, in this order: * - * A referencing PR branch is looked up on origin, the way `prunePendingPushes` - * does, and a record is kept when the remote cannot answer. `verifyBranches: - * false` trusts the pending entries as they stand instead. + * 1. A placement still listed on a pending push whose pushed blob is in + * the default branch's history for that path has landed — the PR merged, + * however the platform merged it — and becomes a record. The path merely + * existing is not enough: another member may have created it after the + * PR was closed, and recording it then would hand their file to this + * author. Nothing is recorded before landing, so a PR closed unmerged + * leaves no record whether or not its branch was deleted, and no provider + * has to be asked whether a PR is open. While the PR is open the pending + * entry itself routes the author's edits back to it (`reuseRecordedDestinations`). + * 2. A record whose file is gone from the default branch is dropped: the + * team deleted the resource. Kept, it would come true again the day + * another member creates that path, and their unrelated resource would + * then read as this author's. + * 3. A record whose bare name is now ALSO a shared-root file is dropped, with + * a warning: the author's root copy can no longer stand for the namespaced + * resource, because the shared-root rule of that name is what every tool + * dir holds at that path, and following the record would push that + * unrelated rule over the author's namespaced one. + * + * Runs after the pull in `push` and after the refresh in `pull`, before + * anything reads the records. Returns whether `state` changed. */ -export async function prunePlacementRecords( +export async function reconcilePlacementRecords( repoPath: string, state: Pick, - options: { verifyBranches?: boolean } = {}, ): Promise { - const verifyBranches = options.verifyBranches ?? true; - const branchAlive = new Map(); - const awaitingReview = async (relativePath: string): Promise => { - for (const entry of state.pendingPushes ?? []) { - if (!entry.items.some((item) => item.relativePath === relativePath)) continue; - if (!verifyBranches) return true; - if (!branchAlive.has(entry.branch)) { - branchAlive.set(entry.branch, await remoteBranchExists(repoPath, entry.branch)); - } - // `null` = could not ask; keep the record rather than guess. - if (branchAlive.get(entry.branch) !== false) return true; + let changed = false; + const fieldFor = (type: string): 'placedRules' | 'placedAgents' | null => ( + type === 'rules' ? 'placedRules' : type === 'agents' ? 'placedAgents' : null + ); + + // 1. Landed placements become records. + for (const entry of state.pendingPushes ?? []) { + for (const item of entry.items) { + if (!item.placed) continue; + const field = fieldFor(item.type); + if (!field) continue; + if (state[field]?.[item.name] === item.relativePath) continue; + if (!await pathExists(path.join(repoPath, item.relativePath))) continue; + // An entry with no blob predates the check; existence is all it can offer. + if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath) !== true) continue; + log.debug(`Recording placement ${field}.${item.name} → ${item.relativePath}: landed on the default branch`); + state[field] = { ...state[field], [item.name]: item.relativePath }; + changed = true; } - return false; - }; + } - let changed = false; - for (const field of ['placedRules', 'placedAgents'] as const) { + // 2 and 3. Records the default branch no longer backs. + for (const [field, root] of [['placedRules', 'rules'], ['placedAgents', 'agents']] as const) { const records = state[field]; if (!records) continue; + const kept: Record = {}; for (const [name, recorded] of Object.entries(records)) { - if (await pathExists(path.join(repoPath, recorded))) continue; - if (await awaitingReview(recorded)) continue; - log.debug(`Dropping placement record ${field}.${name} → ${recorded}: not on the default branch and not awaiting review`); - const { [name]: _dropped, ...rest } = records; - state[field] = rest; - changed = true; + const valid = placedResourcePath(records, root, name); + if (valid && !await pathExists(path.join(repoPath, valid))) { + log.debug(`Dropping placement record ${field}.${name} → ${recorded}: gone from the default branch`); + changed = true; + continue; + } + let shadowed: string | undefined; + for (const candidate of sharedRootPaths(root, name)) { + if (await pathExists(path.join(repoPath, candidate))) { shadowed = candidate; break; } + } + if (shadowed) { + log.warn( + `[${root}] ${name}: ${shadowed} now exists at the shared root, so your local ${name} follows that ` + + `file from here on and no longer stands for ${recorded}. Edit ${recorded} through its namespace.`, + ); + changed = true; + continue; + } + kept[name] = recorded; } + state[field] = kept; } return changed; } From b9ecf7b6ba01bf1fdb674e26f2647e157eca0228 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 19:17:54 +0200 Subject: [PATCH 19/25] fix(push): consume a pending placement once it is recorded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording a landed placement left its `placed` mark on the pending entry. Had the team then deleted the file — which drops the record — and another member recreated the path, the next reconcile recorded it again: the path existed and the blob push had written was still in the default branch's history, so both checks passed, and the unrelated replacement read as this author's resource. The mark and the blob are cleared when the record is written, so a placement is recorded exactly once. --- src/__tests__/placement-records.test.ts | 35 +++++++++++++++++++----- src/__tests__/push-namespace-e2e.test.ts | 9 ++++++ src/utils/pending-push.ts | 8 +++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index 357351c7..3c0686d0 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -31,7 +31,8 @@ describe('reconcilePlacementRecords', () => { const pending = (items: PendingPush['items'], branch = 'teamai/push/me/1'): PendingPush => ({ branch, prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', items, }); - const placedRule = { type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe', placed: true }; + // A factory: recording consumes the mark on the item, so tests must not share one. + const placedRule = () => ({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe', placed: true }); beforeEach(async () => { repoPath = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-placed-')); @@ -42,7 +43,7 @@ describe('reconcilePlacementRecords', () => { it('records a placement once its file is on the default branch', async () => { await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); @@ -51,7 +52,7 @@ describe('reconcilePlacementRecords', () => { it('records nothing while the placement is not on the default branch, whatever its branch is doing', async () => { // Open PR, or closed unmerged with the branch kept: the same from here, // and neither may leave a record. - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); expect(state.placedRules).toEqual({}); @@ -72,19 +73,39 @@ describe('reconcilePlacementRecords', () => { await fse.outputFile(path.join(repoPath, 'never-committed.md'), 'never pushed anywhere\n'); const theirs = git(['hash-object', 'never-committed.md']); - const landed = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, blob: ours }])] }; + const landed = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: ours }])] }; expect(await reconcilePlacementRecords(repoPath, landed)).toBe(true); expect(landed.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); // Same path, but what is there was never what we pushed: somebody else's file. - const shadow = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, blob: theirs }])] }; + const shadow = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: theirs }])] }; expect(await reconcilePlacementRecords(repoPath, shadow)).toBe(false); expect(shadow.placedRules).toEqual({}); }); + it('records a placement once: not again after the team deleted the file and someone recreated the path', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'ours'); + const entry = pending([placedRule()]); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + expect(entry.items[0]?.placed).toBe(false); + + // The team deletes it: the record goes. + await fse.remove(path.join(repoPath, 'rules/fe/my-rule.md')); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + + // Another member recreates the path. The blob we pushed is still in the + // history, so only the consumed mark keeps this from becoming ours again. + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'somebody else\'s'); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({}); + }); + it('does not record a pending item that was not a placement', async () => { await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule, placed: undefined }])] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), placed: undefined }])] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); expect(state.placedRules).toEqual({}); @@ -135,7 +156,7 @@ describe('reconcilePlacementRecords', () => { it('does not record a landed placement that a shared-root file already shadows', async () => { await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); await fse.outputFile(path.join(repoPath, 'rules/my-rule.md'), 'y'); - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule])] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; await reconcilePlacementRecords(repoPath, state); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 1ec9b8f2..660f311d 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -586,6 +586,15 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { // Left in place, the record would claim the next `rules/be-know/my-rule.md` // anybody creates as this author's, and their root copy would push over it. expect(readState(fixture).placedRules ?? {}).toEqual({}); + + // And when somebody does create it, the placement — already recorded once, + // and still on the pending entry — must not come back as a record. + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Somebody else\'s rule\n'); + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules/be-know', 'my-rule.md'), 'utf8')) + .toContain("Somebody else's rule"); }, 60_000); it('never records a placement whose PR was closed without merging, even with the branch kept', async () => { diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 26502707..cdf6225c 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -203,6 +203,7 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { * leaves no record whether or not its branch was deleted, and no provider * has to be asked whether a PR is open. While the PR is open the pending * entry itself routes the author's edits back to it (`reuseRecordedDestinations`). + * Recording consumes the mark, so a placement is recorded exactly once. * 2. A record whose file is gone from the default branch is dropped: the * team deleted the resource. Kept, it would come true again the day * another member creates that path, and their unrelated resource would @@ -231,12 +232,17 @@ export async function reconcilePlacementRecords( if (!item.placed) continue; const field = fieldFor(item.type); if (!field) continue; - if (state[field]?.[item.name] === item.relativePath) continue; if (!await pathExists(path.join(repoPath, item.relativePath))) continue; // An entry with no blob predates the check; existence is all it can offer. if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath) !== true) continue; log.debug(`Recording placement ${field}.${item.name} → ${item.relativePath}: landed on the default branch`); state[field] = { ...state[field], [item.name]: item.relativePath }; + // Consumed: a placement is recorded once. Left marked, it would record + // again after the team deleted the file and another member recreated + // the path — the blob stays in history, so the check above would still + // pass — and hand their file to this author. + item.placed = false; + delete item.blob; changed = true; } } From d292daefd9aef4c9e322bf1642b337dcf37066cf Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 22 Sep 2026 23:54:51 +0200 Subject: [PATCH 20/25] fix(remove): keep placement records until the removal lands, and retire flattened copies `remove` dropped the placement record as soon as its branch was pushed, so a retry during review resolved `vr` to the bare stem and removed that agent from every namespace. Records are now left to the reconcile pass, which drops one once its file is gone from the default branch, or was deleted since the last check (`placementsCheckedAt`) even if another member has recreated the path. A namespaced removal tombstones only `/`, which never matched the flattened `/` copy members hold, so that copy survived pull and the next push republished it. `AgentsHandler.removedStems` reads the tombstone as the flattened stem while no namespace still has that agent, for both the pull cleanup and the push scan. Rules no longer write a bare tombstone, which swept and suppressed other members' unrelated rules of the same name. Agent and rule push scans skip tools the member excluded: remove leaves those copies behind by design, so reading them republished the removed resource. Landing is proven only by history after the full commit the push branch was built on, and a placement whose path was deleted after it landed is spent unrecorded. Single-repo pull no longer reconciles against the member's own checkout; push and remove still do, in a fresh origin/ worktree. --- docs/usage-guide.md | 6 +- docs/usage-guide.zh-CN.md | 6 +- src/__tests__/agents.test.ts | 35 +++++ src/__tests__/placement-records.test.ts | 76 +++++++++ .../pull-placement-reconcile.test.ts | 145 ++++++++++++++++++ src/__tests__/push-namespace-e2e.test.ts | 96 ++++++++++++ src/__tests__/push-pending-pr.test.ts | 1 + src/__tests__/push-role.test.ts | 5 +- src/__tests__/remove.test.ts | 10 +- src/__tests__/rules.test.ts | 10 ++ src/pull.ts | 12 +- src/push.ts | 13 +- src/remove.ts | 38 +---- src/resources/agents.ts | 30 +++- src/resources/rules.ts | 19 ++- src/types.ts | 15 +- src/utils/git.ts | 37 ++++- src/utils/pending-push.ts | 48 +++++- 18 files changed, 531 insertions(+), 71 deletions(-) create mode 100644 src/__tests__/pull-placement-reconcile.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 06b5d569..7828ecc7 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -606,7 +606,7 @@ Choose namespace [1-3] (default: 1 = common): - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported - If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does a new resource that would resolve from `manifest/roles.yaml`. Fix the pull and retry, or name the namespace with `--role ` -- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records +- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records. `teamai remove` itself leaves the record alone: its deletion reaches the default branch only when its PR merges, and until then a retried `remove` still resolves the bare name to the namespaced file - Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. @@ -1495,7 +1495,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; the flattened `` copies are cleaned, and not pushed again, while no namespace still has an agent of that name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI @@ -1916,7 +1916,7 @@ Shared resources (the env block, docs directory, and `~/.teamai/`) are removed * The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, CLAUDE.md block, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. -The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and CLAUDE.md-class injects: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. +The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and CLAUDE.md-class injects: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. To rejoin after uninstalling: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2ca571a1..f34cff9f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -583,7 +583,7 @@ Choose namespace [1-3] (default: 1 = common): - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 - push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;需要从 `manifest/roles.yaml` 解析落点的新资源同样如此。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace -- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步 +- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步。`teamai remove` 本身不清除记录:删除要等其 PR 合并才进入默认分支,在此之前重试 `remove` 仍会把简名解析到带 namespace 的团队文件 - 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 @@ -1454,7 +1454,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;只要没有任何 namespace 仍有该名字的 agent,拍平后的 `` 副本就会被清理,也不会再被推送。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI @@ -1863,7 +1863,7 @@ teamai uninstall --agent claude 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、CLAUDE.md 块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 -同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及 CLAUDE.md 类注入:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 +同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及 CLAUDE.md 类注入:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 卸载后如需重新加入: diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 509056a4..53998197 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -800,4 +800,39 @@ projects: const items = await handler.scanLocalForPush(teamConfig, localConfig); expect(items.find((i) => i.name === 'ghost')).toBeUndefined(); }); + + it('scanLocalForPush reads a namespaced tombstone as the flattened stem once no namespace has it', async () => { + // Every member holds `fe/vr` as `/vr`. After the removal the only + // tombstone is `fe/vr`, and without reading it as `vr` the copy is new. + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: d\n---\n\nold\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items.find((i) => i.name === 'vr')).toBeUndefined(); + expect(await handler.removedStems(localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + }); + + it('keeps the flattened stem live while another namespace still has that agent', async () => { + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.ensureDir(path.join(repoPath, 'agents', 'be')); + await fse.writeFile(path.join(repoPath, 'agents', 'be', 'vr.yaml'), 'name: vr\ndescription: be\ninstructions: x\n'); + + // `vr` here may be be/vr's copy: suppressing it would block editing be/vr. + expect(await handler.removedStems(localConfig)).toEqual(new Set(['fe/vr'])); + }); + + it('scanLocalForPush does not publish the copy an excluded tool still holds', async () => { + // `removeItem` leaves an excluded tool's copy alone, and a namespaced + // removal tombstones only `/`: were this copy read, the next push + // would republish the agent the author just removed (#649 review). + await fse.writeFile( + path.join(homeDir, '.codebuddy/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review.\n', + ); + + const items = await handler.scanLocalForPush(teamConfig, { ...localConfig, enabledAgents: ['claude'] }); + + expect(items.find((i) => i.name === 'vr')).toBeUndefined(); + }); }); diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index 3c0686d0..79bed300 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -83,6 +83,82 @@ describe('reconcilePlacementRecords', () => { expect(shadow.placedRules).toEqual({}); }); + it('proves landing only by commits after the revision the push branch was built on', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'rules/fe/my-rule.md'); + git(['init', '-q', '-b', 'main']); + // The same content sat at this path once, long before the push. + await fse.outputFile(file, 'ours, as pushed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'an old rule']); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'retired']); + const base = git(['rev-parse', '--short', 'HEAD']); + // The placement PR is closed unmerged; a teammate then creates the path. + await fse.outputFile(file, 'somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate rule']); + + const entry = { ...pending([{ ...placedRule(), blob: ours }]), base }; + const closed = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + expect(await reconcilePlacementRecords(repoPath, closed)).toBe(false); + expect(closed.placedRules).toEqual({}); + + // Had the PR merged after that revision, the same blob would prove it. + await fse.outputFile(file, 'ours, as pushed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'merge ours']); + expect(await reconcilePlacementRecords(repoPath, closed)).toBe(true); + expect(closed.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('drops a record whose file was deleted and recreated between two checks', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'agents/fe/vr.yaml'); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(file, 'name: vr\n# the author\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'placement merged']); + const state = { placedRules: {}, placedAgents: { vr: 'agents/fe/vr.yaml' }, pendingPushes: [] } as { + placedRules: Record; placedAgents: Record; + pendingPushes: PendingPush[]; placementsCheckedAt?: string; + }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placementsCheckedAt).toBe(git(['rev-parse', 'HEAD'])); + + // The author's removal merges, and another member publishes their own vr + // at the same path before the author runs anything. + git(['rm', '-q', 'agents/fe/vr.yaml']); git(['commit', '-q', '-m', 'removal merged']); + await fse.outputFile(file, 'name: vr\n# somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate vr']); + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedAgents).toEqual({}); + }); + + it('spends a placement unrecorded when its path was deleted and recreated before the first check', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'rules/fe/my-rule.md'); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, 'README.md'), 'seed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'seed']); + const base = git(['rev-parse', 'HEAD']); + await fse.outputFile(file, 'ours, as pushed\n'); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + git(['add', '-A']); git(['commit', '-q', '-m', 'placement merged']); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'team deleted it']); + await fse.outputFile(file, 'somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate rule']); + + const entry = { ...pending([{ ...placedRule(), blob: ours }]), base }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(entry.items[0]?.placed).toBe(false); + }); + it('records a placement once: not again after the team deleted the file and someone recreated the path', async () => { await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'ours'); const entry = pending([placedRule()]); diff --git a/src/__tests__/pull-placement-reconcile.test.ts b/src/__tests__/pull-placement-reconcile.test.ts new file mode 100644 index 00000000..74a446d3 --- /dev/null +++ b/src/__tests__/pull-placement-reconcile.test.ts @@ -0,0 +1,145 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; + +const testRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-reconcile-')); +const originalHome = process.env.HOME; +process.env.HOME = path.join(testRoot, 'home'); + +vi.mock('../config.js', () => ({ + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +vi.mock('../utils/pending-push.js', () => ({ + reconcilePlacementRecords: vi.fn().mockResolvedValue(false), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +vi.mock('../utils/learnings-publish.js', () => ({ + publishQueuedLearnings: vi.fn().mockResolvedValue({ published: [], remaining: 0 }), +})); +vi.mock('../source.js', () => ({ pullSources: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../hooks.js', () => ({ + injectHooksToAllTools: vi.fn().mockResolvedValue(undefined), + reconcileTeamHooksForConfig: vi.fn().mockResolvedValue([]), +})); +vi.mock('../mcp-reconcile.js', () => ({ + reconcileMcpForConfig: vi.fn().mockResolvedValue({ changes: [], wrote: false }), +})); +vi.mock('../team-push.js', () => ({ reportUsageToTeam: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../usage-tracker.js', () => ({ + readUsageEvents: vi.fn().mockResolvedValue([]), + truncateUsageAfterReport: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../roles.js', () => ({ + loadRolesManifest: vi.fn().mockResolvedValue({ version: 1, roles: [], defaults: { shareTarget: 'primary-role' } }), + resolveRoleResourceNamespaces: vi.fn(() => ({ knowledge: [], skills: [], learnings: [] })), +})); +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +const { pull } = await import('../pull.js'); +const { loadLocalConfigForScope, loadTeamConfig } = await import('../config.js'); +const { reconcilePlacementRecords } = await import('../utils/pending-push.js'); +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +const business = path.join(testRoot, 'product'); +const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team/repo.git', + provider: 'git', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: false }, + }, + toolPaths: {}, +}; + +function config(kind: 'git' | 'self'): LocalConfig { + return kind === 'self' + ? { + repo: { + localPath: path.join(business, '.teamai'), + remote: 'https://example.test/team/repo.git', + kind: 'self', + businessRepoRoot: business, + }, + username: 'alice', + scope: 'user', + additionalRoles: [], + } + : { + repo: { localPath: path.join(testRoot, 'team-repo'), remote: 'https://example.test/team/repo.git', kind: 'git' }, + username: 'alice', + scope: 'user', + additionalRoles: [], + }; +} + +/** + * Placement records are settled against the default branch. An independent + * clone is that branch once pulled; a single-repo member's own checkout is + * whatever they have out — a feature branch, a main not pulled yet — and a + * record dropped against it never comes back (#649 review). There `push` and + * `remove` settle the records in a fresh origin/ worktree instead. + */ +describe('teamai pull settles placement records only against the default branch', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + await fse.outputFile(path.join(business, '.teamai', 'teamai.yaml'), 'team: test\n'); + await fse.outputFile(path.join(testRoot, 'team-repo', 'teamai.yaml'), 'team: test\n'); + }); + + afterAll(async () => { + process.env.HOME = originalHome; + await fse.remove(testRoot); + }); + + it('in an independent clone', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config('git')); + + await pull({ silent: true, force: true }); + + expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(testRoot, 'team-repo'), expect.anything()); + }); + + it('not in single-repo mode, where the checkout is the member\'s own', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config('self')); + + await pull({ silent: true, force: true }); + + expect(reconcilePlacementRecords).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 660f311d..2dfca1e6 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -712,9 +712,105 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(files).not.toContain('rules/fe-know/my-rule.md'); // And the author's own copy went with it, or the next push re-publishes it. expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); + // The removal is only on its branch, so the record still resolves a retry; + // it goes once the default branch no longer has the file. + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + mergeBranch(fixture, branch); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); expect(readState(fixture).placedRules ?? {}).toEqual({}); }, 60_000); + it('does not republish a removed agent from the copy an excluded tool kept', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // Codex is configured for the team but excluded on this machine, and holds + // its own copy of the agent the author publishes and then removes. + commitOnMain(fixture, 'teamai.yaml', [ + fs.readFileSync(path.join(fixture.teamRepo, 'teamai.yaml'), 'utf8'), + ' codex:', + ' agents: .codex/agents', + ].join('\n')); + fs.appendFileSync(path.join(fixture.projectRoot, '.teamai', 'config.yaml'), '\nenabledAgents:\n - claude\n'); + writeLocalResources(fixture); + fs.mkdirSync(path.join(fixture.projectRoot, '.codex/agents'), { recursive: true }); + fs.writeFileSync(path.join(fixture.projectRoot, '.codex/agents', 'vr.toml'), localAgentFile('codex').content); + + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await nextSecond(); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + + // `remove` leaves an excluded tool's copy alone, by design. + expect(fs.existsSync(path.join(fixture.projectRoot, '.codex/agents', 'vr.toml'))).toBe(true); + await nextSecond(); + const again = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(again.output).toContain('Scanning local resources'); + expect(again.output).not.toContain('[agents] vr'); + // Both merged branches are gone from the remote, so any branch is this push's. + expect(branchFiles(fixture).files.filter((f) => /^agents\/(.+\/)?vr\./.test(f))).toEqual([]); + }, 60_000); + + it('cleans the flattened copy of a removed namespaced agent on pull, and never republishes it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await nextSecond(); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + const removal = branchFiles(fixture).branch; + const agentCopy = path.join(fixture.projectRoot, '.claude/agents', 'vr.md'); + expect(fs.existsSync(agentCopy)).toBe(false); + + // While the removal is under review the agent is still on main, and the + // record still delivers it — as a pending removal of any agent would. + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(fs.existsSync(agentCopy)).toBe(true); + + // Merged: the only tombstone is `fe-agents/vr`, but the copy is `vr.md`. + mergeBranch(fixture, removal); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(fs.existsSync(agentCopy)).toBe(false); + + // And a copy that comes back anyway is not a new agent to publish. + fs.writeFileSync(agentCopy, localAgentFile('claude').content); + await nextSecond(); + const again = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + expect(again.output).toContain('Scanning local resources'); + expect(again.output).not.toContain('[agents] vr'); + expect(branchFiles(fixture).files.filter((f) => /^agents\/(.+\/)?vr\./.test(f))).toEqual([]); + }, 60_000); + + it('resolves a retried agent removal through the record while the first removal is unmerged', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + const first = branchFiles(fixture).branch; + + // The first removal PR is still open. Dropping the record there sent the + // retry to the bare stem, which removes `vr` from every namespace. + await nextSecond(); + const retry = await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + + expect(retry.output).toContain('vr was published as fe-agents/vr'); + const { branch, files } = branchFiles(fixture); + expect(branch, retry.output).not.toBe(first); + expect(files).not.toContain('agents/fe-agents/vr.yaml'); + expect(files).toContain('agents/other-ns/vr.yaml'); + + mergeBranch(fixture, branch); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(readState(fixture).placedAgents ?? {}).toEqual({}); + }, 60_000); + it('stops the push when the roles manifest exists but cannot be parsed', async () => { const fixture = track(makeFixture({ agent: 'claude', diff --git a/src/__tests__/push-pending-pr.test.ts b/src/__tests__/push-pending-pr.test.ts index d49efc36..0357d2c5 100644 --- a/src/__tests__/push-pending-pr.test.ts +++ b/src/__tests__/push-pending-pr.test.ts @@ -63,6 +63,7 @@ vi.mock('../utils/git.js', () => ({ isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), getDefaultBranch: vi.fn().mockResolvedValue('main'), getFileContentAtRev: vi.fn().mockResolvedValue(null), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); vi.mock('../resources/index.js', () => ({ diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 6b1e9590..99a851bc 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -73,6 +73,7 @@ vi.mock('../utils/git.js', () => ({ getFileContentAtRev: vi.fn().mockResolvedValue(null), hashObject: vi.fn().mockResolvedValue(null), blobInHistory: vi.fn().mockResolvedValue(null), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); const mockLoadProjectsManifest = vi.fn().mockResolvedValue(null); @@ -1447,12 +1448,14 @@ describe('push namespace routing for rules and agents', () => { // is marked on the pending PR entry and becomes a record when that merges. const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { placedRules?: Record; - pendingPushes: Array<{ items: Array> }>; + pendingPushes: Array<{ base?: string; items: Array> }>; }; expect(saved.placedRules ?? {}).toEqual({}); expect(saved.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', placed: true }), ])); + // Landing is later proven only by history after the commit the branch was built on. + expect(saved.pendingPushes.at(-1)?.base).toBe('base000'); }); it('records where it placed a new agent, so the author can still edit it', async () => { diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index c990627f..5d5e1933 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -126,7 +126,7 @@ scope: 'user', expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); }); - it('tombstones the bare name too, so a copy the sweep skipped cannot come back', async () => { + it('tombstones only the published name, even when the record makes the root copy ours', async () => { await fse.outputFile( path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', ); @@ -134,14 +134,14 @@ scope: 'user', await handler.removeItem('fe-know/my-rule', teamConfig, localConfig); - // The local sweep skips excluded tools, so a root copy can outlive the - // removal there — and the scan calls it `my-rule`, which the published - // tombstone would not match. + // Every member reads the tombstone: a bare `my-rule` would sweep their + // own root rule of that name and stop them publishing one. A copy an + // excluded tool keeps is instead not a push source at all. const tombstones = await fse.readFile( path.join(localConfig.repo.localPath, 'rules', '.removed'), 'utf-8', ); expect(tombstones.split('\n')).toContain('fe-know/my-rule'); - expect(tombstones.split('\n')).toContain('my-rule'); + expect(tombstones.split('\n')).not.toContain('my-rule'); }); it('tombstones only the name given when no record vouches for the bare one', async () => { diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 3245bccb..008d376a 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -95,6 +95,16 @@ scope: 'user', expect(item!.status).toBe('modified'); }); + it('does not read rules from a tool this member excluded', async () => { + // `removeItem` leaves an excluded tool's copy alone; read here, it would + // republish a rule the member just removed (#649 review). + await fse.writeFile(path.join(homeDir, '.claude/rules', 'kept-by-excluded.md'), 'old rule'); + + const items = await handler.scanLocalForPush(teamConfig, { ...localConfig, disabledAgents: ['claude'] }); + + expect(items.find((i) => i.name === 'kept-by-excluded')).toBeUndefined(); + }); + it('should NOT include an unchanged rule', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.writeFile(path.join(teamRulesDir, 'same-rule.md'), 'same content'); diff --git a/src/pull.ts b/src/pull.ts index 3aa11760..64b8e8e7 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -645,7 +645,11 @@ async function cleanupTombstonedResources( for (const { type, toolPathField } of tombstoneTypes) { const handler = getHandler(type); - const tombstones = await handler.readTombstones(localConfig); + // Agents deploy flattened, so a namespaced agent tombstone has to be read + // as the stem the local copy carries (`AgentsHandler.removedStems`). + const tombstones = type === 'agents' + ? await (handler as AgentsHandler).removedStems(localConfig) + : await handler.readTombstones(localConfig); if (tombstones.size === 0) continue; for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { @@ -779,7 +783,11 @@ async function pullForScope( // delivery reads them: a placement whose PR has merged becomes a record, one // whose file the team deleted stops being one, and one shadowed by a new // shared-root file of the same name is withdrawn (#649 review). - if (!options.dryRun) { + // Not in single-repo mode: the refresh leaves the member's own checkout as it + // is — a feature branch, or a main not pulled yet — which is not the default + // branch, and a record dropped against it never comes back. `push` and + // `remove` reconcile there against a fresh origin/ worktree. + if (!options.dryRun && localConfig.repo.kind !== 'self') { try { const recordsState = await loadStateForScope(localConfig); if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { diff --git a/src/push.ts b/src/push.ts index 60905dbc..ce0704c9 100644 --- a/src/push.ts +++ b/src/push.ts @@ -4,7 +4,7 @@ import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.j import { assertNotReadOnly } from './read-only.js'; import { createGit, pullRepo, pushRepoBranch, checkoutMaster, generateBranchName, - resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, + resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, getHeadCommit, } from './utils/git.js'; import { reconcilePlacementRecords, findPendingForItem, partiallySelectedEntries, pendingNamespaceFor, planPushGroups, @@ -504,6 +504,9 @@ async function pushGroup(args: { const gitFiles = [...new Set([...pushedFiles, ...existingSweepers, ...configFiles])]; const branchName = reuse?.branch ?? generateBranchName(localConfig.username); const commitMsg = `[teamai] Push ${items.length} resource(s) from ${localConfig.username}`; + // The default-branch commit the branch is built on, which bounds the + // history that can prove a placement landed (`reconcilePlacementRecords`). + const base = await getHeadCommit(localConfig.repo.localPath) ?? undefined; const hasChanges = await pushRepoBranch( localConfig.repo.localPath, @@ -570,12 +573,16 @@ async function pushGroup(args: { // Remember the open PR so the next run updates it instead of opening a // duplicate. Recorded even when PR creation failed: the branch is on the - // remote, so pushing again must reuse it. + // remote, so pushing again must reuse it. A PR retry pushed nothing, so + // the branch — and the blobs and base that prove its placements — is the + // one already recorded; this run has no branch checked out to hash. recordPendingPush(pushState, { branch: branchName, prUrl, createdAt: new Date().toISOString(), - items: await toPendingItems(items, localConfig.repo.localPath), + ...(hasChanges + ? { base, items: await toPendingItems(items, localConfig.repo.localPath) } + : { base: reuse?.base, items: reuse?.items ?? [] }), }); // Switch back to the default branch so the next group starts clean diff --git a/src/remove.ts b/src/remove.ts index 5d7c36be..79dd3ce8 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; import { assertNotReadOnly } from './read-only.js'; @@ -49,32 +48,6 @@ export async function remove( await removeCore(type, names, options, localConfig, teamConfig); } -/** - * Drop the placement record of each removed resource. - * - * `remove` accepts both spellings — the published `/` and the bare - * `` the author's own copy carries — and the record is always keyed by - * the bare one, so the key comes from the basename. The recorded path still has - * to match the resource being removed, or removing `other-ns/my-rule` would - * drop the record of a `my-rule` that lives somewhere else entirely. - * - * Existence is deliberately NOT the test: the removal only exists on the push - * branch until its PR merges, and `removeCore` checks the default branch back - * out before this runs, so the file is still on disk at this point. - */ -function dropPlacementRecords( - records: Record | undefined, - removed: string[], - teamPathsFor: (publishedName: string) => string[], -): void { - if (!records) return; - for (const name of removed) { - const key = path.basename(name); - const recorded = records[key]; - if (recorded && teamPathsFor(name).includes(recorded)) delete records[key]; - } -} - async function removeCore( type: string, names: string[], @@ -253,13 +226,12 @@ async function removeCore( } if (type === 'rules') { state.pushedRules = state.pushedRules.filter((r) => !found.includes(r)); - // A record left behind would send the author's local copy back to a path - // that is about to stop existing. - dropPlacementRecords(state.placedRules, found, (n) => [`rules/${n}.md`]); - } - if (type === 'agents') { - dropPlacementRecords(state.placedAgents, found, (n) => [`agents/${n}.yaml`, `agents/${n}.md`]); } + // Placement records are NOT dropped here: the removal exists only on its push + // branch until the PR merges, and a retry meanwhile must still resolve the + // bare name to the one namespaced file — for an agent, the bare stem removes + // it from every namespace (#649 review). `reconcilePlacementRecords` drops a + // record once the default branch no longer has its file. // `wiki` is not tracked in pushedX state; nothing to clean here. await saveStateForScope(state, localConfig); } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 90e75e13..73ae8009 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -104,6 +104,27 @@ export function selectAgentsForDirectory( export class AgentsHandler extends ResourceHandler { readonly type = 'agents' as const; + /** + * The tombstones as flattened local copies see them. Removing `fe/vr` + * tombstones only `fe/vr`, but every member holds that agent as `/vr`, + * so the tombstone alone never reaches their copy, and the next push reads it + * as a new agent and republishes it (#649 review). `vr` counts as removed + * here only while no namespace still has an agent of that stem: then the + * flattened copy can stand for nothing else, and while one does it is that + * agent's copy — suppressing it is what round 8 of the review ruled out. + */ + async removedStems(localConfig: LocalConfig): Promise> { + const tombstones = await this.readTombstones(localConfig); + const removed = new Set(tombstones); + const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + for (const tombstone of tombstones) { + const stem = path.posix.basename(tombstone); + if (removed.has(stem)) continue; + if ((await findTeamAgentFiles(teamAgentsDir, stem)).length === 0) removed.add(stem); + } + return removed; + } + /** * Scan local AI tool agents/ directories for files that are new or modified * compared to the team repo. Groups by agent name stem across all tools. @@ -118,7 +139,7 @@ export class AgentsHandler extends ResourceHandler { ): Promise { const requestedNamespace = options?.namespace; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); - const tombstones = await this.readTombstones(localConfig); + const tombstones = await this.removedStems(localConfig); // Single-repo mode: users drop canonical agent files straight into the repo's // own .teamai/agents/ (.yaml, or legacy .md) rather than authoring // them in a tool's agents dir. Those are ALREADY in team-repo format, so we @@ -194,6 +215,11 @@ export class AgentsHandler extends ResourceHandler { for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; + // An excluded tool is neither written nor cleaned by teamai, so what it + // holds is not a source either: `removeItem` leaves its copy behind, and + // a namespaced removal tombstones only `/`, so reading that + // copy republished the agent just removed (#649 review). + if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); const agentsDir = path.join(baseDir, toolPath.agents); if (!await pathExists(agentsDir)) continue; @@ -301,7 +327,7 @@ export class AgentsHandler extends ResourceHandler { // Compare like with like: native files against a native rendering of // the canonical YAML. Unchanged/untargeted copies must not join a merge. for (const [tool, filePath] of toolFiles) { - if (!isKnownTool(tool) || isAgentExcluded(localConfig, tool) + if (!isKnownTool(tool) || (canonicalSpec.targets && !canonicalSpec.targets.includes(tool))) { toolFiles.delete(tool); continue; diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 8a315892..eeb0c6e0 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -71,6 +71,10 @@ export class RulesHandler extends ResourceHandler { for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const rulesPath = toolPath.rules; if (!rulesPath) continue; + // Not written or cleaned by teamai, so not a source either: `removeItem` + // leaves an excluded tool's copy behind, and read here it would republish + // the rule just removed (#649 review). + if (isAgentExcluded(localConfig, tool)) continue; const rulesDir = path.join(resolveToolBaseDir(tool, localConfig), rulesPath); if (!await pathExists(rulesDir)) continue; @@ -355,14 +359,13 @@ export class RulesHandler extends ResourceHandler { if (placed === `rules/${name}.md`) localNames.add(bareName); } - // Record a tombstone so the resource won't be re-pushed. The bare name gets - // one too whenever the record above proved it is this rule: the local sweep - // below skips excluded tools, so a root copy can outlive the removal there, - // and the scan names it `` — which the published tombstone would not - // match (#649 review). - for (const tombstoned of localNames) { - await this.addTombstone(tombstoned, localConfig); - } + // Record a tombstone so the resource won't be re-pushed. Only the name + // given: every member reads the tombstone, and a bare `` would sweep + // and suppress their own unrelated root rule of that name (#649 review). + // Members hold a namespaced rule under `/`, which the published name + // matches; the author's root copy is swept below, and a copy an excluded + // tool keeps is not a push source (`scanLocalForPush`). + await this.addTombstone(name, localConfig); // Remove from each tool's rules directory. `.mdc` tools may have an older // teamai layout wrote `.md` there, so both are removed — otherwise `remove` diff --git a/src/types.ts b/src/types.ts index bde2833c..03e9af01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -604,7 +604,8 @@ export const PendingPushItemSchema = z.object({ /** * Git blob id of the file this push wrote at `relativePath`, for a placed * item. Landing is proven by that blob appearing in the default branch's - * history for the path — not by the path merely existing, which another + * history for the path after the entry's `base` — not by the path merely + * existing, which another * member's unrelated file would also satisfy. */ blob: z.string().optional(), @@ -622,6 +623,12 @@ export const PendingPushSchema = z.object({ branch: z.string(), prUrl: z.string().nullable().default(null), createdAt: z.string(), + /** + * Default-branch commit the branch was built on. A placed item's `blob` + * proves landing only in commits after it: the same content may have sat + * at that path before this push, and that history proves nothing about it. + */ + base: z.string().optional(), items: z.array(PendingPushItemSchema).default([]), }); @@ -660,6 +667,12 @@ export const StateSchema = z.object({ * `placedRules`. */ placedAgents: z.record(z.string(), z.string()).optional(), + /** + * Default-branch commit the placement records were last checked against. A + * record whose file was deleted after it is dropped even if something is at + * that path again: whatever is there now is somebody else's. + */ + placementsCheckedAt: z.string().optional(), pushedSkills: z.array(z.string()).default([]), pushedEnvVars: z.array(z.string()).default([]), /** Push branches whose PR is still open — see PendingPushSchema. */ diff --git a/src/utils/git.ts b/src/utils/git.ts index 1d3dc50e..039f4e52 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -857,14 +857,21 @@ export async function hashObject(repoPath: string, filePath: string): Promise { +export async function blobInHistory( + repoPath: string, + blob: string, + filePath: string, + since?: string, +): Promise { try { - const out = await createGit(repoPath).raw(['log', 'HEAD', `--find-object=${blob}`, '--format=%H', '--', filePath]); + const range = since ? `${since}..HEAD` : 'HEAD'; + const out = await createGit(repoPath).raw(['log', range, `--find-object=${blob}`, '--format=%H', '--', filePath]); return out.trim().length > 0; } catch (e) { log.debug(`git log --find-object failed for ${filePath}: ${(e as Error).message}`); @@ -872,6 +879,30 @@ export async function blobInHistory(repoPath: string, blob: string, filePath: st } } +/** + * Whether a commit on the current branch after `since` deleted `filePath`. + * A path that exists now may still have been deleted and recreated in that + * range, by someone else. Null when git cannot say. + */ +export async function pathDeletedSince(repoPath: string, since: string, filePath: string): Promise { + try { + const out = await createGit(repoPath).raw(['log', `${since}..HEAD`, '--diff-filter=D', '--format=%H', '--', filePath]); + return out.trim().length > 0; + } catch (e) { + log.debug(`git log --diff-filter=D failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + +/** Full commit id of HEAD, or null when there is none. */ +export async function getHeadCommit(localPath: string): Promise { + try { + return (await createGit(localPath).raw(['rev-parse', '--verify', 'HEAD^{commit}'])).trim() || null; + } catch { + return null; + } +} + export async function getFileContentAtRev( repoPath: string, rev: string, diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index cdf6225c..07c69892 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -14,7 +14,7 @@ */ import path from 'node:path'; import { pathExists } from './fs.js'; -import { remoteBranchExists, hashObject, blobInHistory } from './git.js'; +import { remoteBranchExists, hashObject, blobInHistory, pathDeletedSince, getHeadCommit } from './git.js'; import { placedResourcePath } from '../push-namespaces.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -194,8 +194,9 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { * Bring the placement records (`placedRules`, `placedAgents`) in line with * the default branch as just pulled. Three moves, in this order: * - * 1. A placement still listed on a pending push whose pushed blob is in - * the default branch's history for that path has landed — the PR merged, + * 1. A placement still listed on a pending push whose pushed blob entered + * the default branch's history for that path after the revision the + * push branch was built on has landed — the PR merged, * however the platform merged it — and becomes a record. The path merely * existing is not enough: another member may have created it after the * PR was closed, and recording it then would hand their file to this @@ -203,11 +204,14 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { * leaves no record whether or not its branch was deleted, and no provider * has to be asked whether a PR is open. While the PR is open the pending * entry itself routes the author's edits back to it (`reuseRecordedDestinations`). - * Recording consumes the mark, so a placement is recorded exactly once. + * Recording consumes the mark, so a placement is recorded exactly once, + * and a placement whose path was deleted after it landed is spent unrecorded. * 2. A record whose file is gone from the default branch is dropped: the * team deleted the resource. Kept, it would come true again the day * another member creates that path, and their unrelated resource would - * then read as this author's. + * then read as this author's. For the same reason a record whose file + * was deleted since the last check (`placementsCheckedAt`) is dropped + * even when the path exists again. * 3. A record whose bare name is now ALSO a shared-root file is dropped, with * a warning: the author's root copy can no longer stand for the namespaced * resource, because the shared-root rule of that name is what every tool @@ -219,9 +223,10 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { */ export async function reconcilePlacementRecords( repoPath: string, - state: Pick, + state: Pick, ): Promise { let changed = false; + const checkedAt = state.placementsCheckedAt; const fieldFor = (type: string): 'placedRules' | 'placedAgents' | null => ( type === 'rules' ? 'placedRules' : type === 'agents' ? 'placedAgents' : null ); @@ -234,7 +239,19 @@ export async function reconcilePlacementRecords( if (!field) continue; if (!await pathExists(path.join(repoPath, item.relativePath))) continue; // An entry with no blob predates the check; existence is all it can offer. - if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath) !== true) continue; + // Bounded by `base`: the same bytes may have sat at this path before the + // push, and a PR closed unmerged must not borrow that history. + if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath, entry.base) !== true) continue; + // Placement refuses an occupied path, so a deletion since `base` came + // after this placement landed: what is there now was recreated by + // someone else, and the placement is spent without a record. + if (entry.base && await pathDeletedSince(repoPath, entry.base, item.relativePath) === true) { + log.debug(`Not recording placement ${field}.${item.name}: ${item.relativePath} was deleted after it landed`); + item.placed = false; + delete item.blob; + changed = true; + continue; + } log.debug(`Recording placement ${field}.${item.name} → ${item.relativePath}: landed on the default branch`); state[field] = { ...state[field], [item.name]: item.relativePath }; // Consumed: a placement is recorded once. Left marked, it would record @@ -259,6 +276,13 @@ export async function reconcilePlacementRecords( changed = true; continue; } + // Deleted and recreated between two checks — a removal that merged, then + // another member's resource at the same path — is not this author's. + if (valid && checkedAt && await pathDeletedSince(repoPath, checkedAt, valid) === true) { + log.debug(`Dropping placement record ${field}.${name} → ${recorded}: deleted from the default branch since the last check`); + changed = true; + continue; + } let shadowed: string | undefined; for (const candidate of sharedRootPaths(root, name)) { if (await pathExists(path.join(repoPath, candidate))) { shadowed = candidate; break; } @@ -275,5 +299,15 @@ export async function reconcilePlacementRecords( } state[field] = kept; } + + // The revision the surviving records were checked against, so the next run + // sees a deletion that happened in between even if the path is back by then. + if (Object.keys(state.placedRules ?? {}).length + Object.keys(state.placedAgents ?? {}).length > 0) { + const head = await getHeadCommit(repoPath); + if (head && head !== state.placementsCheckedAt) { + state.placementsCheckedAt = head; + changed = true; + } + } return changed; } From c48225e78604cb68f72c3859f58502f935542d8b Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 00:17:04 +0200 Subject: [PATCH 21/25] fix(pull): reconcile single-repo records through origin/, and retire flattened copies per directory Single-repo pull skipped the reconcile pass, so a merged placement stayed unrecorded until the next push or remove. It now reads the default branch as the ref origin/ (existence via `:./`, history up to that ref) instead of the member's own checkout, and changes nothing when the ref cannot be resolved. `placementsCheckedAt` survived its last record, and a record made in the same run was checked against it, so re-placing a resource at a path deleted earlier dropped the new record at once. The checkpoint is cleared with the last record, and records made in this run are not held to it. `removedStems` retired the flattened stem only when no namespace had it at all, so an fe member kept a removed fe/vr while an unrelated be/vr existed. It now asks what this directory is meant to hold, through the same selection pull delivers with. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/agents.test.ts | 24 +++++- src/__tests__/placement-records.test.ts | 80 +++++++++++++++++++ .../pull-placement-reconcile.test.ts | 14 ++-- src/pull.ts | 19 +++-- src/resources/agents.ts | 25 ++++-- src/utils/git.ts | 32 +++++--- src/utils/pending-push.ts | 41 ++++++++-- 9 files changed, 190 insertions(+), 49 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7828ecc7..15361d03 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1495,7 +1495,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; the flattened `` copies are cleaned, and not pushed again, while no namespace still has an agent of that name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, unless their directory still receives an agent of that name from another active namespace. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index f34cff9f..68cea84c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1454,7 +1454,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;只要没有任何 namespace 仍有该名字的 agent,拍平后的 `` 副本就会被清理,也不会再被推送。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;除非成员的目录仍从另一个活跃 namespace 收到同名 agent,否则其拍平后的 `` 副本会被清理,也不会再被推送。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index 53998197..b6f44e77 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -810,16 +810,32 @@ projects: const items = await handler.scanLocalForPush(teamConfig, localConfig); expect(items.find((i) => i.name === 'vr')).toBeUndefined(); - expect(await handler.removedStems(localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); }); - it('keeps the flattened stem live while another namespace still has that agent', async () => { + it('keeps the flattened stem live while this directory still receives an agent of that stem', async () => { await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); await fse.ensureDir(path.join(repoPath, 'agents', 'be')); await fse.writeFile(path.join(repoPath, 'agents', 'be', 'vr.yaml'), 'name: vr\ndescription: be\ninstructions: x\n'); - // `vr` here may be be/vr's copy: suppressing it would block editing be/vr. - expect(await handler.removedStems(localConfig)).toEqual(new Set(['fe/vr'])); + // No roles or projects here, so be/vr is delivered, and `vr` is its copy: + // suppressing it would block editing be/vr. + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr'])); + }); + + it('retires the flattened stem when the surviving same-stem agent is not active here', async () => { + // An fe member: fe/vr is removed, be/vr survives but is not theirs, so the + // `vr` they hold is the removed fe/vr, not a copy of be/vr (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: front\n resources:\n agents: [fe]\n - id: back\n resources:\n agents: [be]\n'); + localConfig.projects = ['front']; + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.outputFile(path.join(repoPath, 'agents/be/vr.yaml'), 'name: vr\ndescription: be\ninstructions: x\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: d\n---\n\nold fe\n'); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + expect((await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' })).find((i) => i.name === 'vr')) + .toBeUndefined(); }); it('scanLocalForPush does not publish the copy an excluded tool still holds', async () => { diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index 79bed300..ca1b4a2c 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -136,6 +136,86 @@ describe('reconcilePlacementRecords', () => { expect(state.placedAgents).toEqual({}); }); + describe('the checkpoint and the ref it is read from', () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + type ReconcileState = Parameters[1]; + const commitFile = async (rel: string, content: string, message: string) => { + await fse.outputFile(path.join(repoPath, rel), content); + git(['add', '-A']); git(['commit', '-q', '-m', message]); + return git(['hash-object', rel]); + }; + + it('clears the checkpoint with the last record, so a later re-placement at that path is recorded', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('rules/fe/my-rule.md', 'first\n', 'placement merged'); + const state: ReconcileState = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + await reconcilePlacementRecords(repoPath, state); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'removal merged']); + await reconcilePlacementRecords(repoPath, state); + expect(state.placedRules).toEqual({}); + expect(state.placementsCheckedAt).toBeUndefined(); + + // The author places the rule again, and that PR merges. + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'second\n', 're-placement merged'); + state.pendingPushes = [{ ...pending([{ ...placedRule(), blob }]), base }]; + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('keeps a record made in this run although the checkpoint predates an earlier deletion of its path', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('rules/fe/other.md', 'other\n', 'another placement'); + const state: ReconcileState = { placedRules: { other: 'rules/fe/other.md' }, placedAgents: {}, pendingPushes: [] }; + await reconcilePlacementRecords(repoPath, state); + // Before the author's next check: my-rule is created and deleted by + // somebody, then the author's own placement of it lands. + await commitFile('rules/fe/my-rule.md', 'somebody\'s\n', 'teammate rule'); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'teammate removal']); + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'ours\n', 'our placement merged'); + state.pendingPushes = [{ ...pending([{ ...placedRule(), blob }]), base }]; + + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({ other: 'rules/fe/other.md', 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('reads the default branch through a ref when the checkout is somewhere else', async () => { + // A single-repo member on a feature branch cut before the placement merged. + git(['init', '-q', '-b', 'main']); + await commitFile('README.md', 'seed\n', 'seed'); + git(['checkout', '-q', '-b', 'feature']); + git(['checkout', '-q', 'main']); + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'ours\n', 'placement merged'); + git(['checkout', '-q', 'feature']); + const state: ReconcileState = { + placedRules: {}, placedAgents: {}, pendingPushes: [{ ...pending([{ ...placedRule(), blob }]), base }], + }; + + expect(await reconcilePlacementRecords(repoPath, state, 'main')).toBe(true); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + expect(state.placementsCheckedAt).toBe(git(['rev-parse', 'main'])); + // Reconciled against the checkout itself, the same record is dropped. + const againstCheckout: ReconcileState = { ...state, placedRules: { ...state.placedRules } }; + await reconcilePlacementRecords(repoPath, againstCheckout); + expect(againstCheckout.placedRules).toEqual({}); + }); + + it('changes nothing when the ref cannot be resolved', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('README.md', 'seed\n', 'seed'); + const state: ReconcileState = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state, 'origin/main')).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + }); + it('spends a placement unrecorded when its path was deleted and recreated before the first check', async () => { const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', diff --git a/src/__tests__/pull-placement-reconcile.test.ts b/src/__tests__/pull-placement-reconcile.test.ts index 74a446d3..052c323b 100644 --- a/src/__tests__/pull-placement-reconcile.test.ts +++ b/src/__tests__/pull-placement-reconcile.test.ts @@ -22,6 +22,7 @@ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn().mockResolvedValue('already up to date'), getHeadRev: vi.fn().mockResolvedValue('abc1234'), createGit: vi.fn(), + getDefaultBranch: vi.fn().mockResolvedValue('main'), })); vi.mock('../utils/pending-push.js', () => ({ @@ -111,10 +112,11 @@ function config(kind: 'git' | 'self'): LocalConfig { * Placement records are settled against the default branch. An independent * clone is that branch once pulled; a single-repo member's own checkout is * whatever they have out — a feature branch, a main not pulled yet — and a - * record dropped against it never comes back (#649 review). There `push` and - * `remove` settle the records in a fresh origin/ worktree instead. + * record dropped against it never comes back, while skipping the pass there + * left a merged placement unrecorded until the next push (#649 review). So a + * single-repo pull reads the default branch as the ref origin/. */ -describe('teamai pull settles placement records only against the default branch', () => { +describe('teamai pull settles placement records against the default branch', () => { beforeEach(async () => { vi.clearAllMocks(); vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); @@ -132,14 +134,14 @@ describe('teamai pull settles placement records only against the default branch' await pull({ silent: true, force: true }); - expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(testRoot, 'team-repo'), expect.anything()); + expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(testRoot, 'team-repo'), expect.anything(), undefined); }); - it('not in single-repo mode, where the checkout is the member\'s own', async () => { + it('in single-repo mode, through origin/ rather than the member\'s checkout', async () => { vi.mocked(loadLocalConfigForScope).mockResolvedValue(config('self')); await pull({ silent: true, force: true }); - expect(reconcilePlacementRecords).not.toHaveBeenCalled(); + expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(business, '.teamai'), expect.anything(), 'origin/main'); }); }); diff --git a/src/pull.ts b/src/pull.ts index 64b8e8e7..f0627bad 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import matter from 'gray-matter'; import { selectAgentsForDirectory } from './resources/agents.js'; import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js'; -import { pullRepo, getHeadRev, createGit } from './utils/git.js'; +import { pullRepo, getHeadRev, createGit, getDefaultBranch } from './utils/git.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; import { pendingLearningsDir } from './utils/pending-learnings.js'; import { learningsRoots } from './utils/learnings-roots.js'; @@ -648,7 +648,7 @@ async function cleanupTombstonedResources( // Agents deploy flattened, so a namespaced agent tombstone has to be read // as the stem the local copy carries (`AgentsHandler.removedStems`). const tombstones = type === 'agents' - ? await (handler as AgentsHandler).removedStems(localConfig) + ? await (handler as AgentsHandler).removedStems(freshConfig, localConfig) : await handler.readTombstones(localConfig); if (tombstones.size === 0) continue; @@ -783,14 +783,17 @@ async function pullForScope( // delivery reads them: a placement whose PR has merged becomes a record, one // whose file the team deleted stops being one, and one shadowed by a new // shared-root file of the same name is withdrawn (#649 review). - // Not in single-repo mode: the refresh leaves the member's own checkout as it - // is — a feature branch, or a main not pulled yet — which is not the default - // branch, and a record dropped against it never comes back. `push` and - // `remove` reconcile there against a fresh origin/ worktree. - if (!options.dryRun && localConfig.repo.kind !== 'self') { + // In single-repo mode the refresh leaves the member's own checkout as it is — + // a feature branch, or a main not pulled yet — so the records are settled + // against origin/ as a ref instead: a record dropped against that + // checkout would never come back (#649 review). + if (!options.dryRun) { try { + const tip = localConfig.repo.kind === 'self' + ? `origin/${await getDefaultBranch(localConfig.repo.localPath)}` + : undefined; const recordsState = await loadStateForScope(localConfig); - if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState, tip)) { await saveStateForScope(recordsState, localConfig); } } catch (e) { diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 73ae8009..a85f9a7f 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -109,18 +109,27 @@ export class AgentsHandler extends ResourceHandler { * tombstones only `fe/vr`, but every member holds that agent as `/vr`, * so the tombstone alone never reaches their copy, and the next push reads it * as a new agent and republishes it (#649 review). `vr` counts as removed - * here only while no namespace still has an agent of that stem: then the - * flattened copy can stand for nothing else, and while one does it is that - * agent's copy — suppressing it is what round 8 of the review ruled out. + * here only while THIS directory is not meant to hold an agent of that stem + * — the same selection pull delivers with (`selectAgentsForDirectory`). Then + * the flattened copy can stand for nothing else. While it is, the copy is + * that agent's, as a `be/vr` still delivered here would be, and suppressing + * it is what round 8 of the review ruled out. A `be/vr` that exists but is + * not active here does not keep a member's stale `fe/vr` copy alive. */ - async removedStems(localConfig: LocalConfig): Promise> { + async removedStems(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise> { const tombstones = await this.readTombstones(localConfig); const removed = new Set(tombstones); - const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + if (![...tombstones].some((tombstone) => tombstone.includes('/'))) return removed; + const resolved = await resolveResourceNamespaces(localConfig); + const { placedAgents } = await loadStateForScope(localConfig); + const desired = new Set(selectAgentsForDirectory( + await this.scanTeamForPull(teamConfig, localConfig), + resolved?.activeNamespaces.agents ?? null, + placedAgents, + ).map((agent) => agent.name)); for (const tombstone of tombstones) { const stem = path.posix.basename(tombstone); - if (removed.has(stem)) continue; - if ((await findTeamAgentFiles(teamAgentsDir, stem)).length === 0) removed.add(stem); + if (!desired.has(stem)) removed.add(stem); } return removed; } @@ -139,7 +148,7 @@ export class AgentsHandler extends ResourceHandler { ): Promise { const requestedNamespace = options?.namespace; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); - const tombstones = await this.removedStems(localConfig); + const tombstones = await this.removedStems(teamConfig, localConfig); // Single-repo mode: users drop canonical agent files straight into the repo's // own .teamai/agents/ (.yaml, or legacy .md) rather than authoring // them in a tool's agents dir. Those are ALREADY in team-repo format, so we diff --git a/src/utils/git.ts b/src/utils/git.ts index 039f4e52..1d263d78 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -858,19 +858,20 @@ export async function hashObject(repoPath: string, filePath: string): Promise { try { - const range = since ? `${since}..HEAD` : 'HEAD'; + const range = since ? `${since}..${tip}` : tip; const out = await createGit(repoPath).raw(['log', range, `--find-object=${blob}`, '--format=%H', '--', filePath]); return out.trim().length > 0; } catch (e) { @@ -880,13 +881,18 @@ export async function blobInHistory( } /** - * Whether a commit on the current branch after `since` deleted `filePath`. - * A path that exists now may still have been deleted and recreated in that - * range, by someone else. Null when git cannot say. + * Whether a commit reachable from `tip` (HEAD by default) after `since` deleted + * `filePath`. A path that exists now may still have been deleted and recreated + * in that range, by someone else. Null when git cannot say. */ -export async function pathDeletedSince(repoPath: string, since: string, filePath: string): Promise { +export async function pathDeletedSince( + repoPath: string, + since: string, + filePath: string, + tip = 'HEAD', +): Promise { try { - const out = await createGit(repoPath).raw(['log', `${since}..HEAD`, '--diff-filter=D', '--format=%H', '--', filePath]); + const out = await createGit(repoPath).raw(['log', `${since}..${tip}`, '--diff-filter=D', '--format=%H', '--', filePath]); return out.trim().length > 0; } catch (e) { log.debug(`git log --diff-filter=D failed for ${filePath}: ${(e as Error).message}`); @@ -894,10 +900,10 @@ export async function pathDeletedSince(repoPath: string, since: string, filePath } } -/** Full commit id of HEAD, or null when there is none. */ -export async function getHeadCommit(localPath: string): Promise { +/** Full commit id of `rev` (HEAD by default), or null when it names none. */ +export async function getHeadCommit(localPath: string, rev = 'HEAD'): Promise { try { - return (await createGit(localPath).raw(['rev-parse', '--verify', 'HEAD^{commit}'])).trim() || null; + return (await createGit(localPath).raw(['rev-parse', '--verify', `${rev}^{commit}`])).trim() || null; } catch { return null; } diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 07c69892..37b95a66 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -14,7 +14,7 @@ */ import path from 'node:path'; import { pathExists } from './fs.js'; -import { remoteBranchExists, hashObject, blobInHistory, pathDeletedSince, getHeadCommit } from './git.js'; +import { remoteBranchExists, hashObject, blobInHistory, pathDeletedSince, getHeadCommit, getFileContentAtRev } from './git.js'; import { placedResourcePath } from '../push-namespaces.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -220,13 +220,31 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { * * Runs after the pull in `push` and after the refresh in `pull`, before * anything reads the records. Returns whether `state` changed. + * + * `tip` names the default branch as a git ref, for a checkout that is not the + * default branch itself — a single-repo member's own working tree. Without + * it, the working tree and HEAD are the default branch as just pulled. */ export async function reconcilePlacementRecords( repoPath: string, state: Pick, + tip?: string, ): Promise { + // A ref that cannot be resolved says nothing about any file: reading every + // record as "gone" against it would drop them all. + if (tip && !await getHeadCommit(repoPath, tip)) { + log.debug(`Placement records not reconciled: ${tip} cannot be resolved here`); + return false; + } + const exists = tip + ? async (rel: string) => await getFileContentAtRev(repoPath, tip, `./${rel}`) !== null + : (rel: string) => pathExists(path.join(repoPath, rel)); + const history = tip ?? 'HEAD'; let changed = false; const checkedAt = state.placementsCheckedAt; + // Recorded in step 1 of this very run, against their own `base`: the + // previous checkpoint predates them and says nothing about them. + const recordedNow = new Set(); const fieldFor = (type: string): 'placedRules' | 'placedAgents' | null => ( type === 'rules' ? 'placedRules' : type === 'agents' ? 'placedAgents' : null ); @@ -237,15 +255,15 @@ export async function reconcilePlacementRecords( if (!item.placed) continue; const field = fieldFor(item.type); if (!field) continue; - if (!await pathExists(path.join(repoPath, item.relativePath))) continue; + if (!await exists(item.relativePath)) continue; // An entry with no blob predates the check; existence is all it can offer. // Bounded by `base`: the same bytes may have sat at this path before the // push, and a PR closed unmerged must not borrow that history. - if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath, entry.base) !== true) continue; + if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath, entry.base, history) !== true) continue; // Placement refuses an occupied path, so a deletion since `base` came // after this placement landed: what is there now was recreated by // someone else, and the placement is spent without a record. - if (entry.base && await pathDeletedSince(repoPath, entry.base, item.relativePath) === true) { + if (entry.base && await pathDeletedSince(repoPath, entry.base, item.relativePath, history) === true) { log.debug(`Not recording placement ${field}.${item.name}: ${item.relativePath} was deleted after it landed`); item.placed = false; delete item.blob; @@ -254,6 +272,7 @@ export async function reconcilePlacementRecords( } log.debug(`Recording placement ${field}.${item.name} → ${item.relativePath}: landed on the default branch`); state[field] = { ...state[field], [item.name]: item.relativePath }; + recordedNow.add(`${field}:${item.name}`); // Consumed: a placement is recorded once. Left marked, it would record // again after the team deleted the file and another member recreated // the path — the blob stays in history, so the check above would still @@ -271,21 +290,22 @@ export async function reconcilePlacementRecords( const kept: Record = {}; for (const [name, recorded] of Object.entries(records)) { const valid = placedResourcePath(records, root, name); - if (valid && !await pathExists(path.join(repoPath, valid))) { + if (valid && !await exists(valid)) { log.debug(`Dropping placement record ${field}.${name} → ${recorded}: gone from the default branch`); changed = true; continue; } // Deleted and recreated between two checks — a removal that merged, then // another member's resource at the same path — is not this author's. - if (valid && checkedAt && await pathDeletedSince(repoPath, checkedAt, valid) === true) { + if (valid && checkedAt && !recordedNow.has(`${field}:${name}`) + && await pathDeletedSince(repoPath, checkedAt, valid, history) === true) { log.debug(`Dropping placement record ${field}.${name} → ${recorded}: deleted from the default branch since the last check`); changed = true; continue; } let shadowed: string | undefined; for (const candidate of sharedRootPaths(root, name)) { - if (await pathExists(path.join(repoPath, candidate))) { shadowed = candidate; break; } + if (await exists(candidate)) { shadowed = candidate; break; } } if (shadowed) { log.warn( @@ -302,12 +322,17 @@ export async function reconcilePlacementRecords( // The revision the surviving records were checked against, so the next run // sees a deletion that happened in between even if the path is back by then. + // With no record left there is nothing it could vouch for, and kept it would + // later read a deletion from before a new record existed as that record's. if (Object.keys(state.placedRules ?? {}).length + Object.keys(state.placedAgents ?? {}).length > 0) { - const head = await getHeadCommit(repoPath); + const head = await getHeadCommit(repoPath, history); if (head && head !== state.placementsCheckedAt) { state.placementsCheckedAt = head; changed = true; } + } else if (state.placementsCheckedAt !== undefined) { + delete state.placementsCheckedAt; + changed = true; } return changed; } From c2a54b35fbd9c22a38b105fcd663c37b40cf5eb7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 00:29:59 +0200 Subject: [PATCH 22/25] fix(remove): stop on a stale clone, and judge PR conflicts by the destination the flag gives `remove` ignored a failed refresh and reconciled the stale clone as if it were the default branch. A placement merged since the last pull was then not recorded, and the bare name fell back to the stem, removing that agent from every namespace. `remove` now stops with exit 1 and removes nothing. Under --role/--project, a pending PR counted as conflicting whenever its recorded namespace differed from the flag's, although only skills and new shared-root rules and agents are moved by it. A modified rule already in a namespace kept its path yet went to a second PR on the same file. Only an item the flag actually moves can conflict with it now. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/push-namespace-e2e.test.ts | 22 +++++++++++++++ src/__tests__/push-role.test.ts | 35 ++++++++++++++++++++++++ src/push.ts | 12 +++++++- src/remove.ts | 14 +++++++++- 6 files changed, 83 insertions(+), 4 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 15361d03..530cd1cf 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -600,7 +600,7 @@ Choose namespace [1-3] (default: 1 = common): - A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, `remove` stops with exit 1 and removes nothing, because a stale clone can resolve the name to the wrong files - With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 68cea84c..b5d1c5fb 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -577,7 +577,7 @@ Choose namespace [1-3] (default: 1 = common): - 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,`remove` 会以退出码 1 停止且不删除任何内容,因为过期的克隆可能把名字解析到错误的文件 - 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index 2dfca1e6..787608b8 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -571,6 +571,28 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { expect(branchFiles(fixture).branch).toBe(''); }, 60_000); + it('refuses to remove when the team clone cannot be refreshed', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + // The placement merges, but this clone never sees it: fetching fails while + // pushing still works, so a removal resolved from the stale tree would land. + mergeBranch(fixture, branchFiles(fixture).branch); + git(['remote', 'set-url', 'origin', path.join(fixture.sandbox, 'nowhere.git')], fixture.teamRepo); + git(['remote', 'set-url', '--push', 'origin', fixture.remote], fixture.teamRepo); + + const result = await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + + // Stale, `vr` resolves to no record and falls back to the bare stem, which + // would remove other-ns/vr as well. + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('could not be refreshed'); + expect(result.output).toContain('Nothing was removed'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + it('pull drops the placement record of a rule the team has since deleted', async () => { const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); writeLocalResources(fixture); diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 99a851bc..961107c3 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1588,6 +1588,41 @@ describe('push namespace routing for rules and agents', () => { expect(said).toContain('separate PR'); }); + it('keeps updating the open PR of a resource the flag does not move', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // A rule already in fe-know, modified, with its edit under review. --role + // relocates only NEW shared-root rules, so this one stays at its path, and + // a "conflict" with the flag sent the same file to a second PR (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/14', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'fe-know/my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ + rules: [{ + name: 'fe-know/my-rule', type: 'rules', sourcePath: '/tmp/local/rules/fe-know/my-rule.md', + relativePath: 'rules/fe-know/my-rule.md', status: 'modified', namespace: 'fe-know', + }], + }, pushedItems); + + await push({ all: true, role: 'other' }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + const branches = mockPushRepoBranch.mock.calls.map((call) => call[3]); + expect(branches).toEqual(['teamai/push/test/20260101-000000']); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).not.toContain('separate PR'); + }); + it('--dry-run reports the same destination the real push would use', async () => { mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(), diff --git a/src/push.ts b/src/push.ts index ce0704c9..d57875f8 100644 --- a/src/push.ts +++ b/src/push.ts @@ -1197,10 +1197,20 @@ async function pushCore( const resolved = resolveProjectNamespace(projectsManifest, options.project, type); return resolved.ok ? resolved.namespace : undefined; }; + // Only what the flag actually MOVES can conflict with it: every selected + // skill (the override above), and a rule or agent only while it is new and at + // the shared root (step 4). Anything else keeps the path it was scanned with, + // so its open PR is still the right one to update, and treating it as a + // conflict opened a second PR on the same file (#649 review). + const scannedByKey = new Map(allItems.map((item) => [`${item.type}:${item.name}`, item])); + const movedByFlag = (item: ResourceItem): boolean => item.type === 'skills' + || (item.status === 'new' && !item.namespace && isAtSharedRoot(item)); const conflictsWithRequest = ( - recorded: { type: string; namespace?: string; relativePath: string }, + recorded: { type: string; name: string; namespace?: string; relativePath: string }, ): boolean => { if (!isPlaceableType(recorded.type as ResourceType)) return false; + const scanned = scannedByKey.get(`${recorded.type}:${recorded.name}`); + if (!scanned || !movedByFlag(scanned)) return false; // The path, not the field: a scan can record an item whose destination is // namespaced while leaving `namespace` unset, and trusting the field let // those entries slip past the check and be force-pushed into (#649 review). diff --git a/src/remove.ts b/src/remove.ts index 79dd3ce8..b20afe16 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -59,10 +59,22 @@ async function removeCore( // Pull latest before making changes. In self mode the worktree is already a // fresh checkout of origin/, so skip the pull. + // A clone that could not be refreshed is not the default branch: a placement + // merged since the last pull is not recorded there, so the bare name the + // author types falls back to the stem and removes that agent from every + // namespace (#649 review). Removing is a write, so stop instead of guessing. if (!selfMode) { try { await pullRepo(localConfig.repo.localPath); - } catch { /* continue even if pull fails */ } + } catch (e) { + log.error( + `The team repo could not be refreshed (${(e as Error).message}), so what "${names.join(', ')}" ` + + 'names cannot be resolved against the current default branch. Nothing was removed. ' + + 'Fix the pull (run `teamai pull` to see why) and retry.', + ); + process.exitCode = 1; + return; + } } // `publishedNameFor` below resolves the bare name the author types through From 665cd4bc476032280175ec17c28168970f6ae29d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 00:54:28 +0200 Subject: [PATCH 23/25] fix(push): prefer an agent's delivered source over the flag, baseline new placements, validate the record's namespace With --role/--project the requested namespace always chose the team file a local agent was compared with, so an untouched copy delivered from an active namespace read as an edit of the requested namespace's agent and overwrote it. Candidates now follow delivery: an active source (the shared root included), then this machine's record, and only then the requested namespace. A placement that landed after the last pull had no `lastPullRev` version, so the pre-push sync skipped it and a teammate's edit before the author's next pull was pushed over. Rules take the version the file was added with as their base; a recorded agent, which has no pre-push sync, is held with a pull-first message when the team file moved past that baseline. `placedResourcePath` now requires a safe namespace segment: a backslash in it is a separator on Windows and walked out of the resource root. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/agents.test.ts | 52 ++++++++++++++-- src/__tests__/pre-push-sync.test.ts | 19 ++++++ src/__tests__/push-namespaces.test.ts | 8 +++ src/push-namespaces.ts | 4 ++ src/resources/agents.ts | 85 ++++++++++++++++++--------- src/utils/git.ts | 15 +++++ src/utils/pre-push-sync.ts | 17 +++++- 9 files changed, 168 insertions(+), 36 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 530cd1cf..607ef76f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -601,7 +601,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source - `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, `remove` stops with exit 1 and removes nothing, because a stale clone can resolve the name to the wrong files -- With `--role`/`--project`, the named namespace also decides which team agent a local edit belongs to. The same agent name may exist in several namespaces, so a copy in one you did not name never blocks publishing yours; a copy at the shared root does, because both would then be active at once +- A local agent is an edit of the team agent it was delivered from: one in an active namespace or at the shared root first, then one this machine placed. Only when neither exists does `--role`/`--project` decide, and the agent is new in that namespace. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this machine last synced it is held until you run `teamai pull`, because agents have no pre-push sync - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b5d1c5fb..cb8e9ef2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -578,7 +578,7 @@ Choose namespace [1-3] (default: 1 = common): - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 - `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,`remove` 会以退出码 1 停止且不删除任何内容,因为过期的克隆可能把名字解析到错误的文件 -- 使用 `--role`/`--project` 时,指定的 namespace 同时决定本地 agent 对应哪个团队文件:同名 agent 允许存在于多个 namespace,因此其他 namespace 的同名副本不会阻止你发布;但共享根目录已有同名 agent 时会阻止,因为两者会同时生效 +- 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 或共享根目录中的 agent,其次是本机放置的 agent。只有两者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在本机上次同步后被团队修改,会暂缓推送,直到你运行 `teamai pull`,因为 agents 没有推送前同步 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index b6f44e77..c15bcdd2 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -315,6 +315,31 @@ projects: expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); }); + // A projects manifest with none of its projects active here: every namespace + // is inactive, and only the shared root is delivered. + const nothingActive = () => fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: elsewhere\n resources:\n agents: [zzz]\n'); + + it('holds a recorded agent that changed on the team since this machine last synced it', async () => { + // Agents have no pre-push sync: a teammate's edit made before the author's + // next pull would be overwritten by the stale local copy (#649 review). + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Published\ninstructions: A teammate rewrote this.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Published\n---\n\nRead it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, lastPullRev: 'abc1234', + }); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('name: reviewer\ndescription: Published\ninstructions: Read it.\n')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('changed on the team since this machine last synced it'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', './agents/fe-agents/reviewer.yaml'); + }); + /** * The layout allows the same stem in several namespaces. An explicit * --role/--project names the destination, so a copy in some OTHER namespace @@ -462,6 +487,7 @@ projects: }); it('publishes into the requested namespace despite a stem in an inactive one', async () => { + await nothingActive(); await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), @@ -476,6 +502,7 @@ projects: }); it('edits the copy in the requested namespace rather than treating it as new', async () => { + await nothingActive(); await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); const requested = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); @@ -490,9 +517,9 @@ projects: expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); }); - it('refuses to publish a namespaced second copy beside a shared-root agent', async () => { - // The root copy reaches every member, so both would be active at once — - // the collision pull reports and skips. + it('edits the shared-root agent it was deployed from, never a namespaced second copy', async () => { + // The root copy reaches every member, so the local file is its copy; a + // namespaced second one would leave two active agents of that name. await fse.outputFile(path.join(repoPath, 'agents/reviewer.yaml'), 'name: reviewer\ndescription: Shared\ninstructions: Read it.\n'); await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), @@ -501,7 +528,24 @@ projects: const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); expect(items).toHaveLength(1); - expect(items[0]?.skipReason).toContain('shared root'); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/reviewer.yaml'); + }); + + it('compares a deployed agent with its active source, not with the requested namespace\'s file', async () => { + // common is active and delivered `vr`; fe/vr is another agent, inactive + // here. `push --role fe` must not read the untouched common copy as an + // edit of fe/vr and write it over that file (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: base\n resources:\n agents: [common]\n'); + localConfig.projects = ['base']; + const common = { name: 'vr', type: 'agents' as const, sourcePath: path.join(repoPath, 'agents/common/vr.yaml'), relativePath: 'agents/common/vr.yaml' }; + await fse.outputFile(common.sourcePath, 'name: vr\ndescription: Common\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Front\ninstructions: Read fe.\n'); + await handler.pullItem(common, teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' })).toEqual([]); }); /** diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index d4d13468..80d257b6 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -16,8 +16,11 @@ vi.mock('../utils/logger.js', () => ({ // Mock getFileContentAtRev since test dirs are not real git repos const mockGetFileContentAtRev = vi.fn<(repoPath: string, rev: string, filePath: string) => Promise>(); +const mockGetFileContentWhenAdded = vi.fn<(repoPath: string, filePath: string) => Promise>() + .mockResolvedValue(null); vi.mock('../utils/git.js', () => ({ getFileContentAtRev: (...args: [string, string, string]) => mockGetFileContentAtRev(...args), + getFileContentWhenAdded: (...args: [string, string]) => mockGetFileContentWhenAdded(...args), createGit: vi.fn(), pullRepo: vi.fn(), pushRepoBranch: vi.fn(), @@ -116,6 +119,22 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); }); + it('syncs a placement that landed after the last pull from the version it was added with', async () => { + // Placed, merged, recorded — and a teammate edits it before the author's + // next pull. At lastPullRev the file did not exist yet (#649 review). + await fse.outputFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'as placed'); + mockGetFileContentAtRev.mockResolvedValue(null); + mockGetFileContentWhenAdded.mockResolvedValueOnce(Buffer.from('as placed')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + expect(await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8')).toBe('teammate v2'); + expect(mockGetFileContentWhenAdded).toHaveBeenCalledWith(repoPath, 'rules/fe-know/my-rule.md'); + }); + it('keeps following the record when a shared-root rule with the same name exists', async () => { // Both sides must resolve the same file. If the sync compared against // rules/my-rule.md while the scanner followed the record, the scan would diff --git a/src/__tests__/push-namespaces.test.ts b/src/__tests__/push-namespaces.test.ts index a212b505..5d887875 100644 --- a/src/__tests__/push-namespaces.test.ts +++ b/src/__tests__/push-namespaces.test.ts @@ -151,6 +151,14 @@ describe('placedResourcePath', () => { expect(placedResourcePath({ x: 'skills/ns/x.md' }, 'rules', 'x')).toBeNull(); }); + it('rejects a namespace segment that is not a safe directory name', () => { + // On Windows `path.join` reads the backslashes as separators, so this + // escapes the resource root although it has three `/` segments (#649 review). + expect(placedResourcePath({ foo: 'rules/..\\..\\victim/foo.md' }, 'rules', 'foo')).toBeNull(); + expect(placedResourcePath({ vr: 'agents/fe\\..\\..\\x/vr.yaml' }, 'agents', 'vr')).toBeNull(); + expect(placedResourcePath({ foo: 'rules/fe:ads/foo.md' }, 'rules', 'foo')).toBeNull(); + }); + it('rejects a record that is not namespaced or not named after the resource', () => { expect(placedResourcePath({ x: 'rules/x.md' }, 'rules', 'x')).toBeNull(); expect(placedResourcePath({ x: 'rules/ns/sub/x.md' }, 'rules', 'x')).toBeNull(); diff --git a/src/push-namespaces.ts b/src/push-namespaces.ts index 6572c7ae..660a25dc 100644 --- a/src/push-namespaces.ts +++ b/src/push-namespaces.ts @@ -157,6 +157,10 @@ export function placedResourcePath( if (segments.length !== 3) return null; if (segments[0] !== root) return null; if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return null; + // The namespace must be the same safe segment push itself would write. A + // `\` in it is a separator on Windows, where `path.join` then walks out of + // the resource root that the `/` split above seemed to confine it to. + if (!isSafeNamespaceSegment(segments[1] ?? '')) return null; // Exactly the resource's own file: `.md` for a rule, `.yaml` or a // legacy `.md` for an agent. A prefix test would accept // `.backup.md` and redirect scanning, syncing and removal onto an diff --git a/src/resources/agents.ts b/src/resources/agents.ts index a85f9a7f..087470d5 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -12,6 +12,7 @@ import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; +import { getFileContentAtRev, getFileContentWhenAdded } from '../utils/git.js'; import { parseAgentYaml, serializeAgentYaml, @@ -160,7 +161,7 @@ export class AgentsHandler extends ResourceHandler { // namespace this directory need not have activated. Without the record it // would read as "no active source" and the author could never edit the // agent they just created (#649 review). - const placedAgents = (await loadStateForScope(localConfig)).placedAgents; + const { placedAgents, lastPullRev } = await loadStateForScope(localConfig); const directItems: AgentResourceItem[] = []; const directStems = new Set(); @@ -197,6 +198,13 @@ export class AgentsHandler extends ResourceHandler { if (teamRelPath !== placed) supersedes = placed; basePath = path.join(localConfig.repo.localPath, placed); baseExists = true; + if (await recordedAgentMovedOn(localConfig.repo.localPath, placed, lastPullRev)) { + directItems.push({ name: stem, type: 'agents', sourcePath: activePath, + relativePath: placed, status: 'modified', namespace: placed.split('/')[1], + skipReason: staleRecordedAgentReason(stem, placed) }); + directStems.add(stem); + continue; + } } } if (baseExists && !supersedes && await fileContentEqual(activePath, basePath)) continue; // unchanged @@ -266,42 +274,34 @@ export class AgentsHandler extends ResourceHandler { // directory is carried into `relativePath` below. const sources = await findTeamAgentFiles(teamAgentsDir, stem); const placedNamespace = placedResourcePath(placedAgents, 'agents', stem)?.split('/')[1]; - // An explicit --role/--project names the destination, so IT decides which - // team file this local agent is an edit of. A copy of the same stem in - // another namespace is a different agent — the layout allows that — and - // must not block publishing this one, which is what activity filtering - // alone did (#649 review). + // Which team file this local agent is a copy of, in the order pull + // delivers them: an active source (the shared root counts) is what was + // deployed here, then the one this machine's record names. Only when + // neither exists does an explicit --role/--project decide — the agent + // then needs a destination, and a same-stem copy in some other inactive + // namespace is a different agent that must not block it (#649 review). + // Letting the flag win outright compared an active agent's rendering + // with the requested namespace's file and overwrote it without an edit. const active = sources.filter( (file) => activeNamespaces === null || !file.namespace || activeNamespaces.includes(file.namespace), ); - // The record is a FALLBACK, not an additional candidate: when an active - // namespace already holds this stem, that is the agent deployed here, and - // `pull` leaves the recorded one undelivered for exactly that reason. - const candidates = requestedNamespace - ? sources.filter((file) => file.namespace === requestedNamespace) - : active.length > 0 - ? active - : sources.filter((file) => file.namespace === placedNamespace); + const inRequested = (files: TeamAgentFile[]) => files.filter((file) => file.namespace === requestedNamespace); + const recorded = placedNamespace ? sources.filter((file) => file.namespace === placedNamespace) : []; + const candidates = active.length > 0 + // Two active sources collide; the flag may say which one is meant. + ? (requestedNamespace && active.length > 1 && inRequested(active).length === 1 ? inRequested(active) : active) + : recorded.length > 0 + ? recorded + : requestedNamespace ? inRequested(sources) : []; if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', skipReason: `Ambiguous agent "${stem}": multiple active sources (${candidates.map((file) => file.path).join(', ')}). Give active agents unique names before pushing.` }); continue; } - // A shared-root copy reaches every member, so a namespaced second copy - // would leave two active agents answering to the same name — exactly the - // collision pull reports and skips. Only reachable with an explicit - // destination; without one the root copy is always a candidate. - const sharedRoot = candidates.length === 0 && sources.find((file) => !file.namespace); - if (sharedRoot) { - items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, - relativePath: `agents/${stem}.yaml`, status: 'modified', - skipReason: `Agent "${stem}" already exists at the shared root (agents/${stem}${sharedRoot.ext}), ` - + `which every member receives. Edit that copy, or rename this agent, rather than publishing ` - + `a second one into "${requestedNamespace}".` }); - continue; - } + // A shared-root copy is always active, so it is a candidate above and + // this local file is an edit of it: no namespaced second copy is made. // With no destination named, a stem that exists only in namespaces this // directory has not activated is not ours to edit. With one named, the // agent is simply new there, and placement writes it to that namespace. @@ -313,6 +313,15 @@ export class AgentsHandler extends ResourceHandler { continue; } const located = candidates[0]; + if (located && active.length === 0 && recorded.length > 0) { + const recordedPath = `agents/${located.namespace}/${stem}${located.ext}`; + if (await recordedAgentMovedOn(localConfig.repo.localPath, recordedPath, lastPullRev)) { + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: recordedPath, status: 'modified', namespace: located.namespace, + skipReason: staleRecordedAgentReason(stem, recordedPath) }); + continue; + } + } const teamYamlPath = located?.ext === '.yaml' ? located.path : path.join(teamAgentsDir, `${stem}.yaml`); const teamMdPath = located?.ext === '.md' ? located.path : path.join(teamAgentsDir, `${stem}.md`); @@ -844,6 +853,28 @@ export async function listTeamAgentDirs(teamAgentsDir: string): Promise { + const current = await readFileSafe(path.join(repoPath, relPath)); + if (current === null) return false; + const baseline = (lastPullRev ? await getFileContentAtRev(repoPath, lastPullRev, `./${relPath}`) : null) + ?? await getFileContentWhenAdded(repoPath, relPath); + return baseline !== null && baseline.toString('utf-8') !== current; +} + +function staleRecordedAgentReason(stem: string, relPath: string): string { + return `Agent "${stem}" (${relPath}) changed on the team since this machine last synced it, ` + + 'so pushing your copy would overwrite that change. Run `teamai pull`, reapply any local edit, and push again.'; +} + /** * Every team file for a stem, root first, then namespaces in directory order, * `.yaml` before `.md` in each. A stem may legitimately live in several diff --git a/src/utils/git.ts b/src/utils/git.ts index 1d263d78..967b18ff 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -900,6 +900,21 @@ export async function pathDeletedSince( } } +/** + * The content `filePath` had in the latest commit that added it, or null. For + * a resource created after the last pull there is no `lastPullRev` version to + * compare with, and this is the version the member's copy started from. + */ +export async function getFileContentWhenAdded(repoPath: string, filePath: string): Promise { + try { + const sha = (await createGit(repoPath).raw(['log', 'HEAD', '--diff-filter=A', '--format=%H', '-1', '--', filePath])).trim(); + return sha ? await getFileContentAtRev(repoPath, sha, `./${filePath}`) : null; + } catch (e) { + log.debug(`git log --diff-filter=A failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + /** Full commit id of `rev` (HEAD by default), or null when it names none. */ export async function getHeadCommit(localPath: string, rev = 'HEAD'): Promise { try { diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index 04661b89..d6e56a50 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -13,7 +13,7 @@ import { readFileSafe, writeFile, } from './fs.js'; -import { getFileContentAtRev } from './git.js'; +import { getFileContentAtRev, getFileContentWhenAdded } from './git.js'; import { ResourceHandler } from '../resources/base.js'; import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; import { teamRuleToCursorMdc, cursorMdcBodyEqualsTeamMd } from '../resources/cursor-mdc.js'; @@ -107,10 +107,21 @@ async function syncRulesToLocal( // in the team repo, and both sides must compare against that file or the // scan reverts a teammate's update. const placed = placedResourcePath(placedRules, 'rules', name); + let viaRecord = false; if (placed && await pathExists(path.join(repoPath, placed))) { teamRelPath = placed; teamFilePath = path.join(repoPath, placed); + viaRecord = true; } + // A placement that landed after the last pull did not exist at + // `lastPullRev`, yet the author's root copy is exactly what landed. Its + // base is the version the file was added with; without it a teammate's + // edit before the author's next pull was skipped here, and the stale + // copy went back over it (#649 review). + const baseVersion = async (): Promise => ( + await getFileContentAtRev(repoPath, lastPullRev, teamRelPath) + ?? (viaRecord ? await getFileContentWhenAdded(repoPath, teamRelPath) : null) + ); // Only process files that exist in both places but differ if (!await pathExists(teamFilePath)) continue; @@ -120,7 +131,7 @@ async function syncRulesToLocal( if (localRaw === null || teamRaw === null) continue; if (cursorMdcBodyEqualsTeamMd(localRaw, teamRaw)) continue; - const oldContent = await getFileContentAtRev(repoPath, lastPullRev, teamRelPath); + const oldContent = await baseVersion(); if (oldContent === null) continue; // Didn't exist at lastPullRev — ambiguous, skip // Compare bodies: the local `.mdc` never matched the team `.md` byte for byte. @@ -134,7 +145,7 @@ async function syncRulesToLocal( if (await fileContentEqual(localFilePath, teamFilePath)) continue; // They differ — check if local matches the old team repo version - const oldContent = await getFileContentAtRev(repoPath, lastPullRev, teamRelPath); + const oldContent = await baseVersion(); if (oldContent === null) continue; // File didn't exist at lastPullRev — ambiguous, skip if (await fileContentEqualToBuffer(localFilePath, oldContent)) { From 859ac583e4e646a77ad980a76ef94eb855150607 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 07:48:49 +0200 Subject: [PATCH 24/25] fix(push): close the round-21 findings on placement, removal and agent sources - A pending namespaced placement whose name a shared-root file now takes is left out of the push with a warning, instead of the open PR being rebuilt with the author's copy over the shared file. - `remove` stops when the placement records cannot be reconciled and saved: a missing record sends the bare name to the stem, which spans namespaces. - An agent skipped for want of a --project agents namespace no longer blocks the rest of the push; the error stands only when nothing else is left. - A placement is marked only with a blob that can prove it landed, and one without is spent rather than recorded because its path exists. - The single-repo `.teamai/rules` scan source is not a tool, so an `enabledAgents` list no longer hides it. - A namespaced agent tombstone retires the flattened stem only where that agent could have been delivered; reconcile keeps the author's dropped record (`retiredPlacedAgents`) so their own copy still counts. - Two active same-name agents stay ambiguous under a flag, and a flag naming a namespace that already holds the agent is a collision, as for rules. - In single-repo mode a root copy equal to an older version of its placed file is held as stale rather than pushed over a teammate's edit. - The recorded-agent hold runs only for a changed copy and says to set the edit aside first; a pending placement is routed to its PR, not skipped; a flag that does not move a shared-root edit says so; several candidate namespaces without a terminal fail with a --role hint; a placement that landed with other content is reported once. --- docs/usage-guide.md | 10 +- docs/usage-guide.zh-CN.md | 10 +- src/__tests__/agents.test.ts | 117 +++++++++++++++++++- src/__tests__/pkg-team-distribution.test.ts | 1 + src/__tests__/placement-records.test.ts | 61 +++++++--- src/__tests__/push-pending-pr.test.ts | 1 + src/__tests__/push-role.test.ts | 92 ++++++++++++++- src/__tests__/push-skill-flag.test.ts | 1 + src/__tests__/push-team-config.test.ts | 1 + src/__tests__/remove-command.test.ts | 63 +++++++++++ src/__tests__/rules.test.ts | 22 ++++ src/__tests__/self-mode-push-scan.test.ts | 37 ++++++- src/push.ts | 82 +++++++++++--- src/remove.ts | 11 +- src/resources/agents.ts | 115 +++++++++++++------ src/resources/rules.ts | 31 +++++- src/types.ts | 15 +++ src/utils/git.ts | 44 ++++++-- src/utils/pending-push.ts | 49 ++++++-- 19 files changed, 654 insertions(+), 109 deletions(-) create mode 100644 src/__tests__/remove-command.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 607ef76f..4b82a61a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -599,14 +599,16 @@ Choose namespace [1-3] (default: 1 = common): - The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` - A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior - `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses +- When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` +- `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so - A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source -- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, `remove` stops with exit 1 and removes nothing, because a stale clone can resolve the name to the wrong files -- A local agent is an edit of the team agent it was delivered from: one in an active namespace or at the shared root first, then one this machine placed. Only when neither exists does `--role`/`--project` decide, and the agent is new in that namespace. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this machine last synced it is held until you run `teamai pull`, because agents have no pre-push sync +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files +- A local agent is an edit of the team agent it was delivered from: one in an active namespace or at the shared root first, then one this machine placed. Only when neither exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this machine last synced it is held until you run `teamai pull`, because agents have no pre-push sync. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported - If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does a new resource that would resolve from `manifest/roles.yaml`. Fix the pull and retry, or name the namespace with `--role ` -- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records. `teamai remove` itself leaves the record alone: its deletion reaches the default branch only when its PR merges, and until then a retried `remove` still resolves the bare name to the namespaced file +- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records. `teamai remove` itself leaves the record alone: its deletion reaches the default branch only when its PR merges, and until then a retried `remove` still resolves the bare name to the namespaced file. If the file reached the default branch with content other than what you pushed (for example a reviewer changed the PR before a squash merge), it is not recorded, and push says so once; run `teamai pull` and edit that file as the team file it now is - Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. @@ -1495,7 +1497,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, unless their directory still receives an agent of that name from another active namespace. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, when it can be that agent's copy (the namespace is active for them, or their machine placed the agent) and their directory does not still receive an agent of that name from another active namespace. A member who never had that namespace keeps their own agent of the same name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index cb8e9ef2..856f2bda 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -576,14 +576,16 @@ Choose namespace [1-3] (default: 1 = common): - 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` - 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 - `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 +- 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` +- `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 - 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 -- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,`remove` 会以退出码 1 停止且不删除任何内容,因为过期的克隆可能把名字解析到错误的文件 -- 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 或共享根目录中的 agent,其次是本机放置的 agent。只有两者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在本机上次同步后被团队修改,会暂缓推送,直到你运行 `teamai pull`,因为 agents 没有推送前同步 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件 +- 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 或共享根目录中的 agent,其次是本机放置的 agent。只有两者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在本机上次同步后被团队修改,会暂缓推送,直到你运行 `teamai pull`,因为 agents 没有推送前同步。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 - push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;需要从 `manifest/roles.yaml` 解析落点的新资源同样如此。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace -- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步。`teamai remove` 本身不清除记录:删除要等其 PR 合并才进入默认分支,在此之前重试 `remove` 仍会把简名解析到带 namespace 的团队文件 +- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步。`teamai remove` 本身不清除记录:删除要等其 PR 合并才进入默认分支,在此之前重试 `remove` 仍会把简名解析到带 namespace 的团队文件。若该文件进入默认分支时的内容与你推送的不同(例如评审者在 squash 合并前修改了 PR),则不会写入记录,push 会提示一次;此时运行 `teamai pull`,并把该文件当作现在的团队文件来编辑 - 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 @@ -1454,7 +1456,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;除非成员的目录仍从另一个活跃 namespace 收到同名 agent,否则其拍平后的 `` 副本会被清理,也不会再被推送。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;当该副本可能属于这个 agent(该 namespace 对成员活跃,或由其本机放置)且成员的目录没有从另一个活跃 namespace 收到同名 agent 时,其拍平后的 `` 副本会被清理,也不会再被推送。从未启用该 namespace 的成员会保留自己的同名 agent。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index c15bcdd2..d9fe1c3c 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -320,6 +321,22 @@ projects: const nothingActive = () => fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), 'version: 1\nprojects:\n - id: elsewhere\n resources:\n agents: [zzz]\n'); + it('does not hold a recorded agent whose local copy already matches the team file', async () => { + // The author's own edit merged after their last pull: the team file moved + // past the baseline, but to exactly what they have, so nothing is pushed. + await nothingActive(); + const current = { name: 'reviewer', type: 'agents' as const, + sourcePath: path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), relativePath: 'agents/fe-agents/reviewer.yaml' }; + await fse.outputFile(current.sourcePath, 'name: reviewer\ndescription: Published\ninstructions: My merged edit.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, lastPullRev: 'abc1234', + }); + await handler.pullItem(current, teamConfig, localConfig); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('name: reviewer\ndescription: Published\ninstructions: Read it.\n')); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + it('holds a recorded agent that changed on the team since this machine last synced it', async () => { // Agents have no pre-push sync: a teammate's edit made before the author's // next pull would be overwritten by the stale local copy (#649 review). @@ -435,6 +452,30 @@ projects: expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); }); + it('holds a root canonical source that is an older version of the placed file', async () => { + // Nothing refreshes .teamai/agents/vr.yaml after placement: a teammate's + // later edit leaves it an old copy nobody edited, and pushing it would + // revert that edit (#649 review). + const self = selfConfig(); + const run = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }); + const placedFile = path.join(repoPath, 'agents/fe/vr.yaml'); + const asPlaced = 'name: vr\ndescription: Published\ninstructions: As placed.\n'; + run(['init', '-q', '-b', 'main']); + await fse.outputFile(placedFile, asPlaced); + run(['add', '-A']); run(['commit', '-q', '-m', 'placement merged']); + await fse.outputFile(placedFile, 'name: vr\ndescription: Published\ninstructions: A teammate improved this.\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'teammate edit']); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), asPlaced); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { placedAgents: { vr: 'agents/fe/vr.yaml' } }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('is an older version of agents/fe/vr.yaml'); + }); + it('follows a renamed canonical source to its new extension and retires the recorded file', async () => { const self = selfConfig(); // Recorded and published as legacy .md; the author has since rewritten it as .yaml. @@ -501,20 +542,60 @@ projects: expect(items[0]?.status).toBe('new'); }); - it('edits the copy in the requested namespace rather than treating it as new', async () => { + it('refuses to place onto a requested namespace\'s agent that was never delivered here', async () => { + // Neither active nor recorded, so this local file is not a copy of + // fe-agents/reviewer: it is new, and pushing it there would overwrite + // somebody else's agent — which rules already refuse (#649 review). await nothingActive(); await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); const requested = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); - await fse.outputFile(requested, 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'); + const theirs = 'name: reviewer\ndescription: Theirs\ninstructions: Read it.\n'; + await fse.outputFile(requested, theirs); await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), - '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + '---\nname: reviewer\ndescription: Mine\n---\n\nMy own agent.\n'); const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); expect(items).toHaveLength(1); - expect(items[0]?.status).toBe('modified'); - expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + expect(items[0]?.skipReason).toContain('agents/fe-agents/reviewer.yaml already exists'); + for (const item of items) await handler.pushItem(item, teamConfig, localConfig); + expect(await fse.readFile(requested, 'utf8')).toBe(theirs); + }); + + it('keeps two active same-stem agents ambiguous even when a flag names one of them', async () => { + // pull reports the collision and leaves `vr` as common's copy; under + // --role fe that copy must not be read as an edit of fe/vr (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: base\n resources:\n agents: [common, fe]\n'); + localConfig.projects = ['base']; + const common = { name: 'vr', type: 'agents' as const, sourcePath: path.join(repoPath, 'agents/common/vr.yaml'), relativePath: 'agents/common/vr.yaml' }; + await fse.outputFile(common.sourcePath, 'name: vr\ndescription: Common\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Front\ninstructions: Read fe.\n'); + await handler.pullItem(common, teamConfig, localConfig); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('Ambiguous agent "vr"'); + }); + + it('sends an agent awaiting review as a placement back to its PR, despite a same stem elsewhere', async () => { + // Without a flag, a stem existing only in an inactive namespace is "no + // active source" — but this one is this machine's, placed and under review. + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/other-ns/vr.yaml'), 'name: vr\ndescription: Other\ninstructions: x\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '---\nname: vr\ndescription: Mine\n---\n\nEdited.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + pendingPushes: [{ branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', placed: true, blob: 'b10b' }] }], + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.status).toBe('new'); }); it('edits the shared-root agent it was deployed from, never a namespaced second copy', async () => { @@ -867,6 +948,32 @@ projects: expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr'])); }); + it('leaves a same-named agent alone on a member who never had the removed agent\'s namespace', async () => { + // An ops member's own `vr` was never fe/vr's copy: removing fe/vr must not + // delete it on pull or stop them pushing it (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: ops\n resources:\n agents: [ops]\n - id: front\n resources:\n agents: [fe]\n'); + localConfig.projects = ['ops']; + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: mine\n---\n\nMine.\n'); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr'])); + expect((await handler.scanLocalForPush(teamConfig, localConfig)).map((i) => i.name)).toContain('vr'); + }); + + it('retires the flattened stem of an agent this machine placed, after its record was dropped', async () => { + // The author's fe was never active; the record said their `vr` was fe/vr. + // Once the removal merged, reconcile dropped the record and retired it. + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: front\n resources:\n agents: [fe]\n'); + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + retiredPlacedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + }); + it('retires the flattened stem when the surviving same-stem agent is not active here', async () => { // An fe member: fe/vr is removed, be/vr survives but is not theirs, so the // `vr` they hold is the removed fe/vr, not a copy of be/vr (#649 review). diff --git a/src/__tests__/pkg-team-distribution.test.ts b/src/__tests__/pkg-team-distribution.test.ts index 8d037569..f8f24ca5 100644 --- a/src/__tests__/pkg-team-distribution.test.ts +++ b/src/__tests__/pkg-team-distribution.test.ts @@ -91,6 +91,7 @@ vi.mock('../utils/pre-push-sync.js', () => ({ })); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(async () => ''), askConfirmation: vi.fn(async () => true), askSelection: vi.fn(async (_prompt: string, count: number) => diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index ca1b4a2c..2291db70 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -41,18 +41,41 @@ describe('reconcilePlacementRecords', () => { }); afterEach(async () => { await fse.remove(repoPath); }); - it('records a placement once its file is on the default branch', async () => { - await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; + /** Commit `content` at `rel` on the default branch, as a merge would; returns its blob. */ + const land = async (rel: string, content: string): Promise => { + const run = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + if (!await fse.pathExists(path.join(repoPath, '.git'))) run(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, rel), content); + run(['add', '-A']); run(['commit', '-q', '-m', `land ${rel}`]); + return run(['hash-object', rel]); + }; + + it('records a placement once the blob it pushed is on the default branch', async () => { + const blob = await land('rules/fe/my-rule.md', 'x'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob }])] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); }); + it('spends a placement with no blob instead of recording it because the path exists', async () => { + // Nothing tells its landing from another member creating the path after + // this PR closed unmerged (#649 review). + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'somebody else\'s'); + const entry = pending([placedRule()]); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(entry.items[0]?.placed).toBe(false); + }); + it('records nothing while the placement is not on the default branch, whatever its branch is doing', async () => { // Open PR, or closed unmerged with the branch kept: the same from here, // and neither may leave a record. - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: 'deadbeef' }])] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); expect(state.placedRules).toEqual({}); @@ -101,14 +124,12 @@ describe('reconcilePlacementRecords', () => { const entry = { ...pending([{ ...placedRule(), blob: ours }]), base }; const closed = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; - expect(await reconcilePlacementRecords(repoPath, closed)).toBe(false); + await reconcilePlacementRecords(repoPath, closed); expect(closed.placedRules).toEqual({}); - - // Had the PR merged after that revision, the same blob would prove it. - await fse.outputFile(file, 'ours, as pushed\n'); - git(['add', '-A']); git(['commit', '-q', '-m', 'merge ours']); - expect(await reconcilePlacementRecords(repoPath, closed)).toBe(true); - expect(closed.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + // The path arrived without what was pushed: indistinguishable from a + // reviewer's edit before a squash merge, so the author is told, once. + expect(entry.items[0]?.placed).toBe(false); + expect(mockWarn.mock.calls.flat().join(' ')).toContain('not with the content you pushed'); }); it('drops a record whose file was deleted and recreated between two checks', async () => { @@ -240,21 +261,22 @@ describe('reconcilePlacementRecords', () => { }); it('records a placement once: not again after the team deleted the file and someone recreated the path', async () => { - await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'ours'); - const entry = pending([placedRule()]); + const blob = await land('rules/fe/my-rule.md', 'ours'); + const entry = pending([{ ...placedRule(), blob }]); const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); expect(entry.items[0]?.placed).toBe(false); // The team deletes it: the record goes. - await fse.remove(path.join(repoPath, 'rules/fe/my-rule.md')); + execFileSync('git', ['rm', '-q', 'rules/fe/my-rule.md'], { cwd: repoPath }); + execFileSync('git', ['-c', 'user.name=T', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'deleted'], { cwd: repoPath }); expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({}); // Another member recreates the path. The blob we pushed is still in the // history, so only the consumed mark keeps this from becoming ours again. - await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'somebody else\'s'); + await land('rules/fe/my-rule.md', 'somebody else\'s'); expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); expect(state.placedRules).toEqual({}); }); @@ -288,6 +310,9 @@ describe('reconcilePlacementRecords', () => { expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedRules).toEqual({ kept: 'rules/fe/kept.md' }); expect(state.placedAgents).toEqual({}); + // The author's flattened agent copies stood for these; removedStems needs that. + expect((state as { retiredPlacedAgents?: Record }).retiredPlacedAgents) + .toEqual({ vr: 'agents/fe/vr.yaml', qa: 'agents/fe/qa.yaml' }); }); it('withdraws a record once a shared-root file of the same name exists, and says so', async () => { @@ -310,9 +335,9 @@ describe('reconcilePlacementRecords', () => { }); it('does not record a landed placement that a shared-root file already shadows', async () => { - await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); - await fse.outputFile(path.join(repoPath, 'rules/my-rule.md'), 'y'); - const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([placedRule()])] }; + const blob = await land('rules/fe/my-rule.md', 'x'); + await land('rules/my-rule.md', 'y'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob }])] }; await reconcilePlacementRecords(repoPath, state); diff --git a/src/__tests__/push-pending-pr.test.ts b/src/__tests__/push-pending-pr.test.ts index 0357d2c5..94fd9175 100644 --- a/src/__tests__/push-pending-pr.test.ts +++ b/src/__tests__/push-pending-pr.test.ts @@ -30,6 +30,7 @@ const mockCreatePullRequest = vi.fn(); const mockAskSelection = vi.fn(); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('1')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: (...args: unknown[]) => mockAskSelection(...args), diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 961107c3..1477f919 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -17,6 +17,7 @@ const mockGetHandler = vi.fn(); let readlineAnswer = '1'; vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn((_prompt: string, defaultValue?: string) => { return Promise.resolve(readlineAnswer || defaultValue || ''); }), @@ -71,8 +72,8 @@ vi.mock('../utils/git.js', () => ({ getDefaultBranch: vi.fn().mockResolvedValue('main'), remoteBranchExists: vi.fn().mockResolvedValue(true), getFileContentAtRev: vi.fn().mockResolvedValue(null), - hashObject: vi.fn().mockResolvedValue(null), - blobInHistory: vi.fn().mockResolvedValue(null), + hashObject: vi.fn().mockResolvedValue('b10b'), + blobInHistory: vi.fn().mockResolvedValue(true), getHeadCommit: vi.fn().mockResolvedValue('base000'), })); @@ -242,6 +243,28 @@ describe('push namespace routing', () => { expect(pushedItems[0].relativePath).toBe('skills/common/skill-a'); }); + it('names the choice and --role instead of prompting when there is no terminal', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), // primaryRole=hai → skills: [common, hai] + teamConfig: makeTeamConfig(), + }); + mockSkillHandler(pushedItems); + const { isInteractive, askQuestion } = await import('../utils/prompt.js'); + vi.mocked(isInteractive).mockReturnValueOnce(false); + vi.mocked(askQuestion).mockClear(); + + await push({ all: true }); + + expect(askQuestion).not.toHaveBeenCalled(); + expect(pushedItems).toHaveLength(0); + expect(process.exitCode).toBe(2); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.error).mock.calls.flat().join(' '); + expect(said).toContain('common, hai'); + expect(said).toContain('--role '); + }); + it('allows selecting a non-default namespace', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ @@ -1041,6 +1064,38 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); }); + it('still pushes an unrelated rule when the only skipped agent needs a destination the project lacks', async () => { + // A stale edited copy of an agent from a dropped role must not stop a + // rule going out: skipped agents do not block the rest (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/agents', + relativePath: 'agents/vr.yaml', status: 'modified', needsDestination: true, + skipReason: 'Agent "vr" has no active source. Activate its role or project before pushing local edits.', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).not.toBe(2); + expect(pushedItems.map((i) => i.relativePath)).toEqual(['rules/docs-know/my-rule.md']); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + it('lets a modified namespaced agent through a project whose agents axis is empty', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ @@ -1282,7 +1337,7 @@ describe('push namespace routing for rules and agents', () => { branch: 'teamai/push/test/20260101-000000', prUrl: 'https://git.woa.com/mr/14', createdAt: '2026-01-01T00:00:00.000Z', - items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true }], + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true, blob: 'b10b' }], }], }; mockLoadStateForScope.mockImplementation(async () => structuredClone(awaiting)); @@ -1588,6 +1643,37 @@ describe('push namespace routing for rules and agents', () => { expect(said).toContain('separate PR'); }); + it('leaves an open placement PR alone once a shared-root file takes the name', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // my-rule is awaiting review at rules/fe-know/; a teammate has since merged + // an unrelated rules/my-rule.md. Reusing the PR by type and name rebuilt it + // with the author's copy over that shared rule (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/15', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true, blob: 'b10b' }], + }], + }); + mockHandlers({ + rules: [{ name: 'my-rule', type: 'rules', sourcePath: '/tmp/local/rules/my-rule.md', relativePath: 'rules/my-rule.md', status: 'modified' }], + }, pushedItems); + + await push({ all: true }); + + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).toContain('rules/my-rule.md now exists at the shared root'); + }); + it('keeps updating the open PR of a resource the flag does not move', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ diff --git a/src/__tests__/push-skill-flag.test.ts b/src/__tests__/push-skill-flag.test.ts index e7f77aa4..0417dfe7 100644 --- a/src/__tests__/push-skill-flag.test.ts +++ b/src/__tests__/push-skill-flag.test.ts @@ -14,6 +14,7 @@ const mockPathExists = vi.fn(); const mockListDirs = vi.fn(); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('1')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: vi.fn((_prompt: string, itemCount: number, defaultAll?: boolean) => { diff --git a/src/__tests__/push-team-config.test.ts b/src/__tests__/push-team-config.test.ts index 6733158a..464f13d6 100644 --- a/src/__tests__/push-team-config.test.ts +++ b/src/__tests__/push-team-config.test.ts @@ -39,6 +39,7 @@ vi.mock('../read-only.js', () => ({ assertNotReadOnly: vi.fn() })); vi.mock('../utils/pre-push-sync.js', () => ({ syncTeamUpdatesToLocal: vi.fn() })); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: vi.fn((_p: string, n: number, all?: boolean) => diff --git a/src/__tests__/remove-command.test.ts b/src/__tests__/remove-command.test.ts new file mode 100644 index 00000000..ba271903 --- /dev/null +++ b/src/__tests__/remove-command.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSaveStateForScope = vi.fn(); +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockResolvedValue({ + localConfig: { + repo: { localPath: '/tmp/team-repo', remote: 'https://example.test/team/repo.git', kind: 'git' }, + username: 'alice', scope: 'user', additionalRoles: [], + }, + teamConfig: { team: 't', repo: 'https://example.test/team/repo.git', provider: 'git', reviewers: [], toolPaths: {} }, + }), + loadStateForScope: vi.fn().mockResolvedValue({ placedAgents: {}, pendingPushes: [] }), + saveStateForScope: (...args: unknown[]) => mockSaveStateForScope(...args), +})); +vi.mock('../read-only.js', () => ({ assertNotReadOnly: vi.fn() })); +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('up to date'), + pushRepoBranch: vi.fn().mockResolvedValue(true), + checkoutMaster: vi.fn(), + generateBranchName: vi.fn().mockReturnValue('teamai/push/alice/1'), +})); +vi.mock('../utils/pending-push.js', () => ({ + // A placement merged since the last run: the records change and must be saved. + reconcilePlacementRecords: vi.fn().mockResolvedValue(true), +})); +vi.mock('../push.js', () => ({ createPrWithFallback: vi.fn(), filterExistingTopLevelPaths: vi.fn() })); +const handler = { + scanTeamForPull: vi.fn().mockResolvedValue([{ name: 'vr', type: 'agents' }]), + scanLocalForPush: vi.fn().mockResolvedValue([]), + publishedNameFor: vi.fn().mockResolvedValue(null), + removeItem: vi.fn().mockResolvedValue(['agents/fe/vr.yaml', 'agents/be/vr.yaml']), +}; +vi.mock('../resources/index.js', () => ({ getHandler: () => handler })); +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn().mockReturnThis(), succeed: vi.fn(), fail: vi.fn() })), +})); + +const { remove } = await import('../remove.js'); +const { log } = await import('../utils/logger.js'); + +/** + * `publishedNameFor` reads the placement records back from disk. When a + * placement merged but its record could not be saved, the bare name the author + * types falls back to the stem, and removing that stem removes the agent from + * every namespace (#649 review). So `remove` stops instead. + */ +describe('teamai remove when the placement records cannot be saved', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + mockSaveStateForScope.mockRejectedValue(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })); + }); + afterEach(() => { process.exitCode = undefined; }); + + it('removes nothing and exits 1', async () => { + await remove('agents', ['vr'], { force: true }); + + expect(handler.removeItem).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Nothing was removed'); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 008d376a..64acd92a 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -728,6 +728,28 @@ scope: 'user', expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('awaiting review'); }); + it("spares the author's root copy while its PR is pending, even once the placement mark is spent", async () => { + // Reconcile spends the mark on a placement it cannot prove (no blob, or + // the path arrived with other content), while the PR may still be open: + // the copy is still the author's work (#649 review). + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'awaiting review'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: false }], + }], + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('awaiting review'); + }); + it('still sweeps a root rule whose record points at a file that is gone', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); diff --git a/src/__tests__/self-mode-push-scan.test.ts b/src/__tests__/self-mode-push-scan.test.ts index f37ecae6..239184aa 100644 --- a/src/__tests__/self-mode-push-scan.test.ts +++ b/src/__tests__/self-mode-push-scan.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -16,7 +17,7 @@ vi.mock('../utils/logger.js', () => ({ import { SkillsHandler } from '../resources/skills.js'; import { RulesHandler } from '../resources/rules.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { getDataHome, type TeamaiConfig, type LocalConfig } from '../types.js'; /** * Regression for single-repo mode: users edit team knowledge directly under @@ -135,4 +136,38 @@ describe('single-repo mode: push scans .teamai knowledge dir', () => { expect(item).toBeDefined(); expect(item!.status).toBe('new'); }); + + it('does not push an old copy of a placed rule over a newer team version', async () => { + // Nothing refreshes .teamai/rules/my-rule.md after it was placed at + // rules/fe/my-rule.md, so once a teammate edits that file the root copy is + // an older version nobody edited — not a change to push (#649 review). + const worktree = path.dirname(worktreeTeamai); + const run = (args: string[]) => execFileSync('git', args, { cwd: worktree, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }); + const placed = path.join(worktreeTeamai, 'rules', 'fe', 'my-rule.md'); + run(['init', '-q', '-b', 'main']); + await fse.outputFile(placed, '# Rule as placed\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'placement merged']); + await fse.outputFile(placed, '# Rule, improved by a teammate\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'teammate edit']); + await fse.writeFile(path.join(bizRoot, '.teamai', 'rules', 'my-rule.md'), '# Rule as placed\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, + }); + + const items = await new RulesHandler().scanLocalForPush(teamConfig, localConfig); + + expect(items.map((i) => i.name)).not.toContain('my-rule'); + }); + + it('still scans .teamai/rules when enabledAgents is set, as single-repo init always writes it', async () => { + // The synthetic scan source is not a tool, so the excluded-tool gate must + // not read it as one outside `enabledAgents` (#649 review). + await fse.writeFile(path.join(bizRoot, '.teamai', 'rules', 'new-rule.md'), '# New rule\n'); + + const items = await new RulesHandler().scanLocalForPush(teamConfig, { ...localConfig, enabledAgents: ['claude'] }); + + expect(items.map((i) => i.name)).toContain('new-rule'); + }); }); diff --git a/src/push.ts b/src/push.ts index d57875f8..67d12720 100644 --- a/src/push.ts +++ b/src/push.ts @@ -18,7 +18,7 @@ import { scanTeamRepoNamespaces } from './resources/skills.js'; import type { GlobalOptions, ResourceItem, ResourceType, LocalConfig, TeamaiConfig, State, } from './types.js'; -import { getDataHome, SYNC_LOCK_FILENAME } from './types.js'; +import { getDataHome, SELF_KNOWLEDGE_SCAN_KEY, SYNC_LOCK_FILENAME } from './types.js'; import { acquireLock, releaseLock } from './update.js'; import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; import { loadRolesManifest, resolveRoleResourceNamespaces, RolesManifestNotFoundError } from './roles.js'; @@ -28,17 +28,9 @@ import { isAtSharedRoot, isPlaceableType, NAMESPACE_AXIS, PLACEABLE_TYPES, resolveProjectNamespace, skillNamespacePath, withNamespace, type PlaceableType, } from './push-namespaces.js'; -import { askQuestion, askSelection } from './utils/prompt.js'; +import { askQuestion, askSelection, isInteractive } from './utils/prompt.js'; import { pathExists, pruneEmptyDirs, readFileSafe, writeFile } from './utils/fs.js'; -/** - * Synthetic toolPaths key used only to make `teamai push` scan the active tree's - * .teamai/{skills,rules} in single-repo mode (see pushCore). It is never written - * to disk and never used by pull — the leading marker keeps it from colliding - * with any real agent id. - */ -const SELF_KNOWLEDGE_SCAN_KEY = '__teamai_self_knowledge__'; - /** * Filter a list of repo-root-relative paths (e.g. "rules/", "env/") down to * those that actually exist on disk. `git add` throws `pathspec did not match @@ -189,6 +181,15 @@ async function resolveNamespaceForNew( const skillsDefault = type === 'skills' ? localConfig.primaryRole : undefined; return { kind: 'namespace', namespace: skillsDefault ?? candidates[0] }; } + // No terminal to ask on (CI, a hook, TEAMAI_NONINTERACTIVE): say what the + // choice is and how to make it, instead of failing inside the prompt. + if (!isInteractive()) { + return { + kind: 'unresolvable', + message: `Several ${NAMESPACE_AXIS[type]} namespaces could take new ${type} (${candidates.join(', ')}), ` + + 'and there is no terminal to ask on. Pass --role to name one.', + }; + } console.log(''); console.log(`Which namespace should new ${type} be pushed to?`); @@ -935,11 +936,10 @@ async function pushCore( fullScan.push(...items); } - // A project that cannot answer for agents fails HERE only for an agent the + // A project that cannot answer for agents is reported below for an agent the // scan itself dropped — skipped as "no active source" with `needsDestination` // set. That one never reaches the listing, so deferring its error until the - // selection proves it is going out means never raising it, and the run would - // end "No new or modified resources" on a flag that could not be honoured. + // selection proves it is going out means never raising it. // // A NEW agent is different: it is listed, so the user can deselect it, and // step 4 raises the same error if it stays selected. Failing for it here @@ -948,11 +948,6 @@ async function pushCore( // placement, so an empty agents axis is none of its business either. const skippedForWantOfDestination = (item: ResourceItem): boolean => item.type === 'agents' && 'needsDestination' in item && item.needsDestination === true; - if (agentsDestinationError && fullScan.some(skippedForWantOfDestination)) { - log.error(agentsDestinationError); - process.exitCode = 2; - return; - } // Preserve blocked items in the full scan so their pending PR records survive. // Exclude them before selection and grouping: pushItem cannot write their paths. @@ -965,6 +960,20 @@ async function pushCore( return true; }); + // Such an agent is skipped like any other, and a skipped agent does not + // block the rest of the push: a stale copy of an agent from a dropped role + // must not stop an unrelated rule going out (#649 review). The error is the + // outcome only when nothing else is left, which is when the run would end + // "No new or modified resources" on a flag it could not honour. + if (agentsDestinationError && fullScan.some(skippedForWantOfDestination)) { + if (allItems.length === 0) { + log.error(agentsDestinationError); + process.exitCode = 2; + return; + } + log.warn(`${agentsDestinationError} The agents skipped above are left out; everything else in this push goes on.`); + } + // Keep the full scan before --skill/--role narrow allItems. prunePendingPushes // must see every pending resource that is still locally present, or narrowing to // one skill would drop the other skills' open-PR records and duplicate them next run. @@ -1175,6 +1184,43 @@ async function pushCore( } const pendingPushes = pushState.pendingPushes; + // A rule or agent awaiting review in a namespace whose name a shared-root + // file now takes: the scan maps the author's root copy onto that shared file + // and calls it modified. Reusing the open PR — matched by type and name — + // would rebuild it with the author's content over the shared file and drop + // the namespaced change from review (#649 review). The shared root owns the + // name in every tool dir, as reconcile already rules for a record, so this + // copy is left out: the open PR stays as it is. + for (let i = allItems.length - 1; i >= 0; i--) { + const item = allItems[i]; + if (!item || (item.type !== 'rules' && item.type !== 'agents')) continue; + if (item.status !== 'modified' || !isAtSharedRoot(item)) continue; + const awaiting = pendingPushes.flatMap((entry) => entry.items.map((recorded) => ({ entry, recorded }))) + .find(({ recorded }) => recorded.type === item.type && recorded.name === item.name + && recorded.relativePath.split('/').length === 3); + if (!awaiting) continue; + log.warn( + `[${item.type}] ${item.name}: ${item.relativePath} now exists at the shared root, so your local ${item.name} ` + + `follows that file and is left out of this push. ${awaiting.recorded.relativePath} stays as it is in ` + + `${awaiting.entry.prUrl ?? `branch ${awaiting.entry.branch}`}.`, + ); + allItems.splice(i, 1); + } + + // The flag places new resources only. An edit of a shared-root rule or agent + // stays at the shared root, which reaches every member — say so rather than + // let the flag look as if it had scoped it. + if (options.role || options.project) { + for (const item of allItems) { + if ((item.type === 'rules' || item.type === 'agents') && item.status === 'modified' && isAtSharedRoot(item)) { + log.warn( + `[${item.type}] ${item.name} is an edit of the shared-root ${item.relativePath}, which every member receives; ` + + `${options.role ? '--role' : '--project'} only places new resources, so it stays there.`, + ); + } + } + } + if (allItems.length === 0) { // No resource changes, but the user may have edited teamai.yaml (sources / // publicSkills) via `teamai source add`. Push that config change on its own diff --git a/src/remove.ts b/src/remove.ts index b20afe16..44f15ded 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -80,13 +80,22 @@ async function removeCore( // `publishedNameFor` below resolves the bare name the author types through // the placement record, and a placement becomes a record only once it has // landed on the default branch — which this may be the first command to see. + // Not best-effort here: `publishedNameFor` reads the records back from disk, + // so a placement that merged but could not be saved as a record resolves to + // the bare stem — and that removes the agent from every namespace. try { const recordsState = await loadStateForScope(localConfig); if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { await saveStateForScope(recordsState, localConfig); } } catch (e) { - log.debug(`Placement record cleanup skipped: ${(e as Error).message}`); + log.error( + `Could not bring this machine's placement records up to date (${(e as Error).message}), ` + + 'so the names given cannot be resolved safely. Nothing was removed. ' + + 'Check that the teamai state file is writable, then retry.', + ); + process.exitCode = 1; + return; } const handler = getHandler(type as ResourceType); diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 087470d5..c551e083 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -12,7 +12,7 @@ import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; -import { getFileContentAtRev, getFileContentWhenAdded } from '../utils/git.js'; +import { getFileContentAtRev, getFileContentWhenAdded, isPastVersionOf } from '../utils/git.js'; import { parseAgentYaml, serializeAgentYaml, @@ -110,27 +110,40 @@ export class AgentsHandler extends ResourceHandler { * tombstones only `fe/vr`, but every member holds that agent as `/vr`, * so the tombstone alone never reaches their copy, and the next push reads it * as a new agent and republishes it (#649 review). `vr` counts as removed - * here only while THIS directory is not meant to hold an agent of that stem - * — the same selection pull delivers with (`selectAgentsForDirectory`). Then - * the flattened copy can stand for nothing else. While it is, the copy is - * that agent's, as a `be/vr` still delivered here would be, and suppressing - * it is what round 8 of the review ruled out. A `be/vr` that exists but is - * not active here does not keep a member's stale `fe/vr` copy alive. + * here only when both hold: + * - `fe/vr` could have been delivered HERE: `fe` is active, or nothing is + * filtered, or this machine placed `vr` in `fe` (its record, or the one + * reconcile retired when the file was deleted). On a member who never + * had `fe`, a `vr` is their own agent, and deleting it — or refusing to + * push it — takes something that was never the team's. + * - THIS directory is not meant to hold another agent of that stem, by the + * same selection pull delivers with (`selectAgentsForDirectory`): while a + * `be/vr` is delivered here, the copy is be/vr's, and suppressing it is + * what round 8 of the review ruled out. */ async removedStems(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise> { const tombstones = await this.readTombstones(localConfig); const removed = new Set(tombstones); if (![...tombstones].some((tombstone) => tombstone.includes('/'))) return removed; - const resolved = await resolveResourceNamespaces(localConfig); - const { placedAgents } = await loadStateForScope(localConfig); + const activeNamespaces = (await resolveResourceNamespaces(localConfig))?.activeNamespaces.agents ?? null; + const { placedAgents, retiredPlacedAgents } = await loadStateForScope(localConfig); + // This machine's record — live, or dropped when the team deleted the file — + // says its flattened copy stood for the agent in that namespace. + const placedIn = (stem: string): string | undefined => ( + placedResourcePath(placedAgents, 'agents', stem) ?? placedResourcePath(retiredPlacedAgents, 'agents', stem) + )?.split('/')[1]; const desired = new Set(selectAgentsForDirectory( await this.scanTeamForPull(teamConfig, localConfig), - resolved?.activeNamespaces.agents ?? null, + activeNamespaces, placedAgents, ).map((agent) => agent.name)); for (const tombstone of tombstones) { - const stem = path.posix.basename(tombstone); - if (!desired.has(stem)) removed.add(stem); + const segments = tombstone.split('/'); + if (segments.length !== 2) continue; + const [namespace, stem] = segments as [string, string]; + const deliveredHere = activeNamespaces === null || activeNamespaces.includes(namespace) + || placedIn(stem) === namespace; + if (deliveredHere && !desired.has(stem)) removed.add(stem); } return removed; } @@ -161,7 +174,12 @@ export class AgentsHandler extends ResourceHandler { // namespace this directory need not have activated. Without the record it // would read as "no active source" and the author could never edit the // agent they just created (#649 review). - const { placedAgents, lastPullRev } = await loadStateForScope(localConfig); + const { placedAgents, lastPullRev, pendingPushes } = await loadStateForScope(localConfig); + // Agents this machine placed in a namespace and has awaiting review: the + // open PR is their destination, not "no active source". + const pendingPlacedAgents = new Set((pendingPushes ?? []).flatMap((entry) => entry.items) + .filter((item) => item.type === 'agents' && item.relativePath.split('/').length === 3) + .map((item) => item.name)); const directItems: AgentResourceItem[] = []; const directStems = new Set(); @@ -198,10 +216,18 @@ export class AgentsHandler extends ResourceHandler { if (teamRelPath !== placed) supersedes = placed; basePath = path.join(localConfig.repo.localPath, placed); baseExists = true; - if (await recordedAgentMovedOn(localConfig.repo.localPath, placed, lastPullRev)) { + // Nothing refreshes this root file after placement — pull + // deploys to tool dirs, and the pre-push sync covers those only + // — so `lastPullRev` says nothing about it. A copy equal to an + // OLDER version of the team file is one nobody edited, and + // pushing it would revert whoever changed the file since + // (#649 review). + if (!supersedes && !await fileContentEqual(activePath, basePath) + && await isPastVersionOf(localConfig.repo.localPath, activePath, placed)) { directItems.push({ name: stem, type: 'agents', sourcePath: activePath, relativePath: placed, status: 'modified', namespace: placed.split('/')[1], - skipReason: staleRecordedAgentReason(stem, placed) }); + skipReason: `${path.relative(localConfig.projectRoot, activePath)} is an older version of ${placed}, ` + + 'which has changed on the team since. Copy the current file over it (or delete it) before editing.' }); directStems.add(stem); continue; } @@ -286,14 +312,11 @@ export class AgentsHandler extends ResourceHandler { (file) => activeNamespaces === null || !file.namespace || activeNamespaces.includes(file.namespace), ); - const inRequested = (files: TeamAgentFile[]) => files.filter((file) => file.namespace === requestedNamespace); const recorded = placedNamespace ? sources.filter((file) => file.namespace === placedNamespace) : []; - const candidates = active.length > 0 - // Two active sources collide; the flag may say which one is meant. - ? (requestedNamespace && active.length > 1 && inRequested(active).length === 1 ? inRequested(active) : active) - : recorded.length > 0 - ? recorded - : requestedNamespace ? inRequested(sources) : []; + // Two active sources stay ambiguous even under a flag: the flattened file + // cannot say which one it was delivered from, and picking the requested + // one compared the other's untouched copy with it (#649 review). + const candidates = active.length > 0 ? active : recorded; if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', @@ -302,10 +325,27 @@ export class AgentsHandler extends ResourceHandler { } // A shared-root copy is always active, so it is a candidate above and // this local file is an edit of it: no namespaced second copy is made. + // + // With a destination named that already holds this stem — never + // delivered here, so this local file is not a copy of it — the agent is + // new there and would land on somebody else's, which rules already + // refuse (#649 review). + const inRequested = requestedNamespace + ? sources.find((file) => file.namespace === requestedNamespace) + : undefined; + if (candidates.length === 0 && inRequested) { + const taken = `agents/${requestedNamespace}/${stem}${inRequested.ext}`; + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: taken, status: 'new', namespace: requestedNamespace, + skipReason: `Agent "${stem}" cannot be placed: ${taken} already exists in the team repo and was never ` + + 'delivered here, so this local copy is not an edit of it and pushing would overwrite it. ' + + 'Activate that namespace and pull to edit the existing one, rename yours, or pick another namespace with --role .' }); + continue; + } // With no destination named, a stem that exists only in namespaces this - // directory has not activated is not ours to edit. With one named, the - // agent is simply new there, and placement writes it to that namespace. - if (!requestedNamespace && sources.length > 0 && candidates.length === 0) { + // directory has not activated is not ours to edit — unless this machine + // has it awaiting review as a placement, whose open PR it goes back to. + if (!requestedNamespace && sources.length > 0 && candidates.length === 0 && !pendingPlacedAgents.has(stem)) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', needsDestination: true, @@ -313,16 +353,6 @@ export class AgentsHandler extends ResourceHandler { continue; } const located = candidates[0]; - if (located && active.length === 0 && recorded.length > 0) { - const recordedPath = `agents/${located.namespace}/${stem}${located.ext}`; - if (await recordedAgentMovedOn(localConfig.repo.localPath, recordedPath, lastPullRev)) { - items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, - relativePath: recordedPath, status: 'modified', namespace: located.namespace, - skipReason: staleRecordedAgentReason(stem, recordedPath) }); - continue; - } - } - const teamYamlPath = located?.ext === '.yaml' ? located.path : path.join(teamAgentsDir, `${stem}.yaml`); const teamMdPath = located?.ext === '.md' ? located.path : path.join(teamAgentsDir, `${stem}.md`); const hasTeamYaml = located?.ext === '.yaml'; @@ -380,6 +410,18 @@ export class AgentsHandler extends ResourceHandler { if (!hasChange) continue; + // Only a copy that differs is worth holding: an unchanged one — the + // author's own merged edit included — has nothing to overwrite with. + if (located && active.length === 0 && recorded.length > 0) { + const recordedPath = `agents/${located.namespace}/${stem}${located.ext}`; + if (await recordedAgentMovedOn(localConfig.repo.localPath, recordedPath, lastPullRev)) { + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: recordedPath, status: 'modified', namespace: located.namespace, + skipReason: staleRecordedAgentReason(stem, recordedPath) }); + continue; + } + } + const status: ResourceItemStatus = (hasTeamYaml || hasTeamMd) ? 'modified' : 'new'; // Determine representative source path (prefer highest mtime) @@ -872,7 +914,8 @@ async function recordedAgentMovedOn(repoPath: string, relPath: string, lastPullR function staleRecordedAgentReason(stem: string, relPath: string): string { return `Agent "${stem}" (${relPath}) changed on the team since this machine last synced it, ` - + 'so pushing your copy would overwrite that change. Run `teamai pull`, reapply any local edit, and push again.'; + + 'so pushing your copy would overwrite that change. `teamai pull` replaces your copy with the team version, ' + + 'so first copy your edit aside, then pull, reapply it, and push again.'; } /** diff --git a/src/resources/rules.ts b/src/resources/rules.ts index eeb0c6e0..88eb9bd3 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -3,7 +3,7 @@ import { isToolInstalledForConfig, ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; -import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from '../types.js'; +import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, resolveToolBaseDir, isAgentExcluded, scopedToolPaths, SELF_KNOWLEDGE_SCAN_KEY } from '../types.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { teamRuleToCursorMdc, mergeCursorBodyIntoTeamMd, cursorMdcBodyEqualsTeamMd } from './cursor-mdc.js'; import { @@ -14,6 +14,7 @@ import { import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; +import { isPastVersionOf } from '../utils/git.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -73,8 +74,10 @@ export class RulesHandler extends ResourceHandler { if (!rulesPath) continue; // Not written or cleaned by teamai, so not a source either: `removeItem` // leaves an excluded tool's copy behind, and read here it would republish - // the rule just removed (#649 review). - if (isAgentExcluded(localConfig, tool)) continue; + // the rule just removed (#649 review). The single-repo scan source is not + // a tool, and `enabledAgents` — which single-repo init always writes — + // never lists it. + if (tool !== SELF_KNOWLEDGE_SCAN_KEY && isAgentExcluded(localConfig, tool)) continue; const rulesDir = path.join(resolveToolBaseDir(tool, localConfig), rulesPath); if (!await pathExists(rulesDir)) continue; @@ -121,6 +124,20 @@ export class RulesHandler extends ResourceHandler { ? copilotInstructionsBodyEqualsTeamMd(localRule, teamRule) : await fileContentEqual(localFilePath, teamFilePath); if (equal) continue; // This tool dir's copy is identical, skip + // Single-repo mode: nothing refreshes the author's `.teamai/rules` + // copy of a rule placed under a namespace — pull deploys to tool + // dirs and the pre-push sync covers those — so a copy equal to an + // OLDER version of the team file is one nobody edited, and pushing + // it would revert whoever changed the rule since (#649 review). + if (tool === SELF_KNOWLEDGE_SCAN_KEY && teamFileName === placedName + && await isPastVersionOf(localConfig.repo.localPath, localFilePath, teamRelPath)) { + log.warn( + `[rules] Skipped ${name}: ${path.relative(resolveToolBaseDir(tool, localConfig), localFilePath)} is an ` + + `older version of ${teamRelPath}, which has changed on the team since. ` + + 'Copy the current file over it (or delete it) before editing.', + ); + continue; + } // Content differs — candidate for "modified" const mtime = await getFileMtime(localFilePath); @@ -453,9 +470,15 @@ export class RulesHandler extends ResourceHandler { teamRuleNames.add(name); } } + // Any pending entry that carries a root-authored rule at a namespaced path, + // not only one still marked `placed`: reconcile spends the mark on a + // placement it cannot prove, while the PR may still be open and the copy + // is still the author's work (#649 review). for (const entry of pendingPushes ?? []) { for (const item of entry.items) { - if (item.placed && item.type === 'rules') teamRuleNames.add(item.name); + if (item.type === 'rules' && !item.name.includes('/') && item.relativePath.split('/').length === 3) { + teamRuleNames.add(item.name); + } } } const tombstones = await this.readTombstones(localConfig); diff --git a/src/types.ts b/src/types.ts index 03e9af01..6dffa604 100644 --- a/src/types.ts +++ b/src/types.ts @@ -673,6 +673,13 @@ export const StateSchema = z.object({ * that path again: whatever is there now is somebody else's. */ placementsCheckedAt: z.string().optional(), + /** + * `placedAgents` records dropped because the team deleted their file, by + * agent name. The author's flattened copy stood for that namespaced agent, so + * once it is tombstoned the copy is the removed agent's, even though no + * record or active namespace says so any longer (`AgentsHandler.removedStems`). + */ + retiredPlacedAgents: z.record(z.string(), z.string()).optional(), pushedSkills: z.array(z.string()).default([]), pushedEnvVars: z.array(z.string()).default([]), /** Push branches whose PR is still open — see PendingPushSchema. */ @@ -1704,6 +1711,14 @@ export function isAgentDisabled(localConfig: { disabledAgents?: string[] }, tool * the team scoped its opt-in with `--agent` (undefined whitelist = all * installed tools). */ +/** + * Synthetic toolPaths key used only to make `teamai push` scan the active tree's + * .teamai/{skills,rules} in single-repo mode (see pushCore). It is never written + * to disk and never used by pull — the leading marker keeps it from colliding + * with any real agent id. It is not a tool, so tool exclusions never apply to it. + */ +export const SELF_KNOWLEDGE_SCAN_KEY = '__teamai_self_knowledge__'; + export function isAgentExcluded( localConfig: { disabledAgents?: string[]; enabledAgents?: string[] }, tool: string, diff --git a/src/utils/git.ts b/src/utils/git.ts index 967b18ff..a2c6e567 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -842,11 +842,6 @@ export async function resetToCleanMaster(git: SimpleGit, localPath?: string): Pr } } -/** - * Get the raw content of a file at a specific git revision. - * Uses `git show :` to retrieve historical file content. - * Returns null if the file doesn't exist at that revision or if the rev is invalid. - */ /** The git blob id of a working-tree file, as `git hash-object` reports it; null if unreadable. */ export async function hashObject(repoPath: string, filePath: string): Promise { try { @@ -890,12 +885,32 @@ export async function pathDeletedSince( since: string, filePath: string, tip = 'HEAD', +): Promise { + return pathChangedSince(repoPath, since, filePath, tip, 'D'); +} + +/** Whether a commit reachable from `tip` after `since` added `filePath`. Null when git cannot say. */ +export async function pathAddedSince( + repoPath: string, + since: string, + filePath: string, + tip = 'HEAD', +): Promise { + return pathChangedSince(repoPath, since, filePath, tip, 'A'); +} + +async function pathChangedSince( + repoPath: string, + since: string, + filePath: string, + tip: string, + filter: 'A' | 'D', ): Promise { try { - const out = await createGit(repoPath).raw(['log', `${since}..${tip}`, '--diff-filter=D', '--format=%H', '--', filePath]); + const out = await createGit(repoPath).raw(['log', `${since}..${tip}`, `--diff-filter=${filter}`, '--format=%H', '--', filePath]); return out.trim().length > 0; } catch (e) { - log.debug(`git log --diff-filter=D failed for ${filePath}: ${(e as Error).message}`); + log.debug(`git log --diff-filter=${filter} failed for ${filePath}: ${(e as Error).message}`); return null; } } @@ -924,6 +939,21 @@ export async function getHeadCommit(localPath: string, rev = 'HEAD'): Promise { + const blob = await hashObject(repoPath, absFile); + return blob !== null && await blobInHistory(repoPath, blob, relPath) === true; +} + +/** + * Get the raw content of a file at a specific git revision. + * Uses `git show :` to retrieve historical file content. + * Returns null if the file doesn't exist at that revision or if the rev is invalid. + */ export async function getFileContentAtRev( repoPath: string, rev: string, diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 37b95a66..2215e79e 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -14,7 +14,7 @@ */ import path from 'node:path'; import { pathExists } from './fs.js'; -import { remoteBranchExists, hashObject, blobInHistory, pathDeletedSince, getHeadCommit, getFileContentAtRev } from './git.js'; +import { remoteBranchExists, hashObject, blobInHistory, pathAddedSince, pathDeletedSince, getHeadCommit, getFileContentAtRev } from './git.js'; import { placedResourcePath } from '../push-namespaces.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -146,15 +146,19 @@ export function recordPendingPush(state: State, entry: PendingPush): void { export async function toPendingItems(items: ResourceItem[], repoPath: string): Promise { const out: PendingPush['items'] = []; for (const i of items) { - const placed = isPlacement(i); - const blob = placed ? await hashObject(repoPath, i.relativePath) : null; + // A placement is marked only with the blob that can prove it landed: + // without one, reconcile would have nothing but the path existing, which + // another member's later file at that path also satisfies (#649 review). + const blob = isPlacement(i) ? await hashObject(repoPath, i.relativePath) : null; + if (isPlacement(i) && !blob) { + log.warn(`[${i.type}] ${i.name}: could not hash ${i.relativePath}, so this machine will not treat it as its placement once merged.`); + } out.push({ type: i.type, name: i.name, relativePath: i.relativePath, namespace: i.namespace, - ...(placed ? { placed: true } : {}), - ...(blob ? { blob } : {}), + ...(blob ? { placed: true, blob } : {}), }); } return out; @@ -227,7 +231,7 @@ function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { */ export async function reconcilePlacementRecords( repoPath: string, - state: Pick, + state: Pick, tip?: string, ): Promise { // A ref that cannot be resolved says nothing about any file: reading every @@ -255,11 +259,37 @@ export async function reconcilePlacementRecords( if (!item.placed) continue; const field = fieldFor(item.type); if (!field) continue; + // Without the blob push wrote, landing cannot be told from another + // member creating the same path after this PR closed unmerged, so the + // mark is spent rather than recorded on the path existing (#649 review). + if (!item.blob) { + log.debug(`Not recording placement ${field}.${item.name}: no blob to prove it landed`); + item.placed = false; + changed = true; + continue; + } if (!await exists(item.relativePath)) continue; - // An entry with no blob predates the check; existence is all it can offer. // Bounded by `base`: the same bytes may have sat at this path before the // push, and a PR closed unmerged must not borrow that history. - if (item.blob && await blobInHistory(repoPath, item.blob, item.relativePath, entry.base, history) !== true) continue; + if (await blobInHistory(repoPath, item.blob, item.relativePath, entry.base, history) !== true) { + // The path arrived after this push, but never with what it pushed: a + // reviewer changed the PR before a squash merge, or somebody else + // created the path. The two cannot be told apart, so nothing is + // recorded — but say so once, or the author's next push meets a + // collision on what may well be their own file. + if (entry.base && await pathAddedSince(repoPath, entry.base, item.relativePath, history) === true) { + log.warn( + `[${item.type}] ${item.name}: ${item.relativePath} reached the default branch after your push, but not ` + + 'with the content you pushed, so it is not treated as yours. If a reviewer changed your PR before it ' + + `merged, run \`teamai pull\` and edit ${item.relativePath} as the team file it now is; your root copy ` + + 'would otherwise be pushed as a new resource.', + ); + item.placed = false; + delete item.blob; + changed = true; + } + continue; + } // Placement refuses an occupied path, so a deletion since `base` came // after this placement landed: what is there now was recreated by // someone else, and the placement is spent without a record. @@ -292,6 +322,9 @@ export async function reconcilePlacementRecords( const valid = placedResourcePath(records, root, name); if (valid && !await exists(valid)) { log.debug(`Dropping placement record ${field}.${name} → ${recorded}: gone from the default branch`); + // The author's flattened copy stood for this agent; once the removal + // is tombstoned, that is still what the copy is (`removedStems`). + if (field === 'placedAgents') state.retiredPlacedAgents = { ...state.retiredPlacedAgents, [name]: valid }; changed = true; continue; } From 23717ec9a7fea760dedfcd52831194971567b8eb Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Wed, 23 Sep 2026 08:17:16 +0200 Subject: [PATCH 25/25] fix(push): stop on unsaved records and stale placements, name namespaced agents on remove - `push` stops, pushing nothing, when the reconciled placement records cannot be saved: the sync and the scan read them back from disk, and a record that could not be withdrawn still redirects the author's copy. - On a stale clone every unflagged placement stops, not only one resolved from an existing roles manifest: the manifest's absence and the skills namespaces detected from the tree are clone state too. - `remove agents /` names one namespaced agent; a bare name only one namespace has resolves to it, and a bare name found in several places is refused rather than removed from all of them. - A record dropped because its file was deleted and recreated is retired like one whose file is simply gone, so the author's flattened copy of the removed agent is still recognised. --- docs/usage-guide.md | 4 +-- docs/usage-guide.zh-CN.md | 4 +-- src/__tests__/placement-records.test.ts | 4 +++ src/__tests__/push-role.test.ts | 43 ++++++++++++++++++++++ src/__tests__/push-skill-flag.test.ts | 6 ++++ src/__tests__/remove-command.test.ts | 47 +++++++++++++++++++++++++ src/push.ts | 32 +++++++++++------ src/remove.ts | 37 ++++++++++++++++++- src/utils/pending-push.ts | 4 +++ 9 files changed, 165 insertions(+), 16 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4b82a61a..4eaee530 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -607,7 +607,7 @@ Choose namespace [1-3] (default: 1 = common): - A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` - An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here - A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported -- If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does a new resource that would resolve from `manifest/roles.yaml`. Fix the pull and retry, or name the namespace with `--role ` +- If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does any new resource placed without `--role`, because its destination comes from that clone (`manifest/roles.yaml`, its absence, or the namespaces the repo already has). Fix the pull and retry, or name the namespace with `--role `. `push` also stops, and pushes nothing, when this machine's placement records cannot be updated and saved - A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records. `teamai remove` itself leaves the record alone: its deletion reaches the default branch only when its PR merges, and until then a retried `remove` still resolves the bare name to the namespaced file. If the file reached the default branch with content other than what you pushed (for example a reviewer changed the PR before a squash merge), it is not recorded, and push says so once; run `teamai pull` and edit that file as the team file it now is - Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone @@ -1497,7 +1497,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, when it can be that agent's copy (the namespace is active for them, or their machine placed the agent) and their directory does not still receive an agent of that name from another active namespace. A member who never had that namespace keeps their own agent of the same name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. A namespaced agent can be named as `/`; a bare name that only one namespace has resolves to it, and a bare name found in several places is refused, with the qualified names listed, rather than removed from all of them. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, when it can be that agent's copy (the namespace is active for them, or their machine placed the agent) and their directory does not still receive an agent of that name from another active namespace. A member who never had that namespace keeps their own agent of the same name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 856f2bda..2ecc2f10 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -584,7 +584,7 @@ Choose namespace [1-3] (default: 1 = common): - 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace - 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 - 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 -- push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;需要从 `manifest/roles.yaml` 解析落点的新资源同样如此。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace +- push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;未使用 `--role` 放置的任何新资源同样如此,因为其落点来自该克隆(`manifest/roles.yaml`、它的缺失,或仓库中已有的 namespace)。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace。若本机的落点记录无法更新并保存,`push` 也会停止且不推送任何内容 - 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步。`teamai remove` 本身不清除记录:删除要等其 PR 合并才进入默认分支,在此之前重试 `remove` 仍会把简名解析到带 namespace 的团队文件。若该文件进入默认分支时的内容与你推送的不同(例如评审者在 squash 合并前修改了 PR),则不会写入记录,push 会提示一次;此时运行 `teamai pull`,并把该文件当作现在的团队文件来编辑 - 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 @@ -1456,7 +1456,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;当该副本可能属于这个 agent(该 namespace 对成员活跃,或由其本机放置)且成员的目录没有从另一个活跃 namespace 收到同名 agent 时,其拍平后的 `` 副本会被清理,也不会再被推送。从未启用该 namespace 的成员会保留自己的同名 agent。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。带 namespace 的 agent 可写作 `/`;只有一个 namespace 拥有的简名会解析到该 agent;若简名出现在多个位置,命令会列出完整名称并拒绝执行,而不是从所有位置删除。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;当该副本可能属于这个 agent(该 namespace 对成员活跃,或由其本机放置)且成员的目录没有从另一个活跃 namespace 收到同名 agent 时,其拍平后的 `` 副本会被清理,也不会再被推送。从未启用该 namespace 的成员会保留自己的同名 agent。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts index 2291db70..13f2d58b 100644 --- a/src/__tests__/placement-records.test.ts +++ b/src/__tests__/placement-records.test.ts @@ -155,6 +155,10 @@ describe('reconcilePlacementRecords', () => { expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); expect(state.placedAgents).toEqual({}); + // Retired just like a record whose file is simply gone: the author's + // flattened copy was the removed agent's, whatever sits there now. + expect((state as { retiredPlacedAgents?: Record }).retiredPlacedAgents) + .toEqual({ vr: 'agents/fe/vr.yaml' }); }); describe('the checkpoint and the ref it is read from', () => { diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 1477f919..2b004f88 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1318,6 +1318,49 @@ describe('push namespace routing for rules and agents', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); }); + it('stops placing a new resource on a stale clone even when that clone has no roles manifest', async () => { + // Its absence is repo state too: a manifest added remotely since the last + // pull would move this rule off the shared root (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockPullRepo.mockRejectedValueOnce(new Error('could not resolve host')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); + }); + + it('stops the push when the reconciled placement records cannot be saved', async () => { + // The sync and the scan read the records back from disk: one that could + // not be withdrawn would still redirect the author's copy (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // No records left, so reconcile clears the checkpoint: the state changed. + mockLoadStateForScope.mockImplementation(async () => ({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], pushedEnvVars: [], + lastUpdateCheck: null, availableUpdate: null, pendingPushes: [], placementsCheckedAt: 'abc', + })); + mockSaveStateForScope.mockRejectedValueOnce(new Error('EACCES: permission denied')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Nothing was pushed'); + }); + it('turns a placement into a record only once its file is on the default branch, before scanning', async () => { // Pushed and awaiting review: nothing on the default branch yet, so no // record — a PR closed unmerged, branch kept or not, looks exactly like diff --git a/src/__tests__/push-skill-flag.test.ts b/src/__tests__/push-skill-flag.test.ts index 0417dfe7..548b4906 100644 --- a/src/__tests__/push-skill-flag.test.ts +++ b/src/__tests__/push-skill-flag.test.ts @@ -51,6 +51,12 @@ vi.mock('../utils/git.js', () => ({ checkoutMaster: (...args: unknown[]) => mockCheckoutMaster(...args), generateBranchName: (...args: unknown[]) => mockGenerateBranchName(...args), resetToCleanMaster: vi.fn(), + // Without these the refresh throws inside push, which then treats the clone + // as stale — and a stale clone may not place a new resource without --role. + isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), + getDefaultBranch: vi.fn().mockResolvedValue('main'), + getFileContentAtRev: vi.fn().mockResolvedValue(null), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); vi.mock('../roles.js', async () => { diff --git a/src/__tests__/remove-command.test.ts b/src/__tests__/remove-command.test.ts index ba271903..4ce04a18 100644 --- a/src/__tests__/remove-command.test.ts +++ b/src/__tests__/remove-command.test.ts @@ -61,3 +61,50 @@ describe('teamai remove when the placement records cannot be saved', () => { expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Nothing was removed'); }); }); + +/** + * Agents deploy flattened, so the team scan names them by bare stem. A machine + * with no placement record could only type that stem, which removes the agent + * from every namespace (#649 review). `/` names one of them, and a + * bare stem that means several is refused rather than guessed. + */ +describe('teamai remove agents names one agent, not a stem every namespace shares', () => { + const vrIn = (namespace: string) => ({ name: 'vr', type: 'agents', namespace, relativePath: `agents/${namespace}/vr.yaml` }); + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + mockSaveStateForScope.mockResolvedValue(undefined); + handler.publishedNameFor.mockResolvedValue(null); + handler.removeItem.mockResolvedValue(['agents/fe/vr.yaml']); + }); + afterEach(() => { process.exitCode = undefined; }); + + it('refuses a bare stem that names agents in several namespaces', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe'), vrIn('be')]); + + await remove('agents', ['vr'], { force: true }); + + expect(handler.removeItem).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('fe/vr, be/vr'); + }); + + it('removes exactly the agent a namespaced name gives', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe'), vrIn('be')]); + + await remove('agents', ['fe/vr'], { force: true }); + + expect(handler.removeItem).toHaveBeenCalledTimes(1); + expect(handler.removeItem.mock.calls[0]?.[0]).toBe('fe/vr'); + }); + + it('resolves a bare stem that only one namespace has to that namespaced agent', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe')]); + + await remove('agents', ['vr'], { force: true }); + + // Named exactly, so the tombstone is `fe/vr`, not a stem other namespaces share. + expect(handler.removeItem.mock.calls[0]?.[0]).toBe('fe/vr'); + }); +}); diff --git a/src/push.ts b/src/push.ts index 67d12720..e7e62148 100644 --- a/src/push.ts +++ b/src/push.ts @@ -368,17 +368,17 @@ async function placeNewResources(args: { ); if (newAtRoot.length === 0) continue; - // Same reasoning as --project in pushCore, for the roles manifest: a - // namespace resolved from an unrefreshed clone may name the wrong members. - // A team with no roles manifest at all resolves from nothing that can go - // stale, and keeps its pre-manifest behaviour. - if ( - teamRepoStale && !options.role - && await pathExists(path.join(localConfig.repo.localPath, 'manifest', 'roles.yaml')) - ) { + // Same reasoning as --project in pushCore: a namespace resolved from an + // unrefreshed clone may name the wrong members. Every unflagged answer + // reads that clone — the roles manifest, its ABSENCE (one added remotely + // since the last pull would move new rules and agents off the shared + // root), and the skills namespaces detected from its tree — so only an + // explicit --role is safe here (#649 review). + if (teamRepoStale && !options.role) { log.error( - `Cannot place new ${type}: the team repo could not be refreshed, so manifest/roles.yaml may be ` - + 'stale. Fix the pull and retry, or name the namespace with --role .', + `Cannot place new ${type}: the team repo could not be refreshed, so where new ${type} belong ` + + '(manifest/roles.yaml, or the namespaces the repo already has) may be out of date. ' + + 'Fix the pull and retry, or name the namespace with --role .', ); process.exitCode = 1; return false; @@ -854,6 +854,10 @@ async function pushCore( // whose file the team deleted stops being one, and one shadowed by a new // shared-root file of the same name is withdrawn (#649 review). Not when the // clone is stale itself — a file missing from an unrefreshed tree proves nothing. + // Not best-effort: the pre-push sync and the scan read the records back from + // disk, so a record that could not be withdrawn (a shared-root file now + // shadows it) would still redirect the author's root copy onto the + // namespaced file and push that shared content over it (#649 review). if (!teamRepoStale) { try { const recordsState = await loadStateForScope(localConfig); @@ -861,7 +865,13 @@ async function pushCore( await saveStateForScope(recordsState, localConfig); } } catch (e) { - log.debug(`Placement record cleanup skipped: ${(e as Error).message}`); + log.error( + `Could not bring this machine's placement records up to date (${(e as Error).message}), ` + + 'so where your resources belong cannot be worked out safely. Nothing was pushed. ' + + 'Check that the teamai state file is writable, then retry.', + ); + process.exitCode = 1; + return; } } diff --git a/src/remove.ts b/src/remove.ts index 44f15ded..b94c0ae5 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -103,10 +103,18 @@ async function removeCore( // Verify which resources exist const teamItems = await handler.scanTeamForPull(teamConfig, localConfig); const localItems = await handler.scanLocalForPush(teamConfig, localConfig); - const allNames = new Set([...teamItems.map((i) => i.name), ...localItems.map((i) => i.name)]); + // Agents deploy flattened, so the team scan names them by bare stem. Their + // `/` is what names ONE of them: without it a machine holding no + // placement record could only type the stem, which removes that agent from + // every namespace (#649 review). + const qualified = (item: { name: string; namespace?: string }): string => ( + type === 'agents' && item.namespace ? `${item.namespace}/${item.name}` : item.name + ); + const allNames = new Set([...teamItems.map(qualified), ...localItems.map((i) => i.name)]); const found: string[] = []; const notFound: string[] = []; + let ambiguous = false; for (const name of names) { // The placement record is consulted FIRST. A resource this machine placed // in a namespace is published as `/`, while the author's local @@ -125,6 +133,25 @@ async function removeCore( found.push(published); continue; } + if (type === 'agents' && !name.includes('/')) { + const sameStem = teamItems.filter((item) => item.name === name).map(qualified); + if (sameStem.length > 1) { + log.error( + `"${name}" names agents in several places (${sameStem.join(', ')}), and removing it would take ` + + `all of them. Name the one to remove, e.g. \`teamai remove agents ${sameStem[0]}\`.`, + ); + ambiguous = true; + continue; + } + // The one team agent of that stem, named exactly, so the tombstone names + // it and not the stem every other namespace shares. + const only = sameStem[0]; + if (only && only !== name) { + log.info(`${name} is ${only}`); + found.push(only); + continue; + } + } if (allNames.has(name)) { found.push(name); } else { @@ -136,6 +163,14 @@ async function removeCore( log.warn(`Not found (skipping): ${notFound.join(', ')}`); } + // Stop the whole run rather than remove the other names alone: the user + // asked for all of them, and has to say which of the ambiguous ones. + if (ambiguous) { + log.error('Nothing was removed.'); + process.exitCode = 1; + return; + } + if (found.length === 0) { log.error('No matching resources found to remove'); log.info(`Available ${type}:`); diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 2215e79e..81c8a357 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -333,6 +333,10 @@ export async function reconcilePlacementRecords( if (valid && checkedAt && !recordedNow.has(`${field}:${name}`) && await pathDeletedSince(repoPath, checkedAt, valid, history) === true) { log.debug(`Dropping placement record ${field}.${name} → ${recorded}: deleted from the default branch since the last check`); + // The file this record named was deleted, exactly as in the branch + // above; what is there now is somebody else's, but the author's copy + // still stood for the removed agent (#649 review). + if (field === 'placedAgents') state.retiredPlacedAgents = { ...state.retiredPlacedAgents, [name]: valid }; changed = true; continue; }