@lythos/skill-deck 0.4.0 → 0.5.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,22 +1,97 @@
1
1
  # @lythos/skill-deck
2
2
 
3
- > Declarative skill deck governance for AI agents. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
3
+ > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks — deny-by-default, max-cards budgeting, transient expiry.
4
4
 
5
- ## Why
5
+ ## For AI Agents
6
6
 
7
- 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.
7
+ This package exposes a **CLI**. Invoke via:
8
8
 
9
- `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.
9
+ ```bash
10
+ bunx @lythos/skill-deck <command> [options]
11
+ ```
10
12
 
11
- ## Install
13
+ No installation required. `bunx` auto-downloads the package.
12
14
 
13
- ```bash
14
- bun add -d @lythos/skill-deck
15
- # or use directly
16
- bunx @lythos/skill-deck <command>
15
+ ### skill-deck.toml (minimal)
16
+
17
+ ```toml
18
+ [deck]
19
+ max_cards = 10
20
+
21
+ [tool]
22
+ skills = ["lythoskill-deck"]
23
+ ```
24
+
25
+ ### skill-deck.toml (full reference)
26
+
27
+ ```toml
28
+ [deck]
29
+ max_cards = 10 # Hard limit on total skills
30
+ working_set = ".claude/skills" # Where symlinks are created
31
+ cold_pool = "~/.agents/skill-repos" # Where skills are downloaded
32
+
33
+ [innate] # Always-loaded skills
34
+ skills = ["lythoskill-deck"]
35
+
36
+ [tool] # Auto-triggered skills
37
+ skills = ["skill-a", "skill-b"]
38
+
39
+ [combo] # Multi-skill bundles
40
+ skills = ["report-generation-combo"]
41
+
42
+ [transient] # Temporary skills with expiry
43
+ [transient.handoff]
44
+ path = "./skills/handoff" # Local path (not cold pool)
45
+ expires = "2026-05-01" # ISO date; warns at ≤14 days
17
46
  ```
18
47
 
19
- ## Quick Start
48
+ ### When to invoke
49
+
50
+ | Situation | Command |
51
+ |-----------|---------|
52
+ | Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck link` |
53
+ | Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck validate` |
54
+ | Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck add owner/repo` |
55
+ | Use a custom deck file or working dir | `bunx @lythos/skill-deck link --deck ./my-deck.toml --workdir /path/to/project` |
56
+
57
+ ### Commands
58
+
59
+ | Command | Args | Description |
60
+ |---------|------|-------------|
61
+ | `link` | `[--deck <path>] [--workdir <dir>]` | Sync working set. Removes undeclared skills (deny-by-default). |
62
+ | `validate` | `[deck.toml] [--workdir <dir>]` | Validate deck config without modifying files. |
63
+ | `add` | `<locator> [--via <backend>] [--deck <path>]` | Download skill to cold pool and append to skill-deck.toml. |
64
+
65
+ ### Options
66
+
67
+ | Flag | Description | Default |
68
+ |------|-------------|---------|
69
+ | `--deck <path>` | Path to skill-deck.toml | Find upward from cwd |
70
+ | `--workdir <dir>` | Working directory | cwd |
71
+ | `--via <backend>` | Download backend for `add`: `git` or `skills.sh` | `git` |
72
+
73
+ ### Safety guards
74
+
75
+ `link` refuses to operate if `working_set` resolves to your home directory or root (`/`). It also only removes **symlinks** from the working set — real files or directories are skipped with a warning.
76
+
77
+ ### Exit codes
78
+
79
+ | Code | Meaning |
80
+ |------|---------|
81
+ | `0` | Success |
82
+ | `1` | Validation failed, deck not found, or budget exceeded |
83
+
84
+ ---
85
+
86
+ ## For Humans
87
+
88
+ ### Why
89
+
90
+ 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.
91
+
92
+ `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.
93
+
94
+ ### Quick Start
20
95
 
21
96
  ```bash
22
97
  # 1. Create a skill-deck.toml
@@ -32,32 +107,28 @@ EOF
32
107
  bunx @lythos/skill-deck link
33
108
  ```
34
109
 
