@lythos/skill-deck 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @lythos/skill-deck
2
2
 
3
+ ![Coverage](https://img.shields.io/badge/coverage-82%25-brightgreen)
4
+
3
5
  > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
4
6
 
5
7
  ## For AI Agents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -0,0 +1,117 @@
1
+ # Deck Package Coverage Gaps
2
+
3
+ > Generated from `bun test --coverage packages/lythoskill-deck/src/*.test.ts`
4
+ > Lines: 82.35% | Funcs: 73.81% | Date: 2026-05-04
5
+
6
+ ## 策略声明
7
+
8
+ 以下缺口是**故意保留**的。覆盖这些分支的意图已经看到,处理方式不是"为覆盖率改代码",而是:
9
+
10
+ 1. **提取行为后单测** — 如果某段逻辑值得验证,把它提取为纯函数,单独写 test
11
+ 2. **直接说明不覆盖** — 如果分支是纯边界防护(`catch {}`、交互式确认、外部依赖),写明原因即可
12
+ 3. **拒绝为覆盖率改写代码** — 不为了触发分支而拆散可读性高的连续逻辑
13
+
14
+ 具体不追的类别:
15
+ - `catch {}` silent ignore(fs 边界错误,测了也是 mock,无业务意义)
16
+ - 需要 >1GB 文件才能触发的分支(formatSize GB)
17
+ - 依赖外部 network 的分支(git clone 真实失败、skills.sh backend)
18
+ - 交互式 CLI 分支(prune confirm())
19
+ - 仅为凑分支覆盖率而进行的微重构(降低可读性)
20
+
21
+ ---
22
+
23
+ ## add.ts — 62.37% lines
24
+
25
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
26
+ |----|------|-----------|--------|
27
+ | 44, 46–50 | `deckPath` resolve + `findDeckToml` 失败 | CLI 路径解析,可用 spawnSync 追但成本高 | ⬜ 低优先级 |
28
+ | 55–59 | cold pool mkdir | 正常路径已覆盖,异常分支为 `catch {}` | ❌ 不追 |
29
+ | 62–71 | `targetDir` exists 错误 | 需构造 cold pool 冲突,有意义但成本中等 | ⬜ 低优先级 |
30
+ | 87–89 | `skills.sh` / `vercel` backend | 依赖外部 `npx skills` 命令,无 mock 价值 | ❌ 不追 |
31
+ | 106–108 | auto-migrate string-array → dict | 有意义,但需 mock `git clone` 全流程 | ⬜ 低优先级 |
32
+ | 123–124, 127–131 | alias resolve / `nameParts.length` | shorthand path 分支,有意义 | ⬜ 低优先级 |
33
+ | 136–141 | invalid type rejection | **已覆盖** (C9) | ✅ |
34
+ | 143–154 | git clone 成功后的 `findSkillDir` | 正常路径已覆盖 | ✅ |
35
+ | 163–164 | `--as` alias | 正常路径已覆盖 | ✅ |
36
+ | 172–174, 182–183 | error handling (catch) | `catch {}` 无意义 | ❌ 不追 |
37
+ | 206, 210 | skills.sh 路径 | 外部依赖 | ❌ 不追 |
38
+ | 221–227 | `writeFileSync` / `linkDeck` | 正常路径已覆盖 | ✅ |
39
+ | 235–240 | catch final error | `catch {}` | ❌ 不追 |
40
+
41
+ ## link.ts — 60.71% lines
42
+
43
+ link.ts 是 deck 包最大文件(~540 行), uncovered 行最多。主要缺口:
44
+
45
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
46
+ |----|------|-----------|--------|
47
+ | 103–111 | `parseSkillFrontmatter` 异常(SKILL.md 不存在/解析失败) | 有意义,需构造无 SKILL.md 的 fixture | ⬜ 低优先级 |
48
+ | 116, 119–123 | `calculateDirSize` 异常 / `readdirSync` 失败 | `catch {}` | ❌ 不追 |
49
+ | 131–142 | backup 逻辑(nonSymlink > 100MB tar) | 需构造大文件或 mock `tar`,成本高 | ❌ 不追 |
50
+ | 146–147 | backup 路径构造 | 同上 | ❌ 不追 |
51
+ | 162–171 | transient 过期警告(`days <= 14` / `days <= 0`) | 有意义,需调系统时间或 mock Date | ⬜ 低优先级 |
52
+ | 180 | managed_dirs 重叠检测 | 有意义,需构造父子目录冲突 | ⬜ 低优先级 |
53
+ | 206–211, 218–225 | `readdirSync` / `symlinkSync` 异常 | `catch {}` | ❌ 不追 |
54
+ | 237–239, 244–245 | 各种路径 resolve | 边界情况 | ❌ 不追 |
55
+ | 247–261 | `findSource` 多种匹配策略 | 部分已覆盖,剩余为罕见 fallback | ⬜ 低优先级 |
56
+ | 265–278 | budget exceeded 详细报告 | 正常路径已覆盖 | ✅ |
57
+ | 285–287, 298–300, 307–310 | 各种 `existsSync` / `lstatSync` 边界 | `catch {}` | ❌ 不追 |
58
+ | 326 | `content_hash` 计算 | 正常路径已覆盖 | ✅ |
59
+ | 334–372 | lock 文件 schema 校验 | 正常路径已覆盖 | ✅ |
60
+ | 407–408 | `readlinkSync` 异常 | `catch {}` | ❌ 不追 |
61
+ | 454–461 | `parseDeck` fallback | 正常路径已覆盖 | ✅ |
62
+ | 479–480, 488–490, 492–498 | `readdirSync` / `rmSync` 边界 | `catch {}` | ❌ 不追 |
63
+ | 524–525, 537 | 各种 cleanup / 路径 | 边界 | ❌ 不追 |
64
+
65
+ ## parse-deck.ts — 92.45% lines
66
+
67
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
68
+ |----|------|-----------|--------|
69
+ | 47–50 | legacy string-array `continue`(空 name) | 有意义但 trivial | ⬜ 低优先级 |
70
+
71
+ ## prune.ts — 80.43% lines
72
+
73
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
74
+ |----|------|-----------|--------|
75
+ | 25–27 | `formatSize` GB 分支 | 需 >1GB 文件 | ❌ 不追 |
76
+ | 74 | `calculateDirSize` catch | `catch {}` | ❌ 不追 |
77
+ | 81–87 | `scanColdPoolRepos` catch | `catch {}` | ❌ 不追 |
78
+ | 98–100 | empty cold pool | **已覆盖** (C17) | ✅ |
79
+ | 118–122 | all-referenced no-op | **已覆盖** (C16) | ✅ |
80
+ | 129–130 | failed > 0 exit(1) | 需构造删除失败(权限不足) | ❌ 不追 |
81
+ | 167 | formatSize MB 分支 | 正常路径已覆盖 | ✅ |
82
+ | 172–173 | 删除失败 catch | `catch {}` | ❌ 不追 |
83
+ | 185–186 | formatSize 边界 | 正常路径已覆盖 | ✅ |
84
+
85
+ ## refresh.ts — 88.97% lines
86
+
87
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
88
+ |----|------|-----------|--------|
89
+ | 43, 49 | deck 不存在 / `findDeckToml` 失败 | CLI 路径解析 | ⬜ 低优先级 |
90
+ | 70–71 | localhost skip | **已覆盖** (C15) | ✅ |
91
+ | 83–85 | not-git | **已覆盖** (C16) | ✅ |
92
+ | 101 | `result.error`(findSource 失败) | 有意义 | ⬜ 低优先级 |
93
+ | 109–110 | `gitRoot` null 后的 skip | **已覆盖** (C16) | ✅ |
94
+ | 125 | `pullResult.status === "failed"` | 需 mock `git pull` 返回错误 | ⬜ 低优先级 |
95
+ | 148–150 | `updated` / `upToDate` / `failed` 计数 | **已覆盖** (C12-C14) | ✅ |
96
+ | 199 | `failed > 0` exit(1) | 需构造 failed 状态 | ⬜ 低优先级 |
97
+
98
+ ## remove.ts — 91.53% lines
99
+
100
+ | 行 | 代码 | 未覆盖原因 | 是否追 |
101
+ |----|------|-----------|--------|
102
+ | 22–24 | deck 不存在 error | CLI 路径解析 | ⬜ 低优先级 |
103
+ | 44 | legacy string-array filter | **已覆盖** (C11.b) | ✅ |
104
+
105
+ ## schema.ts — 100.00%
106
+
107
+ 全部覆盖 ✅
108
+
109
+ ---
110
+
111
+ ## 如果未来要推到 90%+
112
+
113
+ 优先级排序:
114
+ 1. **link.ts `parseSkillFrontmatter` 异常** — 有业务意义(broken SKILL.md),中等成本
115
+ 2. **link.ts transient 过期警告** — 有业务意义,需 mock Date
116
+ 3. **add.ts auto-migrate** — 有业务意义,需 mock git clone
117
+ 4. **link.ts managed_dirs 重叠** — 有业务意义,需构造目录冲突
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * add.test.ts — unit tests for add.ts
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/add.test.ts
6
+ */
7
+
8
+ import { describe, it, expect, afterEach, spyOn, mock } from 'bun:test'
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, cpSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import * as childProcess from 'node:child_process'
13
+
14
+ // Control homedir() return value for tests that need default cold_pool under tmpdir
15
+ let mockHomeDir = '/tmp'
16
+ mock.module('node:os', () => ({
17
+ homedir: () => mockHomeDir,
18
+ }))
19
+
20
+ let cleanup: string[] = []
21
+ let execSpy: ReturnType<typeof spyOn> | null = null
22
+
23
+ afterEach(() => {
24
+ if (execSpy) {
25
+ execSpy.mockRestore()
26
+ execSpy = null
27
+ }
28
+ for (const dir of cleanup) {
29
+ rmSync(dir, { recursive: true, force: true })
30
+ }
31
+ cleanup = []
32
+ })
33
+
34
+ function makeTmp(): string {
35
+ const dir = mkdtempSync(join(tmpdir(), 'deck-add-'))
36
+ cleanup.push(dir)
37
+ return dir
38
+ }
39
+
40
+ function mockGitClone(fixturePath: string) {
41
+ const originalExec = childProcess.execFileSync
42
+ execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
43
+ if (cmd === 'git' && args[0] === 'clone') {
44
+ const dest = args[args.length - 1]
45
+ cpSync(fixturePath, dest, { recursive: true })
46
+ return Buffer.from('')
47
+ }
48
+ return originalExec(cmd, args, options)
49
+ }) as any)
50
+ }
51
+
52
+ describe('addSkill', () => {
53
+ it('C6: add to empty project creates deck.toml and cold pool', async () => {
54
+ const projectDir = makeTmp()
55
+ mockHomeDir = projectDir
56
+
57
+ const fixtureDir = makeTmp()
58
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: test-skill\n---\n')
59
+
60
+ mockGitClone(fixtureDir)
61
+
62
+ // Dynamic import so add.ts picks up the mocked homedir()
63
+ const { addSkill } = await import('./add.ts')
64
+
65
+ await addSkill('github.com/owner/repo', { workdir: projectDir })
66
+
67
+ const deckPath = join(projectDir, 'skill-deck.toml')
68
+ expect(existsSync(deckPath)).toBe(true)
69
+
70
+ const deckContent = readFileSync(deckPath, 'utf-8')
71
+ expect(deckContent).toContain('[tool.skills.repo]')
72
+ expect(deckContent).toContain('path = "github.com/owner/repo"')
73
+
74
+ const coldPoolDir = join(projectDir, '.agents', 'skill-repos', 'github.com', 'owner', 'repo')
75
+ expect(existsSync(coldPoolDir)).toBe(true)
76
+ expect(existsSync(join(coldPoolDir, 'SKILL.md'))).toBe(true)
77
+ })
78
+
79
+ it('C7: add to existing deck appends entry', async () => {
80
+ const projectDir = makeTmp()
81
+ const coldPoolRel = 'cold-pool'
82
+ const coldPool = join(projectDir, coldPoolRel)
83
+ mkdirSync(coldPool, { recursive: true })
84
+
85
+ // Pre-place skill-a in cold pool
86
+ const skillADir = join(coldPool, 'github.com', 'owner', 'repo-a')
87
+ mkdirSync(skillADir, { recursive: true })
88
+ writeFileSync(join(skillADir, 'SKILL.md'), '---\nname: skill-a\n---\n')
89
+
90
+ // Create deck.toml with skill-a
91
+ const deckContent = `[deck]\nmax_cards = 10\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo-a"\n`
92
+ const deckPath = join(projectDir, 'skill-deck.toml')
93
+ writeFileSync(deckPath, deckContent)
94
+
95
+ // Fixture for skill-b
96
+ const fixtureDir = makeTmp()
97
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
98
+
99
+ mockGitClone(fixtureDir)
100
+
101
+ const { addSkill } = await import('./add.ts')
102
+ await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath })
103
+
104
+ const newContent = readFileSync(deckPath, 'utf-8')
105
+ expect(newContent).toContain('[tool.skills.skill-a]')
106
+ expect(newContent).toContain('path = "github.com/owner/repo-a"')
107
+ expect(newContent).toContain('[tool.skills.repo-b]')
108
+ expect(newContent).toContain('path = "github.com/owner/repo-b"')
109
+ })
110
+
111
+ it('C8: alias collision rejects', async () => {
112
+ const projectDir = makeTmp()
113
+ const coldPoolRel = 'cold-pool'
114
+ const coldPool = join(projectDir, coldPoolRel)
115
+ mkdirSync(coldPool, { recursive: true })
116
+
117
+ const deckContent = `[deck]\nmax_cards = 10\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.foo]\npath = "github.com/owner/repo-a"\n`
118
+ const deckPath = join(projectDir, 'skill-deck.toml')
119
+ writeFileSync(deckPath, deckContent)
120
+
121
+ const fixtureDir = makeTmp()
122
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill-b\n---\n')
123
+
124
+ mockGitClone(fixtureDir)
125
+
126
+ const errors: string[] = []
127
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
128
+ errors.push(String(msg))
129
+ })
130
+
131
+ const originalExit = process.exit
132
+ let exitCode: number | undefined
133
+ process.exit = ((code?: number) => {
134
+ exitCode = code ?? 0
135
+ throw new Error(`EXIT:${code}`)
136
+ }) as typeof process.exit
137
+
138
+ try {
139
+ const { addSkill } = await import('./add.ts')
140
+ await addSkill('github.com/owner/repo-b', { workdir: projectDir, deck: deckPath, as: 'foo' })
141
+ expect(false).toBe(true) // should not reach here
142
+ } catch (err: any) {
143
+ expect(exitCode).toBe(1)
144
+ expect(errors.some(e => e.includes('Alias "foo" already exists'))).toBe(true)
145
+ } finally {
146
+ process.exit = originalExit
147
+ errorSpy.mockRestore()
148
+ }
149
+ })
150
+
151
+ it('C9: invalid skill type rejects', async () => {
152
+ const projectDir = makeTmp()
153
+ const coldPoolRel = 'cold-pool'
154
+ const coldPool = join(projectDir, coldPoolRel)
155
+ mkdirSync(coldPool, { recursive: true })
156
+
157
+ const fixtureDir = makeTmp()
158
+ writeFileSync(join(fixtureDir, 'SKILL.md'), '---\nname: skill\n---\n')
159
+
160
+ const originalExec = childProcess.execFileSync
161
+ const execSpy = spyOn(childProcess, 'execFileSync').mockImplementation(((cmd: string, args: string[], options?: any) => {
162
+ if (cmd === 'git' && args[0] === 'clone') {
163
+ const dest = args[args.length - 1]
164
+ cpSync(fixtureDir, dest, { recursive: true })
165
+ return Buffer.from('')
166
+ }
167
+ return originalExec(cmd, args, options)
168
+ }) as any)
169
+
170
+ const errors: string[] = []
171
+ const errorSpy = spyOn(console, 'error').mockImplementation((msg: string) => {
172
+ errors.push(String(msg))
173
+ })
174
+
175
+ const originalExit = process.exit
176
+ let exitCode: number | undefined
177
+ process.exit = ((code?: number) => {
178
+ exitCode = code ?? 0
179
+ throw new Error(`EXIT:${code}`)
180
+ }) as typeof process.exit
181
+
182
+ try {
183
+ const { addSkill } = await import('./add.ts')
184
+ await addSkill('github.com/owner/repo', { deck: join(projectDir, 'skill-deck.toml'), workdir: projectDir, type: 'invalid' })
185
+ expect(false).toBe(true)
186
+ } catch (err: any) {
187
+ expect(exitCode).toBe(1)
188
+ expect(errors.some(e => e.includes('Invalid type'))).toBe(true)
189
+ } finally {
190
+ process.exit = originalExit
191
+ errorSpy.mockRestore()
192
+ execSpy.mockRestore()
193
+ }
194
+ })
195
+ })
package/src/add.ts CHANGED
@@ -252,7 +252,7 @@ export async function addSkill(locator: string, options: { via?: string; deck?:
252
252
 
253
253
  console.log('🔗 Running deck link...')
254
254
  const { linkDeck } = await import('./link.js')
255
- linkDeck(deckPath === join(workdir, 'skill-deck.toml') ? undefined : deckPath, workdir)
255
+ linkDeck(deckPath, workdir)
256
256
 
257
257
  } catch (err) {
258
258
  console.error(`❌ Failed to add skill: ${err}`)
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * link.test.ts — unit tests for link.ts pure functions
4
+ *
5
+ * Run: bun test packages/lythoskill-deck/src/link.test.ts
6
+ *
7
+ * Co-located with src per ADR-20260503180000000 (curator-mind framework selection)
8
+ * and the existing precedent in packages/lythoskill-curator/src/cli.test.ts.
9
+ *
10
+ * Tests use real fs in mkdtempSync sandboxes — no mocks. Each it() owns its own
11
+ * tmpdir and afterEach cleans up to avoid cross-test state leakage.
12
+ */
13
+
14
+ import { describe, it, expect, afterEach } from 'bun:test'
15
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, lstatSync, readlinkSync, readdirSync, existsSync, symlinkSync } from 'node:fs'
16
+ import { join, resolve } from 'node:path'
17
+ import { tmpdir, homedir } from 'node:os'
18
+ import { createHash } from 'node:crypto'
19
+ import { spawnSync } from 'node:child_process'
20
+
21
+ import { findDeckToml, expandHome, findSource, linkDeck } from './link.ts'
22
+
23
+ let cleanup: string[] = []
24
+
25
+ afterEach(() => {
26
+ for (const dir of cleanup) {
27
+ rmSync(dir, { recursive: true, force: true })
28
+ }
29
+ cleanup = []
30
+ })
31
+
32
+ function makeTmp(): string {
33
+ const dir = mkdtempSync(join(tmpdir(), 'deck-link-'))
34
+ cleanup.push(dir)
35
+ return dir
36
+ }
37
+
38
+ function placeSkill(coldPool: string, relPath: string): string {
39
+ const skillDir = join(coldPool, relPath)
40
+ mkdirSync(skillDir, { recursive: true })
41
+ writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: fixture\n---\n')
42
+ return skillDir
43
+ }
44
+
45
+ describe('findDeckToml', () => {
46
+ it('returns absolute path when skill-deck.toml is present', () => {
47
+ const dir = makeTmp()
48
+ const tomlPath = join(dir, 'skill-deck.toml')
49
+ writeFileSync(tomlPath, '[[skills]]\n')
50
+ expect(findDeckToml(dir)).toBe(tomlPath)
51
+ })
52
+
53
+ it('returns null when skill-deck.toml is absent', () => {
54
+ const dir = makeTmp()
55
+ expect(findDeckToml(dir)).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('expandHome', () => {
60
+ it('expands ~/<path> to homedir-anchored absolute path', () => {
61
+ expect(expandHome('~/foo/bar', '/anywhere')).toBe(join(homedir(), 'foo/bar'))
62
+ })
63
+
64
+ it('resolves relative paths against base', () => {
65
+ expect(expandHome('foo/bar', '/some/base')).toBe(resolve('/some/base', 'foo/bar'))
66
+ })
67
+ })
68
+
69
+ describe('findSource', () => {
70
+ it('resolves fully-qualified host.tld/owner/repo/skill via cold-pool skills/ subdir', () => {
71
+ const coldPool = makeTmp()
72
+ const projectDir = makeTmp()
73
+ const expected = placeSkill(coldPool, 'github.com/lythos-labs/lythoskill/skills/lythoskill-deck')
74
+ const result = findSource('github.com/lythos-labs/lythoskill/lythoskill-deck', coldPool, projectDir)
75
+ expect(result.path).toBe(expected)
76
+ })
77
+
78
+ it('resolves a direct cold-pool hit when name matches a top-level dir with SKILL.md', () => {
79
+ const coldPool = makeTmp()
80
+ const projectDir = makeTmp()
81
+ const expected = placeSkill(coldPool, 'my-skill')
82
+ const result = findSource('my-skill', coldPool, projectDir)
83
+ expect(result.path).toBe(expected)
84
+ })
85
+
86
+ it('resolves a monorepo layout (repo/skill → coldPool/repo/skills/skill)', () => {
87
+ const coldPool = makeTmp()
88
+ const projectDir = makeTmp()
89
+ const expected = placeSkill(coldPool, 'mono-repo/skills/inner-skill')
90
+ const result = findSource('mono-repo/inner-skill', coldPool, projectDir)
91
+ expect(result.path).toBe(expected)
92
+ })
93
+
94
+ it('falls back to projectDir/skills/<name> for project-local skills', () => {
95
+ const coldPool = makeTmp()
96
+ const projectDir = makeTmp()
97
+ const expected = placeSkill(projectDir, 'skills/local-skill')
98
+ const result = findSource('local-skill', coldPool, projectDir)
99
+ expect(result.path).toBe(expected)
100
+ })
101
+
102
+ it('returns {path: null} when no strategy resolves the name', () => {
103
+ const coldPool = makeTmp()
104
+ const projectDir = makeTmp()
105
+ const result = findSource('nonexistent-skill', coldPool, projectDir)
106
+ expect(result.path).toBeNull()
107
+ expect(result.error).toBeUndefined()
108
+ })
109
+ })
110
+
111
+ describe('linkDeck reconciler', () => {
112
+ it('B1.tracer: empty deck creates working set and lock with zero skills', () => {
113
+ const projectDir = makeTmp()
114
+ const coldPoolRel = 'cold-pool'
115
+ const coldPool = join(projectDir, coldPoolRel)
116
+ mkdirSync(coldPool, { recursive: true })
117
+
118
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n`
119
+ const deckPath = join(projectDir, 'skill-deck.toml')
120
+ writeFileSync(deckPath, deckContent)
121
+
122
+ linkDeck(deckPath, projectDir, true)
123
+
124
+ const workingSet = join(projectDir, '.claude', 'skills')
125
+ expect(existsSync(workingSet)).toBe(true)
126
+ expect(lstatSync(workingSet).isDirectory()).toBe(true)
127
+
128
+ const lockPath = join(projectDir, 'skill-deck.lock')
129
+ expect(existsSync(lockPath)).toBe(true)
130
+
131
+ const lock = JSON.parse(readFileSync(lockPath, 'utf-8'))
132
+ expect(lock.version).toBe('1.0.0')
133
+ expect(lock.skills).toEqual([])
134
+ expect(lock.constraints.total_cards).toBe(0)
135
+ expect(lock.constraints.max_cards).toBe(10)
136
+ expect(lock.constraints.within_budget).toBe(true)
137
+ expect(lock.working_set).toBe('.claude/skills')
138
+ expect(lock.cold_pool).toBe(coldPoolRel)
139
+
140
+ const expectedHash = createHash('sha256').update(deckContent).digest('hex')
141
+ expect(lock.deck_source.content_hash).toBe(expectedHash)
142
+ expect(lock.deck_source.path).toBe('skill-deck.toml')
143
+ })
144
+
145
+ it('B2: declared skill with existing cold pool creates correct symlink', () => {
146
+ const projectDir = makeTmp()
147
+ const coldPoolRel = 'cold-pool'
148
+ const coldPool = join(projectDir, coldPoolRel)
149
+
150
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
151
+ writeFileSync(
152
+ join(skillDir, 'SKILL.md'),
153
+ '---\nname: test-skill\ndeck_niche: testing\ndeck_managed_dirs: ["docs/"]\n---\n'
154
+ )
155
+
156
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
157
+ const deckPath = join(projectDir, 'skill-deck.toml')
158
+ writeFileSync(deckPath, deckContent)
159
+
160
+ linkDeck(deckPath, projectDir, true)
161
+
162
+ const workingSet = join(projectDir, '.claude', 'skills')
163
+ const symlinkPath = join(workingSet, 'my-alias')
164
+
165
+ expect(existsSync(symlinkPath)).toBe(true)
166
+ expect(lstatSync(symlinkPath).isSymbolicLink()).toBe(true)
167
+
168
+ const target = readlinkSync(symlinkPath)
169
+ expect(target).toBe(skillDir)
170
+
171
+ const lock = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
172
+ expect(lock.skills).toHaveLength(1)
173
+
174
+ const skill = lock.skills[0]
175
+ expect(skill.name).toBe('github.com/owner/repo/skill')
176
+ expect(skill.alias).toBe('my-alias')
177
+ expect(skill.type).toBe('tool')
178
+ expect(skill.source).toBe(join('github.com', 'owner', 'repo', 'skill'))
179
+ expect(skill.dest).toBe(join('.claude', 'skills', 'my-alias'))
180
+ expect(skill.content_hash).toMatch(/^[a-f0-9]{64}$/)
181
+ expect(skill.deck_niche).toBe('testing')
182
+ expect(skill.deck_managed_dirs).toEqual(['docs/'])
183
+ expect(skill.linked_at).toBeDefined()
184
+ })
185
+
186
+ it('B2.b: idempotent re-run preserves symlink state', async () => {
187
+ const projectDir = makeTmp()
188
+ const coldPoolRel = 'cold-pool'
189
+ const coldPool = join(projectDir, coldPoolRel)
190
+
191
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
192
+
193
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.my-alias]\npath = "github.com/owner/repo/skill"\n`
194
+ const deckPath = join(projectDir, 'skill-deck.toml')
195
+ writeFileSync(deckPath, deckContent)
196
+
197
+ linkDeck(deckPath, projectDir, true)
198
+
199
+ const lock1 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
200
+ const symlinkPath = join(projectDir, '.claude', 'skills', 'my-alias')
201
+ const target1 = readlinkSync(symlinkPath)
202
+
203
+ await new Promise(r => setTimeout(r, 50))
204
+
205
+ linkDeck(deckPath, projectDir, true)
206
+
207
+ const lock2 = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
208
+ const target2 = readlinkSync(symlinkPath)
209
+
210
+ const entries = readdirSync(join(projectDir, '.claude', 'skills'))
211
+ .filter(e => !e.startsWith('.') && !e.startsWith('_'))
212
+ expect(entries).toHaveLength(1)
213
+ expect(target2).toBe(target1)
214
+ expect(target2).toBe(skillDir)
215
+
216
+ expect(lock2.generated_at).not.toBe(lock1.generated_at)
217
+
218
+ expect(lock2.skills).toHaveLength(1)
219
+ expect(lock2.skills[0].alias).toBe(lock1.skills[0].alias)
220
+ expect(lock2.skills[0].source).toBe(lock1.skills[0].source)
221
+ expect(lock2.skills[0].dest).toBe(lock1.skills[0].dest)
222
+ })
223
+
224
+ it('B3: deny-by-default removes undeclared symlinks from working set', () => {
225
+ const projectDir = makeTmp()
226
+ const coldPoolRel = 'cold-pool'
227
+ const coldPool = join(projectDir, coldPoolRel)
228
+
229
+ const skillADir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
230
+ const skillBDir = placeSkill(coldPool, 'github.com/owner/repo/skill-b')
231
+
232
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n`
233
+ const deckPath = join(projectDir, 'skill-deck.toml')
234
+ writeFileSync(deckPath, deckContent)
235
+
236
+ // Pre-populate working set with both skill-a (declared) and skill-b (undeclared)
237
+ const workingSet = join(projectDir, '.claude', 'skills')
238
+ mkdirSync(workingSet, { recursive: true })
239
+ symlinkSync(skillADir, join(workingSet, 'skill-a'))
240
+ symlinkSync(skillBDir, join(workingSet, 'skill-b'))
241
+
242
+ linkDeck(deckPath, projectDir, true)
243
+
244
+ // skill-a should remain
245
+ expect(existsSync(join(workingSet, 'skill-a'))).toBe(true)
246
+ expect(lstatSync(join(workingSet, 'skill-a')).isSymbolicLink()).toBe(true)
247
+
248
+ // skill-b should be removed
249
+ expect(existsSync(join(workingSet, 'skill-b'))).toBe(false)
250
+
251
+ const lock = JSON.parse(readFileSync(join(projectDir, 'skill-deck.lock'), 'utf-8'))
252
+ expect(lock.skills).toHaveLength(1)
253
+ expect(lock.skills[0].alias).toBe('skill-a')
254
+ })
255
+
256
+ it('B4: same-type alias collision exits with fatal error', () => {
257
+ const projectDir = makeTmp()
258
+ const coldPoolRel = 'cold-pool'
259
+ const coldPool = join(projectDir, coldPoolRel)
260
+ placeSkill(coldPool, 'github.com/owner-a/repo/foo')
261
+ placeSkill(coldPool, 'github.com/owner-b/repo/foo')
262
+
263
+ // Legacy string-array format: two skills with same basename
264
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner-a/repo/foo", "github.com/owner-b/repo/foo"]\n`
265
+ const deckPath = join(projectDir, 'skill-deck.toml')
266
+ writeFileSync(deckPath, deckContent)
267
+
268
+ const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
269
+ cwd: projectDir,
270
+ encoding: 'utf-8',
271
+ })
272
+
273
+ expect(result.status).toBe(1)
274
+ expect(result.stderr).toContain('Alias collision')
275
+ })
276
+
277
+ it('B4.b: cross-type alias collision exits with fatal error', () => {
278
+ const projectDir = makeTmp()
279
+ const coldPoolRel = 'cold-pool'
280
+ const coldPool = join(projectDir, coldPoolRel)
281
+ placeSkill(coldPool, 'github.com/owner-a/repo/foo')
282
+ placeSkill(coldPool, 'github.com/owner-b/repo/foo')
283
+
284
+ const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[innate.skills.foo]\npath = "github.com/owner-a/repo/foo"\n\n[tool.skills.foo]\npath = "github.com/owner-b/repo/foo"\n`
285
+ const deckPath = join(projectDir, 'skill-deck.toml')
286
+ writeFileSync(deckPath, deckContent)
287
+
288
+ const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
289
+ cwd: projectDir,
290
+ encoding: 'utf-8',
291
+ })
292
+
293
+ expect(result.status).toBe(1)
294
+ expect(result.stderr).toContain('Alias collision')
295
+ })
296
+
297
+ it('B5: max_cards exceeded exits before modifying working set', () => {
298
+ const projectDir = makeTmp()
299
+ const coldPoolRel = 'cold-pool'
300
+ const coldPool = join(projectDir, coldPoolRel)
301
+ placeSkill(coldPool, 'github.com/owner/repo/skill-a')
302
+ placeSkill(coldPool, 'github.com/owner/repo/skill-b')
303
+ placeSkill(coldPool, 'github.com/owner/repo/skill-c')
304
+
305
+ const deckContent = `[deck]\nmax_cards = 2\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool.skills.skill-a]\npath = "github.com/owner/repo/skill-a"\n\n[tool.skills.skill-b]\npath = "github.com/owner/repo/skill-b"\n\n[tool.skills.skill-c]\npath = "github.com/owner/repo/skill-c"\n`
306
+ const deckPath = join(projectDir, 'skill-deck.toml')
307
+ writeFileSync(deckPath, deckContent)
308
+
309
+ const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
310
+ cwd: projectDir,
311
+ encoding: 'utf-8',
312
+ })
313
+
314
+ expect(result.status).toBe(1)
315
+ expect(result.stderr).toContain('Budget exceeded')
316
+
317
+ // Working set should not be created (fail-fast before mkdir)
318
+ expect(existsSync(join(projectDir, '.claude', 'skills'))).toBe(false)
319
+ })
320
+ })