Skip to content

fix(pull): report actual sync outcome instead of team-repo item count - #597

Closed
Morrowga wants to merge 4 commits into
Tencent:mainfrom
Morrowga:fix/pull-phantom-sync-count
Closed

Morrowga wants to merge 4 commits into
Tencent:mainfrom
Morrowga:fix/pull-phantom-sync-count

Conversation

@Morrowga

Copy link
Copy Markdown

Fixes #574
Fixes #585

What this fixes

pull and init --agent both had the same underlying issue: the reported outcome didn't reflect what was actually written to disk.

pull (rules): pull.ts used the team-repo's rule count (scanTeamForPull's result) for the Synced N rule(s) message and totalSynced tally — regardless of whether any configured tool's rules/ directory actually existed. RulesHandler.pullItem() correctly skips writing to an uninstalled tool (by design, per isToolInstalled's own docstring), but that skip is only logged at debug level. So a user with no tool directories yet would see ✔ Synced 7 rule(s) with zero files actually written — exactly #574's repro.

init --agent: declaring --agent claude,codex never created those tool directories or ran an initial sync — it only printed "will auto-sync on each session start," relying entirely on a SessionStart hook that may not fire before the user's first real session.

--force: didn't bypass isToolInstalled anywhere, despite --force's own description ("sync regardless").