35
- ## Commands
36
-
37
- ```
38
- lythoskill-deck — Declarative skill deck governance — cold pool, working set, deny-by-default
39
-
40
- Usage: lythoskill-deck link | lythoskill-deck validate [deck.toml]
41
-
42
- Commands:
43
- link Sync working set with skill-deck.toml
44
- validate [deck.toml] Validate deck configuration
45
-
46
- Options:
47
- --deck <path> Specify skill-deck.toml path
48
- --workdir <dir> Specify working directory
49
- ```
50
-
51
- ## Key Concepts
110
+ ### Key Concepts
52
111
 
53
112
  | Concept | One-liner |
54
113
  |---------|-----------|
55
114
  | **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
56
115
  | **skill-deck.toml** | Declares desired state: "this project uses these skills." |
57
- | **`deck link`** | Reconciler. Makes `.claude/skills/` match the declaration. |
58
- | **Working Set** | `.claude/skills/` — symlinks only. What the agent actually scans. |
116
+ | **`deck link`** | Reconciler. Makes the working set match the declaration. |
117
+ | **Working Set** | Symlinks only. Default: `.claude/skills/` — where agents scan for skills. |
59
118
  | **deny-by-default** | Undeclared skills are physically absent from the working set. |
60
119
 
120
+ ### Agent skill scan locations
121
+
122
+ Different agents look for skills in different directories. `skill-deck.toml` configures the working set to match your agent:
123
+
124
+ | Agent | Default skills directory |
125
+ |-------|--------------------------|
126
+ | Claude Code | `.claude/skills/` |
127
+ | Cursor | `.cursor/skills/` |
128
+ | Generic / custom | Configure `working_set` in `skill-deck.toml` |
129
+
130
+ > **If you are an agent**: verify where your platform scans for skills, then set `working_set` to that path before running `deck link`.
131
+
61
132
  ## Skill Documentation
62
133
 
63
134
  This package is the **Starter** layer (CLI implementation).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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 ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-add.ts — Skill acquisition command
