Description
teamai push reports success for a skill whose local copy has lost a file, while the team repo keeps that file. The skill is then reported (modified) on every subsequent scan, with no way to clear it.
Detection works. dirTeamSubsetEqual collects the team copy's file set recursively (src/utils/fs.ts:453-472) and compares each path against the local copy. A file present in the team copy and missing locally produces a null hash and a false result (src/utils/fs.ts:347-354, :377), so scanLocalForPush lists the skill as (modified).
The transfer does not match the comparison. SkillsHandler.pushItem calls await copyDir(item.sourcePath, dest) (src/resources/skills.ts:534), and copyDir is fse.copy(src, dest, { overwrite: true, filter: (p) => !isIgnored(path.basename(p)) }) (src/utils/fs.ts:165-168). That merges the local tree over the team tree and removes nothing. pushGroup then stages the directory path (src/push.ts:168-170, :192-198), so the commit carries the files that were written and no deletion.
compare: team file set -> local tree missing file seen -> (modified)
transfer: local tree -> team file set merge, nothing pruned -> file stays
The comparison is subset-directional. The transfer is additive. A deletion falls into the gap between them.
Reproduction
- In the team repo, place a skill at
skills/front/my-skill/ containing SKILL.md and references/old.md.
- Run
teamai pull so the local copy carries both files.
- Delete
references/old.md from the local copy of the skill.
- Run
teamai push. The skill is listed as (modified) and the command reports success.
- Open the resulting PR. It carries no deletion of
references/old.md, and the file remains in the team repo.
- Run
teamai push again. The skill is listed as (modified) again.
Step 6 repeats for every subsequent scan, because the condition that produced the flag is never resolved.
Expected behavior
teamai push removes from the team copy the files that the local copy no longer has, so the PR carries the deletion and the skill stops reporting (modified).
Where removing files is not wanted, teamai push states that the deletion was not carried, rather than reporting success.
Suggested fix
Make the skill transfer mirror the local tree rather than merge over it. collectFiles already produces both file sets, so the delete set is the difference:
pushItem(item, dest)
await copyDir(item.sourcePath, dest)
+ remove from dest every file in the team set that the local set does not have
ensureSkillFrontmatter(dest)
Two exclusions the pruning must respect, or push will delete files it should keep:
CONTRIBUTORS, which lives only in the team copy and is passed as the ignore argument to the comparison (src/resources/skills.ts:444, appended at :546-557).
- The entries
isIgnored drops at every level: .DS_Store, node_modules, .git, *.pyc (src/utils/fs.ts:5-17).
Environment
Read from source at main (97a0277, package version 0.22.0). The installed CLI is 0.24.0, the current npm latest.
- OS: macOS 26.5.2
- Node.js: v22.22.2
- teamai: 0.24.0
- Provider: GitHub
- AI tool(s): Claude Code
Logs
No --verbose output applies. The finding comes from reading source at the commit above.
The copy behaviour it rests on is confirmed against the fs-extra build that teamai 0.24.0 installs. The script below reproduces copyDir exactly as src/utils/fs.ts:165-168 calls it, over a team copy holding SKILL.md, references/old.md and CONTRIBUTORS, and a local copy with SKILL.md edited, references/old.md deleted and references/new.md added.
const isIgnored = (n) => ['.DS_Store','node_modules','.git'].includes(n) || n.endsWith('.pyc');
await fse.copy(local, team, { overwrite: true, filter: (p) => !isIgnored(path.basename(p)) });
Result:
team after copy: CONTRIBUTORS, SKILL.md, references/new.md, references/old.md
references/old.md still present: true
references/new.md carried: true
CONTRIBUTORS survived: true
references/old.md survives the copy, which is the defect. CONTRIBUTORS survives for the same reason, which is why pruning has to exclude it.
Related
A file added locally inside an existing skill is not detected on its own, because the comparison walks the team copy's file set. That case is largely covered in practice: a skill routes to its references, so an added file arrives with an edit to SKILL.md or another tracked file, and copyDir then carries the whole directory including the new file. The rationale for the subset direction is stated at src/utils/fs.ts:418-421. Any pruning added for the deletion case should leave that behaviour intact.
Description
teamai pushreports success for a skill whose local copy has lost a file, while the team repo keeps that file. The skill is then reported(modified)on every subsequent scan, with no way to clear it.Detection works.
dirTeamSubsetEqualcollects the team copy's file set recursively (src/utils/fs.ts:453-472) and compares each path against the local copy. A file present in the team copy and missing locally produces a null hash and afalseresult (src/utils/fs.ts:347-354,:377), soscanLocalForPushlists the skill as(modified).The transfer does not match the comparison.
SkillsHandler.pushItemcallsawait copyDir(item.sourcePath, dest)(src/resources/skills.ts:534), andcopyDirisfse.copy(src, dest, { overwrite: true, filter: (p) => !isIgnored(path.basename(p)) })(src/utils/fs.ts:165-168). That merges the local tree over the team tree and removes nothing.pushGroupthen stages the directory path (src/push.ts:168-170,:192-198), so the commit carries the files that were written and no deletion.The comparison is subset-directional. The transfer is additive. A deletion falls into the gap between them.
Reproduction
skills/front/my-skill/containingSKILL.mdandreferences/old.md.teamai pullso the local copy carries both files.references/old.mdfrom the local copy of the skill.teamai push. The skill is listed as(modified)and the command reports success.references/old.md, and the file remains in the team repo.teamai pushagain. The skill is listed as(modified)again.Step 6 repeats for every subsequent scan, because the condition that produced the flag is never resolved.
Expected behavior
teamai pushremoves from the team copy the files that the local copy no longer has, so the PR carries the deletion and the skill stops reporting(modified).Where removing files is not wanted,
teamai pushstates that the deletion was not carried, rather than reporting success.Suggested fix
Make the skill transfer mirror the local tree rather than merge over it.
collectFilesalready produces both file sets, so the delete set is the difference:pushItem(item, dest) await copyDir(item.sourcePath, dest) + remove from dest every file in the team set that the local set does not have ensureSkillFrontmatter(dest)Two exclusions the pruning must respect, or push will delete files it should keep:
CONTRIBUTORS, which lives only in the team copy and is passed as the ignore argument to the comparison (src/resources/skills.ts:444, appended at:546-557).isIgnoreddrops at every level:.DS_Store,node_modules,.git,*.pyc(src/utils/fs.ts:5-17).Environment
Read from source at
main(97a0277, package version 0.22.0). The installed CLI is 0.24.0, the current npmlatest.Logs
No
--verboseoutput applies. The finding comes from reading source at the commit above.The copy behaviour it rests on is confirmed against the
fs-extrabuild that teamai 0.24.0 installs. The script below reproducescopyDirexactly assrc/utils/fs.ts:165-168calls it, over a team copy holdingSKILL.md,references/old.mdandCONTRIBUTORS, and a local copy withSKILL.mdedited,references/old.mddeleted andreferences/new.mdadded.Result:
references/old.mdsurvives the copy, which is the defect.CONTRIBUTORSsurvives for the same reason, which is why pruning has to exclude it.Related
A file added locally inside an existing skill is not detected on its own, because the comparison walks the team copy's file set. That case is largely covered in practice: a skill routes to its references, so an added file arrives with an edit to
SKILL.mdor another tracked file, andcopyDirthen carries the whole directory including the new file. The rationale for the subset direction is stated atsrc/utils/fs.ts:418-421. Any pruning added for the deletion case should leave that behaviour intact.