@lythos/skill-deck 0.14.6 → 0.15.0

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
@@ -2,358 +2,133 @@
2
2
 
3
3
  ![Coverage](https://img.shields.io/badge/coverage-82%25-brightgreen) ![CI](https://img.shields.io/badge/CI-71%20unit%20%2B%2021%20CLI%20BDD-brightgreen) ![Agent BDD](https://img.shields.io/badge/Agent%20BDD-5%20local-blue) ![Intent/Plan](https://img.shields.io/badge/arch-intent%2Fplan%2Fexecute-8A2BE2)
4
4
 
5
- > Declarative skill deck governance. Declare skills, sync working set via symlinks deny-by-default, max-cards budgeting, transient expiry. **Compatible with skills.sh syntax.**
5
+ > Declarative skill deck governance. Declare which skills a project needs in `skill-deck.toml`, run `deck link`, and the working set becomes an exact mirror. Undeclared skills are physically removed deny-by-default.
6
6
 
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- # Add a skill from skills.sh (owner/repo syntax — no conversion needed)
11
- bunx @lythos/skill-deck@0.14.6 add vercel-labs/agent-skills
10
+ # Add a skill from skills.sh syntax (owner/repo, no conversion needed)
11
+ bunx @lythos/skill-deck@0.15.0 add vercel-labs/agent-skills
12
12
 
13
- # Or with @skill filter (same as npx skills add):
14
- bunx @lythos/skill-deck@0.14.6 add mattpocock/skills@tdd
13
+ # Or with @skill filter:
14
+ bunx @lythos/skill-deck@0.15.0 add mattpocock/skills@tdd
15
15
 
16
- # Or FQ locator:
17
- bunx @lythos/skill-deck@0.14.6 add github.com/anthropics/skills/skills/frontend-design
16
+ # Or full-qualified locator:
17
+ bunx @lythos/skill-deck@0.15.0 add github.com/anthropics/skills/skills/frontend-design
18
18
 
19
- # Sync working set (deny-by-default):
20
- bunx @lythos/skill-deck@0.14.6 link
19
+ # Sync working set (creates symlinks, removes undeclared skills):
20
+ bunx @lythos/skill-deck@0.15.0 link
21
21
  ```
22
22
 
23
- ## For AI Agents
24
-
25
- This package exposes a **CLI**. Invoke via:
26
-
27
- ```bash
28
- bunx @lythos/skill-deck@0.14.6 <command> [options]
29
- ```
30
-
31
- No installation required. `bunx` auto-downloads the package.
32
-
33
- ### skill-deck.toml (minimal)
34
-
35
- ```toml
36
- [deck]
37
- max_cards = 10
38
-
39
- [tool.skills.lythoskill-deck]
40
- path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
41
- source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
42
- ```
43
-
44
- ### skill-deck.toml (full reference)
45
-
46
- ```toml
47
- [deck]
48
- max_cards = 10 # Hard limit on total skills
49
- working_set = ".claude/skills" # Where symlinks are created
50
- cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
51
-
52
- [innate.skills.lythoskill-deck] # Always-loaded skills
53
- path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
54
- source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
55
-
56
- [tool.skills.tdd] # Auto-triggered skills
57
- path = "github.com/mattpocock/skills/skills/engineering/tdd"
58
-
59
- [tool.skills.gstack]
60
- path = "github.com/garrytan/gstack"
61
-
62
- [transient.trial-skill] # Trial skills with auto-expiry
63
- path = "./skills/experimental"
64
- expires = "2026-06-01" # ISO date; warns at ≤14 days
65
-
66
- # combo is a meta-declaration, not a skill type (doesn't count against max_cards):
67
- [combo.report-generation]
68
- skills = ["web-search", "docx", "mermaid"]
69
- prompt = "Search for latest info, then generate professional document with diagrams"
70
- ```
71
-
72
- ### When to invoke
73
-
74
- | Situation | Command |
75
- |-----------|---------|
76
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.14.6 link` |
77
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.14.6 validate` |
78
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.14.6 add owner/repo` |
79
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.14.6 refresh` |
80
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.14.6 refresh tdd` |
81
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.14.6 remove tdd` |
82
- | Switch skill to symlink mode (live) | `bunx @lythos/skill-deck@0.14.6 to-symlink tdd` |
83
- | Switch skill to snapshot mode (pinned) | `bunx @lythos/skill-deck@0.14.6 to-snapshot tdd` |
84
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.14.6 link --deck ./my-deck.toml --workdir /path/to/project` |
85
-
86
- ### Commands
23
+ ## Commands
87
24
 
88
25
  | Command | Args | Description |
89
26
  |---------|------|-------------|
90
- | `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
91
- | `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
92
- | `add` | `<locator> [--alias <alias>] [--type <type>] [--deck <path>]` | Add skill to cold pool + deck.toml. Accepts skills.sh syntax (owner/repo, owner/repo@skill, github:owner/repo) and FQ locators. |
93
- | `refresh` | `[<fq\|alias>] [--deck <path>] [--exec]` | Scan declared skills for upstream updates (plan-only by default). Add `--exec` to actually pull. Agent-driven apply preferred. |
94
- | `remove` | `<fq\|alias> [--deck <path>]` | Remove skill from deck.toml and working set. Cold pool untouched. |
95
- | `to-symlink` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch a skill to symlink mode (live link, follows cold pool) |
96
- | `to-snapshot` | `<alias> [--deck <path>] [--workdir <dir>]` | Switch a skill to snapshot mode (pinned cp of current HEAD) |
27
+ | `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills. |
28
+ | `add` | `<locator> [--alias] [--type] [--deck]` | Add skill to cold pool + deck.toml. Accepts skills.sh syntax and FQ locators. |
29
+ | `refresh` | `[<alias>] [--deck] [--exec]` | Scan declared skills for upstream updates. Plan-only by default; add `--exec` to pull. |
30
+ | `validate` | `[deck.toml] [--workdir]` | Validate deck config without modifying files. |
31
+ | `remove` | `<alias> [--deck]` | Remove skill from deck.toml and working set. Cold pool untouched. |
32
+ | `to-symlink` | `<alias> [--deck] [--workdir]` | Switch a skill to symlink mode (live link, follows cold pool). |
33
+ | `to-snapshot` | `<alias> [--deck] [--workdir]` | Switch a skill to snapshot mode (pinned copy of current HEAD). |
34
+ | `migrate-schema` | `[--dry-run]` | Convert legacy string-array deck.toml to alias-as-key dict. |
97
35
 
98
- ### Options
36
+ ## Options
99
37
 
100
38
  | Flag | Description | Default |
101
39
  |------|-------------|---------|
102
40
  | `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
103
41
  | `--workdir <dir>` | Working directory | cwd |
42
+ | `--alias <name>` | Explicit alias (default: basename of path) | — |
43
+ | `--type <type>` | Target section for `add`: `innate`, `tool`, `transient` | `tool` |
44
+ | `--mode <mode>` | Link mode: `symlink` (default) or `snapshot` | `symlink` |
45
+ | `--no-backup` | Skip tar backup when removing non-symlink entries | — |
46
+ | `--dry-run` | Show plan without executing | — |
47
+ | `--exec` | For `refresh`: execute git pull instead of plan-only | — |
104
48
 
105
- | `--alias <alias>` | Explicit alias for the skill (default: basename of path) | — |
106
- | `--type <type>` | Target section for `add`: `innate`, `tool`, or `transient` | `tool` |
107
- | `--mode <mode>` | Link mode for `add`/`link`: `symlink` (default) or `snapshot` (copy, pinned to cold pool HEAD) | `symlink` |
108
-
109
- ### Safety guards
49
+ ## Safety guards
110
50
 
111
51
  `link` refuses to operate if `working_set` resolves to your home directory or root (`/`).
112
52
 
113
- **Snapshot mode** (`--mode snapshot` or `link --mode snapshot`): copies the source directory into the working set instead of symlinking. Snapshots are pinned to the cold pool version at link time — useful when you want the working set to be a stable copy rather than a live symlink that follows cold pool updates. Use `deck to-symlink <alias>` to switch back to symlink mode, or `deck to-snapshot <alias>` to pin a symlink as a snapshot.
53
+ **Snapshot mode** (`--mode snapshot`): copies the source directory into the working set instead of symlinking. Snapshots are pinned to the cold pool version at link time. Use `deck to-symlink <alias>` to switch back.
114
54
 
115
- ### Exit codes
55
+ ## Exit codes
116
56
 
117
57
  | Code | Meaning |
118
58
  |------|---------|
119
59
  | `0` | Success |
120
60
  | `1` | Validation failed, deck not found, or budget exceeded |
121
61
 
122
- ---
123
-
124
- ## For Humans
125
-
126
- ### Why
62
+ ## Why
127
63
 
128
64
  When an AI agent has access to 50+ skills, context window pollution and silent conflicts become real problems. Two skills claiming the same niche, redundant descriptions, incompatible assumptions — all invisible until the agent hallucinates.
129
65
 
130
- `skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to `.claude/skills/` and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
131
-
132
- ### Quick Start
66
+ `skill-deck.toml` solves this by declaring *exactly* which skills the agent should see. `deck link` creates symlinks from the cold pool to the working set and **removes everything else**. Deny-by-default means undeclared skills physically do not exist in the agent's view.
133
67
 
134
- ```bash
135
- # 1. Create a skill-deck.toml
136
- cat > skill-deck.toml << 'EOF'
137
- [deck]
138
- max_cards = 10
139
-
140
- [tool.skills.lythoskill-deck]
141
- path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
142
- source = "https://github.com/lythos-labs/lythoskill/blob/HEAD/skills/lythoskill-deck/SKILL.md"
143
- EOF
144
-
145
- # 2. Link — creates symlinks in .claude/skills/
146
- bunx @lythos/skill-deck@0.14.6 link
147
- ```
148
-
149
- ### Key Concepts
68
+ ## Key Concepts
150
69
 
151
70
  | Concept | One-liner |
152
71
  |---------|-----------|
153
72
  | **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
154
73
  | **skill-deck.toml** | Declares desired state: "this project uses these skills." |
155
74
  | **`deck link`** | Reconciler. Makes the working set match the declaration. |
156
- | **Working Set** | Per-skill mode: symlink (live) or snapshot (pinned copy). Default: `.claude/skills/`. |
75
+ | **Working Set** | Where symlinks are created. Default: `.claude/skills/`. |
157
76
  | **deny-by-default** | Undeclared skills are physically absent from the working set. |
158
77
 
159
- ### Agent skill scan locations
160
-
161
- Different agents look for skills in different directories. `skill-deck.toml` configures the working set to match your agent:
162
-
163
- | Agent | Default skills directory |
164
- |-------|--------------------------|
165
- | Claude Code | `.claude/skills/` |
166
- | Cursor | `.cursor/skills/` |
167
- | Generic / custom | Configure `working_set` in `skill-deck.toml` |
78
+ ## Platform Quick Reference
168
79
 
169
- > **If you are an agent**: verify where your platform scans for skills, then set `working_set` to that path before running `deck link`.
80
+ Different agents scan different directories. Set `working_set` in `skill-deck.toml` to match your agent:
170
81
 
171
- ### For OpenClaw
82
+ | Agent | Default path | Notes |
83
+ |-------|-------------|-------|
84
+ | Claude Code | `.claude/skills/` | Default. No config needed. |
85
+ | Cursor | `.cursor/skills/` | Set `working_set` before `link`. |
86
+ | OpenClaw | `.agents/skills/` | Project-level deck recommended. See [POSSE syndication](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-05-multi-agent-posse-syndication.md). |
87
+ | Hermes | `.hermes/skills/` | Use `external_dirs` in `~/.hermes/config.yaml` to point to project path. |
172
88
 
173
- OpenClaw loads skills from multiple locations, in priority order:
89
+ > **If you are an agent**: verify where your platform scans for skills, then set `working_set` before running `deck link`.
174
90
 
175
- | Priority | Location | Use case |
176
- |----------|----------|----------|
177
- | 1 | `<workspace>/skills` | Workspace-level override |
178
- | 2 | `<workspace>/.agents/skills` | **Project deck (recommended)** |
179
- | 3 | `~/.agents/skills` | Personal agent skills |
180
- | 4 | `~/.openclaw/skills` | Global managed skills |
181
-
182
- **Per-project deck** (most common):
183
- ```toml
184
- [deck]
185
- working_set = ".agents/skills"
186
- ```
187
- This matches OpenClaw's "project agent skills" path. Run `deck link` from your project root.
188
-
189
- **Global deck** (manage all OpenClaw skills centrally):
190
- ```toml
191
- [deck]
192
- working_set = "~/.openclaw/skills"
193
- ```
194
- Create this in your home directory. One global deck can syndicate to all projects via OpenClaw's fallback chain.
195
-
196
- ### For Hermes
197
-
198
- Hermes keeps skills in `~/.hermes/skills/` and supports scanning additional directories via `external_dirs` in `~/.hermes/config.yaml`. This makes Hermes + deck integration clean: deck manages the working set, Hermes reads it through `external_dirs`.
199
-
200
- **Recommended: project-level deck + external_dirs**
201
-
202
- ```toml
203
- # skill-deck.toml
204
- [deck]
205
- working_set = ".hermes/skills"
206
- ```
207
-
208
- ```yaml
209
- # ~/.hermes/config.yaml
210
- skills:
211
- external_dirs:
212
- - /absolute/path/to/your/project/.hermes/skills
213
- ```
214
-
215
- Run `deck link` from your project root. Hermes picks up the syndicated skills without touching its primary `~/.hermes/skills/` directory.
216
-
217
- **Alternative: direct mode (not recommended)**
218
-
219
- You can point deck directly at `~/.hermes/skills`:
220
-
221
- ```toml
222
- [deck]
223
- working_set = "~/.hermes/skills"
224
- ```
225
-
226
- Caution: deck's deny-by-default will remove any skills not declared in your deck, including Hermes' bundled skills. Only use this if your deck explicitly declares every skill you want Hermes to see.
227
-
228
- ### Troubleshooting
91
+ ## Troubleshooting
229
92
 
230
93
  | Symptom | Cause | Fix |
231
94
  |---------|-------|-----|
232
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.14.6 add github.com/owner/repo/skill` or clone manually into cold pool |
233
- | `link` skips entries with warnings | Real files/directories exist in working set (not symlinks) | Delete the real directories in `working_set` and re-run `link`. Never create directories manually there |
234
- | `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone with `git clone` or use `deck add` which clones by default |
235
- | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
236
- | `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` in `skill-deck.toml` or remove unused skills |
237
- | `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Check `skill-deck.toml` has correct relative path (e.g. `.claude/skills/`) |
238
- | Agent doesn't see skills after `link` | `working_set` path doesn't match agent's scan location | Claude Code: `.claude/skills/`; Cursor: `.cursor/skills/`; Kimi: check your platform docs. Set `working_set` correctly |
239
- | Broken symlinks in working set | Skill moved or deleted from cold pool | Re-run `link` — it recreates symlinks automatically |
240
- | `deck add` fails with 404 | Locator format wrong or repo doesn't exist | Format: `github.com/owner/repo/skill-name` (path to skill directory inside repo) |
95
+ | `❌ Skill not found: <name>` | Skill declared but not in cold pool | `deck add github.com/owner/repo/skill` or clone manually |
96
+ | `link` skips entries with warnings | Real files/directories exist in working set | Delete real directories in `working_set` and re-run `link` |
97
+ | `refresh` reports "Not a git repository" | Skill was copied (not cloned) into cold pool | Re-clone or use `deck add` |
98
+ | `link` refuses with "budget exceeded" | Declared skills > `max_cards` | Increase `max_cards` or remove unused skills |
99
+ | `link` refuses with "unsafe working_set" | `working_set` resolves to `~` or `/` | Use a relative path (e.g. `.claude/skills/`) |
100
+ | Agent doesn't see skills after `link` | `working_set` path doesn't match agent's scan location | Verify platform docs and set `working_set` correctly |
101
+ | Broken symlinks in working set | Skill moved or deleted from cold pool | Re-run `link` recreates symlinks automatically |
102
+ | `deck add` fails with 404 | Locator format wrong or repo doesn't exist | Format: `github.com/owner/repo/skill-name` |
241
103
  | `skill-deck.toml not found` | Running `link` outside project tree | Run from project root, or use `--deck ./path/to/skill-deck.toml` |
242
104
 
243
- ## K8s-Style Reconciliation: Agent as Controller
105
+ ## Architecture
244
106
 
245
- Deck follows Kubernetes' reconciliation model. The agent (Claude, Cursor, etc.) is the **controller manager** — it reads state, builds a plan, shows it to the user, then executes:
107
+ Deck separates pure logic from IO:
246
108
 
247
109
  ```
248
- scan (observe state) plan (compute diff) confirm → execute → verify
249
- ↑ │
250
- └──────────────────── reconciliation loop ─────────────────────┘
251
- ```
252
-
253
- | K8s Concept | Deck Equivalent |
254
- |-------------|-----------------|
255
- | Desired state (YAML manifest) | `skill-deck.toml` |
256
- | Actual state (running pods) | Working set (`~/.claude/skills/`) |
257
- | Controller manager (reconcile loop) | Agent reads state → builds plan → user confirms |
258
- | `kubectl apply` | `deck link` |
259
- | Namespace (isolation) | Per-project deck file |
260
- | PersistentVolume | Cold pool (`~/.agents/skill-repos/`) |
261
-
262
- The loop doesn't run automatically (no daemon). The agent is the loop — it observes, plans, confirms, and executes on demand. This is K8s-style **declarative governance**: declare what you want, reconcile to match.
263
-
264
- ## Multi-Agent POSSE Syndication
265
-
266
- Not "switching between agents" — **syndicating everywhere simultaneously**. Like IndieWeb's POSSE (Publish on your Own Site, Syndicate Elsewhere):
267
-
268
- ```
269
- Cold Pool (~/.agents/skill-repos/) ← canonical "own site"
270
- ↓ deck link --workdir
271
- ├── .claude/skills/ ← syndicate to Claude Code
272
- ├── .cursor/skills/ ← syndicate to Cursor
273
- ├── .codex/skills/ ← syndicate to Codex
274
- └── .windsurf/skills/ ← syndicate to Windsurf
275
- ```
276
-
277
- One cold pool, one deck declaration, synced to every agent you use. Adding a new platform is updating a key-value registry — no code changes needed. See [multi-agent-posse-syndication](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-05-multi-agent-posse-syndication.md).
278
-
279
- ## Migration: For Existing Skill Users
280
-
281
- If you already have skills installed (in working set, globally, or mixed), deck respects your existing state:
282
-
283
- ```
284
- 1. SCAN Agent surveys: what's in ~/.claude/skills/? What's global? What's mixed?
285
- curator scan helps — indexes cold pool or existing working set.
286
-
287
- 2. PLAN Agent shows: "We found 12 skills. After migration:
288
- - 2 → innate (deck infrastructure)
289
- - 4 → tool section
290
- - 3 → cold pool (already there, just link)
291
- - 3 → backup only (unused, stale)
292
- All 12 backed up to ~/.agents/lythos/backups/<date>.tar.gz"
293
-
294
- 3. BACKUP Always. `link` creates tar backups for non-symlink entries before removal.
295
- Use `--no-backup` only if you're certain.
296
-
297
- 4. EXECUTE deck link — creates symlinks (default) or snapshots (--mode snapshot), removes undeclared entries.
298
-
299
- 5. VERIFY Agent checks: all declared skills resolve? Working set clean?
300
- If unhappy: tar xf backup → rollback to pre-migration state.
301
- ```
302
-
303
- **Key principle**: existing skill users aren't beginners. They have working setups. Migration is a conversation — scan, show the plan, confirm before acting. Backup is non-negotiable.
304
-
305
- ## Architecture: Intent / Plan / Execute
306
-
307
- Deck commands separate pure logic from IO:
308
-
309
- ```
310
- deck.toml → RefreshPlan / PrunePlan (pure) → execute with injectable IO
110
+ deck.tomlRefreshPlan / PrunePlan (pure) execute with injectable IO
311
111
  ```
312
112
 
313
113
  - **Plan**: `buildRefreshPlan()`, `buildPrunePlan()` — pure functions, unit-testable
314
114
  - **Execute**: `executeRefreshPlan(plan, io)`, `executePrunePlan(plan, io)` — IO injected (`gitPull`, `delete`, `log`)
315
- - **Config**: `workdir`, `coldPool`, `deckPath` all accept explicit overrides, defaults are fallback
316
115
 
317
- This enables testing without real git operations inject mock `gitPull`, capture `log` output, assert expected behavior.
116
+ This enables testing without real git operations. Full pattern: [Intent / Plan / Execute](https://github.com/lythos-labs/lythoskill/blob/main/cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md).
318
117
 
319
118
  ## Test Coverage
320
119
 
321
- | Layer | Count | CI | Notes |
322
- |-------|-------|----|-------|
323
- | Unit tests | 71 | ✅ | Plan generation, link, add, remove, schema |
324
- | CLI BDD | 21 | ✅ | End-to-end via real CLI invocations in tmpdir |
325
- | Agent BDD | 5 | | Requires `claude -p` CLI; `.agent.test.ts` convention |
326
-
327
- Coverage is honest — no gate, no inflation. Agent BDD scenarios run locally only.
120
+ | Layer | Count | CI |
121
+ |-------|-------|----|
122
+ | Unit tests | 71 | ✅ |
123
+ | CLI BDD | 21 | ✅ |
124
+ | Agent BDD | 5 | local only |
328
125
 
329
126
  ## More Documentation
330
127
 
331
- - **Skill layer** (agent-facing instructions):
332
- [`packages/lythoskill-deck/skill/SKILL.md`](https://github.com/lythos-labs/lythoskill/blob/main/packages/lythoskill-deck/skill/SKILL.md)
333
- - **Full project README** (ecosystem overview, cold pool setup):
334
- [`README.md`](https://github.com/lythos-labs/lythoskill#readme)
335
- - **Architecture** (thin-skill pattern, three-layer separation):
336
- [`AGENTS.md`](https://github.com/lythos-labs/lythoskill/blob/main/AGENTS.md)
128
+ - **Skill layer** (agent-facing): [SKILL.md](https://github.com/lythos-labs/lythoskill/blob/main/packages/lythoskill-deck/skill/SKILL.md)
129
+ - **Project README** (ecosystem overview): [README.md](https://github.com/lythos-labs/lythoskill#readme)
130
+ - **Architecture**: [AGENTS.md](https://github.com/lythos-labs/lythoskill/blob/main/AGENTS.md)
337
131
 
338
132
  ## License
339
133
 
340
134
  MIT
341
-
342
- <!-- test-stats -->
343
- ![pass](https://img.shields.io/badge/71_pass-0_fail-brightgreen) ![coverage](https://img.shields.io/badge/coverage-82%25-yellow)
344
-
345
- ```
346
- File | % Funcs | % Lines | Uncovered Line #s
347
- | --- | --- | --- |
348
- All files | 74.76 | 81.66 |
349
- src/add.ts | 83.33 | 71.17 | 43,45-49,54-58,61-70,86-88,103-105,123-124,132-134,166,170,181-187,195-200
350
- src/link.ts | 43.75 | 61.40 | 112-120,125,128-132,140-151,155-156,171-180,189,215-216,219-220,227-234,246-248,253-254,256-270,274-277,280-287,294-296,307-309,316-319,335,343-346,348-353,355-358,360-373,375-376,379-381,416-417,463-470,488-489,497-499,501-507,533-534,546
351
- src/parse-deck.ts | 100.00 | 92.45 | 47-50
352
- src/prune-plan.ts | 75.00 | 97.27 | 179-181
353
- src/prune.ts | 50.00 | 36.21 | 24-26,29-40,44-73,77-90,94-100,111-113,125-126,144,149-150
354
- src/refresh-plan.ts | 75.00 | 97.66 | 170-172
355
- src/refresh.ts | 85.71 | 87.30 | 43-44,56-58,73,77-78
356
- src/remove.ts | 60.00 | 91.53 | 22-24,44
357
- src/schema.ts | 100.00 | 100.00 |
358
- ```
359
- <!-- /test-stats -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.14.6",
3
+ "version": "0.15.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -31,8 +31,8 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@iarna/toml": "^2.2.5",
34
- "@lythos/cold-pool": "^0.14.6",
35
- "@lythos/infra": "^0.14.6",
34
+ "@lythos/cold-pool": "^0.15.0",
35
+ "@lythos/infra": "^0.15.0",
36
36
  "yaml": "^2.8.3",
37
37
  "zod": "^4.3.6"
38
38
  },
package/src/link.test.ts CHANGED
@@ -285,4 +285,85 @@ describe('linkDeck reconciler', () => {
285
285
  expect(existsSync(join(dest, 'SKILL.md'))).toBe(true)
286
286
  })
287
287
 
288
+ it('B4: also_link_to fan-out creates symlinks in additional targets', () => {
289
+ const projectDir = makeTmp()
290
+ const coldPoolRel = 'cold-pool'
291
+ const coldPool = join(projectDir, coldPoolRel)
292
+ mkdirSync(join(projectDir, '.claude'), { recursive: true })
293
+
294
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill')
295
+ const deckPath = join(projectDir, 'skill-deck.toml')
296
+ const deckContent = `[deck]
297
+ working_set = ".claude/skills"
298
+ cold_pool = "${coldPoolRel}"
299
+ also_link_to = [".agents/skills", ".kimi/skills"]
300
+
301
+ [tool.skills.my-alias]
302
+ path = "github.com/owner/repo/skill"
303
+ `
304
+ writeFileSync(deckPath, deckContent)
305
+ linkDeck(deckPath, projectDir, { noBackup: true })
306
+
307
+ const primary = join(projectDir, '.claude', 'skills', 'my-alias')
308
+ const agents = join(projectDir, '.agents', 'skills', 'my-alias')
309
+ const kimi = join(projectDir, '.kimi', 'skills', 'my-alias')
310
+
311
+ expect(existsSync(primary)).toBe(true)
312
+ expect(lstatSync(primary).isSymbolicLink()).toBe(true)
313
+ expect(existsSync(agents)).toBe(true)
314
+ expect(lstatSync(agents).isSymbolicLink()).toBe(true)
315
+ expect(existsSync(kimi)).toBe(true)
316
+ expect(lstatSync(kimi).isSymbolicLink()).toBe(true)
317
+
318
+ // All point to the same source
319
+ expect(readlinkSync(primary)).toBe(skillDir)
320
+ expect(readlinkSync(agents)).toBe(skillDir)
321
+ expect(readlinkSync(kimi)).toBe(skillDir)
322
+ })
323
+
324
+ it('B5: also_link_to respects deny-by-default in each target', () => {
325
+ const projectDir = makeTmp()
326
+ const coldPoolRel = 'cold-pool'
327
+ const coldPool = join(projectDir, coldPoolRel)
328
+
329
+ const skillA = placeSkill(coldPool, 'github.com/owner/skill-a')
330
+ const skillB = placeSkill(coldPool, 'github.com/owner/skill-b')
331
+
332
+ // First link with both skills
333
+ const deckPath = join(projectDir, 'skill-deck.toml')
334
+ const deckV1 = `[deck]
335
+ working_set = ".claude/skills"
336
+ cold_pool = "${coldPoolRel}"
337
+ also_link_to = [".agents/skills"]
338
+
339
+ [tool.skills.skill-a]
340
+ path = "github.com/owner/skill-a"
341
+
342
+ [tool.skills.skill-b]
343
+ path = "github.com/owner/skill-b"
344
+ `
345
+ writeFileSync(deckPath, deckV1)
346
+ linkDeck(deckPath, projectDir, { noBackup: true })
347
+
348
+ expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-b'))).toBe(true)
349
+
350
+ // Second link: remove skill-b
351
+ const deckV2 = `[deck]
352
+ working_set = ".claude/skills"
353
+ cold_pool = "${coldPoolRel}"
354
+ also_link_to = [".agents/skills"]
355
+
356
+ [tool.skills.skill-a]
357
+ path = "github.com/owner/skill-a"
358
+ `
359
+ writeFileSync(deckPath, deckV2)
360
+ linkDeck(deckPath, projectDir, { noBackup: true })
361
+
362
+ // skill-b should be removed from BOTH working_set and also_link_to
363
+ expect(existsSync(join(projectDir, '.claude', 'skills', 'skill-a'))).toBe(true)
364
+ expect(existsSync(join(projectDir, '.claude', 'skills', 'skill-b'))).toBe(false)
365
+ expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-a'))).toBe(true)
366
+ expect(existsSync(join(projectDir, '.agents', 'skills', 'skill-b'))).toBe(false)
367
+ })
368
+
288
369
  })
package/src/link.ts CHANGED
@@ -39,6 +39,20 @@ export function expandHome(p: string, base: string): string {
39
39
  return resolve(base, p);
40
40
  }
41
41
 
42
+ export function parseAlsoLinkTo(raw: any, projectDir: string): { targets: string[], deprecated: boolean } {
43
+ if (Array.isArray(raw)) {
44
+ return {
45
+ targets: raw.filter((v: any) => typeof v === 'string').map(p => expandHome(p, projectDir)),
46
+ deprecated: false,
47
+ };
48
+ }
49
+ if (typeof raw === 'string' && raw.trim()) {
50
+ const targets = raw.split(',').map(s => s.trim()).filter(Boolean).map(p => expandHome(p, projectDir));
51
+ return { targets, deprecated: true };
52
+ }
53
+ return { targets: [], deprecated: false };
54
+ }
55
+
42
56
  function hashContent(content: string): string {
43
57
  return createHash("sha256").update(content).digest("hex");
44
58
  }
@@ -180,6 +194,11 @@ const COLD_POOL_RAW = parsedToml.deck?.cold_pool || "~/.agents/skill-repos";
180
194
  const WORKING_SET = expandHome(WORKING_SET_RAW, PROJECT_DIR);
181
195
  const COLD_POOL = expandHome(COLD_POOL_RAW, PROJECT_DIR);
182
196
  const MAX_CARDS = Number(parsedToml.deck?.max_cards || 10);
197
+ const ALSO_LINK_TO_RESULT = parseAlsoLinkTo(parsedToml.deck?.also_link_to, PROJECT_DIR);
198
+ const ALSO_LINK_TO = ALSO_LINK_TO_RESULT.targets;
199
+ if (ALSO_LINK_TO_RESULT.deprecated) {
200
+ console.warn('⚠️ Deprecation: also_link_to as comma-separated string is deprecated. Use TOML array: also_link_to = [".agents/skills"]');
201
+ }
183
202
 
184
203
  // ── 收集声明 ────────────────────────────────────────────────
185
204
 
@@ -336,89 +355,108 @@ if (
336
355
  console.warn(` cold_pool: ${resolvedColdPool}`);
337
356
  }
338
357
 
339
- // ── 收束 working set ────────────────────────────────────────
358
+ // ── 目录收束(复用于 working_set also_link_to)────────
359
+ function reconcileTargetDir(
360
+ targetDir: string,
361
+ declared: DeclaredSkill[],
362
+ declaredNames: Set<string>,
363
+ noBackup: boolean | undefined,
364
+ mode: 'symlink' | 'snapshot',
365
+ PROJECT_DIR: string,
366
+ ): void {
367
+ mkdirSync(targetDir, { recursive: true });
368
+
369
+ const nonSymlinks: string[] = [];
370
+ try {
371
+ for (const entry of readdirSync(targetDir)) {
372
+ if (entry.startsWith("_") || entry.startsWith(".")) continue;
373
+ const entryPath = join(targetDir, entry);
374
+ try {
375
+ const st = lstatSync(entryPath);
376
+ if (!st.isSymbolicLink()) nonSymlinks.push(entry);
377
+ } catch { continue; }
378
+ }
379
+ } catch {}
340
380
 
341
- mkdirSync(WORKING_SET, { recursive: true });
381
+ if (nonSymlinks.length > 0) {
382
+ let totalSize = 0;
383
+ for (const e of nonSymlinks) totalSize += calculateDirSize(join(targetDir, e));
342
384
 
343
- // Pre-flight: 备份并清理非 symlink 实体(真实目录/文件)
344
- const nonSymlinks: string[] = [];
345
- try {
346
- for (const entry of readdirSync(WORKING_SET)) {
347
- if (entry.startsWith("_") || entry.startsWith(".")) continue;
348
- const entryPath = join(WORKING_SET, entry);
349
- try {
350
- const st = lstatSync(entryPath);
351
- if (!st.isSymbolicLink()) {
352
- nonSymlinks.push(entry);
353
- }
354
- } catch { continue; }
355
- }
356
- } catch {}
385
+ if (!noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
386
+ console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, targetDir)} (> 100MB total).`);
387
+ console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
388
+ console.error(" Use --no-backup to skip backup, or clean up manually.");
389
+ process.exit(1);
390
+ }
357
391
 
358
- if (nonSymlinks.length > 0) {
359
- // 计算总大小
360
- let totalSize = 0;
361
- for (const e of nonSymlinks) {
362
- totalSize += calculateDirSize(join(WORKING_SET, e));
363
- }
392
+ if (!noBackup) {
393
+ const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
394
+ const bakPath = join(PROJECT_DIR, ".claude", bakName);
395
+ mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
396
+ const tarArgs = ["czf", bakPath, "--", ...nonSymlinks.map(e => "./" + relative(PROJECT_DIR, join(targetDir, e)))];
397
+ try {
398
+ execFileSync("tar", tarArgs, { cwd: PROJECT_DIR, stdio: "pipe" });
399
+ console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
400
+ } catch (err: any) {
401
+ console.error(`❌ Backup failed: ${err.message || err}`);
402
+ console.error(" Use --no-backup to skip backup, or fix the issue and retry.");
403
+ process.exit(1);
404
+ }
405
+ } else {
406
+ console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
407
+ }
364
408
 
365
- if (!opts?.noBackup && totalSize > BACKUP_SIZE_THRESHOLD) {
366
- console.error(`❌ Found ${nonSymlinks.length} real directories in ${relative(PROJECT_DIR, WORKING_SET)} (> 100MB total).`);
367
- console.error(` Manual review required: ${nonSymlinks.join(", ")}`);
368
- console.error(` Use --no-backup to skip backup (removes without saving), or clean up manually.`);
369
- process.exit(1);
409
+ for (const e of nonSymlinks) rmSync(join(targetDir, e), { recursive: true, force: true });
370
410
  }
371
411
 
372
- if (!opts?.noBackup) {
373
- const bakName = `skills.bak.${formatBackupDate(new Date())}.tar.gz`;
374
- const bakPath = join(PROJECT_DIR, ".claude", bakName);
375
- mkdirSync(join(PROJECT_DIR, ".claude"), { recursive: true });
412
+ try {
413
+ for (const entry of readdirSync(targetDir)) {
414
+ if (entry.startsWith("_") || entry.startsWith(".")) continue;
415
+ if (!declaredNames.has(entry)) {
416
+ const entryPath = join(targetDir, entry);
417
+ try {
418
+ const st = lstatSync(entryPath);
419
+ if (!st.isSymbolicLink()) continue;
420
+ } catch { continue; }
421
+ rmSync(entryPath, { recursive: true, force: true });
422
+ console.log(` 🗑️ Removed: ${entry}`);
423
+ }
424
+ }
425
+ } catch {}
376
426
 
377
- const tarArgs = [
378
- "czf", bakPath, "--",
379
- ...nonSymlinks.map(e => "./" + relative(PROJECT_DIR, join(WORKING_SET, e))),
380
- ];
427
+ for (const item of declared) {
428
+ const dest = join(targetDir, item.alias);
429
+ try { lstatSync(dest); rmSync(dest, { recursive: true, force: true }); } catch {}
381
430
  try {
382
- execFileSync("tar", tarArgs, {
383
- cwd: PROJECT_DIR,
384
- stdio: "pipe",
385
- });
386
- console.log(`📦 Backed up ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} to .claude/${bakName}`);
431
+ mkdirSync(dirname(dest), { recursive: true });
432
+ const linkMode = item.mode ?? mode;
433
+ if (linkMode === 'snapshot') cpSync(item.sourcePath, dest, { recursive: true });
434
+ else symlinkSync(item.sourcePath, dest);
387
435
  } catch (err: any) {
388
- console.error(`❌ Backup failed: ${err.message || err}`);
389
- console.error(` Use --no-backup to skip backup, or fix the issue and retry.`);
390
- process.exit(1);
436
+ console.error(`❌ Link failed: ${item.alias}: ${err.message || err}`);
437
+ continue;
391
438
  }
392
- } else {
393
- console.log(`⚠️ --no-backup: removing ${nonSymlinks.length} entr${nonSymlinks.length === 1 ? "y" : "ies"} without backup`);
394
- }
395
-
396
- for (const e of nonSymlinks) {
397
- rmSync(join(WORKING_SET, e), { recursive: true, force: true });
439
+ console.log(` 🔗 ${item.alias}`);
398
440
  }
399
441
  }
400
442
 
401
- // 清理未声明的 symlink
443
+ // ── 收束 working set ────────────────────────────────────────
444
+
402
445
  const declaredNames = new Set(declared.map(d => d.alias));
403
- try {
404
- for (const entry of readdirSync(WORKING_SET)) {
405
- if (entry.startsWith("_") || entry.startsWith(".")) continue;
406
- if (!declaredNames.has(entry)) {
407
- const entryPath = join(WORKING_SET, entry);
408
- try {
409
- const st = lstatSync(entryPath);
410
- if (!st.isSymbolicLink()) continue; // 已在上文处理
411
- } catch { continue; }
412
- rmSync(entryPath, { recursive: true, force: true });
413
- console.log(` 🗑️ Removed: ${entry}`);
414
- }
415
- }
416
- } catch {}
446
+ reconcileTargetDir(WORKING_SET, declared, declaredNames, opts?.noBackup, MODE, PROJECT_DIR);
447
+
448
+ // also_link_to fan-out (POSSE pattern, ADR-20260517152850372)
449
+ for (const target of ALSO_LINK_TO) {
450
+ console.log('');
451
+ console.log('📋 also_link_to: ' + relative(PROJECT_DIR, target));
452
+ reconcileTargetDir(target, declared, declaredNames, opts?.noBackup, MODE, PROJECT_DIR);
453
+ }
454
+
455
+ // ── 收集元数据 ──────────────────────────────────────────────
417
456
 
418
- // 创建 symlink
419
- const linkedSkills: LinkedSkill[] = [];
457
+ const linkedSkills: LinkedSkill[] = [];
420
458
 
421
- for (const item of declared) {
459
+ for (const item of declared) {
422
460
  const dest = join(WORKING_SET, item.alias);
423
461
 
424
462
  // 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
@@ -142,4 +142,64 @@ describe('removeSkill', () => {
142
142
  expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
143
143
  expect(existsSync(skillDir)).toBe(true)
144
144
  })
145
+
146
+ it('C12: remove cleans also_link_to targets', async () => {
147
+ const projectDir = makeTmp()
148
+ const coldPoolRel = 'cold-pool'
149
+ const coldPool = join(projectDir, coldPoolRel)
150
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
151
+
152
+ const deckContent = `[deck]
153
+ max_cards = 10
154
+ working_set = ".claude/skills"
155
+ cold_pool = "${coldPoolRel}"
156
+ also_link_to = [".agents/skills", ".kimi/skills"]
157
+
158
+ [tool.skills.skill-a]
159
+ path = "github.com/owner/repo/skill-a"
160
+ `
161
+ const deckPath = join(projectDir, 'skill-deck.toml')
162
+ writeFileSync(deckPath, deckContent)
163
+
164
+ // Create symlinks in all 3 targets to simulate post-link state
165
+ const targets = [
166
+ join(projectDir, '.claude', 'skills'),
167
+ join(projectDir, '.agents', 'skills'),
168
+ join(projectDir, '.kimi', 'skills'),
169
+ ]
170
+ for (const t of targets) {
171
+ mkdirSync(t, { recursive: true })
172
+ symlinkSync(skillDir, join(t, 'skill-a'))
173
+ }
174
+
175
+ const { removeSkill } = await import('./remove.ts')
176
+ removeSkill('skill-a', deckPath, projectDir)
177
+
178
+ const deckContentAfter = readFileSync(deckPath, 'utf-8')
179
+ expect(deckContentAfter).not.toContain('[tool.skills.skill-a]')
180
+
181
+ for (const t of targets) {
182
+ expect(existsSync(join(t, 'skill-a'))).toBe(false)
183
+ }
184
+ expect(existsSync(skillDir)).toBe(true)
185
+ })
186
+
187
+ it('C13: remove with empty also_link_to preserves backward compat', async () => {
188
+ const projectDir = makeTmp()
189
+ const coldPoolRel = 'cold-pool'
190
+ const coldPool = join(projectDir, coldPoolRel)
191
+ const skillDir = placeSkill(coldPool, 'github.com/owner/repo/skill-a')
192
+
193
+ const deckPath = buildDeck(projectDir, coldPoolRel, 'skill-a', 'github.com/owner/repo/skill-a')
194
+
195
+ const workingSet = join(projectDir, '.claude', 'skills')
196
+ mkdirSync(workingSet, { recursive: true })
197
+ symlinkSync(skillDir, join(workingSet, 'skill-a'))
198
+
199
+ const { removeSkill } = await import('./remove.ts')
200
+ removeSkill('skill-a', deckPath, projectDir)
201
+
202
+ expect(existsSync(join(workingSet, 'skill-a'))).toBe(false)
203
+ expect(existsSync(skillDir)).toBe(true)
204
+ })
145
205
  })
package/src/remove.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { parse as parseToml, stringify as stringifyToml } from "@iarna/toml";
10
10
  import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
11
  import { resolve, dirname, join } from "node:path";
12
- import { findDeckToml, expandHome } from "./link.js";
12
+ import { findDeckToml, expandHome, parseAlsoLinkTo } from "./link.js";
13
13
  import { parseDeck } from "./parse-deck.js";
14
14
  import { ColdPool } from "@lythos/cold-pool";
15
15
  import { homedir } from "node:os";
@@ -33,6 +33,12 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
33
33
 
34
34
  const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
35
35
 
36
+ const ALSO_LINK_TO_RESULT = parseAlsoLinkTo(deck.deck?.also_link_to, PROJECT_DIR);
37
+ const ALSO_LINK_TO = ALSO_LINK_TO_RESULT.targets;
38
+ if (ALSO_LINK_TO_RESULT.deprecated) {
39
+ console.warn('⚠️ Deprecation: also_link_to as comma-separated string is deprecated. Use TOML array: also_link_to = [".agents/skills"]');
40
+ }
41
+
36
42
  // ── 定位目标 ────────────────────────────────────────────────
37
43
 
38
44
  const { entries: parsedEntries } = parseDeck(deckRaw);
@@ -97,6 +103,18 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
97
103
  console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
98
104
  }
99
105
 
106
+ // ── 删 also_link_to symlinks ─────────────────────────────────
107
+
108
+ for (const target of ALSO_LINK_TO) {
109
+ const linkPath = join(target, alias);
110
+ if (existsSync(linkPath)) {
111
+ rmSync(linkPath, { recursive: true, force: true });
112
+ console.log(` 🗑️ Removed also_link_to symlink: ${linkPath}`);
113
+ } else {
114
+ console.log(` ⚠️ also_link_to symlink not found: ${linkPath}`);
115
+ }
116
+ }
117
+
100
118
  // ── Metadata cleanup ────────────────────────────────────────
101
119
 
102
120
  try {