@lythos/skill-deck 0.9.36 → 0.9.37
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 +71 -14
- package/package.json +1 -1
- package/src/add.ts +2 -1
- package/src/cli.ts +2 -2
- package/src/reconcile.test.ts +82 -0
- package/src/reconcile.ts +134 -10
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.
|
|
12
|
+
bunx @lythos/skill-deck@0.9.37 <command> [options]
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
No installation required. `bunx` auto-downloads the package.
|
|
@@ -55,17 +55,17 @@ 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.
|
|
59
|
-
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.
|
|
60
|
-
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.
|
|
61
|
-
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.
|
|
62
|
-
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.
|
|
63
|
-
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.
|
|
64
|
-
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.
|
|
65
|
-
| Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.
|
|
66
|
-
| Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.
|
|
67
|
-
| Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.
|
|
68
|
-
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.
|
|
58
|
+
| Sync working set with `skill-deck.toml` | `bunx @lythos/skill-deck@0.9.37 link` |
|
|
59
|
+
| Validate `skill-deck.toml` before committing | `bunx @lythos/skill-deck@0.9.37 validate` |
|
|
60
|
+
| Download a skill to cold pool and add to deck | `bunx @lythos/skill-deck@0.9.37 add owner/repo` |
|
|
61
|
+
| Pull latest versions of declared skills | `bunx @lythos/skill-deck@0.9.37 refresh` |
|
|
62
|
+
| Refresh a single skill by alias | `bunx @lythos/skill-deck@0.9.37 refresh tdd` |
|
|
63
|
+
| Remove a skill from deck and working set | `bunx @lythos/skill-deck@0.9.37 remove tdd` |
|
|
64
|
+
| GC unreferenced repos from cold pool | `bunx @lythos/skill-deck@0.9.37 prune` |
|
|
65
|
+
| Switch skill from snapshot to sync mode | `bunx @lythos/skill-deck@0.9.37 sync tdd` |
|
|
66
|
+
| Switch skill from sync to snapshot mode | `bunx @lythos/skill-deck@0.9.37 freeze tdd` |
|
|
67
|
+
| Check cold pool for drift vs lock file | `bunx @lythos/skill-deck@0.9.37 reconcile` |
|
|
68
|
+
| Use a custom deck file or working dir | `bunx @lythos/skill-deck@0.9.37 link --deck ./my-deck.toml --workdir /path/to/project` |
|
|
69
69
|
|
|
70
70
|
### Commands
|
|
71
71
|
|
|
@@ -128,7 +128,7 @@ path = "github.com/lythos-labs/lythoskill/skills/lythoskill-deck"
|
|
|
128
128
|
EOF
|
|
129
129
|
|
|
130
130
|
# 2. Link — creates symlinks in .claude/skills/
|
|
131
|
-
bunx @lythos/skill-deck@0.9.
|
|
131
|
+
bunx @lythos/skill-deck@0.9.37 link
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
### Key Concepts
|
|
@@ -153,11 +153,68 @@ Different agents look for skills in different directories. `skill-deck.toml` con
|
|
|
153
153
|
|
|
154
154
|
> **If you are an agent**: verify where your platform scans for skills, then set `working_set` to that path before running `deck link`.
|
|
155
155
|
|
|
156
|
+
### For OpenClaw
|
|
157
|
+
|
|
158
|
+
OpenClaw loads skills from multiple locations, in priority order:
|
|
159
|
+
|
|
160
|
+
| Priority | Location | Use case |
|
|
161
|
+
|----------|----------|----------|
|
|
162
|
+
| 1 | `<workspace>/skills` | Workspace-level override |
|
|
163
|
+
| 2 | `<workspace>/.agents/skills` | **Project deck (recommended)** |
|
|
164
|
+
| 3 | `~/.agents/skills` | Personal agent skills |
|
|
165
|
+
| 4 | `~/.openclaw/skills` | Global managed skills |
|
|
166
|
+
|
|
167
|
+
**Per-project deck** (most common):
|
|
168
|
+
```toml
|
|
169
|
+
[deck]
|
|
170
|
+
working_set = ".agents/skills"
|
|
171
|
+
```
|
|
172
|
+
This matches OpenClaw's "project agent skills" path. Run `deck link` from your project root.
|
|
173
|
+
|
|
174
|
+
**Global deck** (manage all OpenClaw skills centrally):
|
|
175
|
+
```toml
|
|
176
|
+
[deck]
|
|
177
|
+
working_set = "~/.openclaw/skills"
|
|
178
|
+
```
|
|
179
|
+
Create this in your home directory. One global deck can syndicate to all projects via OpenClaw's fallback chain.
|
|
180
|
+
|
|
181
|
+
### For Hermes
|
|
182
|
+
|
|
183
|
+
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`.
|
|
184
|
+
|
|
185
|
+
**Recommended: project-level deck + external_dirs**
|
|
186
|
+
|
|
187
|
+
```toml
|
|
188
|
+
# skill-deck.toml
|
|
189
|
+
[deck]
|
|
190
|
+
working_set = ".hermes/skills"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
# ~/.hermes/config.yaml
|
|
195
|
+
skills:
|
|
196
|
+
external_dirs:
|
|
197
|
+
- /absolute/path/to/your/project/.hermes/skills
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Run `deck link` from your project root. Hermes picks up the syndicated skills without touching its primary `~/.hermes/skills/` directory.
|
|
201
|
+
|
|
202
|
+
**Alternative: direct mode (not recommended)**
|
|
203
|
+
|
|
204
|
+
You can point deck directly at `~/.hermes/skills`:
|
|
205
|
+
|
|
206
|
+
```toml
|
|
207
|
+
[deck]
|
|
208
|
+
working_set = "~/.hermes/skills"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
156
213
|
### Troubleshooting
|
|
157
214
|
|
|
158
215
|
| Symptom | Cause | Fix |
|
|
159
216
|
|---------|-------|-----|
|
|
160
|
-
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.
|
|
217
|
+
| `❌ Skill not found: <name>` | Skill declared in deck but not in cold pool | `bunx @lythos/skill-deck@0.9.37 add github.com/owner/repo/skill` or clone manually into cold pool |
|
|
161
218
|
| `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 |
|
|
162
219
|
| `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 |
|
|
163
220
|
| `deck update` prints deprecation warning | `update` was renamed to `refresh` in v0.8+ | Use `deck refresh` instead |
|
package/package.json
CHANGED
package/src/add.ts
CHANGED
|
@@ -249,10 +249,11 @@ export async function addSkill(
|
|
|
249
249
|
'# Skill Deck — generated by lythoskill-deck',
|
|
250
250
|
'# Edit working_set for your agent platform (uncomment one):',
|
|
251
251
|
'# working_set = ".claude/skills" # Claude Code (also read by Cursor, Copilot)',
|
|
252
|
-
'# working_set = ".agents/skills" # Codex CLI, OpenClaw',
|
|
252
|
+
'# working_set = ".agents/skills" # Codex CLI, OpenClaw (project-level)',
|
|
253
253
|
'# working_set = ".cursor/skills" # Cursor-native',
|
|
254
254
|
'# working_set = ".github/skills" # GitHub Copilot',
|
|
255
255
|
'# working_set = ".windsurf/skills" # Windsurf',
|
|
256
|
+
'# For OpenClaw global skills: working_set = "~/.openclaw/skills" (global deck)',
|
|
256
257
|
'# After editing, run: bunx @lythos/skill-deck@latest link',
|
|
257
258
|
'',
|
|
258
259
|
].join('\n')
|
package/src/cli.ts
CHANGED
|
@@ -61,7 +61,7 @@ const HELP_CONFIG = {
|
|
|
61
61
|
{ name: 'prune', description: 'GC cold pool repos no longer referenced by any deck', args: '[--yes]' },
|
|
62
62
|
{ name: 'sync', description: 'Switch skill from snapshot (cp) to sync (symlink)', args: '<alias>' },
|
|
63
63
|
{ name: 'freeze', description: 'Switch skill from sync (symlink) to snapshot (cp), pinning current HEAD', args: '<alias>' },
|
|
64
|
-
{ name: 'reconcile', description: 'Compare lock file
|
|
64
|
+
{ name: 'reconcile', description: 'Compare lock file vs cold pool, report drift', args: '[--apply] [--yes]' },
|
|
65
65
|
{ name: 'migrate-schema', description: 'Convert string-array deck.toml to alias-as-key dict', args: '[--dry-run]' },
|
|
66
66
|
],
|
|
67
67
|
options: [
|
|
@@ -141,7 +141,7 @@ switch (command) {
|
|
|
141
141
|
}
|
|
142
142
|
case 'reconcile': {
|
|
143
143
|
const apply = args.includes('--apply')
|
|
144
|
-
reconcileDeck(deckPath, workdir, apply)
|
|
144
|
+
await reconcileDeck(deckPath, workdir, apply, yes)
|
|
145
145
|
break
|
|
146
146
|
}
|
|
147
147
|
case 'prune': {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { reconcileDeck } from './reconcile.js'
|
|
6
|
+
|
|
7
|
+
describe('reconcileDeck', () => {
|
|
8
|
+
let projectDir: string
|
|
9
|
+
let coldPoolDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
projectDir = mkdtempSync(join(tmpdir(), 'deck-reconcile-'))
|
|
13
|
+
coldPoolDir = join(projectDir, 'cold-pool')
|
|
14
|
+
mkdirSync(coldPoolDir, { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
// Cleanup handled by OS temp dir lifecycle
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function writeLock(skills: any[]) {
|
|
22
|
+
const lock = {
|
|
23
|
+
version: '1.0.0' as const,
|
|
24
|
+
generated_at: new Date().toISOString(),
|
|
25
|
+
deck_source: { path: join(projectDir, 'skill-deck.toml'), content_hash: 'abc' },
|
|
26
|
+
working_set: '.claude/skills',
|
|
27
|
+
cold_pool: coldPoolDir,
|
|
28
|
+
skills,
|
|
29
|
+
constraints: {
|
|
30
|
+
total_cards: skills.length,
|
|
31
|
+
max_cards: 10,
|
|
32
|
+
within_budget: skills.length <= 10,
|
|
33
|
+
transient_warnings: [],
|
|
34
|
+
dir_overlaps: [],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(join(projectDir, 'skill-deck.lock'), JSON.stringify(lock, null, 2))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeDeck() {
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(projectDir, 'skill-deck.toml'),
|
|
43
|
+
`[deck]\ncold_pool = "${coldPoolDir}"\nworking_set = ".claude/skills"\n\n[tool.skills.test]\npath = "github.com/owner/repo/test"\n`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('reports no drift when lock matches cold pool', async () => {
|
|
48
|
+
writeDeck()
|
|
49
|
+
writeLock([
|
|
50
|
+
{
|
|
51
|
+
name: 'test',
|
|
52
|
+
alias: 'test',
|
|
53
|
+
deck_niche: 'test',
|
|
54
|
+
type: 'tool',
|
|
55
|
+
source: 'github.com/owner/repo/test',
|
|
56
|
+
dest: join(projectDir, '.claude/skills/test'),
|
|
57
|
+
mode: 'symlink',
|
|
58
|
+
linked_at: new Date().toISOString(),
|
|
59
|
+
deck_managed_dirs: [],
|
|
60
|
+
},
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
// No cold pool repo = missing, but that's fine for this test
|
|
64
|
+
// We just verify it doesn't crash
|
|
65
|
+
await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('shows plan-only mode by default', async () => {
|
|
69
|
+
writeDeck()
|
|
70
|
+
writeLock([])
|
|
71
|
+
|
|
72
|
+
await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('--apply requires --yes or TTY confirmation', async () => {
|
|
76
|
+
writeDeck()
|
|
77
|
+
writeLock([])
|
|
78
|
+
|
|
79
|
+
// With --yes, apply proceeds even without TTY
|
|
80
|
+
await reconcileDeck(join(projectDir, 'skill-deck.toml'), projectDir, true, true)
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/reconcile.ts
CHANGED
|
@@ -11,10 +11,34 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
11
11
|
import { resolve, dirname, join } from 'node:path'
|
|
12
12
|
import { findDeckToml, expandHome } from './link.js'
|
|
13
13
|
import { parse as parseToml } from '@iarna/toml'
|
|
14
|
-
import { ColdPool, buildReconcilePlan, type ReconcileDesiredState } from '@lythos/cold-pool'
|
|
14
|
+
import { ColdPool, buildReconcilePlan, type ReconcileDesiredState, getRepoHeadRef } from '@lythos/cold-pool'
|
|
15
15
|
import { SkillDeckLockSchema } from './schema.js'
|
|
16
|
+
import { addSkill } from './add.js'
|
|
17
|
+
import { refreshDeck } from './refresh.js'
|
|
18
|
+
import { pruneDeck } from './prune.js'
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
function isTTY(): boolean {
|
|
21
|
+
return process.stdin.isTTY && process.stdout.isTTY
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function promptYesNo(question: string): Promise<boolean> {
|
|
25
|
+
if (!isTTY()) return false
|
|
26
|
+
const { createInterface } = await import('node:readline')
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
30
|
+
rl.close()
|
|
31
|
+
resolve(answer.trim().toLowerCase() === 'y')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function reconcileDeck(
|
|
37
|
+
cliDeckPath?: string,
|
|
38
|
+
cliWorkdir?: string,
|
|
39
|
+
apply?: boolean,
|
|
40
|
+
yes?: boolean,
|
|
41
|
+
): Promise<void> {
|
|
18
42
|
const cliDeck = cliDeckPath || process.argv.find((_, i, a) => a[i - 1] === '--deck')
|
|
19
43
|
const DECK_PATH = cliDeck
|
|
20
44
|
? resolve(cliDeck)
|
|
@@ -52,11 +76,17 @@ export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?:
|
|
|
52
76
|
const coldPoolRaw = lockData.cold_pool || '~/.agents/skill-repos'
|
|
53
77
|
const COLD_POOL = expandHome(coldPoolRaw, PROJECT_DIR)
|
|
54
78
|
|
|
79
|
+
// Build alias → skill info map for locating missing skills
|
|
80
|
+
const skillByAlias = new Map<string, { source: string; type: string; mode: string }>()
|
|
81
|
+
for (const s of lockData.skills) {
|
|
82
|
+
skillByAlias.set(s.alias, { source: s.source, type: s.type, mode: s.mode })
|
|
83
|
+
}
|
|
84
|
+
|
|
55
85
|
// Build desired state from lock
|
|
56
86
|
const desired: ReconcileDesiredState = {
|
|
57
87
|
deckPath: DECK_PATH,
|
|
58
|
-
skills: lockData.skills.map(s => ({
|
|
59
|
-
locator: s.source,
|
|
88
|
+
skills: lockData.skills.map((s) => ({
|
|
89
|
+
locator: s.source,
|
|
60
90
|
alias: s.alias,
|
|
61
91
|
})),
|
|
62
92
|
}
|
|
@@ -99,16 +129,110 @@ export function reconcileDeck(cliDeckPath?: string, cliWorkdir?: string, apply?:
|
|
|
99
129
|
console.log(` Reason: ${entry.reason}`)
|
|
100
130
|
}
|
|
101
131
|
|
|
102
|
-
if (apply) {
|
|
103
|
-
console.log(`\n🏗️ --apply: convergence not yet implemented.`)
|
|
104
|
-
console.log(` For missing: use 'deck add <locator>'`)
|
|
105
|
-
console.log(` For behind: use 'deck refresh'`)
|
|
106
|
-
console.log(` For extra: use 'cold-pool prune'`)
|
|
107
|
-
} else {
|
|
132
|
+
if (!apply) {
|
|
108
133
|
console.log(`\n💡 Plan-first. Use --apply to converge, or handle individually:`)
|
|
109
134
|
console.log(` deck add <locator> → restore missing`)
|
|
110
135
|
console.log(` deck refresh → update behind`)
|
|
111
136
|
console.log(` cold-pool prune → GC extras`)
|
|
137
|
+
pool.metadata.close()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Apply convergence ────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// Confirmation
|
|
144
|
+
if (!yes) {
|
|
145
|
+
const confirmed = await promptYesNo('\nApply these changes?')
|
|
146
|
+
if (!confirmed) {
|
|
147
|
+
console.log('❌ Aborted. No changes made.')
|
|
148
|
+
pool.metadata.close()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(`\n🏗️ Applying convergence...`)
|
|
154
|
+
|
|
155
|
+
const failures: string[] = []
|
|
156
|
+
|
|
157
|
+
// 1. Missing → deck add
|
|
158
|
+
for (const entry of plan.missing) {
|
|
159
|
+
for (const alias of entry.aliases) {
|
|
160
|
+
const info = skillByAlias.get(alias)
|
|
161
|
+
if (!info) {
|
|
162
|
+
failures.push(`Missing skill info for alias: ${alias}`)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
console.log(` ➕ Adding ${alias}...`)
|
|
167
|
+
await addSkill(info.source, {
|
|
168
|
+
deck: DECK_PATH,
|
|
169
|
+
workdir: PROJECT_DIR,
|
|
170
|
+
alias,
|
|
171
|
+
type: info.type,
|
|
172
|
+
mode: info.mode as 'symlink' | 'snapshot',
|
|
173
|
+
})
|
|
174
|
+
console.log(` ✅ Added ${alias}`)
|
|
175
|
+
} catch (e: any) {
|
|
176
|
+
failures.push(`Add ${alias}: ${e.message}`)
|
|
177
|
+
console.error(` ❌ Failed to add ${alias}: ${e.message}`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 2. Behind → check actual HEAD vs recorded, then refresh if different
|
|
183
|
+
for (const entry of plan.behind) {
|
|
184
|
+
try {
|
|
185
|
+
const recordedRef = pool.metadata.getRepoRef(entry.host, entry.owner, entry.repo)
|
|
186
|
+
if (!recordedRef) {
|
|
187
|
+
console.log(` ⏭️ Skipping ${entry.host}/${entry.owner}/${entry.repo} — no recorded HEAD`)
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const currentRef = await getRepoHeadRef(entry.repoPath)
|
|
191
|
+
if (currentRef === recordedRef) {
|
|
192
|
+
console.log(` ✅ ${entry.host}/${entry.owner}/${entry.repo} is up to date (${currentRef.slice(0, 8)})`)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
console.log(` 🔄 Refreshing ${entry.host}/${entry.owner}/${entry.repo} (${recordedRef.slice(0, 8)} → ${currentRef.slice(0, 8)})...`)
|
|
196
|
+
// Refresh all aliases for this repo
|
|
197
|
+
for (const alias of entry.aliases) {
|
|
198
|
+
try {
|
|
199
|
+
refreshDeck(DECK_PATH, PROJECT_DIR, alias)
|
|
200
|
+
console.log(` ✅ Refreshed ${alias}`)
|
|
201
|
+
} catch (e: any) {
|
|
202
|
+
failures.push(`Refresh ${alias}: ${e.message}`)
|
|
203
|
+
console.error(` ❌ Failed to refresh ${alias}: ${e.message}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (e: any) {
|
|
207
|
+
failures.push(`Behind ${entry.host}/${entry.owner}/${entry.repo}: ${e.message}`)
|
|
208
|
+
console.error(` ❌ Failed to check/refresh ${entry.host}/${entry.owner}/${entry.repo}: ${e.message}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 3. Extra → prune (global, only once)
|
|
213
|
+
if (plan.extra.length > 0) {
|
|
214
|
+
try {
|
|
215
|
+
console.log(` 🗑️ Pruning extras...`)
|
|
216
|
+
await pruneDeck(DECK_PATH, PROJECT_DIR, true)
|
|
217
|
+
console.log(` ✅ Prune complete`)
|
|
218
|
+
} catch (e: any) {
|
|
219
|
+
failures.push(`Prune: ${e.message}`)
|
|
220
|
+
console.error(` ❌ Prune failed: ${e.message}`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Summary
|
|
225
|
+
console.log(`\n📋 Convergence summary:`)
|
|
226
|
+
console.log(` Missing resolved: ${plan.missing.length}`)
|
|
227
|
+
console.log(` Behind resolved: ${plan.behind.length}`)
|
|
228
|
+
console.log(` Extra resolved: ${plan.extra.length}`)
|
|
229
|
+
if (failures.length > 0) {
|
|
230
|
+
console.log(` ❌ Failures: ${failures.length}`)
|
|
231
|
+
for (const f of failures) {
|
|
232
|
+
console.log(` - ${f}`)
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
console.log(` ✅ All operations successful`)
|
|
112
236
|
}
|
|
113
237
|
|
|
114
238
|
pool.metadata.close()
|