diff --git a/test/helpers/skill-census.ts b/test/helpers/skill-census.ts
new file mode 100644
index 000000000..378201041
--- /dev/null
+++ b/test/helpers/skill-census.ts
@@ -0,0 +1,96 @@
+/**
+ * Shared skill census — the ONE place that counts skills (C11).
+ *
+ * Three consumers (hermetic seeding, context-bill ground truth, catalog-budget
+ * test) each need a DIFFERENT count of "the skills", and hand-rolled walks
+ * encode the wrong one somewhere. The counts diverge because of two facts:
+ *
+ * 1. `connect-chrome/` is a directory SYMLINK to `open-gstack-browser/`.
+ * `Dirent.isDirectory()` is false for it (scripts/discover-skills.ts
+ * skips it) while setup's trailing-slash shell glob follows it.
+ * 2. The root `SKILL.md` is a router, registered by `setup` under the
+ * alias name `_gstack-command`, not as an authored skill.
+ *
+ * So: use `physicalSkillFiles` when you mean "every SKILL.md a filesystem
+ * walk can reach" (context-bill's walker), `authoredSkills` when you mean
+ * "distinct skills a human maintains" (catalog budget), and
+ * `registryEntries` when you mean "what a host discovers after ./setup"
+ * (hermetic seeding must mirror this exactly).
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+
+const SKIP = new Set(['node_modules', '.git', 'dist']);
+
+export interface SkillCensus {
+ /** Every reachable `
/SKILL.md` (symlinked dirs INCLUDED) plus the
+ * root `SKILL.md` router. Relative paths from root. */
+ physicalSkillFiles: string[];
+ /** Distinct authored skills: symlink-deduped by realpath, root router
+ * EXCLUDED. Each entry is the canonical directory name. */
+ authoredSkills: string[];
+ /** What `setup` registers into `~/.claude/skills/` with prefixing off:
+ * one entry per unique frontmatter `name:` (falling back to dir name),
+ * plus the `_gstack-command` root alias when the root router exists.
+ * Symlinked dirs collapse here because they share a frontmatter name. */
+ registryEntries: string[];
+}
+
+/** First `name:` from SKILL.md frontmatter, mirroring `setup`'s
+ * `grep -m1 '^name:'` (whitespace stripped). Empty string if absent. */
+export function frontmatterName(skillMdPath: string): string {
+ let body: string;
+ try {
+ body = fs.readFileSync(skillMdPath, 'utf-8');
+ } catch {
+ return '';
+ }
+ const m = body.match(/^name:\s*(.+)$/m);
+ return m ? m[1].replace(/\s+/g, '') : '';
+}
+
+export function skillCensus(root: string): SkillCensus {
+ const physicalSkillFiles: string[] = [];
+ const authoredByRealpath = new Map();
+ const registrySet = new Set();
+
+ if (fs.existsSync(path.join(root, 'SKILL.md'))) {
+ physicalSkillFiles.push('SKILL.md');
+ registrySet.add('_gstack-command');
+ }
+
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+ if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
+ const dirPath = path.join(root, entry.name);
+ // Follow directory symlinks like setup's shell glob does; Dirent alone
+ // reports symlinks as non-directories.
+ let isDir = entry.isDirectory();
+ if (!isDir && entry.isSymbolicLink()) {
+ try {
+ isDir = fs.statSync(dirPath).isDirectory();
+ } catch {
+ continue; // dangling symlink
+ }
+ }
+ if (!isDir) continue;
+
+ const skillMd = path.join(dirPath, 'SKILL.md');
+ if (!fs.existsSync(skillMd)) continue;
+
+ physicalSkillFiles.push(`${entry.name}/SKILL.md`);
+
+ const real = fs.realpathSync(dirPath);
+ if (!authoredByRealpath.has(real)) {
+ authoredByRealpath.set(real, path.basename(real));
+ }
+
+ registrySet.add(frontmatterName(skillMd) || entry.name);
+ }
+
+ return {
+ physicalSkillFiles: physicalSkillFiles.sort(),
+ authoredSkills: [...authoredByRealpath.values()].sort(),
+ registryEntries: [...registrySet].sort(),
+ };
+}
diff --git a/test/skill-census.test.ts b/test/skill-census.test.ts
new file mode 100644
index 000000000..b9bb21578
--- /dev/null
+++ b/test/skill-census.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Pins the three-count contract of test/helpers/skill-census.ts (C11).
+ *
+ * No hardcoded totals here — the catalog-budget test owns the ratchet.
+ * This file pins the STRUCTURAL relationships that make the three counts
+ * mean different things, using the live repo as the fixture.
+ */
+
+import { describe, expect, it } from 'bun:test';
+import * as fs from 'fs';
+import * as path from 'path';
+import { frontmatterName, skillCensus } from './helpers/skill-census';
+
+const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
+const census = skillCensus(ROOT);
+
+describe('skillCensus', () => {
+ it('physicalSkillFiles includes the root router and the symlinked dir', () => {
+ expect(census.physicalSkillFiles).toContain('SKILL.md');
+ expect(census.physicalSkillFiles).toContain('connect-chrome/SKILL.md');
+ expect(census.physicalSkillFiles).toContain('open-gstack-browser/SKILL.md');
+ });
+
+ it('authoredSkills dedupes the connect-chrome symlink and excludes the root router', () => {
+ expect(census.authoredSkills).toContain('open-gstack-browser');
+ expect(census.authoredSkills).not.toContain('connect-chrome');
+ // Root router is not an authored skill; its dir entry would be '' anyway.
+ for (const name of census.authoredSkills) expect(name.length).toBeGreaterThan(0);
+ });
+
+ it('registryEntries carries the root alias and collapses shared frontmatter names', () => {
+ expect(census.registryEntries).toContain('_gstack-command');
+ expect(
+ census.registryEntries.filter((n) => n === 'open-gstack-browser'),
+ ).toHaveLength(1);
+ });
+
+ it('count relationships hold: physical = authored + root + symlink dups', () => {
+ const symlinkDups = census.physicalSkillFiles.length - 1 - census.authoredSkills.length;
+ expect(symlinkDups).toBeGreaterThanOrEqual(1); // connect-chrome today
+ // Registry = unique frontmatter names + root alias. It can only collapse
+ // entries relative to physical, never invent them.
+ expect(census.registryEntries.length).toBeLessThanOrEqual(census.physicalSkillFiles.length);
+ expect(census.registryEntries.length).toBeGreaterThan(census.authoredSkills.length - 1);
+ });
+
+ it('frontmatterName mirrors setup: first ^name: line, whitespace stripped', () => {
+ const qa = frontmatterName(path.join(ROOT, 'qa', 'SKILL.md'));
+ expect(qa).toBe('qa');
+ const alias = frontmatterName(path.join(ROOT, 'connect-chrome', 'SKILL.md'));
+ expect(alias).toBe('open-gstack-browser');
+ expect(frontmatterName(path.join(ROOT, 'no-such-dir', 'SKILL.md'))).toBe('');
+ });
+
+ it('every registry entry a host would see resolves back to a physical SKILL.md', () => {
+ const names = new Set(
+ census.physicalSkillFiles
+ .filter((p) => p !== 'SKILL.md')
+ .map((p) => frontmatterName(path.join(ROOT, p)) || path.dirname(p)),
+ );
+ for (const entry of census.registryEntries) {
+ if (entry === '_gstack-command') {
+ expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(true);
+ } else {
+ expect(names.has(entry)).toBe(true);
+ }
+ }
+ });
+});