Changes

  • pull.ts: added hasInstalledRulesTarget() a single check per pull (not per-rule, since isToolInstalled doesn't vary across rules in the same batch) that gates whether the success message/count fires. When no tool is installed, warns with actionable next steps instead of claiming success.
  • rules.ts: pullAllRules() takes an optional force param; when true, pre-creates each configured tool's rules/ directory before the write loop, so isToolInstalled naturally passes and the existing write path proceeds — rather than special-casing a bypass inside pullItem (which isn't possible without changing the ResourceHandler abstract contract shared by every resource type).
  • known-agents.ts / init.ts / bootstrap.ts: renamed seedSelfModeToolDirs → seedEnabledAgentDirs (per this issue's own suggestion) and reused it in both init's git and http paths. init --agent now seeds the declared tools' directories and runs a real pullForScope immediately.
  • pull.ts: exported pullForScope so init.ts can call it directly for the initial sync.

Scope

This covers rules specifically, matching this issue's repro and acceptance criteria. The same phantom-success pattern (team-repo count vs. actual-write count) exists in pull.ts for skills/docs/env/agents/team-culture/shared-instructions — same shape, different call sites. Flagging this as a known related gap rather than expanding this PR further; happy to follow up separately if useful.

Verification

  • npx tsc --noEmit clean
  • Full existing suite: 234 files / 3267 tests passing (one pre-existing flaky timeout in local-agent.test.ts's uninstall_teamai tests, confirmed via git stash to fail identically on unmodified main under full-suite load — unrelated to this change)
  • Added tests for both the rules-reporting fix and the init --agent sync, each confirmed to fail on unmodified code and pass with the fix (via git stash/stash pop)
  • Manually ran teamai init <repo> --agent claude end-to-end against a real throwaway GitHub repo and fresh directory — confirmed .claude/rules/*.md actually lands on disk with no manual mkdir, matching the acceptance criteria

Not covered

  • --force bypass is implemented for rules only, not the other resource types
  • Multi-provider verification (gitlab/tgit) — tested against GitHub specifically

pull's rules sync used the team-repo item count for its 'Synced N
rule(s)' message and totalSynced tally, regardless of whether any
configured tool's rules/ directory actually existed. When no tool was
installed, pullItem silently skipped every write (by design) but the
log still reported success with the full count.

- pull.ts: add hasInstalledRulesTarget(), a single per-pull check
  (isToolInstalled doesn't vary per rule) used to gate the success
  message and totalSynced increment; warn with actionable guidance
  when nothing was actually written.
- rules.ts: pullAllRules() now accepts an optional force parameter;
  when true, pre-creates each configured tool's rules/ directory
  before the write loop so --force means 'sync regardless', as its
  own description says, without touching pullItem's signature (part
  of the ResourceHandler abstract contract).
- known-agents.ts/init.ts/bootstrap.ts: renamed seedSelfModeToolDirs
  to seedEnabledAgentDirs and reused it from init's git and http
  paths. init --agent now seeds the declared tools' directories and
  runs one real pullForScope immediately, instead of leaving them
  empty until a SessionStart hook fires (which may never happen).
- pull.ts: exported pullForScope so init.ts can trigger the initial
  sync directly.

Fixes Tencent#574
Fixes Tencent#585
@SaulMoro

Copy link
Copy Markdown
Collaborator

The hasInstalledRulesTarget gate is the right shape, and the test that fails on unmodified code is the part that makes this reviewable. One observation that could turn the "known related gap" in your Scope section into a few lines here, rather than a follow-up PR.

That per-tool installed-directory test already exists seven times in src/pull.ts. At cccbe1d, before this PR:

where line field it reads
skills sync 368 toolPath.skills
getInstalledResourceTargets 487 skills, rules, agents
tombstone cleanup 771 toolPathField, from the tombstoneTypes table at 753
skills cleanup 825 toolPath.skills
rules cleanup 992, 1023 toolPath.rules
agents cleanup 1266 toolPath.agents

Every one walks scopedToolPaths, reads a single toolPath field, calls ResourceHandler.isToolInstalled, and filters on isAgentExcluded. hasInstalledRulesTarget makes it eight.

If that helper takes the field as a parameter instead, something like hasInstalledTargetFor(teamConfig, localConfig, field), then the generic branch at src/pull.ts:740-745 can gate skills, docs and agents with no new code, and getInstalledResourceTargets reduces to the same helper over its three fields. The phantom count for the other resource types closes here instead of staying open.

Not blocking. Rules-only is a coherent scope if you would rather keep the diff tight for review, and the tests you added do not change either way.

Merge: resolved conflict in src/pull.ts — kept hasInstalledRulesTarget
(this PR's fix) alongside upstream's cleanupTombstonedResources
refactor (independent additions, no logic overlap).

Generalize: per review feedback on this PR, the 'walk tools, check
isToolInstalled, skip if excluded' pattern behind hasInstalledRulesTarget
already existed 7 times in pull.ts. Replaced it and getInstalledResourceTargets
with two shared helpers:
  - installedToolsFor(teamConfig, localConfig, field): tool ids with an
    installed <field> directory (rules/skills/agents)
  - hasInstalledTargetFor(...): boolean convenience wrapper

Applied the same phantom-success gate already built for rules to the
shared skills/agents write path, so 'no tool installed' now warns
instead of silently claiming success there too.

docs was checked and deliberately left out: DocsHandler.pullItem writes
unconditionally to a single fixed directory (fse.copy, which creates
the destination as needed) — there is no isToolInstalled gate or
per-tool skip to fix for docs.

Fixes Tencent#574
Fixes Tencent#585
@Morrowga

Copy link
Copy Markdown
Author

The hasInstalledRulesTarget gate is the right shape, and the test that fails on unmodified code is the part that makes this reviewable. One observation that could turn the "known related gap" in your Scope section into a few lines here, rather than a follow-up PR.

That per-tool installed-directory test already exists seven times in src/pull.ts. At cccbe1d, before this PR:

where line field it reads
skills sync 368 toolPath.skills
getInstalledResourceTargets 487 skills, rules, agents
tombstone cleanup 771 toolPathField, from the tombstoneTypes table at 753
skills cleanup 825 toolPath.skills
rules cleanup 992, 1023 toolPath.rules
agents cleanup 1266 toolPath.agents
Every one walks scopedToolPaths, reads a single toolPath field, calls ResourceHandler.isToolInstalled, and filters on isAgentExcluded. hasInstalledRulesTarget makes it eight.

If that helper takes the field as a parameter instead, something like hasInstalledTargetFor(teamConfig, localConfig, field), then the generic branch at src/pull.ts:740-745 can gate skills, docs and agents with no new code, and getInstalledResourceTargets reduces to the same helper over its three fields. The phantom count for the other resource types closes here instead of staying open.

Not blocking. Rules-only is a coherent scope if you would rather keep the diff tight for review, and the tests you added do not change either way.

Good catch generalized it. installedToolsFor(teamConfig, localConfig, field) now backs both the phantom-success gate and getInstalledResourceTargets, and I applied the same gate to the shared skills/agents write path.

One adjustment: left docs out. DocsHandler.pullItem writes unconditionally to a single fixed directory via fse.copy (which creates the destination as needed) there's no isToolInstalled check or per-tool skip there, so there's nothing to gate. Happy to be corrected if I'm missing something on that.

Merge conflict with #598's cleanupTombstonedResources refactor is resolved too kept both, no overlap.

@SaulMoro

Copy link
Copy Markdown
Collaborator

installedToolsFor is the right shape, and you were right about docs — I was wrong to include it. DocsHandler.pullItem copies into one fixed localDocsDir with fse.copy, which creates the destination, so there is no per-tool skip to gate. Nothing to correct there.

One thing from reading the new commit, not blocking.

The gate is a boolean over tools, and the write path is per tool. hasInstalledTargetFor (src/pull.ts:499) reduces installedToolsFor to .length > 0, while SkillsHandler.pullItem skips each uninstalled tool on its own (src/resources/skills.ts:505, and src/resources/agents.ts:421 for agents). So with enabledAgents: [claude, codex], Claude's directory present and Codex's absent, the gate passes and Synced 12 skills prints while Codex receives nothing — #574's report, one level down.

The list is already in hand, since installedToolsFor returns the tools and only the caller discards them:

-const hasTarget = await hasInstalledTargetFor(freshConfig, localConfig, type as 'skills' | 'agents');
-if (!hasTarget) {
+const installed = await installedToolsFor(freshConfig, localConfig, type as 'skills' | 'agents');
+if (installed.length === 0) {
   log.warn(`… no installed tool directory found — nothing written…`);
 } else {
   …
-  log.success(`[${scopeLabel}] Synced ${items.length} ${type}`);
+  log.success(`[${scopeLabel}] Synced ${items.length} ${type} → ${installed.join(', ')}`);

That turns the count into a claim a reader can check, and it costs a variable. Whether a partially missing tool should warn as well as be omitted from that list is a design call I would leave to you — naming the tools that were written is already most of the value for someone reading their terminal.

Unrelated, in case the file list raises an eyebrow: #599 (phase 1 of #598) rewrites src/doctor.ts, which also appears here. The two do not collide — your branch's copy of that file is main's, carried in with the upstream merge.

Per review: hasInstalledTargetFor collapsed the installed-tools list to
a boolean, so 'Synced N agents' still printed as a blanket success even
when only some configured tools (e.g. claude, not codex) were installed
— codex silently got nothing with no indication in the log. Switched
the caller to installedToolsFor directly and appended the tool list to
the success message: 'Synced 1 agents → claude'.

Also fixes an unrelated missing closing brace introduced while editing
this block (broke the whole file's parse — caught by tsc --noEmit
before commit).

Added a test with two configured tools, only one installed, asserting
the message names the installed one and omits the other. Confirmed via
git stash that it fails on the prior boolean-based code.
@Morrowga

Copy link
Copy Markdown
Author

installedToolsFor is the right shape, and you were right about docs — I was wrong to include it. DocsHandler.pullItem copies into one fixed localDocsDir with fse.copy, which creates the destination, so there is no per-tool skip to gate. Nothing to correct there.

One thing from reading the new commit, not blocking.

The gate is a boolean over tools, and the write path is per tool. hasInstalledTargetFor (src/pull.ts:499) reduces installedToolsFor to .length > 0, while SkillsHandler.pullItem skips each uninstalled tool on its own (src/resources/skills.ts:505, and src/resources/agents.ts:421 for agents). So with enabledAgents: [claude, codex], Claude's directory present and Codex's absent, the gate passes and Synced 12 skills prints while Codex receives nothing — #574's report, one level down.

The list is already in hand, since installedToolsFor returns the tools and only the caller discards them:

-const hasTarget = await hasInstalledTargetFor(freshConfig, localConfig, type as 'skills' | 'agents');
-if (!hasTarget) {
+const installed = await installedToolsFor(freshConfig, localConfig, type as 'skills' | 'agents');
+if (installed.length === 0) {
   log.warn(`… no installed tool directory found — nothing written…`);
 } else {
   …
-  log.success(`[${scopeLabel}] Synced ${items.length} ${type}`);
+  log.success(`[${scopeLabel}] Synced ${items.length} ${type} → ${installed.join(', ')}`);

That turns the count into a claim a reader can check, and it costs a variable. Whether a partially missing tool should warn as well as be omitted from that list is a design call I would leave to you — naming the tools that were written is already most of the value for someone reading their terminal.

Unrelated, in case the file list raises an eyebrow: #599 (phase 1 of #598) rewrites src/doctor.ts, which also appears here. The two do not collide — your branch's copy of that file is main's, carried in with the upstream merge.

Fixed exactly as you showed installedToolsFor now feeds the message directly instead of getting collapsed to a boolean. Synced 1 agents → claude when codex isn't installed, confirmed with a test (two configured tools, one installed) that fails on the old boolean-based code via git stash and passes with the fix.

One thing worth flagging: the same gap exists in the rules block (src/pull.ts:718) —hasInstalledTargetFor(freshConfig, localConfig, 'rules') has the identical boolean-collapse problem, and rules is actually #574's original report. I left it as-is for now since your diff was scoped to skills/agents, but happy to apply the same fix there too if you'd like this PR to close that gap as well rather than leaving it open.

Also caught and fixed an unrelated typo of my own while making this change a missing closing brace that broke the file's parse, caught by tsc --noEmit before it got anywhere near a commit.

@SaulMoro

Copy link
Copy Markdown
Collaborator

Yes, close rules here. It is the case #574 actually reported, so fixing the phantom count everywhere except that line would read oddly from the issue's side. Same shape as what you just did:

-          const hasTarget = await hasInstalledTargetFor(freshConfig, localConfig, 'rules');
-          if (hasTarget) {
-            log.success(`[${scopeLabel}] Synced ${items.length} rule(s)${skipped.length > 0 ? ` (skipped ${skipped.length} by tags)` : ''}`);
+          const installed = await installedToolsFor(freshConfig, localConfig, 'rules');
+          if (installed.length > 0) {
+            log.success(`[${scopeLabel}] Synced ${items.length} rule(s) → ${installed.join(', ')}${skipped.length > 0 ? ` (skipped ${skipped.length} by tags)` : ''}`);

After that, hasInstalledTargetFor (src/pull.ts:499) has no callers left. It only ever existed for the line you are replacing, so it can go with it.

Skills kept the opaque count

The gate landed for skills, the message did not. Skills logs through logSyncDetail, which never receives the list:

src/pull.ts:830   const installed = await installedToolsFor(...)
            831   if (installed.length === 0)   log.warn(...)
            839   else if (type === 'skills')   logSyncDetail(...)   prints "Synced 12 skills (all updated)"
            841   else                          log.success(...)     prints "Synced 1 agents → claude"

So with enabledAgents: [claude, codex], .claude/skills present and .codex absent, skills still prints Synced 12 skills (all updated) while codex receives nothing. That is the terminal line #574 was filed about, and skills is the resource type with the most items in most teams.

logSyncDetail has exactly one caller, so the parameter is cheap:

 function logSyncDetail(
   type: ResourceType,
   items: ResourceItem[],
   existingNames: Set<string>,
   verbose: boolean,
   scopeLabel?: string,
   skippedCount?: number,
+  installedTools?: string[],
 ): void {
   const prefix = scopeLabel ? `[${scopeLabel}] ` : '';
+  const target = installedTools?.length ? ` → ${installedTools.join(', ')}` : '';

Then ${type}${target} in its three log.success branches, and pass installed at the call site on line 839.

One asymmetry to know about

In rules the check runs after pullAllRules, and pullAllRules pre-creates the tool directories when --force is set. So under --force the rules list will name every configured tool, which is the right answer, but it is reporting what exists now. In skills and agents the check runs before the writes, so those lists report what existed before. Neither is a bug. It matters if you write a test that asserts on the rules message with --force.

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Please resolve the P1 finding and conflicts.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/resources/agent-format.ts:56 redeclares both AGENT_FILE_EXTENSIONS and agentStemFromFilename, already declared at lines 37 and 43. This produces duplicate-identifier errors and prevents TypeScript compilation/build.
  • [P1 blocking] src/init.ts:488 and src/init.ts:1651 call pullForScope with two arguments, but src/pull.ts:738 requires the reported: Set<string> argument. This is another compile error; if bypassed, reported.add(...) can also fail at runtime.
  • [P1 blocking] src/resources/rules.ts:330 makes pull --force create rule directories for every configured tool without checking isAgentExcluded. With default toolPaths, this creates directories for disabled or unselected agents. It also uses resolveBaseDir rather than resolveToolBaseDir, so user-scope Copilot files are seeded outside COPILOT_HOME. Additionally, --force is documented as bypassing the unchanged-revision cache, not opting users into every tool.
  • [P1 blocking] src/init.ts:489 and src/init.ts:1652 always print “Synced skills, rules, and docs” after pullForScope returns, but pullForScope handles failures such as refresh errors or missing teamai.yaml by logging and returning normally. Init can therefore report a successful initial sync when nothing was synced—the same class of misleading output this PR aims to fix.
  • [P1 blocking] The PR description lacks the repository-required e2e matrix. It records only one GitHub/Claude real-CLI run and explicitly says GitLab/TGit were not tested; AGENTS.md requires real-CLI verification for Claude, Codex, CodeBuddy, and OpenCode across git, gitlab, and github providers before PR.

@jeff-r2026 jeff-r2026 closed this Sep 21, 2026
jeff-r2026 pushed a commit that referenced this pull request Sep 23, 2026
…#751)

* fix(pull): gate the generic sync report on a tool that can receive it

The generic branch of the sync loop reported the team repo's item count
for every resource type: `Synced N skills` counted what the repo holds,
not what landed. Skills are written per tool into that tool's own
directory, and a brand-new member has none of them yet — the handler
skips such a tool by design and only logs at debug. So the first pull
after `init` printed a success while nothing was on disk, which is the
phantom-success half of #585.

`hasInstalledTargetFor` asks the same question `getInstalledResourceTargets`
already asks, for one resource field, and the generic branch now gates
its report on it. Docs need no gate: they are copied to the team's own
docs directory, which the copy creates, so that report was already
truthful.

Only the report is gated. The writes still run, so a tool root created
later — Cursor makes `.cursor/` on first launch — is filled by the next
pull, and `pull --force` fills it now.

Fixes #585 (the generic branch). #597 fixed the same shape for rules
and left this one open; this closes it for skills.

* fix(pull): gate the agents sync report on a receiving tool too

The gate only covered skills, so the generic branch still printed
`Synced N agents` when no installed tool could receive them — the same
phantom-success shape #585 describes, one resource type over.
AgentsHandler then resolves no destinations and writes nothing.

`hasInstalledTargetFor` also duplicated the walk that
`getInstalledResourceTargets` already performs. That function now takes
an optional `field`, and the gate calls it, so reporting and writing share
one resolver instead of two that can drift — an external HERMES_HOME or an
unresolvable workspace no longer disagrees between them.

Docs, rules and env never reach this branch; hooks and mcp have no
tool-path field to probe, so they keep reporting unconditionally.

Tests add the agent case the file's header already claimed: no tool
directory suppresses the claim and nothing lands, and the claim returns
once the directory exists. The suppression case is RED without the gate.

---------

Co-authored-by: ydflow <314143294+ydflow@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants