What happens
In a monorepo, deploy:skills is generated only when a package has a skills/ directory:
// packages/cli/src/lib/turbo-config.ts
hasSkills: discovery.packages.some(pkg => pkg.hasSkills),
deploySkillsTasks is gated on that flag, so a workspace whose skills/ sits at the root gets no deploy:skills task at all. It disappears the moment packages: globs are added to pnpm-workspace.yaml and the root stops being the lone package — nothing else about the skills changes.
The inconsistency
Two lines apart in the same function, the root is counted for one capability and not the other:
hasE2e: discovery.root.hasVitestE2e || discovery.packages.some(pkg => pkg.hasVitestE2e),
hasSkills: discovery.packages.some(pkg => pkg.hasSkills),
discovery.root is a full PackageCapabilities, and capabilitiesFor already computes hasSkills for it. The information is present and unused.
Why it is silent
The turbo check in gtb verify reports only tasks that the generated expectation has and turbo.json lacks:
return Object.keys(expected.tasks)
.filter(name => !ignored.has(name))
.filter(name => tasks === undefined || !Object.hasOwn(tasks, name))
.map(name => `turbo.json: missing task '${name}'`);
When the flag is false the expectation contains no deploy:skills, so there is nothing to miss and verify passes. Hand-writing //#deploy:skills into turbo.json works until the next gtb sync, which rewrites the file without it — and verify still says nothing, because it does not report extra tasks either. The failure is silent in both directions: the task never appears, and its disappearance is never reported.
Suggested fix
Count the root, and emit the root's copy as a root task — the shape hasRootEslint and rootLintTasks already use:
hasSkills: discovery.root.hasSkills || discovery.packages.some(pkg => pkg.hasSkills),
hasRootSkills: discovery.isMonorepo && discovery.root.hasSkills,
with deploySkillsTasks emitting rootTaskKey(taskNames.deploySkills) for the root's copy, its inputs subtracting the package globs the way rootLintTasks does, and the build aggregate depending on it the way lint already depends on //#lint:eslint in turbo-aggregates.ts. gtb verify then covers it for free, since it becomes part of the generated expectation.
Context
Hit in gtbuchanan/skills, converting from a single-package repo to a monorepo. Everything that can move into a package is moving — the eval harness, the shared CLI doubles, the eval suites themselves. The skills cannot, so the root ends up carrying its configuration and skills/, and nothing else.
They cannot move because of how they are consumed. Both paths require real directories at <root>/skills/<name>/SKILL.md: the skills CLI fetching a repository tarball, and a dotfiles script that walks a local clone and symlinks each skill directory into ~/.agents/skills. The workspace's packages are private, so a skill inside one would reach neither consumer. Per-package skills serve a different model — a published package shipping skills to whoever installs it — and that is not what these are.
So a workspace can package everything it reasonably can and still have authored skills at the root. That is the shape hasSkills currently reads as "this workspace has no skills".
Related, lower priority
The same root-blindness costs a little elsewhere. After such a conversion the root still holds eslint.config.ts and vitest.config.ts; both are TypeScript, both are linted through the generated //#lint:eslint, and nothing typechecks them, because there is no root typecheck:ts counterpart gated on discovery.root.hasTypeScript. Much smaller than the skills gap, and worth splitting out if it complicates the fix.
What happens
In a monorepo,
deploy:skillsis generated only when a package has askills/directory:deploySkillsTasksis gated on that flag, so a workspace whoseskills/sits at the root gets nodeploy:skillstask at all. It disappears the momentpackages:globs are added topnpm-workspace.yamland the root stops being the lone package — nothing else about the skills changes.The inconsistency
Two lines apart in the same function, the root is counted for one capability and not the other:
discovery.rootis a fullPackageCapabilities, andcapabilitiesForalready computeshasSkillsfor it. The information is present and unused.Why it is silent
The turbo check in
gtb verifyreports only tasks that the generated expectation has andturbo.jsonlacks:When the flag is false the expectation contains no
deploy:skills, so there is nothing to miss and verify passes. Hand-writing//#deploy:skillsintoturbo.jsonworks until the nextgtb sync, which rewrites the file without it — and verify still says nothing, because it does not report extra tasks either. The failure is silent in both directions: the task never appears, and its disappearance is never reported.Suggested fix
Count the root, and emit the root's copy as a root task — the shape
hasRootEslintandrootLintTasksalready use:with
deploySkillsTasksemittingrootTaskKey(taskNames.deploySkills)for the root's copy, its inputs subtracting the package globs the wayrootLintTasksdoes, and thebuildaggregate depending on it the waylintalready depends on//#lint:eslintinturbo-aggregates.ts.gtb verifythen covers it for free, since it becomes part of the generated expectation.Context
Hit in
gtbuchanan/skills, converting from a single-package repo to a monorepo. Everything that can move into a package is moving — the eval harness, the shared CLI doubles, the eval suites themselves. The skills cannot, so the root ends up carrying its configuration andskills/, and nothing else.They cannot move because of how they are consumed. Both paths require real directories at
<root>/skills/<name>/SKILL.md: theskillsCLI fetching a repository tarball, and a dotfiles script that walks a local clone and symlinks each skill directory into~/.agents/skills. The workspace's packages are private, so a skill inside one would reach neither consumer. Per-package skills serve a different model — a published package shipping skills to whoever installs it — and that is not what these are.So a workspace can package everything it reasonably can and still have authored skills at the root. That is the shape
hasSkillscurrently reads as "this workspace has no skills".Related, lower priority
The same root-blindness costs a little elsewhere. After such a conversion the root still holds
eslint.config.tsandvitest.config.ts; both are TypeScript, both are linted through the generated//#lint:eslint, and nothing typechecks them, because there is no roottypecheck:tscounterpart gated ondiscovery.root.hasTypeScript. Much smaller than the skills gap, and worth splitting out if it complicates the fix.