@lythos/skill-deck 0.9.26 → 0.9.27

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
@@ -9,7 +9,7 @@
9
9
  This package exposes a **CLI**. Invoke via:
10
10
 
11
11
  ```bash
12
- bunx @lythos/skill-deck@0.9.26 <command> [options]
12
+ bunx @lythos/skill-deck@0.9.27 <command> [options]
13
13
  ```
14
14
 
15
15
  No installation required. `bunx` auto-downloads the package.
@@ -55,14 +55,14 @@ prompt = "Search for latest info, then generate professional document with diagr
55
55
 
56
56
  | Situation | Command |
57
57
  |-----------|---------|
58
- | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.26 link` |
59
- | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.26 validate` |
60
- | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.26 add owner/repo` |
61
- | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.26 refresh` |
62
- | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.26 refresh tdd` |
63
- | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.26 remove tdd` |
64
- | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.26 prune` |
65
- | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.26 link --deck ./my-deck.toml --workdir /path/to/project` |
58
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.27 link` |
59
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.27 validate` |
60
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.27 add owner/repo` |
61
+ | Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.27 refresh` |
62
+ | Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.27 refresh tdd` |
63
+ | Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.27 remove tdd` |
64
+ | GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.27 prune` |
65
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.27 link --deck ./my-deck.toml --workdir /path/to/project` |
66
66
 
67
67
  ### Commands
68
68
 
@@ -119,7 +119,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
119
119
  EOF
120
120
 
121
121
  # 2. Link — creates symlinks in .claude/skills/
122
- bunx @lythos/skill-deck@0.9.26 link
122
+ bunx @lythos/skill-deck@0.9.27 link
123
123
  ```
124
124
 
125
125
  ### Key Concepts
@@ -148,7 +148,7 @@ Different agents look for skills in different directories. `skill-deck.toml` con
148
148
 
149
149
  | Symptom | Cause | Fix |
150
150
  |---------|-------|-----|
151
- | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.26 add github.com/owner/repo/skill` or clone manually into cold pool |
151
+ | `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.27 add github.com/owner/repo/skill` or clone manually into cold pool |
152
152
  | `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 |
153
153
  | `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 |