4
+ *
5
+ * Downloads a skill to the cold pool, updates skill-deck.toml, and links.
6
+ * Supports multiple backends (git clone, skills.sh) without locking users
7
+ * into a single download method.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
11
+ import { mkdtempSync } from 'node:fs'
12
+ import { tmpdir, homedir } from 'node:os'
13
+ import { join, basename, dirname, resolve } from 'node:path'
14
+ import { execSync } from 'node:child_process'
15
+ import { parse as parseToml, stringify as stringifyToml } from '@iarna/toml'
16
+ import { findDeckToml, expandHome } from './link.js'
17
+
18
+ interface ParsedLocator {
19
+ host: string
20
+ owner: string
21
+ repo: string
22
+ skill: string | null
23
+ raw: string
24
+ }
25
+
26
+ function parseLocator(input: string): ParsedLocator | null {
27
+ // Format: host.tld/owner/repo/skill or host.tld/owner/repo
28
+ // owner/repo/skill or owner/repo (shorthand for github.com)
29
+ const parts = input.split('/').filter(Boolean)
30
+ if (parts.length < 2) return null
31
+
32
+ const hasHost = parts[0].includes('.')
33
+
34
+ if (hasHost) {
35
+ if (parts.length < 3) return null
36
+ const host = parts[0]
37
+ const owner = parts[1]
38
+ const repo = parts[2]
39
+ const skill = parts.length > 3 ? parts.slice(3).join('/') : null
40
+ return { host, owner, repo, skill, raw: input }
41
+ }
42
+
43
+ const host = 'github.com'
44
+ const owner = parts[0]
45
+ const repo = parts[1]
46
+ const skill = parts.length > 2 ? parts.slice(2).join('/') : null
47
+ return { host, owner, repo, skill, raw: input }
48
+ }
49
+
50
+ function findSkillDir(repoPath: string, skill: string | null): string | null {
51
+ if (skill) {
52
+ const inSkills = join(repoPath, 'skills', skill)
53
+ if (existsSync(join(inSkills, 'SKILL.md'))) return inSkills
54
+ const direct = join(repoPath, skill)
55
+ if (existsSync(join(direct, 'SKILL.md'))) return direct
56
+ return null
57
+ }
58
+ if (existsSync(join(repoPath, 'SKILL.md'))) return repoPath
59
+ const skillsDir = join(repoPath, 'skills')
60
+ if (existsSync(skillsDir)) {
61
+ const entries = readdirSync(skillsDir, { withFileTypes: true })
62
+ const dirs = entries.filter(e => e.isDirectory())
63
+ if (dirs.length === 1) {
64
+ const candidate = join(skillsDir, dirs[0].name)
65
+ if (existsSync(join(candidate, 'SKILL.md'))) return candidate
66
+ }
67
+ }
68
+ return null
69
+ }
70
+
71
+ function resolvePath(p: string): string {
72
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2))
73
+ return resolve(p)
74
+ }
75
+
76
+ export async function addSkill(locator: string, options: { via?: string; deck?: string; workdir?: string }) {
77
+ const workdir = options.workdir ? resolvePath(options.workdir) : process.cwd()
78
+ const deckPath = options.deck
79
+ ? resolvePath(options.deck)
80
+ : findDeckToml(workdir) || join(workdir, 'skill-deck.toml')
81
+
82
+ const parsed = parseLocator(locator)
83
+ if (!parsed) {
84
+ console.error(`❌ Invalid locator: ${locator}`)
85
+ console.error(` Expected: github.com/owner/repo[/skill] or owner/repo[/skill]`)
86
+ process.exit(1)
87
+ }
88
+
89
+ const backend = options.via || 'git'
90
+
91
+ let coldPool = join(homedir(), '.agents', 'skill-repos')
92
+ if (existsSync(deckPath)) {
93
+ try {
94
+ const deckRaw = readFileSync(deckPath, 'utf-8')
95
+ const deck = parseToml(deckRaw) as any
96
+ coldPool = expandHome(deck.deck?.cold_pool || '~/.agents/skill-repos', workdir)
97
+ } catch { /* use default */ }
98
+ }
99
+
100
+ const targetDir = join(coldPool, parsed.host, parsed.owner, parsed.repo)
101
+
102
+ if (existsSync(targetDir)) {
103
+ console.error(`❌ Already exists in cold pool: ${targetDir}`)
104
+ console.error(` To update: rm -rf ${targetDir} and re-run`)
105
+ process.exit(1)
106
+ }
107
+
108
+ if (!existsSync(coldPool)) {
109
+ console.log(`📁 Creating cold pool: ${coldPool}`)
110
+ mkdirSync(coldPool, { recursive: true })
111
+ }
112
+
113
+ const tmpDir = mkdtempSync(join(tmpdir(), 'lythoskill-deck-add-'))
114
+ const tmpRepo = join(tmpDir, 'repo')
115
+
116
+ try {
117
+ if (backend === 'skills.sh' || backend === 'vercel') {
118
+ const skillsShLocator = `${parsed.owner}/${parsed.repo}`
119
+ console.log(`📦 Downloading via skills.sh: ${skillsShLocator}`)
120
+ execSync(`npx skills add ${skillsShLocator} -g`, { cwd: tmpDir, stdio: 'inherit' })
121
+ } else {
122
+ const gitUrl = `https://${parsed.host}/${parsed.owner}/${parsed.repo}.git`
123
+ console.log(`📦 Cloning: ${gitUrl}`)
124
+ execSync(`git clone --depth 1 ${gitUrl} ${tmpRepo}`, { stdio: 'inherit' })
125
+ }
126
+
127
+ if (!existsSync(tmpRepo)) {
128
+ if (backend === 'skills.sh' || backend === 'vercel') {
129
+ console.error(`❌ skills.sh backend installs globally, not to cold pool.`)
130
+ console.error(` Please manually place the skill at: ${targetDir}`)
131
+ console.error(` Or use: deck add ${locator} --via git`)
132
+ process.exit(1)
133
+ }
134
+ console.error(`❌ Download failed: expected output not found at ${tmpRepo}`)
135
+ process.exit(1)
136
+ }
137
+
138
+ mkdirSync(dirname(targetDir), { recursive: true })
139
+ renameSync(tmpRepo, targetDir)
140
+
141
+ const skillDir = findSkillDir(targetDir, parsed.skill)
142
+ if (!skillDir) {
143
+ console.error(`❌ No SKILL.md found in downloaded repo`)
144
+ console.error(` Checked: ${targetDir}`)
145
+ process.exit(1)
146
+ }
147
+
148
+ const skillName = parsed.skill ? basename(parsed.skill) : parsed.repo
149
+ console.log(`✅ Skill ready: ${skillName}`)
150
+ console.log(` Location: ${skillDir}`)
151
+
152
+ if (existsSync(deckPath)) {
153
+ const deckRaw = readFileSync(deckPath, 'utf-8')
154
+ const deck = parseToml(deckRaw) as any
155
+ const toolSkills = deck.tool?.skills || []
156
+ if (!toolSkills.includes(skillName)) {
157
+ if (!deck.tool) deck.tool = {}
158
+ if (!deck.tool.skills) deck.tool.skills = []
159
+ deck.tool.skills.push(skillName)
160
+ writeFileSync(deckPath, stringifyToml(deck))
161
+ console.log(`📝 Added "${skillName}" to ${deckPath}`)
162
+ } else {
163
+ console.log(`📝 "${skillName}" already declared in ${deckPath}`)
164
+ }
165
+ } else {
166
+ const minimal = { deck: { max_cards: 10 }, tool: { skills: [skillName] } }
167
+ writeFileSync(deckPath, stringifyToml(minimal))
168
+ console.log(`📝 Created ${deckPath} with "${skillName}"`)
169
+ }
170
+
171
+ console.log('🔗 Running deck link...')
172
+ const { linkDeck } = await import('./link.js')
173
+ linkDeck(deckPath === join(workdir, 'skill-deck.toml') ? undefined : deckPath, workdir)
174
+
175
+ } catch (err) {
176
+ console.error(`❌ Failed to add skill: ${err}`)
177
+ process.exit(1)
178
+ } finally {
179
+ rmSync(tmpDir, { recursive: true, force: true })
180
+ }
181
+ }
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { linkDeck } from './link.js'
3
3
  import { validateDeck } from './validate.js'
4
+ import { addSkill } from './add.js'
4
5
  import { formatHelp } from './help.js'
5
6
 
6
7
  const HELP_CONFIG = {
@@ -8,19 +9,26 @@ const HELP_CONFIG = {
8
9
  description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
9
10
  commands: [
10
11
  { name: 'link', description: 'Sync working set with skill-deck.toml' },
12
+ { name: 'add', description: 'Download skill to cold pool and add to deck', args: '<locator>' },
11
13
  { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
12
14
  ],
13
15
  options: [
14
- { flag: '--deck <path>', description: 'Specify skill-deck.toml path' },
15
- { flag: '--workdir <dir>', description: 'Specify working directory' },
16
+ { flag: '--deck <path>', description: 'Specify skill-deck.toml path (default: find upward from cwd)' },
17
+ { flag: '--workdir <dir>', description: 'Specify working directory (default: cwd)' },
18
+ { flag: '--via <backend>', description: 'Download backend: git (default) | skills.sh' },
16
19
  ],
17
20
  }
18
21
 
19
- const command = process.argv[2]
20
- const deckFlagIdx = process.argv.indexOf('--deck')
21
- const workdirFlagIdx = process.argv.indexOf('--workdir')
22
- const deckPath = deckFlagIdx >= 0 ? process.argv[deckFlagIdx + 1] : undefined
23
- const workdir = workdirFlagIdx >= 0 ? process.argv[workdirFlagIdx + 1] : undefined
22
+ const args = process.argv.slice(2)
23
+ const command = args[0]
24
+
25
+ const deckFlagIdx = args.indexOf('--deck')
26
+ const workdirFlagIdx = args.indexOf('--workdir')
27
+ const viaFlagIdx = args.indexOf('--via')
28
+
29
+ const deckPath = deckFlagIdx >= 0 ? args[deckFlagIdx + 1] : undefined
30
+ const workdir = workdirFlagIdx >= 0 ? args[workdirFlagIdx + 1] : undefined
31
+ const via = viaFlagIdx >= 0 ? args[viaFlagIdx + 1] : undefined
24
32
 
25
33
  switch (command) {
26
34
  case '--help':
@@ -30,6 +38,15 @@ switch (command) {
30
38
  case 'link':
31
39
  linkDeck(deckPath, workdir)
32
40
  break
41
+ case 'add': {
42
+ const locator = args[1]
43
+ if (!locator) {
44
+ console.error('❌ Missing locator. Usage: deck add <github.com/owner/repo[/skill]>')
45
+ process.exit(1)
46
+ }
47
+ await addSkill(locator, { via, deck: deckPath, workdir })
48
+ break
49
+ }
33
50
  case 'validate':
34
51
  validateDeck(deckPath, workdir)
35
52
  break
package/src/link.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  existsSync, mkdirSync, readFileSync, readdirSync,
15
15
  symlinkSync, lstatSync, rmSync, writeFileSync,
16
16
  } from "fs";
17
- import { resolve, dirname, join } from "path";
17
+ import { resolve, dirname, join, basename, relative } from "path";
18
18
  import { homedir } from "os";
19
19
  import {
20
20
  SkillDeckLockSchema,
@@ -152,7 +152,7 @@ for (const section of ["innate", "tool", "combo"] as const) {
152
152
  if (!name || typeof name !== "string") continue;
153
153
  const src = findSource(name, COLD_POOL, PROJECT_DIR);
154
154
  if (!src) {
155
- errors.push(`skill 未找到: ${name}`);
155
+ errors.push(`Skill not found: ${name}`);
156
156
  continue;
157
157
  }
158
158
  declared.push({ name, type: section, sourcePath: src });
@@ -165,7 +165,7 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
165
165
  if (!t?.path) continue;
166
166
  const src = resolve(PROJECT_DIR, t.path);
167
167
  if (!existsSync(src)) {
168
- errors.push(`transient 路径不存在: ${key} → ${src}`);
168
+ errors.push(`Transient path does not exist: ${key} → ${src}`);
169
169
  continue;
170
170
  }
171
171
  declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
@@ -179,23 +179,54 @@ if (errors.length > 0) {
179
179
  // ── 预算检查(硬约束,链接前检查)──────────────────────────
180
180
 
181
181
  if (declared.length > MAX_CARDS) {
182
- console.error(`❌ 超出预算: 声明 ${declared.length} 个,上限 ${MAX_CARDS}`);
183
- console.error(` 减少 skill-deck.toml 中的声明,或调整 max_cards`);
182
+ console.error(`❌ Budget exceeded: declared ${declared.length}, max ${MAX_CARDS}`);
183
+ console.error(` Reduce declarations in skill-deck.toml or increase max_cards`);
184
184
  process.exit(1);
185
185
  }
186
186
 
187
+ // ── 工作目录安全 guard ──────────────────────────────────────
188
+
189
+ const resolvedWorkingSet = resolve(WORKING_SET);
190
+ const resolvedHome = resolve(homedir());
191
+ const resolvedCwd = resolve(process.cwd());
192
+ const resolvedColdPool = resolve(COLD_POOL);
193
+
194
+ if (resolvedWorkingSet === resolvedHome || resolvedWorkingSet === "/") {
195
+ console.error(`❌ Refusing operation: working_set resolves to home or root directory (${resolvedWorkingSet})`);
196
+ console.error(` Check working_set in skill-deck.toml`);
197
+ process.exit(1);
198
+ }
199
+
200
+ const relWs = relative(resolvedColdPool, resolvedWorkingSet);
201
+ if (
202
+ resolvedWorkingSet.startsWith(resolvedColdPool + "/") &&
203
+ !relWs.split("/").some(p => p.startsWith("."))
204
+ ) {
205
+ console.warn(`⚠️ working_set is inside cold_pool and not hidden — may be picked up by cold-pool scans`);
206
+ console.warn(` working_set: ${resolvedWorkingSet}`);
207
+ console.warn(` cold_pool: ${resolvedColdPool}`);
208
+ }
209
+
187
210
  // ── 收束 working set ────────────────────────────────────────
188
211
 
189
212
  mkdirSync(WORKING_SET, { recursive: true });
190
213
 
191
- // 清理未声明的条目
214
+ // 清理未声明的条目(只删 symlink,防呆)
192
215
  const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
193
216
  try {
194
217
  for (const entry of readdirSync(WORKING_SET)) {
195
218
  if (entry.startsWith("_")) continue;
196
219
  if (!declaredNames.has(entry)) {
197
- rmSync(join(WORKING_SET, entry), { recursive: true, force: true });
198
- console.log(` 🗑️ 移除: ${entry}`);
220
+ const entryPath = join(WORKING_SET, entry);
221
+ try {
222
+ const st = lstatSync(entryPath);
223
+ if (!st.isSymbolicLink()) {
224
+ console.warn(`⚠️ Skipping non-symlink entry: ${entry}`);
225
+ continue;
226
+ }
227
+ } catch { continue; }
228
+ rmSync(entryPath, { recursive: true, force: true });
229
+ console.log(` 🗑️ Removed: ${entry}`);
199
230
  }
200
231
  }
201
232
  } catch {}
@@ -216,7 +247,7 @@ for (const item of declared) {
216
247
  mkdirSync(dirname(dest), { recursive: true });
217
248
  symlinkSync(item.sourcePath, dest);
218
249
  } catch (err: any) {
219
- console.error(`❌ 链接失败: ${item.name}: ${err.message}`);
250
+ console.error(`❌ Link failed: ${item.name}: ${err.message}`);
220
251
  continue;
221
252
  }
222
253
 
@@ -260,9 +291,9 @@ for (const s of linkedSkills) {
260
291
  const days = Math.ceil((exp - now) / 86400000);
261
292
  transientWarnings.push({ name: s.name, expires: s.expires, days_remaining: days });
262
293
  if (days <= 0) {
263
- console.warn(`⚠️ 过期: ${s.name}(到期 ${s.expires})— 评估是否仍需要`);
294
+ console.warn(`⚠️ Expired: ${s.name} (expires ${s.expires}) — evaluate if still needed`);
264
295
  } else if (days <= 14) {
265
- console.warn(`⏰ 即将过期: ${s.name}(剩余 ${days} 天)`);
296
+ console.warn(`⏰ Expiring soon: ${s.name} (${days} days remaining)`);
266
297
  }
267
298
  }
268
299
 
@@ -282,7 +313,7 @@ const dirOverlaps: { dir: string; skills: string[] }[] = [];
282
313
  for (const [dir, owners] of dirOwners) {
283
314
  if (owners.length > 1) {
284
315
  dirOverlaps.push({ dir, skills: owners });
285
- console.warn(`⚠️ 目录重叠: ${dir} ← ${owners.join(", ")}`);
316
+ console.warn(`⚠️ Directory overlap: ${dir} ← ${owners.join(", ")}`);
286
317
  }
287
318
  }
288
319
 
@@ -297,7 +328,7 @@ for (let i = 0; i < allDirs.length; i++) {
297
328
  const cross = parentOwners.filter(o => !childOwners.includes(o));
298
329
  if (cross.length > 0) {
299
330
  const msg = `${allDirs[i]} (${parentOwners.join(",")}) 包含 ${allDirs[j]} (${childOwners.join(",")})`;
300
- console.warn(`⚠️ 目录包含关系: ${msg}`);
331
+ console.warn(`⚠️ Directory containment: ${msg}`);
301
332
  dirOverlaps.push({ dir: `${allDirs[i]} ⊃ ${allDirs[j]}`, skills: [...new Set([...parentOwners, ...childOwners])] });
302
333
  }
303
334
  }
@@ -326,7 +357,7 @@ const lock: SkillDeckLock = {
326
357
 
327
358
  const parsed = SkillDeckLockSchema.safeParse(lock);
328
359
  if (!parsed.success) {
329
- console.error("❌ Lock schema 校验失败:", JSON.stringify(parsed.error.format(), null, 2));
360
+ console.error("❌ Lock schema validation failed:", JSON.stringify(parsed.error.format(), null, 2));
330
361
  process.exit(1);
331
362
  }
332
363
 
@@ -336,10 +367,10 @@ writeFileSync(LOCK_PATH, JSON.stringify(parsed.data, null, 2) + "\n");
336
367
  // ── 报告 ────────────────────────────────────────────────────
337
368
 
338
369
  console.log("");
339
- console.log(`✅ 同步完成: ${linkedSkills.length}/${MAX_CARDS} skill`);
370
+ console.log(`✅ Sync complete: ${linkedSkills.length}/${MAX_CARDS} skills`);
340
371
  console.log(` lock: ${LOCK_PATH}`);
341
372
  if (dirOverlaps.length > 0) {
342
- console.log(` ⚠️ ${dirOverlaps.length} 个目录重叠(详见上方警告)`);
373
+ console.log(` ⚠️ ${dirOverlaps.length} directory overlap(s) (see warnings above)`);
343
374
  }
344
375
  }
345
376