You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Several bugs in this repo share one shape. The command reports success and nothing lands on disk: #574, #525, #342, #335, #331, #508, #436, and #585, which is still open.
src/doctor.ts already holds checks that catch part of that shape. Three things stop them from being useful.
doctor runs only after a person already suspects a problem. A member runs teamai pull, reads Synced N rule(s), and finds out days later that the hooks never reached the tool settings.
The result is still not reusable.#569 landed after this issue was filed and fixed half of it: doctor() now returns allPassed (src/doctor.ts:234) and the command exits 1 when a check fails (src/index.ts:222), so teamai doctor && deploy is safe now. The registry is still inline at src/doctor.ts:102-199, so no other caller can run it, and there is still no --json, so a hook, a CI job, or an agent can only read the exit code, never which check failed or how to fix it.
Nothing checks the payload. Every check verifies plumbing: provider CLI, config, clone, hook injection, env block. None of them verifies that the skills pull reported are on disk and readable by the agent — which is what the bugs above are actually about.
Proposed solution
No new command. Extract the check registry, then read it in three places.
teamai doctor
buildChecks()
render()
- process.exitCode = anyFailed ? 1 : 0 landed in #569+ --json emits [{ name, ok, fix }]
teamai pull
sync()
report("Synced N rule(s)")
+ if (!silent) buildChecks(), then print the failures
hook session-start
pull({ silent: true }) unchanged, runs no checks
buildChecks()
+ + delivery check: every desired skill resolves to a directory+ on disk with a parseable SKILL.md
Phase 1 exports the result. It is useful on its own. Move the check registry out of doctor() into an exported buildChecks(ctx): Promise<Check[]>, fed by an exported resolveDoctorContext(): Promise<DoctorContext | null>. The registry is inline today at src/doctor.ts:102-199. A context object rather than (config, scope): the registry also needs the toolPaths narrowing (src/doctor.ts:89-94) and the resolveHookScope baseDir (src/doctor.ts:100), and passing the two loose inputs would make every caller re-derive both — the second place that drifts on its own, which this issue rejects for inline post-conditions. resolveDoctorContext is therefore part of the phase-2 contract too. doctor() then calls both, renders, and returns allPassed as it does now. Add --json, which emits the { name, ok, fix } shape the checks already carry. codebase --json and codebase review --json set the precedent, and #580 restored the same flag on the cache commands.
Phase 2 runs the checks at the end of an interactive pull. Call resolveDoctorContext() and buildChecks() after the sync and print each failure with the fix string it already has. Gate the call on the existing silent flag. The session-start hook calls pull({ silent: true }) at src/hook-handlers.ts:98, and the host awaits that handler, so the gate is what keeps session startup free: the hook path runs no checks, and only an explicit teamai pull does.
One blind spot is worth closing here. buildHookChecks skips a tool whose settings directory does not exist (src/doctor.ts:33). That is the same silent skip #574 reports in pull, reproduced inside doctor. After #569, toolPaths is already narrowed to the enabled, non-excluded tools (src/doctor.ts:89-94), so "declared in enabledAgents, no directory on disk" is a one-line change from continue to a failing check.
Phase 3 adds the check that looks at the payload. The delivery check is a set comparison:
flowchart LR
D["desired set<br>role namespaces ∪ subscribed tags − excluded<br>src/pull.ts:704-741"]
R{"resolveSkillDestination + stat<br>per enabled tool<br>src/resources/skills.ts:22"}
OK["ok — release<br>.claude/skills/release/SKILL.md"]
BAD["landed, invisible — incident<br>frontmatter name does not match the directory<br>the agent never discovers it"]
MISS["missing — security-scan<br>never written to this tool"]
D --> R
R -->|"directory exists, SKILL.md parses, name matches"| OK
R -->|"directory exists, frontmatter broken"| BAD
R -->|"no directory"| MISS
Loading
On failure the check lists the missing ones per tool, with teamai pull as the fix.
The desired set is already computed and already discarded. pullForScope builds it at src/pull.ts:739 as desiredSkillNames, uses it at src/pull.ts:853 to decide what not to delete, and drops it when the run ends. Phase 3 extracts that block into an exported, write-free resolveDesiredSkills(teamConfig, localConfig), calls it from pullForScope where it stands today, and calls it again from the check. Re-deriving the policy inside doctor would put role namespaces, tag subscriptions and excludes in a second place, which is the drift this issue already rejects for inline post-conditions.
ResourceHandler.diff() computes this direction already — removed at src/resources/base.ts:132 is "in the team repo, not local" — and has no production callers — only two assertions in src/__tests__/remove.test.ts exercise it. It cannot be reused as is: scanLocalForPush skips a local copy whose content equals the team repo's (src/resources/skills.ts:365), which is the success case, so every correctly delivered skill would report as missing. The check needs presence, not push candidacy.
This does not overlap #597, which now gates rules, skills and agents at write time. The two verify the same property at different moments:
rules, skills and agents, through installedToolsFor
skills first
catches
the write never happened
that, plus drift after a correct pull: directory deleted by hand, tool reinstalled, role changed, #590's over-deletion
blind to
anything after the run ends
a failure inside a run that nobody follows with a check
Both are worth having.
Skills first, because the destination resolver already exists. Rules and agents are the same check with a different toolPath field, and docs is the same check against a single directory. mcp and the CLAUDE.md injections — culture at src/pull.ts:1017, shared instructions at src/pull.ts:1032-1043 — are a different shape: they land as an entry or a marked section inside a file, so their check is closer to the hook check that already exists.
What this covers, and what it does not
buildChecks verifies these today:
provider CLI installed, or provider token configured (gf, gh, GitLab, GitCode)
team repo cloned
teamai.yaml parses
teamai hooks present in <tool> settings (one check per installed tool)
env block present in the shell profile
Phase 2 makes those run at the end of an interactive pull, which covers part of #574 and #342.
Phase 3 covers what a write-time gate cannot see. #597 has since generalized its own: installedToolsFor(teamConfig, localConfig, field) now backs rules, skills and agents, and docs is left out with a reason — DocsHandler.pullItem copies into one fixed directory that fse.copy creates, so there is no per-tool skip to gate. Three things survive that:
Per-tool granularity. The gate passes when at least one tool directory exists, while SkillsHandler.pullItem skips each uninstalled tool silently. With claude installed and codex not, Synced 12 skills still prints and Codex receives none — [bug] init 不做初始同步,且 pull 在工具目录不存在时静默跳过却报成功,以及codex必须在启动后进行一次会话才会执行teamai pull #574 one level down.
Landed but invisible.SKILL.md missing, frontmatter unparseable, name not matching the directory ([bug] 换行符不一致导致skill有两份名字跟描述,正则只匹配LF #372's class). The write succeeded, so no write-time gate has anything to report.
env is also ungated: it counts its source variables at src/pull.ts:764. Hooks and mcp never reach this loop at all — resourceTypes is ['skills', 'rules', 'docs', 'env', 'agents'] (src/pull.ts:667) and both are reconciled separately.
Alternatives considered
A teamai verify command. Rejected, because it is doctor under a second name, and the repo's rule is to extend an existing command first.
Post-condition assertions written inline in each writer. Rejected, because the same assertion then lives in several places and each copy drifts on its own.
For phase 3, having pull write an installed manifest that doctor reads instead of resolving destinations from state. Cheaper to compute, but it goes stale exactly when it matters: the manifest still says a skill was installed after someone deletes the directory, which is the case the check exists to catch.
Folding this into #585. #585 covers init --agent seeding and the specific miscount when a tool directory is missing, and #597 implements it. The two do not overlap, and #597 should land first.
Additional context
One decision before I open a PR, and it only affects phase 3. Extracting resolveDesiredSkills out of pullForScope touches the pull path, which is the hottest code in the repo right now (#597 is open against it). The alternative is to land phases 1 and 2 first and keep phase 3 behind #597. I would extract it, because the check is the only part of this issue that addresses the bugs it opens with, but the sequencing is your call.
Phase 1 is implemented in #599, against c674ffe. It extracts the two functions above, adds --json, and touches no other command; src/pull.ts is untouched, so it does not collide with #597. Phases 2 and 3 are still open.
Problem
Several bugs in this repo share one shape. The command reports success and nothing lands on disk: #574, #525, #342, #335, #331, #508, #436, and #585, which is still open.
src/doctor.tsalready holds checks that catch part of that shape. Three things stop them from being useful.doctorruns only after a person already suspects a problem. A member runsteamai pull, readsSynced N rule(s), and finds out days later that the hooks never reached the tool settings.The result is still not reusable. #569 landed after this issue was filed and fixed half of it:
doctor()now returnsallPassed(src/doctor.ts:234) and the command exits 1 when a check fails (src/index.ts:222), soteamai doctor && deployis safe now. The registry is still inline atsrc/doctor.ts:102-199, so no other caller can run it, and there is still no--json, so a hook, a CI job, or an agent can only read the exit code, never which check failed or how to fix it.Nothing checks the payload. Every check verifies plumbing: provider CLI, config, clone, hook injection, env block. None of them verifies that the skills
pullreported are on disk and readable by the agent — which is what the bugs above are actually about.Proposed solution
No new command. Extract the check registry, then read it in three places.
Phase 1 exports the result. It is useful on its own. Move the check registry out of
doctor()into an exportedbuildChecks(ctx): Promise<Check[]>, fed by an exportedresolveDoctorContext(): Promise<DoctorContext | null>. The registry is inline today atsrc/doctor.ts:102-199. A context object rather than(config, scope): the registry also needs thetoolPathsnarrowing (src/doctor.ts:89-94) and theresolveHookScopebaseDir (src/doctor.ts:100), and passing the two loose inputs would make every caller re-derive both — the second place that drifts on its own, which this issue rejects for inline post-conditions.resolveDoctorContextis therefore part of the phase-2 contract too.doctor()then calls both, renders, and returnsallPassedas it does now. Add--json, which emits the{ name, ok, fix }shape the checks already carry.codebase --jsonandcodebase review --jsonset the precedent, and #580 restored the same flag on the cache commands.Phase 2 runs the checks at the end of an interactive pull. Call
resolveDoctorContext()andbuildChecks()after the sync and print each failure with thefixstring it already has. Gate the call on the existingsilentflag. The session-start hook callspull({ silent: true })atsrc/hook-handlers.ts:98, and the host awaits that handler, so the gate is what keeps session startup free: the hook path runs no checks, and only an explicitteamai pulldoes.One blind spot is worth closing here.
buildHookChecksskips a tool whose settings directory does not exist (src/doctor.ts:33). That is the same silent skip #574 reports inpull, reproduced insidedoctor. After #569,toolPathsis already narrowed to the enabled, non-excluded tools (src/doctor.ts:89-94), so "declared inenabledAgents, no directory on disk" is a one-line change fromcontinueto a failing check.Phase 3 adds the check that looks at the payload. The delivery check is a set comparison:
flowchart LR D["desired set<br>role namespaces ∪ subscribed tags − excluded<br>src/pull.ts:704-741"] R{"resolveSkillDestination + stat<br>per enabled tool<br>src/resources/skills.ts:22"} OK["ok — release<br>.claude/skills/release/SKILL.md"] BAD["landed, invisible — incident<br>frontmatter name does not match the directory<br>the agent never discovers it"] MISS["missing — security-scan<br>never written to this tool"] D --> R R -->|"directory exists, SKILL.md parses, name matches"| OK R -->|"directory exists, frontmatter broken"| BAD R -->|"no directory"| MISSOn failure the check lists the missing ones per tool, with
teamai pullas the fix.The desired set is already computed and already discarded.
pullForScopebuilds it atsrc/pull.ts:739asdesiredSkillNames, uses it atsrc/pull.ts:853to decide what not to delete, and drops it when the run ends. Phase 3 extracts that block into an exported, write-freeresolveDesiredSkills(teamConfig, localConfig), calls it frompullForScopewhere it stands today, and calls it again from the check. Re-deriving the policy insidedoctorwould put role namespaces, tag subscriptions and excludes in a second place, which is the drift this issue already rejects for inline post-conditions.ResourceHandler.diff()computes this direction already —removedatsrc/resources/base.ts:132is "in the team repo, not local" — and has no production callers — only two assertions insrc/__tests__/remove.test.tsexercise it. It cannot be reused as is:scanLocalForPushskips a local copy whose content equals the team repo's (src/resources/skills.ts:365), which is the success case, so every correctly delivered skill would report as missing. The check needs presence, not push candidacy.This does not overlap #597, which now gates rules, skills and agents at write time. The two verify the same property at different moments:
pull, at write timebuildChecks, any time afterinstalledToolsForBoth are worth having.
Skills first, because the destination resolver already exists. Rules and agents are the same check with a different
toolPathfield, anddocsis the same check against a single directory.mcpand the CLAUDE.md injections — culture atsrc/pull.ts:1017, shared instructions atsrc/pull.ts:1032-1043— are a different shape: they land as an entry or a marked section inside a file, so their check is closer to the hook check that already exists.What this covers, and what it does not
buildChecksverifies these today:Phase 2 makes those run at the end of an interactive pull, which covers part of #574 and #342.
Phase 3 covers what a write-time gate cannot see. #597 has since generalized its own:
installedToolsFor(teamConfig, localConfig, field)now backs rules, skills and agents, anddocsis left out with a reason —DocsHandler.pullItemcopies into one fixed directory thatfse.copycreates, so there is no per-tool skip to gate. Three things survive that:SkillsHandler.pullItemskips each uninstalled tool silently. Withclaudeinstalled andcodexnot,Synced 12 skillsstill prints and Codex receives none — [bug] init 不做初始同步,且 pull 在工具目录不存在时静默跳过却报成功,以及codex必须在启动后进行一次会话才会执行teamai pull #574 one level down.SKILL.mdmissing, frontmatter unparseable,namenot matching the directory ([bug] 换行符不一致导致skill有两份名字跟描述,正则只匹配LF #372's class). The write succeeded, so no write-time gate has anything to report.envis also ungated: it counts its source variables atsrc/pull.ts:764. Hooks and mcp never reach this loop at all —resourceTypesis['skills', 'rules', 'docs', 'env', 'agents'](src/pull.ts:667) and both are reconciled separately.Alternatives considered
A
teamai verifycommand. Rejected, because it isdoctorunder a second name, and the repo's rule is to extend an existing command first.Post-condition assertions written inline in each writer. Rejected, because the same assertion then lives in several places and each copy drifts on its own.
For phase 3, having
pullwrite an installed manifest thatdoctorreads instead of resolving destinations from state. Cheaper to compute, but it goes stale exactly when it matters: the manifest still says a skill was installed after someone deletes the directory, which is the case the check exists to catch.Folding this into #585. #585 covers
init --agentseeding and the specific miscount when a tool directory is missing, and #597 implements it. The two do not overlap, and #597 should land first.Additional context
One decision before I open a PR, and it only affects phase 3. Extracting
resolveDesiredSkillsout ofpullForScopetouches the pull path, which is the hottest code in the repo right now (#597 is open against it). The alternative is to land phases 1 and 2 first and keep phase 3 behind #597. I would extract it, because the check is the only part of this issue that addresses the bugs it opens with, but the sequencing is your call.Phase 1 is implemented in #599, against
c674ffe. It extracts the two functions above, adds--json, and touches no other command;src/pull.tsis untouched, so it does not collide with #597. Phases 2 and 3 are still open.Line numbers refer to
c674ffe.Related: #597, #585, #574, #569, #525, #342, #335, #331, #508, #436.