@lythos/skill-deck 0.1.9 → 0.4.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
@@ -1,40 +1,78 @@
1
1
  # @lythos/skill-deck
2
2
 
3
- > Declarative skill deck governance. Reconcile declared skills against your cold pool via symlinks.
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.
4
4
 
5
- Part of the [lythoskill](https://github.com/lythos-labs/lythoskill) meta-skill ecosystem.
5
+ ## Why
6
6
 
7
- ## What it does
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.
8
8
 
9
- Manages your agent's working set of skills. You declare which skills you want in `skill-deck.toml`; `deck link` creates symlinks from the cold pool to `.claude/skills/`. Supports deny-by-default isolation, max_cards budgeting, transient expiry, and managed directory overlap detection.
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.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```bash
14
14
  bun add -d @lythos/skill-deck
15
- # or
15
+ # or use directly
16
16
  bunx @lythos/skill-deck <command>
17
17
  ```
18
18
 
19
- ## Commands
19
+ ## Quick Start
20
20
 
21
21
  ```bash
22
- # Link declared skills to working set
22
+ # 1. Create a skill-deck.toml
23
+ cat > skill-deck.toml << 'EOF'
24
+ [deck]
25
+ max_cards = 10
26
+
27
+ [tool]
28
+ skills = ["lythoskill-deck"]
29
+ EOF
30
+
31
+ # 2. Link — creates symlinks in .claude/skills/
23
32
  bunx @lythos/skill-deck link
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ```
38
+ lythoskill-deck — Declarative skill deck governance — cold pool, working set, deny-by-default
24
39
 
25
- # Link with custom deck file
26
- bunx @lythos/skill-deck link --deck ./my-deck.toml
40
+ Usage: lythoskill-deck link | lythoskill-deck validate [deck.toml]
27
41
 
28
- # Show current deck status
29
- bunx @lythos/skill-deck status
42
+ Commands:
43
+ link Sync working set with skill-deck.toml
44
+ validate [deck.toml] Validate deck configuration
30
45
 
31
- # Migrate from old deck format
32
- bunx @lythos/skill-deck migrate
46
+ Options:
47
+ --deck <path> Specify skill-deck.toml path
48
+ --workdir <dir> Specify working directory
33
49
  ```
34
50
 
51
+ ## Key Concepts
52
+
53
+ | Concept | One-liner |
54
+ |---------|-----------|
55
+ | **Cold Pool** | All downloaded skills (`~/.agents/skill-repos/`). Agent cannot see here. |
56
+ | **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. |
59
+ | **deny-by-default** | Undeclared skills are physically absent from the working set. |
60
+
61
+ ## Skill Documentation
62
+
63
+ This package is the **Starter** layer (CLI implementation).
64
+ The agent-visible **Skill** layer documentation is here:
65
+ [packages/lythoskill-deck/skill/SKILL.md](../../packages/lythoskill-deck/skill/SKILL.md)
66
+
35
67
  ## Architecture
36
68
 
37
- This is the **Starter** layer of the thin-skill pattern. The agent-visible **Skill** layer lives in `packages/lythoskill-deck/skill/` and is built to `skills/lythoskill-deck/`.
69
+ Part of the [lythoskill](https://github.com/lythos-labs/lythoskill) ecosystem the thin-skill pattern separates heavy logic (this npm package) from lightweight agent instructions (SKILL.md).
70
+
71
+ ```
72
+ Starter (this package) → npm publish → bunx @lythos/skill-deck ...
73
+ Skill (packages/<name>/skill/) → build → SKILL.md + thin scripts
74
+ Output (skills/<name>/) → git commit → agent-visible skill
75
+ ```
38
76
 
39
77
  ## License
40
78
 
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "@lythos/skill-deck",
3
- "version": "0.1.9",
3
+ "version": "0.4.0",
4
4
  "description": "Declarative skill deck governance — cold pool, working set, deny-by-default",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "skill",
8
+ "claude-code",
9
+ "agent-skills",
10
+ "llm-tooling",
11
+ "lythoskill"
12
+ ],
13
+ "author": "lythos-labs",
5
14
  "license": "MIT",
6
15
  "type": "module",
7
16
  "bin": {
@@ -14,6 +23,16 @@
14
23
  ],
15
24
  "dependencies": {
16
25
  "@iarna/toml": "^2.2.5",
26
+ "yaml": "^2.8.3",
17
27
  "zod": "^4.3.6"
18
- }
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/lythos-labs/lythoskill.git",
32
+ "directory": "packages/lythoskill-deck"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/lythos-labs/lythoskill/issues"
36
+ },
37
+ "homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-deck#readme"
19
38
  }
package/src/cli.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  #!/usr/bin/env bun
2
2
  import { linkDeck } from './link.js'
3
+ import { validateDeck } from './validate.js'
4
+ import { formatHelp } from './help.js'
5
+
6
+ const HELP_CONFIG = {
7
+ binName: 'lythoskill-deck',
8
+ description: 'Declarative skill deck governance — cold pool, working set, deny-by-default',
9
+ commands: [
10
+ { name: 'link', description: 'Sync working set with skill-deck.toml' },
11
+ { name: 'validate', description: 'Validate deck configuration', args: '[deck.toml]' },
12
+ ],
13
+ options: [
14
+ { flag: '--deck <path>', description: 'Specify skill-deck.toml path' },
15
+ { flag: '--workdir <dir>', description: 'Specify working directory' },
16
+ ],
17
+ }
3
18
 
4
19
  const command = process.argv[2]
5
20
  const deckFlagIdx = process.argv.indexOf('--deck')
@@ -8,10 +23,17 @@ const deckPath = deckFlagIdx >= 0 ? process.argv[deckFlagIdx + 1] : undefined
8
23
  const workdir = workdirFlagIdx >= 0 ? process.argv[workdirFlagIdx + 1] : undefined
9
24
 
10
25
  switch (command) {
26
+ case '--help':
27
+ case '-h':
28
+ console.log(formatHelp(HELP_CONFIG))
29
+ process.exit(0)
11
30
  case 'link':
12
31
  linkDeck(deckPath, workdir)
13
32
  break
33
+ case 'validate':
34
+ validateDeck(deckPath, workdir)
35
+ break
14
36
  default:
15
- console.error('Usage: lythoskill-deck link [--deck <path>] [--workdir <dir>]')
37
+ console.error(formatHelp(HELP_CONFIG))
16
38
  process.exit(1)
17
39
  }
package/src/help.ts ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * help.ts — Declarative help text formatter
4
+ *
5
+ * Commands and options are defined as data (HelpConfig).
6
+ * formatHelp() produces aligned usage output.
7
+ * This is the SSOT: build pipeline captures `bun cli.ts --help`
8
+ * and writes it to skill/references/COMMANDS.md.
9
+ */
10
+
11
+ export interface CommandDef {
12
+ name: string
13
+ description: string
14
+ args?: string
15
+ }
16
+
17
+ export interface OptionDef {
18
+ flag: string
19
+ description: string
20
+ }
21
+
22
+ export interface HelpConfig {
23
+ binName: string
24
+ description?: string
25
+ commands: CommandDef[]
26
+ options?: OptionDef[]
27
+ }
28
+
29
+ export function formatHelp(config: HelpConfig): string {
30
+ const lines: string[] = []
31
+
32
+ if (config.description) {
33
+ lines.push(`${config.binName} -- ${config.description}`)
34
+ lines.push('')
35
+ }
36
+
37
+ // Usage line
38
+ const usages = config.commands.map(
39
+ (c) => `${config.binName} ${c.name}${c.args ? ' ' + c.args : ''}`
40
+ )
41
+ lines.push(`Usage: ${usages.join(' | ')}`)
42
+ lines.push('')
43
+
44
+ // Commands
45
+ lines.push('Commands:')
46
+ const cmdWidth = Math.max(
47
+ ...config.commands.map((c) => c.name.length + (c.args ? c.args.length + 1 : 0))
48
+ )
49
+ for (const cmd of config.commands) {
50
+ const left = ` ${cmd.name}${cmd.args ? ' ' + cmd.args : ''}`
51
+ const pad = ' '.repeat(Math.max(cmdWidth - left.length + 4, 2))
52
+ lines.push(`${left}${pad}${cmd.description}`)
53
+ }
54
+
55
+ // Options
56
+ if (config.options && config.options.length > 0) {
57
+ lines.push('')
58
+ lines.push('Options:')
59
+ const optWidth = Math.max(...config.options.map((o) => o.flag.length))
60
+ for (const opt of config.options) {
61
+ const left = ` ${opt.flag}`
62
+ const pad = ' '.repeat(Math.max(optWidth - left.length + 4, 2))
63
+ lines.push(`${left}${pad}${opt.description}`)
64
+ }
65
+ }
66
+
67
+ return lines.join('\n')
68
+ }
package/src/link.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { parse as parseToml } from "@iarna/toml";
11
+ import YAML from "yaml";
11
12
  import { createHash } from "crypto";
12
13
  import {
13
14
  existsSync, mkdirSync, readFileSync, readdirSync,
@@ -17,19 +18,18 @@ import { resolve, dirname, join } from "path";
17
18
  import { homedir } from "os";
18
19
  import {
19
20
  SkillDeckLockSchema,
20
- SkillDeckTomlSchema,
21
21
  type SkillDeckLock, type LinkedSkill, type ConstraintReport,
22
22
  } from "./schema.js";
23
23
 
24
24
  // ── 路径工具 ────────────────────────────────────────────────
25
25
 
26
- function findDeckToml(from: string): string | null {
26
+ export function findDeckToml(from: string): string | null {
27
27
  const p = join(from, "skill-deck.toml");
28
28
  if (existsSync(p)) return p;
29
29
  return null;
30
30
  }
31
31
 
32
- function expandHome(p: string, base: string): string {
32
+ export function expandHome(p: string, base: string): string {
33
33
  if (p.startsWith("~/")) return join(homedir(), p.slice(2));
34
34
  return resolve(base, p);
35
35
  }
@@ -37,53 +37,21 @@ function expandHome(p: string, base: string): string {
37
37
  function hashContent(content: string): string {
38
38
  return createHash("sha256").update(content).digest("hex");
39
39
  }
40
-
41
40
  // ── Front matter 提取 ───────────────────────────────────────
42
41
 
43
- function getFrontMatter(skillMdPath: string): string {
42
+ function parseSkillFrontmatter(skillMdPath: string): Record<string, any> {
44
43
  try {
45
44
  const c = readFileSync(skillMdPath, "utf-8");
46
- if (!c.startsWith("---")) return "";
47
- const parts = c.split("---");
48
- return parts.length >= 3 ? parts[1] : "";
49
- } catch { return ""; }
45
+ const match = c.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
46
+ if (!match) return {};
47
+ return YAML.parse(match[1]) || {};
48
+ } catch { return {}; }
50
49
  }
51
50
 
52
- function extractField(fm: string, field: string): string {
53
- const m = fm.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
54
- return m ? m[1].trim() : "";
55
- }
56
-
57
- function extractArrayField(fm: string, field: string): string[] {
58
- const lines = fm.split("\n");
59
- const results: string[] = [];
60
- let collecting = false;
61
- for (const line of lines) {
62
- if (line.match(new RegExp(`^${field}:\\s*$`))) {
63
- collecting = true;
64
- continue;
65
- }
66
- if (line.match(new RegExp(`^${field}:\\s*\\[`))) {
67
- const inline = line.match(/\[(.+)\]/);
68
- if (inline) return inline[1].split(",").map(s => s.trim().replace(/^["']|["']$/g, ""));
69
- collecting = true;
70
- continue;
71
- }
72
- if (collecting) {
73
- const item = line.match(/^\s+-\s+(.+)/);
74
- if (item) {
75
- results.push(item[1].trim().replace(/^["']|["']$/g, ""));
76
- } else if (line.trim() !== "" && !line.match(/^\s*#/)) {
77
- break;
78
- }
79
- }
80
- }
81
- return results;
82
- }
83
51
 
84
52
  // ── 冷池查找 ────────────────────────────────────────────────
85
53
 
86
- function findSource(name: string, coldPool: string, projectDir: string): string | null {
54
+ export function findSource(name: string, coldPool: string, projectDir: string): string | null {
87
55
  // 0. Fully-qualified path: host.tld/owner/repo/skill
88
56
  // → cold_pool/host.tld/owner/repo/skills/skill
89
57
  // Also handles host.tld/owner/repo (standalone skill without skills/ subdir)
@@ -161,44 +129,16 @@ if (!existsSync(DECK_PATH)) {
161
129
  const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : dirname(DECK_PATH);
162
130
  const deckRaw = readFileSync(DECK_PATH, "utf-8");
163
131
  const deckHash = hashContent(deckRaw);
164
- const parsedToml = parseToml(deckRaw) as any;
165
-
166
- // 预处理:TOML 解析器可能在空表上生成 Symbol 键,先清理
167
- function stripSymbols(obj: any): any {
168
- if (obj == null) return obj;
169
- if (Array.isArray(obj)) return obj.map(stripSymbols);
170
- if (typeof obj === 'object') {
171
- const clean: Record<string, any> = {};
172
- for (const key of Object.keys(obj)) {
173
- clean[key] = stripSymbols(obj[key]);
174
- }
175
- return clean;
176
- }
177
- return obj;
178
- }
179
- const cleanToml = stripSymbols(parsedToml);
180
-
181
- // Schema 校验:防止模板与运行时解析漂移
182
- const schemaResult = SkillDeckTomlSchema.safeParse(cleanToml);
183
- if (!schemaResult.success) {
184
- console.error("❌ skill-deck.toml 格式错误:");
185
- for (const issue of schemaResult.error.issues) {
186
- const path = issue.path.map(String).filter(p => p !== "undefined").join(".") || "<root>";
187
- console.error(` ${path}: ${issue.message}`);
188
- }
189
- process.exit(1);
190
- }
191
- const deck = schemaResult.data;
132
+ const deck = parseToml(deckRaw) as any;
192
133
 
193
- const WORKING_SET = expandHome(deck.deck.working_set, PROJECT_DIR);
194
- const COLD_POOL = expandHome(deck.deck.cold_pool, PROJECT_DIR);
195
- const MAX_CARDS = deck.deck.max_cards;
134
+ const WORKING_SET = expandHome(deck.deck?.working_set || ".claude/skills", PROJECT_DIR);
135
+ const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
136
+ const MAX_CARDS = Number(deck.deck?.max_cards || 10);
196
137
 
197
138
  // ── 收集声明 ────────────────────────────────────────────────
198
139
 
199
140
  interface DeclaredSkill {
200
- name: string; // 完整声明名(如 github.com/.../lythoskill-deck)
201
- baseName: string; // working set 中的目录名(如 lythoskill-deck)
141
+ name: string;
202
142
  type: "innate" | "tool" | "combo" | "transient";
203
143
  sourcePath: string;
204
144
  expires?: string;
@@ -215,8 +155,7 @@ for (const section of ["innate", "tool", "combo"] as const) {
215
155
  errors.push(`skill 未找到: ${name}`);
216
156
  continue;
217
157
  }
218
- const baseName = name.split("/").pop() || name;
219
- declared.push({ name, baseName, type: section, sourcePath: src });
158
+ declared.push({ name, type: section, sourcePath: src });
220
159
  }
221
160
  }
222
161
 
@@ -229,7 +168,7 @@ for (const [key, value] of Object.entries(deck.transient || {})) {
229
168
  errors.push(`transient 路径不存在: ${key} → ${src}`);
230
169
  continue;
231
170
  }
232
- declared.push({ name: key, baseName: key, type: "transient", sourcePath: src, expires: t.expires });
171
+ declared.push({ name: key, type: "transient", sourcePath: src, expires: t.expires });
233
172
  }
234
173
 
235
174
  if (errors.length > 0) {
@@ -250,11 +189,11 @@ if (declared.length > MAX_CARDS) {
250
189
  mkdirSync(WORKING_SET, { recursive: true });
251
190
 
252
191
  // 清理未声明的条目
253
- const declaredBaseNames = new Set(declared.map(d => d.baseName));
192
+ const declaredNames = new Set(declared.map(d => d.name.split("/")[0]));
254
193
  try {
255
194
  for (const entry of readdirSync(WORKING_SET)) {
256
195
  if (entry.startsWith("_")) continue;
257
- if (!declaredBaseNames.has(entry)) {
196
+ if (!declaredNames.has(entry)) {
258
197
  rmSync(join(WORKING_SET, entry), { recursive: true, force: true });
259
198
  console.log(` 🗑️ 移除: ${entry}`);
260
199
  }
@@ -265,7 +204,7 @@ try {
265
204
  const linkedSkills: LinkedSkill[] = [];
266
205
 
267
206
  for (const item of declared) {
268
- const dest = join(WORKING_SET, item.baseName);
207
+ const dest = join(WORKING_SET, item.name);
269
208
 
270
209
  // 幂等:已存在则删除重建(lstat 不跟随 symlink,能处理断链/自引用 symlink)
271
210
  try {
@@ -274,17 +213,22 @@ for (const item of declared) {
274
213
  } catch {}
275
214
 
276
215
  try {
216
+ mkdirSync(dirname(dest), { recursive: true });
277
217
  symlinkSync(item.sourcePath, dest);
278
218
  } catch (err: any) {
279
- console.error(`❌ 链接失败: ${item.baseName}: ${err.message}`);
219
+ console.error(`❌ 链接失败: ${item.name}: ${err.message}`);
280
220
  continue;
281
221
  }
282
222
 
283
223
  // 提取元数据
284
224
  const skillMdPath = join(item.sourcePath, "SKILL.md");
285
- const fm = getFrontMatter(skillMdPath);
286
- const niche = extractField(fm, "deck_niche");
287
- const managedDirs = extractArrayField(fm, "deck_managed_dirs");
225
+ const fm = parseSkillFrontmatter(skillMdPath);
226
+ const niche = String(fm["deck_niche"] || "");
227
+ const managedDirs = Array.isArray(fm["deck_managed_dirs"])
228
+ ? fm["deck_managed_dirs"].map(String)
229
+ : fm["deck_managed_dirs"]
230
+ ? [String(fm["deck_managed_dirs"])]
231
+ : [];
288
232
  let contentHash: string | undefined;
289
233
  try {
290
234
  contentHash = hashContent(readFileSync(skillMdPath, "utf-8"));
@@ -302,7 +246,7 @@ for (const item of declared) {
302
246
  deck_managed_dirs: managedDirs,
303
247
  });
304
248
 
305
- console.log(` 🔗 ${item.baseName}`);
249
+ console.log(` 🔗 ${item.name}`);
306
250
  }
307
251
 
308
252
  // ── Transient 过期检查 ──────────────────────────────────────
package/src/schema.ts CHANGED
@@ -1,30 +1,5 @@
1
1
  import { z } from "zod";
2
2
 
3
- // ── skill-deck.toml 声明文件 Schema ─────────────────────────
4
- // 防止模板与运行时解析漂移(template drift)
5
-
6
- export const SkillDeckTomlSchema = z.object({
7
- deck: z.object({
8
- working_set: z.string().default(".claude/skills"),
9
- cold_pool: z.string().default("~/.agents/skill-repos"),
10
- max_cards: z.number().int().min(1).default(10),
11
- }),
12
- innate: z.object({
13
- skills: z.array(z.string()).default([]),
14
- }).optional(),
15
- tool: z.object({
16
- skills: z.array(z.string()).default([]),
17
- }).optional(),
18
- combo: z.object({
19
- skills: z.array(z.string()).default([]),
20
- }).optional(),
21
- // transient: 动态子表,key 是 skill 名,value 是任意配置
22
- // 用 z.any() 避免空 [transient] 导致 record key 验证失败
23
- transient: z.record(z.string(), z.any()).optional(),
24
- });
25
-
26
- export type SkillDeckToml = z.infer<typeof SkillDeckTomlSchema>;
27
-
28
3
  // ── 单个已链接 Skill ────────────────────────────────────────
29
4
  export const LinkedSkillSchema = z.object({
30
5
  name: z.string(),
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * deck-validate.ts — Skill Deck configuration validator
4
+ *
5
+ * 读取 skill-deck.toml → 校验 schema、引用有效性、约束合规性。
6
+ * 不做:创建 symlink、修改文件系统。
7
+ */
8
+
9
+ import { parse as parseToml } from "@iarna/toml";
10
+ import { existsSync, readFileSync } from "fs";
11
+ import { resolve } from "path";
12
+ import { findDeckToml, expandHome, findSource } from "./link.js";
13
+
14
+ export function validateDeck(cliDeckPath?: string, cliWorkdir?: string): void {
15
+ const PROJECT_DIR = cliWorkdir ? resolve(cliWorkdir) : process.cwd();
16
+ const DECK_PATH = cliDeckPath
17
+ ? resolve(cliDeckPath)
18
+ : findDeckToml(PROJECT_DIR) || resolve(PROJECT_DIR, "skill-deck.toml");
19
+
20
+ if (!existsSync(DECK_PATH)) {
21
+ console.error(`❌ skill-deck.toml not found: ${DECK_PATH}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const deckRaw = readFileSync(DECK_PATH, "utf-8");
26
+ let deck: any;
27
+ try {
28
+ deck = parseToml(deckRaw);
29
+ } catch (err: any) {
30
+ console.error(`❌ TOML parse error: ${err.message}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ const errors: string[] = [];
35
+ const warnings: string[] = [];
36
+
37
+ // ── Validate deck section ──────────────────────────────────
38
+
39
+ if (!deck.deck || typeof deck.deck !== "object") {
40
+ errors.push("[deck] section is required");
41
+ } else {
42
+ const maxCards = deck.deck.max_cards;
43
+ if (maxCards === undefined) {
44
+ errors.push("deck.max_cards is required");
45
+ } else if (!Number.isInteger(maxCards) || maxCards < 1) {
46
+ errors.push(`deck.max_cards must be a positive integer, got ${maxCards}`);
47
+ }
48
+ }
49
+
50
+ const COLD_POOL = expandHome(deck.deck?.cold_pool || "~/.agents/skill-repos", PROJECT_DIR);
51
+ const MAX_CARDS = Number(deck.deck?.max_cards || 10);
52
+
53
+ // ── Validate skill declarations ────────────────────────────
54
+
55
+ const declaredNames = new Set<string>();
56
+ let declaredCount = 0;
57
+
58
+ for (const section of ["innate", "tool", "combo"] as const) {
59
+ const skills = deck[section]?.skills;
60
+ if (skills === undefined) continue;
61
+ if (!Array.isArray(skills)) {
62
+ errors.push(`[${section}].skills must be an array`);
63
+ continue;
64
+ }
65
+ for (const name of skills) {
66
+ if (!name || typeof name !== "string") {
67
+ errors.push(`[${section}] contains invalid skill name`);
68
+ continue;
69
+ }
70
+ declaredCount++;
71
+ if (declaredNames.has(name)) {
72
+ warnings.push(`Skill "${name}" is declared in multiple sections`);
73
+ }
74
+ declaredNames.add(name);
75
+
76
+ const src = findSource(name, COLD_POOL, PROJECT_DIR);
77
+ if (!src) {
78
+ errors.push(`Skill not found: ${name} (${section})`);
79
+ }
80
+ }
81
+ }
82
+
83
+ // ── Validate transient section ─────────────────────────────
84
+
85
+ const transientCount = Object.keys(deck.transient || {}).length;
86
+ if (deck.transient) {
87
+ for (const [key, value] of Object.entries(deck.transient)) {
88
+ const t = value as any;
89
+ if (!t || typeof t !== "object") {
90
+ errors.push(`transient.${key} must be a table`);
91
+ continue;
92
+ }
93
+ if (!t.path) {
94
+ errors.push(`transient.${key} missing required field: path`);
95
+ continue;
96
+ }
97
+ const src = resolve(PROJECT_DIR, t.path);
98
+ if (!existsSync(src)) {
99
+ errors.push(`transient path does not exist: ${key} → ${src}`);
100
+ }
101
+ if (t.expires) {
102
+ const d = new Date(t.expires);
103
+ if (isNaN(d.getTime())) {
104
+ errors.push(`transient.${key} has invalid expires format: ${t.expires}`);
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // ── Budget check ───────────────────────────────────────────
111
+
112
+ const total = declaredCount + transientCount;
113
+ if (total > MAX_CARDS) {
114
+ errors.push(`Budget exceeded: declared ${total} skill(s), max_cards = ${MAX_CARDS}`);
115
+ }
116
+
117
+ // ── Report ─────────────────────────────────────────────────
118
+
119
+ if (warnings.length > 0) {
120
+ for (const w of warnings) console.warn(`⚠️ ${w}`);
121
+ }
122
+
123
+ if (errors.length > 0) {
124
+ for (const e of errors) console.error(`❌ ${e}`);
125
+ console.error(`\n❌ Validation failed: ${errors.length} error(s)`);
126
+ process.exit(1);
127
+ }
128
+
129
+ console.log(`✅ Validation passed: ${total} skill(s), max_cards = ${MAX_CARDS}`);
130
+ }