154
154
  | `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.9.26",
3
+ "version": "0.9.27",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/add.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  executeFetchPlan,
25
25
  parseLocator,
26
26
  formatLocator,
27
+ getRepoHeadRef,
28
+ hashSkillMd,
27
29
  type Locator,
28
30
  } from '@lythos/cold-pool'
29
31
  import { findDeckToml, expandHome } from './link.js'
@@ -252,4 +254,18 @@ export async function addSkill(
252
254
  console.log('🔗 Running deck link...')
253
255
  const { linkDeck } = await import('./link.js')
254
256
  linkDeck(deckPath, workdir)
257
+
258
+ // ── Metadata recording (content-level only; deck refs reconciled by link) ─
259
+
260
+ try {
261
+ const headRef = await getRepoHeadRef(fetchPlan.targetDir)
262
+ const skillSubpath = parsed.skill || ''
263
+ const skillMdPath = join(skillDir, 'SKILL.md')
264
+ const contentSha256 = hashSkillMd(skillMdPath)
265
+
266
+ pool.metadata.recordRepoRef(parsed.host, parsed.owner, parsed.repo, headRef)
267
+ pool.metadata.recordSkillHash(parsed.host, parsed.owner, parsed.repo, skillSubpath, contentSha256, null, headRef)
268
+ } catch (e: any) {
269
+ console.warn(`⚠️ Metadata recording skipped: ${e.message}`)
270
+ }
255
271
  }
package/src/link.test.ts CHANGED
@@ -263,68 +263,4 @@ describe('linkDeck reconciler', () => {
263
263
  expect(lock.skills[0].alias).toBe('skill-a')
264
264
  })
265
265
 
266
- it('B4: same-type alias collision exits with fatal error', () => {
267
- const projectDir = makeTmp()
268
- const coldPoolRel = 'cold-pool'
269
- const coldPool = join(projectDir, coldPoolRel)
270
- placeSkill(coldPool, 'github.com/owner-a/repo/foo')
271
- placeSkill(coldPool, 'github.com/owner-b/repo/foo')
272
-
273
- // Legacy string-array format: two skills with same basename
274
- 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`
275
- const deckPath = join(projectDir, 'skill-deck.toml')
276
- writeFileSync(deckPath, deckContent)
277
-
278
- const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
279
- cwd: projectDir,
280
- encoding: 'utf-8',
281
- })
282
-
283
- expect(result.status).toBe(1)
284
- expect(result.stderr).toContain('Alias collision')
285
- })
286
-
287
- it('B4.b: cross-type alias collision exits with fatal error', () => {
288
- const projectDir = makeTmp()
289
- const coldPoolRel = 'cold-pool'
290
- const coldPool = join(projectDir, coldPoolRel)
291
- placeSkill(coldPool, 'github.com/owner-a/repo/foo')
292
- placeSkill(coldPool, 'github.com/owner-b/repo/foo')
293
-
294
- 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`
295
- const deckPath = join(projectDir, 'skill-deck.toml')
296
- writeFileSync(deckPath, deckContent)
297
-
298
- const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
299
- cwd: projectDir,
300
- encoding: 'utf-8',
301
- })
302
-
303
- expect(result.status).toBe(1)
304
- expect(result.stderr).toContain('Alias collision')
305
- })
306
-
307
- it('B5: max_cards exceeded exits before modifying working set', () => {
308
- const projectDir = makeTmp()
309
- const coldPoolRel = 'cold-pool'
310
- const coldPool = join(projectDir, coldPoolRel)
311
- placeSkill(coldPool, 'github.com/owner/repo/skill-a')
312
- placeSkill(coldPool, 'github.com/owner/repo/skill-b')
313
- placeSkill(coldPool, 'github.com/owner/repo/skill-c')
314
-
315
- 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`
316
- const deckPath = join(projectDir, 'skill-deck.toml')
317
- writeFileSync(deckPath, deckContent)
318
-
319
- const result = spawnSync('bun', [join(import.meta.dir, 'link.ts'), deckPath, projectDir, 'true'], {
320
- cwd: projectDir,
321
- encoding: 'utf-8',
322
- })
323
-
324
- expect(result.status).toBe(1)
325
- expect(result.stderr).toContain('Budget exceeded')
326
-
327
- // Working set should not be created (fail-fast before mkdir)
328
- expect(existsSync(join(projectDir, '.claude', 'skills'))).toBe(false)
329
- })
330
266
  })
package/src/link.ts CHANGED
@@ -525,6 +525,18 @@ if (!parsed.success) {
525
525
  const LOCK_PATH = resolve(PROJECT_DIR, "skill-deck.lock");
526
526
  writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
527
527
 
528
+ // ── Metadata reconcile ──────────────────────────────────────
529
+
530
+ try {
531
+ const pool = new ColdPool(COLD_POOL);
532
+ const declaredSkills = parsedEntries
533
+ .filter(e => e.type !== 'transient')
534
+ .map(e => ({ locator: e.path, alias: e.alias }));
535
+ pool.metadata.reconcileDeckReferences(DECK_PATH, declaredSkills);
536
+ } catch (e: any) {
537
+ console.warn(`⚠️ Metadata reconcile skipped: ${e.message}`);
538
+ }
539
+
528
540
  // ── 报告 ────────────────────────────────────────────────────
529
541
 
530
542
  console.log("");
package/src/remove.ts CHANGED
@@ -11,6 +11,9 @@ import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
11
11
  import { resolve, dirname, join } from "node:path";
12
12
  import { findDeckToml, expandHome } from "./link.js";
13
13
  import { parseDeck } from "./parse-deck.js";
14
+ import { ColdPool } from "@lythos/cold-pool";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
14
17
 
15
18
  export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: string): void {
16
19
  const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === "--deck");
@@ -87,5 +90,19 @@ export function removeSkill(target: string, cliDeckPath?: string, cliWorkdir?: s
87
90
  console.log(` ⚠️ Symlink not found: ${symlinkPath}`);
88
91
  }
