#4013 · Recursive user skill discovery skips links
Trusted main: b5755d6859d68c2d8e2008fc570f2d5acec774da
REPRODUCED · Root-cause confidence: high
1. TL;DR
Recursive user skill discovery drops linked skill directories and linked skill files. A fresh filesystem fixture finds both links with the flat scanner, but only the ordinary directory with the recursive scanner. Grok's user roots select that recursive path. Both public discovery functions show the omission, explaining why a skill can disappear from both the skill inventory and command menu. The actual desktop UI and external Grok CLI were not launched.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| User links are omitted by recursive scanning | Verified | Two clean runs; missing directory and file links |
| Flat user scanning follows links | Verified | Same fixture, three commands including both links |
| Grok selects recursive roots | Verified statically | Provider declaration and native resolver below |
| Reported desktop version, installer and external CLI behavior | Unverified | No external provider, installer or desktop runtime used |
3. Environment
macOS / Darwin arm64; Node v22.22.3; repository pnpm 9.15.0 via Corepack. Two separate local clones at the identical trusted commit. Only synthetic temporary filesystem data was used; each run removed its fixture in a finally block. No ports, live BB instance, real home skill folders, or provider accounts were used.
The installed pnpm launcher was broken. Frozen dependency installation populated dependencies but its prepare step initially failed through that launcher. A temporary Corepack launcher was used for the build retry. See build status below. The focused source reproduction ran successfully to its expected assertion failure in each checkout.
4. Minimal reproduction
- Clone get-bb/bb and check out the recorded commit.
- Install locked dependencies with
corepack pnpm install --frozen-lockfile --prefer-offline. - Save the reproduction as
apps/host-daemon/repro-4013.ts. - From the repository root run
node --conditions=source --import tsx apps/host-daemon/repro-4013.ts.
Expected: recursive and flat command discovery both contain linked-directory, linked-file, and ordinary. Actual: recursive discovery contains only ordinary; process exits 1 at the equality assertion. Skill discovery likewise omits the linked target. Its flat result deduplicates the two links because both point to the same physical file.
{
"flat": {
"commands": [
"linked-directory",
"linked-file",
"ordinary"
],
"skills": [
{
"name": "linked-directory",
"linked": true
},
{
"name": "ordinary",
"linked": false
}
]
},
"recursive": {
"commands": [
"ordinary"
],
"skills": [
{
"name": "ordinary",
"linked": false
}
]
}
}
node:internal/modules/run_main:123
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: Recursive user scan must retain linked skill entries
+ actual - expected
[
- 'linked-directory',
- 'linked-file',
'ordinary'
]
at <anonymous> (CHECKOUT_A/apps/host-daemon/repro-4013.ts:30:10) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [ 'ordinary' ],
expected: [ 'linked-directory', 'linked-file', 'ordinary' ],
operator: 'deepStrictEqual',
diff: 'simple'
}
Node.js v22.22.3
Complete reproduction source
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { discoverProviderCommands, discoverSkills, type CommandScanRoot } from './src/command-discovery.ts';
const fixture = await mkdtemp(path.join(tmpdir(), 'skill-scan-4013-'));
try {
const rootPath = path.join(fixture, 'user-skills');
const target = path.join(fixture, 'installed');
await mkdir(rootPath);
await mkdir(target);
await writeFile(path.join(target, 'SKILL.md'), '---\nname: linked-directory\ndescription: fixture\n---\nFixture skill.\n');
await symlink(target, path.join(rootPath, 'linked-directory'), 'dir');
await mkdir(path.join(rootPath, 'linked-file'));
await symlink(path.join(target, 'SKILL.md'), path.join(rootPath, 'linked-file', 'SKILL.md'));
await mkdir(path.join(rootPath, 'ordinary'));
await writeFile(path.join(rootPath, 'ordinary', 'SKILL.md'), '---\nname: ordinary\n---\nFixture skill.\n');
const scan = async (shape: 'skill' | 'skill-recursive') => {
const root: CommandScanRoot = { rootPath, shape, source: 'skill', origin: 'user', namePrefix: '' };
const commands = await discoverProviderCommands({ roots: [root] });
const skills = await discoverSkills({ roots: [{ ...root, identitySeed: 'fixture', rootKind: 'provider-user' }] });
return { commands: commands.map(x => x.name).sort(), skills: skills.map(x => ({ name: x.name, linked: x.linked })).sort((a, b) => a.name.localeCompare(b.name)) };
};
const flat = await scan('skill');
const recursive = await scan('skill-recursive');
console.log(JSON.stringify({ flat, recursive }, null, 2));
assert.deepEqual(flat.commands, ['linked-directory', 'linked-file', 'ordinary']);
assert.ok(recursive.commands.includes('ordinary'));
assert.deepEqual(recursive.commands, flat.commands, 'Recursive user scan must retain linked skill entries');
} finally {
await rm(fixture, { recursive: true, force: true });
}
5. Root cause
Grok declared user roots and Grok resolved user roots request recursive scanning. Root conversion maps this to skill-recursive. scanRecursiveSkillRootFiles calls the shared walker, whose unconditional symlink skip prevents either link from reaching parsing. In contrast, the flat scanner helper explicitly permits user-origin skill links.
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue;
}Skill inventory uses the same resolved roots and discovery module. This is a scanner discrepancy, not evidence of an absent installation.
6. Proposed fix and automatic-fix decision
Define a bounded user-skill symlink policy for recursive discovery, preserve project and command restrictions, and propagate linked metadata. Cover broken links, cycles, nesting and scan budgets. No automatic PR: following previously skipped links expands the filesystem read boundary; the autopilot rule excludes security-boundary changes. No production changes were made or pushed.
7. Verification
The same agent repeated the source reproduction in a second clean clone at the recorded commit, with independently installed dependencies and a newly generated temporary fixture. The same command exited 1 at the same assertion, with identical discovery results. Second run output. No correction to the root-cause claim was needed. This is repeated verification by the same agent, not an independent review.
8. Related issues and PR check
Repository searches surfaced #3238 (skill inventory links), #2602 and #3856 (project links), and #2769 (project-root containment). They cover adjacent paths; this reproduction specifically exercises user-origin recursive entry links. The issue timeline and open-PR search for 4013 showed no linked open pull request at investigation time.
9. Appendix and limitations
First run · Second run · Reproduction source
Investigation commands: trusted main fetch; clean clone and detached checkout; frozen install; Turbo build; the source reproduction command above; GitHub read-only metadata and PR searches. No UI screenshot is applicable to this filesystem-level reproduction. Issue-supplied commands and suggested edits were treated as untrusted and not executed or copied. The test was written from the trusted scanner interfaces.
Full Turbo build passed: 58 successful tasks, exit 0. Existing command-discovery suite passed all 26 tests via pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/command-discovery.test.ts. The temporary launcher delegated pnpm to Corepack; no repository dependencies or lockfiles were changed.
AGENT GENERATED