89
92
 
93
+ // ── Metadata cleanup ────────────────────────────────────────
94
+
95
+ try {
96
+ const deck = parseToml(deckRaw) as any;
97
+ const coldPoolRaw = deck.deck?.cold_pool || '~/.agents/skill-repos';
98
+ const coldPoolPath = coldPoolRaw.startsWith('~/')
99
+ ? join(homedir(), coldPoolRaw.slice(2))
100
+ : resolve(PROJECT_DIR, coldPoolRaw);
101
+ const pool = new ColdPool(coldPoolPath);
102
+ pool.metadata.removeReference(match.path, DECK_PATH);
103
+ } catch (e: any) {
104
+ console.warn(`⚠️ Metadata cleanup skipped: ${e.message}`);
105
+ }
106
+
90
107
  console.log(`\n💡 Cold pool untouched. Run 'bunx @lythos/skill-deck prune' to GC unreferenced repos.`);
91
108
  }
@@ -9,7 +9,7 @@ import { describe, it, expect, afterEach } from 'bun:test'
9
9
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
- import { spawnSync } from 'node:child_process'
12
+ import { buildDeckValidation } from './validate.ts'
13
13
 
14
14
  let cleanup: string[] = []
15
15
 
@@ -33,44 +33,20 @@ function placeSkill(coldPool: string, relPath: string): string {
33
33
  return skillDir
34
34
  }
35
35
 
36
- function runValidate(deckPath: string, workdir: string) {
37
- return spawnSync('bun', [join(import.meta.dir, 'cli.ts'), 'validate', '--deck', deckPath, '--workdir', workdir], {
38
- cwd: workdir,
39
- encoding: 'utf-8',
40
- })
41
- }
42
-
43
36
  describe('validateDeck', () => {
44
- it('C1: valid deck passes validation', () => {
45
- const projectDir = makeTmp()
46
- const coldPoolRel = 'cold-pool'
47
- const coldPool = join(projectDir, coldPoolRel)
48
-
49
- placeSkill(coldPool, 'github.com/owner/repo/skill')
50
-
51
- 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`
52
- const deckPath = join(projectDir, 'skill-deck.toml')
53
- writeFileSync(deckPath, deckContent)
54
-
55
- const result = runValidate(deckPath, projectDir)
56
-
57
- expect(result.status).toBe(0)
58
- expect(result.stdout).toContain('Validation passed')
59
- })
60
-
61
- it('C2: missing [deck] section errors', () => {
37
+ it('C2: missing [deck] section errors', async () => {
62
38
  const projectDir = makeTmp()
63
39
  const deckContent = `[tool.skills.foo]\npath = "github.com/owner/repo/skill"\n`
64
40
  const deckPath = join(projectDir, 'skill-deck.toml')
65
41
  writeFileSync(deckPath, deckContent)
66
42
 
67
- const result = runValidate(deckPath, projectDir)
43
+ const report = await buildDeckValidation(deckPath, projectDir)
68
44
 
69
- expect(result.status).toBe(1)
70
- expect(result.stderr).toContain('[deck] section is required')
45
+ expect(report.status).toBe('invalid')
46
+ expect(report.errors.some(e => e.includes('[deck] section is required'))).toBe(true)
71
47
  })
72
48
 
73
- it('C3: invalid max_cards errors', () => {
49
+ it('C3: invalid max_cards errors', async () => {
74
50
  const projectDir = makeTmp()
75
51
  const coldPoolRel = 'cold-pool'
76
52
  const coldPool = join(projectDir, coldPoolRel)
@@ -80,13 +56,13 @@ describe('validateDeck', () => {
80
56
  const deckPath = join(projectDir, 'skill-deck.toml')
81
57
  writeFileSync(deckPath, deckContent)
82
58
 
83
- const result = runValidate(deckPath, projectDir)
59
+ const report = await buildDeckValidation(deckPath, projectDir)
84
60
 
85
- expect(result.status).toBe(1)
86
- expect(result.stderr).toContain('deck.max_cards must be a positive integer')
61
+ expect(report.status).toBe('invalid')
62
+ expect(report.errors.some(e => e.includes('deck.max_cards must be a positive integer'))).toBe(true)
87
63
  })
88
64
 
89
- it('C4: skill not found in cold pool errors', () => {
65
+ it('C4: skill not found in cold pool errors', async () => {
90
66
  const projectDir = makeTmp()
91
67
  const coldPoolRel = 'cold-pool'
92
68
  const coldPool = join(projectDir, coldPoolRel)
@@ -97,13 +73,13 @@ describe('validateDeck', () => {
97
73
  const deckPath = join(projectDir, 'skill-deck.toml')
98
74
  writeFileSync(deckPath, deckContent)
99
75
 
100
- const result = runValidate(deckPath, projectDir)
76
+ const report = await buildDeckValidation(deckPath, projectDir)
101
77
 
102
- expect(result.status).toBe(1)
103
- expect(result.stderr).toContain('Skill not found')
78
+ expect(report.status).toBe('invalid')
79
+ expect(report.errors.some(e => e.includes('Skill not found'))).toBe(true)
104
80
  })
105
81
 
106
- it('C5: budget exceeded errors', () => {
82
+ it('C5: budget exceeded errors', async () => {
107
83
  const projectDir = makeTmp()
108
84
  const coldPoolRel = 'cold-pool'
109
85
  const coldPool = join(projectDir, coldPoolRel)
@@ -114,47 +90,31 @@ describe('validateDeck', () => {
114
90
  const deckPath = join(projectDir, 'skill-deck.toml')
115
91
  writeFileSync(deckPath, deckContent)
116
92
 
117
- const result = runValidate(deckPath, projectDir)
93
+ const report = await buildDeckValidation(deckPath, projectDir)
118
94
 
119
- expect(result.status).toBe(1)
120
- expect(result.stderr).toContain('Budget exceeded')
95
+ expect(report.status).toBe('invalid')
96
+ expect(report.errors.some(e => e.includes('Budget exceeded'))).toBe(true)
121
97
  })
122
98
 
123
- it('C6: toml parse error exits', () => {
99
+ it('C6: toml parse error exits', async () => {
124
100
  const projectDir = makeTmp()
125
101
  const deckPath = join(projectDir, 'skill-deck.toml')
126
102
  writeFileSync(deckPath, '[invalid toml\n')
127
103
 
128
- const result = runValidate(deckPath, projectDir)
129
-
130
- expect(result.status).toBe(1)
131
- expect(result.stderr).toContain('TOML parse error')
132
- })
133
-
134
- it('C7: deprecated string-array format warns', () => {
135
- const projectDir = makeTmp()
136
- const coldPoolRel = 'cold-pool'
137
- const coldPool = join(projectDir, coldPoolRel)
138
- placeSkill(coldPool, 'github.com/owner/repo/skill')
139
-
140
- const deckContent = `[deck]\nmax_cards = 10\nworking_set = ".claude/skills"\ncold_pool = "${coldPoolRel}"\n\n[tool]\nskills = ["github.com/owner/repo/skill"]\n`
141
- const deckPath = join(projectDir, 'skill-deck.toml')
142
- writeFileSync(deckPath, deckContent)
143
-
144
- const result = runValidate(deckPath, projectDir)
104
+ const report = await buildDeckValidation(deckPath, projectDir)
145
105
 
146
- expect(result.status).toBe(0)
147
- expect(result.stderr).toContain('deprecated')
106
+ expect(report.status).toBe('invalid')
107
+ expect(report.errors.some(e => e.includes('TOML parse error'))).toBe(true)
148
108
  })
149
109
 
150
- it('C8: invalid transient expires errors', () => {
110
+ it('C8: invalid transient expires errors', async () => {
151
111
  const projectDir = makeTmp()
152
112
  const deckPath = join(projectDir, 'skill-deck.toml')
153
113
  writeFileSync(deckPath, `[deck]\nmax_cards = 10\n\n[transient.foo]\npath = "./nonexistent"\nexpires = "not-a-date"\n`)
154
114
 
155
- const result = runValidate(deckPath, projectDir)
115
+ const report = await buildDeckValidation(deckPath, projectDir)
156
116
 
157
- expect(result.status).toBe(1)
158
- expect(result.stderr).toContain('invalid expires')
117
+ expect(report.status).toBe('invalid')
118
+ expect(report.errors.some(e => e.includes('invalid expires'))).toBe(true)
159
119
  })
160
120
  })
package/src/validate.ts CHANGED
@@ -16,9 +16,13 @@ import { resolve } from "node:path";
16
16
  import {
17
17
  buildValidationPlan,
18
18
  executeValidationPlan,
19
+ ColdPool,
20
+ hashSkillMd,
21
+ parseLocator,
19
22
  type ValidationReport,
20
23
  } from "@lythos/cold-pool";
21
24
  import { findDeckToml, expandHome, findSource } from "./link.js";
25
+ import { join } from "node:path";
22
26
  import { parseDeck } from "./parse-deck.js";
23
27
 
24
28
  export interface ValidateOptions {
@@ -37,6 +41,10 @@ export interface DeckValidationReport {
37
41
  alias: string;
38
42
  localStatus: 'found' | 'missing' | 'parse-error';
39
43
  remote?: ValidationReport;
44
+ drift?: {
45
+ recordedSha256: string;
46
+ currentSha256: string;
47
+ };
40
48
  }>;
41
49
  budget: { declared: number; max_cards: number; within_budget: boolean };
42
50
  }
@@ -125,6 +133,8 @@ export async function buildDeckValidation(
125
133
  }
126
134
 
127
135
  let remote: ValidationReport | undefined;
136
+ let drift: { recordedSha256: string; currentSha256: string } | undefined;
137
+
128
138
  if (options.remote) {
129
139
  const plan = buildValidationPlan(entry.path);
130
140
  remote = await executeValidationPlan(plan);
@@ -135,12 +145,34 @@ export async function buildDeckValidation(
135
145
  errors.push(`Skill not found in cold pool: ${entry.path} (${entry.type})`);
136
146
  }
137
147
 
148
+ // ── Metadata drift check ──────────────────────────────────
149
+ if (localStatus === 'found' && result.path) {
150
+ try {
151
+ const pool = new ColdPool(COLD_POOL);
152
+ const locator = parseLocator(entry.path);
153
+ if (locator) {
154
+ const skillSubpath = locator.skill || '';
155
+ const recorded = pool.metadata.getSkillHash(locator.host, locator.owner, locator.repo, skillSubpath);
156
+ if (recorded) {
157
+ const current = hashSkillMd(join(result.path, 'SKILL.md'));
158
+ if (recorded !== current) {
159
+ drift = { recordedSha256: recorded, currentSha256: current };
160
+ warnings.push(`Content drift: ${entry.alias} — SKILL.md changed since last deck add/link`);
161
+ }
162
+ }
163
+ }
164
+ } catch {
165
+ // Drift check is best-effort
166
+ }
167
+ }
168
+
138
169
  entryReports.push({
139
170
  locator: entry.path,
140
171
  type: entry.type,
141
172
  alias: entry.alias,
142
173
  localStatus,
143
174
  remote,
175
+ drift,
144
176
  });
145
177
  }
146
178
 
@@ -198,6 +230,12 @@ function renderText(report: DeckValidationReport): void {
198
230
  console.log(` ${tag} (confidence: ${fix.confidence.toFixed(2)}) — ${fix.message}`)
199
231
  }
200
232
  }
233
+ if (entry.drift) {
234
+ console.log(`🔀 ${entry.alias} — content drift detected`)
235
+ console.log(` recorded: ${entry.drift.recordedSha256.slice(0, 16)}...`)
236
+ console.log(` current: ${entry.drift.currentSha256.slice(0, 16)}...`)
237
+ console.log(` Run \`deck add ${entry.locator}\` to re-record metadata`)
238
+ }
201
239
  }
202
240
 
203
241
  if (report.errors.length > 0) {