@lythos/cold-pool 0.9.45 → 0.9.46
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/package.json +4 -1
- package/src/cli.ts +216 -0
- package/src/cold-pool.test.ts +10 -0
- package/src/cold-pool.ts +61 -24
- package/src/index.ts +3 -0
- package/src/metadata-db.ts +179 -18
- package/src/prune-plan.ts +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.46",
|
|
4
4
|
"description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
},
|
|
25
25
|
"main": "src/index.ts",
|
|
26
26
|
"types": "src/index.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"cold-pool": "src/cli.ts"
|
|
29
|
+
},
|
|
27
30
|
"files": [
|
|
28
31
|
"src",
|
|
29
32
|
"README.md",
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* @lythos/cold-pool CLI — cold pool management commands.
|
|
4
|
+
*
|
|
5
|
+
* Commands:
|
|
6
|
+
* prune Scan cold pool for unreferenced repos (uses metadata DB FSM)
|
|
7
|
+
* reconcile Compare a lock file's desired state against cold pool
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
|
|
14
|
+
import { buildPrunePlan, executePrunePlan } from './prune-plan.js'
|
|
15
|
+
import { parseLocator } from './parse-locator.js'
|
|
16
|
+
|
|
17
|
+
const CMD = 'cold-pool'
|
|
18
|
+
|
|
19
|
+
function help(): void {
|
|
20
|
+
console.log(`@lythos/cold-pool — Cold pool management CLI
|
|
21
|
+
|
|
22
|
+
Usage: bunx @lythos/cold-pool <command> [options]
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
prune [--yes] [--dry-run]
|
|
26
|
+
Scan cold pool for repos with no active deck references and
|
|
27
|
+
offer to delete them. Uses metadata DB deck_refs FSM for
|
|
28
|
+
cross-deck reference counting (only prunes if ALL refs are removed).
|
|
29
|
+
|
|
30
|
+
--yes Skip confirmation
|
|
31
|
+
--dry-run Report only, no deletion
|
|
32
|
+
|
|
33
|
+
reconcile [--lock <path>]
|
|
34
|
+
Read a skill-deck.lock file and compare its desired state against
|
|
35
|
+
the cold pool. Plan-only (report drift). Use individual deck
|
|
36
|
+
commands to converge.
|
|
37
|
+
|
|
38
|
+
--lock <path> Path to skill-deck.lock (default: ./skill-deck.lock)
|
|
39
|
+
|
|
40
|
+
help Show this help
|
|
41
|
+
`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function main(): Promise<void> {
|
|
45
|
+
const args = process.argv.slice(2)
|
|
46
|
+
const command = args[0]
|
|
47
|
+
|
|
48
|
+
if (!command || command === 'help' || command === '--help') {
|
|
49
|
+
help()
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolve cold pool path (env var > default)
|
|
54
|
+
const coldPoolPath = process.env.LYTHOS_COLD_POOL ?? DEFAULT_COLD_POOL_PATH
|
|
55
|
+
|
|
56
|
+
switch (command) {
|
|
57
|
+
// ── prune ─────────────────────────────────────────────────
|
|
58
|
+
case 'prune': {
|
|
59
|
+
const yes = args.includes('--yes')
|
|
60
|
+
const dryRun = args.includes('--dry-run')
|
|
61
|
+
|
|
62
|
+
if (!existsSync(coldPoolPath)) {
|
|
63
|
+
console.log('📭 Cold pool does not exist. Nothing to prune.')
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const plan = buildPrunePlan(coldPoolPath)
|
|
68
|
+
|
|
69
|
+
if (plan.candidates.length === 0) {
|
|
70
|
+
console.log('✅ All cold pool repositories are referenced. Nothing to prune.')
|
|
71
|
+
process.exit(0)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (dryRun) {
|
|
75
|
+
executePrunePlan(plan, { log: console.log })
|
|
76
|
+
process.exit(0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Interactive confirmation
|
|
80
|
+
if (!yes) {
|
|
81
|
+
const { createInterface } = await import('node:readline')
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
83
|
+
const answer = await new Promise<string>((resolve) => {
|
|
84
|
+
rl.question(`\nDelete ${plan.candidates.length} unreferenced repo(s)? [y/N] `, resolve)
|
|
85
|
+
})
|
|
86
|
+
rl.close()
|
|
87
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
88
|
+
console.log('❎ Prune cancelled.')
|
|
89
|
+
process.exit(0)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const results = executePrunePlan(plan, {
|
|
93
|
+
delete: (p: string) => rmSync(p, { recursive: true, force: true }),
|
|
94
|
+
log: console.log,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
if (results.some((r) => !r.deleted)) process.exit(1)
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Non-interactive (--yes)
|
|
102
|
+
const results = executePrunePlan(plan, {
|
|
103
|
+
delete: (p: string) => rmSync(p, { recursive: true, force: true }),
|
|
104
|
+
log: console.log,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (results.some((r) => !r.deleted)) process.exit(1)
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── reconcile ─────────────────────────────────────────────
|
|
112
|
+
case 'reconcile': {
|
|
113
|
+
const lockIdx = args.indexOf('--lock')
|
|
114
|
+
const lockPath = lockIdx >= 0 ? args[lockIdx + 1] : undefined
|
|
115
|
+
|
|
116
|
+
if (!lockPath) {
|
|
117
|
+
console.error('❌ Missing --lock <path>. Usage: cold-pool reconcile --lock ./skill-deck.lock')
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!existsSync(lockPath)) {
|
|
122
|
+
console.error(`❌ Lock file not found: ${lockPath}`)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let lock: any
|
|
127
|
+
try {
|
|
128
|
+
lock = JSON.parse(readFileSync(lockPath, 'utf-8'))
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
console.error(`❌ Failed to parse lock file: ${e.message}`)
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!lock.skills || !Array.isArray(lock.skills)) {
|
|
135
|
+
console.error('❌ Lock file has no "skills" array. Run deck link first.')
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!existsSync(coldPoolPath)) {
|
|
140
|
+
console.log('📭 Cold pool does not exist. Nothing to reconcile.')
|
|
141
|
+
process.exit(0)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const pool = new ColdPool(coldPoolPath)
|
|
145
|
+
const desired: ReconcileDesiredState = {
|
|
146
|
+
deckPath: lockPath,
|
|
147
|
+
skills: lock.skills.map((s: any) => ({ locator: s.source, alias: s.alias })),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const missing: string[] = []
|
|
151
|
+
const behind: string[] = []
|
|
152
|
+
const extra: string[] = []
|
|
153
|
+
|
|
154
|
+
// Reconcile: check each declared skill
|
|
155
|
+
for (const skill of lock.skills) {
|
|
156
|
+
const parsed = parseLocator(skill.source)
|
|
157
|
+
if (!parsed) continue
|
|
158
|
+
if (!pool.has(parsed)) {
|
|
159
|
+
missing.push(`${skill.alias} (${skill.source})`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Scan cold pool for extras: repos not referenced by this lock
|
|
164
|
+
const allRepos = pool.list()
|
|
165
|
+
const declaredSources = new Set(lock.skills.map((s: any) => s.source))
|
|
166
|
+
for (const repoPath of allRepos) {
|
|
167
|
+
const repoRel = repoPath.slice(coldPoolPath.length + 1)
|
|
168
|
+
const isReferenced = [...declaredSources].some(
|
|
169
|
+
(src) => src === repoRel || src.startsWith(repoRel + '/'),
|
|
170
|
+
)
|
|
171
|
+
if (!isReferenced) {
|
|
172
|
+
extra.push(repoRel)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pool.metadata.close()
|
|
177
|
+
|
|
178
|
+
// Report
|
|
179
|
+
console.log(`\n📊 Reconcile Report`)
|
|
180
|
+
console.log(` Cold pool: ${coldPoolPath}`)
|
|
181
|
+
console.log(` Skills declared: ${lock.skills.length}`)
|
|
182
|
+
|
|
183
|
+
if (missing.length === 0 && behind.length === 0 && extra.length === 0) {
|
|
184
|
+
console.log(`\n✅ No drift detected — cold pool matches desired state.`)
|
|
185
|
+
process.exit(0)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`\n🔍 Drift detected:`)
|
|
189
|
+
console.log(` ❌ Missing: ${missing.length}`)
|
|
190
|
+
console.log(` ⚠️ Behind: ${behind.length}`)
|
|
191
|
+
console.log(` 📦 Extra: ${extra.length}`)
|
|
192
|
+
|
|
193
|
+
if (missing.length > 0) {
|
|
194
|
+
console.log(`\n ❌ Missing repos (declared but not in cold pool):`)
|
|
195
|
+
for (const m of missing) console.log(` ${m}`)
|
|
196
|
+
}
|
|
197
|
+
if (extra.length > 0) {
|
|
198
|
+
console.log(`\n 📦 Extra repos (in cold pool but not declared):`)
|
|
199
|
+
for (const e of extra) console.log(` ${e}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(`\n💡 Plan-only. Use 'deck add <locator>' to restore missing, or 'cold-pool prune' to remove extras.`)
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
default:
|
|
207
|
+
console.error(`❌ Unknown command: ${command}`)
|
|
208
|
+
help()
|
|
209
|
+
process.exit(1)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
main().catch((e: Error) => {
|
|
214
|
+
console.error(`❌ ${e.message}`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
})
|
package/src/cold-pool.test.ts
CHANGED
|
@@ -46,7 +46,9 @@ describe('ColdPool — fs-backed read accessors', () => {
|
|
|
46
46
|
// "Directory layers = FQ locator segments."
|
|
47
47
|
const root = mkdtempSync(join(tmpdir(), 'cold-pool-test-'))
|
|
48
48
|
mkdirSync(join(root, 'github.com/owner/repo-a'), { recursive: true })
|
|
49
|
+
writeFileSync(join(root, 'github.com/owner/repo-a/SKILL.md'), '# a')
|
|
49
50
|
mkdirSync(join(root, 'github.com/owner/repo-b'), { recursive: true })
|
|
51
|
+
writeFileSync(join(root, 'github.com/owner/repo-b/SKILL.md'), '# b')
|
|
50
52
|
mkdirSync(join(root, 'localhost/me/skill-x'), { recursive: true })
|
|
51
53
|
writeFileSync(join(root, 'localhost/me/skill-x/SKILL.md'), '# x')
|
|
52
54
|
// Hidden dir should be skipped
|
|
@@ -111,6 +113,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
|
|
|
111
113
|
dir('github.com'),
|
|
112
114
|
dir('github.com/owner'),
|
|
113
115
|
dir('github.com/owner/repo-a'),
|
|
116
|
+
file('github.com/owner/repo-a/SKILL.md'),
|
|
114
117
|
]
|
|
115
118
|
const plan = buildListPlan(root, entries)
|
|
116
119
|
expect(plan.entries).toEqual([
|
|
@@ -124,6 +127,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
|
|
|
124
127
|
dir('github.com'),
|
|
125
128
|
dir('github.com/owner'),
|
|
126
129
|
dir('github.com/owner/repo-a'),
|
|
130
|
+
file('github.com/owner/repo-a/SKILL.md'),
|
|
127
131
|
dir('github.com/owner/.DS_Store'),
|
|
128
132
|
]
|
|
129
133
|
const plan = buildListPlan(root, entries)
|
|
@@ -160,6 +164,7 @@ describe('buildListPlan — pure classification (no IO)', () => {
|
|
|
160
164
|
dir('github.com'),
|
|
161
165
|
dir('github.com/owner'),
|
|
162
166
|
dir('github.com/owner/repo-a'),
|
|
167
|
+
file('github.com/owner/repo-a/SKILL.md'),
|
|
163
168
|
dir('localhost'),
|
|
164
169
|
dir('localhost/old-skill'),
|
|
165
170
|
file('localhost/old-skill/SKILL.md'),
|
|
@@ -175,8 +180,11 @@ describe('buildListPlan — pure classification (no IO)', () => {
|
|
|
175
180
|
dir('github.com'),
|
|
176
181
|
dir('github.com/owner'),
|
|
177
182
|
dir('github.com/owner/repo-a'),
|
|
183
|
+
file('github.com/owner/repo-a/SKILL.md'),
|
|
178
184
|
dir('github.com/owner/repo-b'),
|
|
185
|
+
file('github.com/owner/repo-b/SKILL.md'),
|
|
179
186
|
dir('github.com/owner/repo-c'),
|
|
187
|
+
file('github.com/owner/repo-c/SKILL.md'),
|
|
180
188
|
]
|
|
181
189
|
const plan = buildListPlan(root, entries)
|
|
182
190
|
expect(plan.entries).toHaveLength(3)
|
|
@@ -188,9 +196,11 @@ describe('buildListPlan — pure classification (no IO)', () => {
|
|
|
188
196
|
dir('github.com'),
|
|
189
197
|
dir('github.com/a'),
|
|
190
198
|
dir('github.com/a/r1'),
|
|
199
|
+
file('github.com/a/r1/SKILL.md'),
|
|
191
200
|
dir('gitlab.com'),
|
|
192
201
|
dir('gitlab.com/b'),
|
|
193
202
|
dir('gitlab.com/b/r2'),
|
|
203
|
+
file('gitlab.com/b/r2/SKILL.md'),
|
|
194
204
|
]
|
|
195
205
|
const plan = buildListPlan(root, entries)
|
|
196
206
|
expect(plan.entries).toHaveLength(2)
|
package/src/cold-pool.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { homedir } from 'node:os'
|
|
3
3
|
import { join, relative } from 'node:path'
|
|
4
4
|
import type { Locator } from './types.js'
|
|
@@ -28,17 +28,22 @@ export interface ListPlan {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Pure: given a cold-pool root path and a flat list of all fs entries
|
|
31
|
-
* (with relPath), classify every
|
|
31
|
+
* (with relPath), classify every directory containing SKILL.md.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Walk ordering:
|
|
34
|
+
* 1. Terminal repos at depth 3+ (canonical — host/owner/repo)
|
|
35
|
+
* 2. Legacy depth-2 <host>/<name>/SKILL.md (monorepo roots)
|
|
36
|
+
* 3. Legacy depth-1 <host>/SKILL.md (flat repos)
|
|
37
|
+
* 4. Fallback: any other directory with SKILL.md at any depth
|
|
38
|
+
*
|
|
39
|
+
* SKILL.md presence is the single authoritative marker for a skill
|
|
40
|
+
* directory. Terminal-depth heuristics alone miss real-world layouts
|
|
41
|
+
* like monorepos with subdirs, multi-skill repos, and mixed-depth clones.
|
|
36
42
|
*/
|
|
37
43
|
export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
|
|
38
44
|
const plan: ListPlanEntry[] = []
|
|
39
45
|
const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
|
|
40
46
|
|
|
41
|
-
// Determine whether a dir is terminal (no child dirs)
|
|
42
47
|
function isTerminal(relPath: string): boolean {
|
|
43
48
|
const prefix = relPath + '/'
|
|
44
49
|
for (const d of dirSet) {
|
|
@@ -47,37 +52,39 @@ export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPla
|
|
|
47
52
|
return true
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
// Check whether a dir directly contains SKILL.md
|
|
51
55
|
function hasSkillMd(dirRel: string): boolean {
|
|
52
56
|
return allEntries.some(e => e.relPath === `${dirRel}/SKILL.md` && !e.isDirectory)
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
//
|
|
59
|
+
// Phase 1: Process terminal dirs (backward compat, but only WITH SKILL.md)
|
|
56
60
|
for (const d of dirSet) {
|
|
57
61
|
if (d.startsWith('.') || d.split('/').some(s => s.startsWith('.'))) continue
|
|
58
|
-
if (!isTerminal(d)) continue
|
|
62
|
+
if (!isTerminal(d) || !hasSkillMd(d)) continue
|
|
59
63
|
|
|
60
64
|
const segments = d.split('/')
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
if (segments.length >= 3) {
|
|
67
|
+
// Canonical or deeper: host/owner/repo[ /sub...]
|
|
68
|
+
plan.push({ path: join(rootPath, d), kind: 'canonical' })
|
|
69
|
+
} else if (segments.length === 2) {
|
|
70
|
+
plan.push({ path: join(rootPath, d), kind: 'legacy-depth2' })
|
|
71
|
+
} else if (segments.length === 1) {
|
|
64
72
|
plan.push({ path: join(rootPath, d), kind: 'legacy-depth1' })
|
|
65
|
-
continue
|
|
66
73
|
}
|
|
74
|
+
}
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
// Phase 2: Non-terminal dirs with SKILL.md (monorepo roots, multi-skill repos)
|
|
77
|
+
// These are missed by phase 1 because they have subdirectories.
|
|
78
|
+
for (const d of dirSet) {
|
|
79
|
+
if (d.startsWith('.') || d.split('/').some(s => s.startsWith('.'))) continue
|
|
80
|
+
if (isTerminal(d)) continue
|
|
81
|
+
if (!hasSkillMd(d)) continue
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
plan.push({ path: join(rootPath, d), kind: 'canonical' })
|
|
80
|
-
}
|
|
83
|
+
const segments = d.split('/')
|
|
84
|
+
const kind = segments.length >= 3 ? 'canonical' : segments.length === 2 ? 'legacy-depth2' : 'legacy-depth1'
|
|
85
|
+
// Deduplicate (phase 1 may have already added this path)
|
|
86
|
+
if (!plan.some(e => e.path === join(rootPath, d))) {
|
|
87
|
+
plan.push({ path: join(rootPath, d), kind })
|
|
81
88
|
}
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -103,6 +110,36 @@ export class ColdPool {
|
|
|
103
110
|
return existsSync(this.resolveDir(locator))
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Find all skill directories by scanning for SKILL.md in the cold pool.
|
|
115
|
+
* Returns absolute paths.
|
|
116
|
+
*
|
|
117
|
+
* Both `list()` (and its underlying `buildListPlan()`) and this method now
|
|
118
|
+
* converge on SKILL.md presence as the authoritative marker. `list()`
|
|
119
|
+
* additionally classifies entries by canonical/legacy kind.
|
|
120
|
+
*/
|
|
121
|
+
findSkillDirectories(): string[] {
|
|
122
|
+
const dirs: string[] = []
|
|
123
|
+
if (!existsSync(this.path)) return dirs
|
|
124
|
+
|
|
125
|
+
function walk(dir: string, push: (d: string) => void): void {
|
|
126
|
+
let dirents: ReturnType<typeof readdirSync>
|
|
127
|
+
try { dirents = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
128
|
+
for (const d of dirents) {
|
|
129
|
+
if (!d.isDirectory() || d.name.startsWith('.')) continue
|
|
130
|
+
const sub = join(dir, d.name)
|
|
131
|
+
if (existsSync(join(sub, 'SKILL.md'))) {
|
|
132
|
+
push(sub)
|
|
133
|
+
} else {
|
|
134
|
+
walk(sub, push)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
walk(this.path, (d) => dirs.push(d))
|
|
140
|
+
return dirs
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
/** Enumerate cold-pool entries. Delegates classification to pure buildListPlan. */
|
|
107
144
|
list(): string[] {
|
|
108
145
|
if (!existsSync(this.path)) return []
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,9 @@ export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
|
|
|
39
39
|
|
|
40
40
|
export { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
|
|
41
41
|
|
|
42
|
+
export type { PruneCandidate, PrunePlan, PruneResult, PruneIO } from './prune-plan.js'
|
|
43
|
+
export { buildPrunePlan, executePrunePlan } from './prune-plan.js'
|
|
44
|
+
|
|
42
45
|
export type {
|
|
43
46
|
DesiredSkill,
|
|
44
47
|
ReconcileDesiredState,
|
package/src/metadata-db.ts
CHANGED
|
@@ -7,10 +7,22 @@
|
|
|
7
7
|
* Three tables:
|
|
8
8
|
* repos — per-repo HEAD ref tracking
|
|
9
9
|
* skills — per-skill content hash (git blob hash of SKILL.md)
|
|
10
|
-
* deck_refs — cross-deck reference index
|
|
10
|
+
* deck_refs — cross-deck reference index with FSM state tracking
|
|
11
|
+
*
|
|
12
|
+
* deck_refs FSM (v3+):
|
|
13
|
+
* state: 'added' | 'linked' | 'removed'
|
|
14
|
+
* added ──link()──→ linked ──remove()──→ removed ──add()──→ added
|
|
15
|
+
* added ──remove()──→ removed (never linked)
|
|
16
|
+
* removed ──reconcile()──→ linked (reconciled back into active state)
|
|
17
|
+
*
|
|
18
|
+
* Prune uses state to distinguish "truly unreferenced" (all refs = removed)
|
|
19
|
+
* from "has active decks" (any ref = added/linked), preventing cross-deck
|
|
20
|
+
* accidental deletion.
|
|
11
21
|
*/
|
|
12
22
|
|
|
13
|
-
import { SqliteDb } from '
|
|
23
|
+
import { SqliteDb } from '@lythos/infra'
|
|
24
|
+
|
|
25
|
+
export type DeckRefState = 'added' | 'linked' | 'removed'
|
|
14
26
|
|
|
15
27
|
export interface RepoRef {
|
|
16
28
|
host: string
|
|
@@ -34,9 +46,13 @@ export interface DeckReference {
|
|
|
34
46
|
skillLocator: string
|
|
35
47
|
deckPath: string
|
|
36
48
|
declaredAlias: string | null
|
|
49
|
+
state: DeckRefState | null
|
|
50
|
+
mode: string | null
|
|
51
|
+
linkedAt: string | null
|
|
52
|
+
removedAt: string | null
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
const CURRENT_SCHEMA =
|
|
55
|
+
const CURRENT_SCHEMA = 6
|
|
40
56
|
|
|
41
57
|
export class MetadataDB extends SqliteDb {
|
|
42
58
|
constructor(dbPath: string) {
|
|
@@ -74,12 +90,17 @@ export class MetadataDB extends SqliteDb {
|
|
|
74
90
|
skill_locator TEXT NOT NULL,
|
|
75
91
|
deck_path TEXT NOT NULL,
|
|
76
92
|
declared_alias TEXT,
|
|
93
|
+
state TEXT NOT NULL DEFAULT 'linked',
|
|
94
|
+
mode TEXT DEFAULT 'symlink',
|
|
95
|
+
linked_at TEXT,
|
|
96
|
+
removed_at TEXT,
|
|
77
97
|
PRIMARY KEY (skill_locator, deck_path)
|
|
78
98
|
)
|
|
79
99
|
`)
|
|
80
100
|
|
|
81
101
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_deck ON deck_refs(deck_path)`)
|
|
82
102
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_locator ON deck_refs(skill_locator)`)
|
|
103
|
+
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_state ON deck_refs(state)`)
|
|
83
104
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_skills_repo ON skills(host, owner, repo)`)
|
|
84
105
|
|
|
85
106
|
// Schema migrations
|
|
@@ -89,6 +110,22 @@ export class MetadataDB extends SqliteDb {
|
|
|
89
110
|
version: 2,
|
|
90
111
|
sql: `ALTER TABLE skills ADD COLUMN git_blob_hash TEXT`,
|
|
91
112
|
},
|
|
113
|
+
{
|
|
114
|
+
version: 3,
|
|
115
|
+
sql: `ALTER TABLE deck_refs ADD COLUMN state TEXT NOT NULL DEFAULT 'linked'`,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
version: 4,
|
|
119
|
+
sql: `ALTER TABLE deck_refs ADD COLUMN linked_at TEXT`,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
version: 5,
|
|
123
|
+
sql: `ALTER TABLE deck_refs ADD COLUMN removed_at TEXT`,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
version: 6,
|
|
127
|
+
sql: `ALTER TABLE deck_refs ADD COLUMN mode TEXT DEFAULT 'symlink'`,
|
|
128
|
+
},
|
|
92
129
|
])
|
|
93
130
|
}
|
|
94
131
|
|
|
@@ -151,50 +188,174 @@ export class MetadataDB extends SqliteDb {
|
|
|
151
188
|
return row?.content_sha256 ?? null
|
|
152
189
|
}
|
|
153
190
|
|
|
154
|
-
// ── Deck References
|
|
191
|
+
// ── Deck References — FSM ───────────────────────────────────
|
|
155
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Add a reference. Sets state='added' (declared but may not be linked).
|
|
195
|
+
* Can re-activate a previously-removed reference (state: removed → added).
|
|
196
|
+
*/
|
|
156
197
|
addReference(skillLocator: string, deckPath: string, declaredAlias: string | null): void {
|
|
157
198
|
this.exec(
|
|
158
|
-
`INSERT OR REPLACE INTO deck_refs (skill_locator, deck_path, declared_alias)
|
|
159
|
-
VALUES ($locator, $deck, $alias)`,
|
|
199
|
+
`INSERT OR REPLACE INTO deck_refs (skill_locator, deck_path, declared_alias, state, linked_at, removed_at)
|
|
200
|
+
VALUES ($locator, $deck, $alias, 'added', NULL, NULL)`,
|
|
160
201
|
{ $locator: skillLocator, $deck: deckPath, $alias: declaredAlias },
|
|
161
202
|
)
|
|
162
203
|
}
|
|
163
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Soft-remove: sets state='removed' instead of deleting the row.
|
|
207
|
+
* The historical record is preserved for cross-deck reference counting.
|
|
208
|
+
* Removes linked_at to indicate the skill is no longer active.
|
|
209
|
+
*/
|
|
164
210
|
removeReference(skillLocator: string, deckPath: string): void {
|
|
165
211
|
this.exec(
|
|
166
|
-
`
|
|
167
|
-
|
|
212
|
+
`UPDATE deck_refs SET state = 'removed', removed_at = $now
|
|
213
|
+
WHERE skill_locator = $locator AND deck_path = $deck`,
|
|
214
|
+
{ $locator: skillLocator, $deck: deckPath, $now: this.now() },
|
|
168
215
|
)
|
|
169
216
|
}
|
|
170
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Soft-remove all refs for a deck (same as removeReference, bulk).
|
|
220
|
+
*/
|
|
171
221
|
removeAllReferencesForDeck(deckPath: string): void {
|
|
172
|
-
this.exec(
|
|
222
|
+
this.exec(
|
|
223
|
+
`UPDATE deck_refs SET state = 'removed', removed_at = $now WHERE deck_path = $deck`,
|
|
224
|
+
{ $deck: deckPath, $now: this.now() },
|
|
225
|
+
)
|
|
173
226
|
}
|
|
174
227
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Get active (non-removed) referencing decks for a skill locator.
|
|
230
|
+
* Legacy rows with state=NULL are treated as active.
|
|
231
|
+
* Returns only active references — for full history use getAllRefsForLocator.
|
|
232
|
+
*/
|
|
233
|
+
getReferencingDecks(
|
|
234
|
+
skillLocator: string,
|
|
235
|
+
): Array<{ deckPath: string; alias: string | null; state: string | null }> {
|
|
236
|
+
const rows = this.queryAll<{ deck_path: string; declared_alias: string | null; state: string | null }>(
|
|
237
|
+
`SELECT deck_path, declared_alias, state FROM deck_refs
|
|
238
|
+
WHERE skill_locator = $locator AND (state IS NULL OR state != 'removed')`,
|
|
178
239
|
{ $locator: skillLocator },
|
|
179
240
|
)
|
|
180
|
-
return rows.map((r) => ({ deckPath: r.deck_path, alias: r.declared_alias }))
|
|
241
|
+
return rows.map((r) => ({ deckPath: r.deck_path, alias: r.declared_alias, state: r.state }))
|
|
181
242
|
}
|
|
182
243
|
|
|
183
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Get ALL refs for a locator including removed (historical) ones.
|
|
246
|
+
* Used by prune to determine if a repo has NO active refs across any deck.
|
|
247
|
+
*/
|
|
248
|
+
getAllRefsForLocator(skillLocator: string): DeckReference[] {
|
|
249
|
+
const rows = this.queryAll<{
|
|
250
|
+
skill_locator: string
|
|
251
|
+
deck_path: string
|
|
252
|
+
declared_alias: string | null
|
|
253
|
+
state: string | null
|
|
254
|
+
mode: string | null
|
|
255
|
+
linked_at: string | null
|
|
256
|
+
removed_at: string | null
|
|
257
|
+
}>(
|
|
258
|
+
`SELECT skill_locator, deck_path, declared_alias, state, mode, linked_at, removed_at
|
|
259
|
+
FROM deck_refs WHERE skill_locator = $locator`,
|
|
260
|
+
{ $locator: skillLocator },
|
|
261
|
+
)
|
|
262
|
+
return rows.map((r) => ({
|
|
263
|
+
skillLocator: r.skill_locator,
|
|
264
|
+
deckPath: r.deck_path,
|
|
265
|
+
declaredAlias: r.declared_alias,
|
|
266
|
+
state: r.state as DeckRefState | null,
|
|
267
|
+
mode: r.mode,
|
|
268
|
+
linkedAt: r.linked_at,
|
|
269
|
+
removedAt: r.removed_at,
|
|
270
|
+
}))
|
|
271
|
+
}
|
|
184
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Get all distinct skill locators that have at least one active ref
|
|
275
|
+
* (state = NULL/added/linked, not 'removed'). Used by prune to determine
|
|
276
|
+
* which repos must NOT be deleted.
|
|
277
|
+
*/
|
|
278
|
+
getAllActiveLocators(): string[] {
|
|
279
|
+
const rows = this.queryAll<{ skill_locator: string }>(
|
|
280
|
+
`SELECT DISTINCT skill_locator FROM deck_refs
|
|
281
|
+
WHERE state IS NULL OR state IN ('added', 'linked')`,
|
|
282
|
+
)
|
|
283
|
+
return rows.map((r) => r.skill_locator)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Update the state of a single reference. Validates the transition:
|
|
288
|
+
* added ↔ linked, added/linked → removed, removed → added
|
|
289
|
+
* Invalid transitions (e.g., linked → added, null → removed) are silently
|
|
290
|
+
* accepted to avoid hard failures from caller ordering issues.
|
|
291
|
+
*/
|
|
292
|
+
updateRefState(skillLocator: string, deckPath: string, newState: DeckRefState): void {
|
|
293
|
+
const now = this.now()
|
|
294
|
+
switch (newState) {
|
|
295
|
+
case 'linked':
|
|
296
|
+
this.exec(
|
|
297
|
+
`UPDATE deck_refs SET state = 'linked', linked_at = $now
|
|
298
|
+
WHERE skill_locator = $locator AND deck_path = $deck`,
|
|
299
|
+
{ $locator: skillLocator, $deck: deckPath, $now: now },
|
|
300
|
+
)
|
|
301
|
+
break
|
|
302
|
+
case 'removed':
|
|
303
|
+
this.removeReference(skillLocator, deckPath)
|
|
304
|
+
break
|
|
305
|
+
case 'added':
|
|
306
|
+
this.exec(
|
|
307
|
+
`UPDATE deck_refs SET state = 'added', removed_at = NULL
|
|
308
|
+
WHERE skill_locator = $locator AND deck_path = $deck`,
|
|
309
|
+
{ $locator: skillLocator, $deck: deckPath },
|
|
310
|
+
)
|
|
311
|
+
break
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Reconcile all deck references atomically.
|
|
317
|
+
*
|
|
318
|
+
* Compared to the old DELETE+INSERT approach, this uses state transitions:
|
|
319
|
+
* - Skills still declared → state='linked' (active, just linked)
|
|
320
|
+
* - Skills no longer declared → state='removed' (was active, now gone)
|
|
321
|
+
*
|
|
322
|
+
* The key semantic change: we NEVER hard-delete from deck_refs during normal
|
|
323
|
+
* operations. This enables cross-deck reference counting via getAllActiveLocators().
|
|
324
|
+
*/
|
|
185
325
|
reconcileDeckReferences(
|
|
186
326
|
deckPath: string,
|
|
187
327
|
declaredSkills: Array<{ locator: string; alias: string | null }>,
|
|
188
328
|
): void {
|
|
189
329
|
this.db.transaction(() => {
|
|
190
|
-
|
|
330
|
+
// Get current refs for this deck (any state)
|
|
331
|
+
const currentRefs = this.queryAll<{ skill_locator: string; state: string | null }>(
|
|
332
|
+
`SELECT skill_locator, state FROM deck_refs WHERE deck_path = $deck`,
|
|
333
|
+
{ $deck: deckPath },
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
const declaredSet = new Map<string, string | null>()
|
|
337
|
+
for (const s of declaredSkills) {
|
|
338
|
+
declaredSet.set(s.locator, s.alias)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Mark removed: skills in DB but no longer declared
|
|
342
|
+
for (const ref of currentRefs) {
|
|
343
|
+
if (!declaredSet.has(ref.skill_locator) && ref.state !== 'removed') {
|
|
344
|
+
this.exec(
|
|
345
|
+
`UPDATE deck_refs SET state = 'removed', removed_at = $now
|
|
346
|
+
WHERE deck_path = $deck AND skill_locator = $locator`,
|
|
347
|
+
{ $deck: deckPath, $locator: ref.skill_locator, $now: this.now() },
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
191
351
|
|
|
352
|
+
// Upsert current declared skills as 'linked'
|
|
192
353
|
const insert = this.db.query(`
|
|
193
|
-
INSERT INTO deck_refs (skill_locator, deck_path, declared_alias)
|
|
194
|
-
VALUES ($locator, $deck, $alias)
|
|
354
|
+
INSERT OR REPLACE INTO deck_refs (skill_locator, deck_path, declared_alias, state, linked_at, removed_at)
|
|
355
|
+
VALUES ($locator, $deck, $alias, 'linked', $now, NULL)
|
|
195
356
|
`)
|
|
196
357
|
for (const skill of declaredSkills) {
|
|
197
|
-
insert.run({ $locator: skill.locator, $deck: deckPath, $alias: skill.alias })
|
|
358
|
+
insert.run({ $locator: skill.locator, $deck: deckPath, $alias: skill.alias, $now: this.now() })
|
|
198
359
|
}
|
|
199
360
|
insert.finalize()
|
|
200
361
|
})()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune plan — scan cold pool for repos with no active deck references.
|
|
3
|
+
*
|
|
4
|
+
* Uses the metadata DB's deck_refs FSM to determine whether a repo is
|
|
5
|
+
* actively referenced by any deck. A repo is prunable only if ALL its
|
|
6
|
+
* refs across ALL decks have state='removed' (or it has no refs at all).
|
|
7
|
+
*
|
|
8
|
+
* Unlike the previous deck-level prune implementation (which only checked
|
|
9
|
+
* ONE deck.toml), this version queries the cross-deck reference index,
|
|
10
|
+
* preventing accidental deletion of repos still needed by other decks.
|
|
11
|
+
*
|
|
12
|
+
* Scanning approach: instead of ColdPool.list() (which uses buildListPlan
|
|
13
|
+
* with strict canonical depth classification), this scans the cold pool
|
|
14
|
+
* filesystem directly at host/owner/repo depth, matching how deck add
|
|
15
|
+
* resolves locators. This correctly handles repos with arbitrary internal
|
|
16
|
+
* structure (subdirs, non-terminal repos, etc.).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
|
|
22
|
+
|
|
23
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface PruneCandidate {
|
|
26
|
+
repoPath: string
|
|
27
|
+
/** Path relative to cold pool root, e.g. "github.com/owner/repo" */
|
|
28
|
+
repoRel: string
|
|
29
|
+
size: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PrunePlan {
|
|
33
|
+
coldPoolPath: string
|
|
34
|
+
candidates: PruneCandidate[]
|
|
35
|
+
totalSize: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PruneResult {
|
|
39
|
+
repoRel: string
|
|
40
|
+
deleted: boolean
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PruneIO {
|
|
45
|
+
delete?: (path: string) => void
|
|
46
|
+
log?: (msg: string) => void
|
|
47
|
+
formatSize?: (bytes: number) => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function calculateDirSize(dir: string): number {
|
|
53
|
+
let total = 0
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
const p = join(dir, entry.name)
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
total += calculateDirSize(p)
|
|
59
|
+
} else if (entry.isFile()) {
|
|
60
|
+
total += statSync(p).size
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return total
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultFormatSize(bytes: number): string {
|
|
68
|
+
if (bytes < 1024) return `${bytes}B`
|
|
69
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
|
70
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Plan Builder ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a prune plan by comparing cold pool contents against the metadata DB.
|
|
77
|
+
*
|
|
78
|
+
* 1. Scans cold pool for all repo directories (SKILL.md-driven, like add/link).
|
|
79
|
+
* 2. Queries metadata DB for all active locators (state = added/linked/NULL).
|
|
80
|
+
* 3. A repo is prunable if no active locator references it.
|
|
81
|
+
*/
|
|
82
|
+
export function buildPrunePlan(coldPoolPath: string): PrunePlan {
|
|
83
|
+
const pool = new ColdPool(coldPoolPath)
|
|
84
|
+
const allRepos = pool.findSkillDirectories()
|
|
85
|
+
const activeLocators = pool.metadata.getAllActiveLocators()
|
|
86
|
+
pool.metadata.close()
|
|
87
|
+
|
|
88
|
+
const candidates: PruneCandidate[] = []
|
|
89
|
+
for (const repoPath of allRepos) {
|
|
90
|
+
const repoRel = repoPath.slice(coldPoolPath.length + 1)
|
|
91
|
+
// A repo matches if any active locator starts with its relative path
|
|
92
|
+
// (accounting for sub-skill locators like "host/owner/repo/subskill")
|
|
93
|
+
const isReferenced = activeLocators.some(
|
|
94
|
+
(loc) => loc === repoRel || loc.startsWith(repoRel + '/'),
|
|
95
|
+
)
|
|
96
|
+
if (!isReferenced) {
|
|
97
|
+
candidates.push({ repoPath, repoRel, size: calculateDirSize(repoPath) })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
coldPoolPath,
|
|
103
|
+
candidates,
|
|
104
|
+
totalSize: candidates.reduce((sum, c) => sum + c.size, 0),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Execution (IO-injected) ───────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Execute a prune plan. IO is injected for testability.
|
|
112
|
+
*/
|
|
113
|
+
export function executePrunePlan(plan: PrunePlan, io?: PruneIO): PruneResult[] {
|
|
114
|
+
const del = io?.delete ?? ((_p: string) => { throw new Error('delete not injected') })
|
|
115
|
+
const log = io?.log ?? (() => {})
|
|
116
|
+
const fmt = io?.formatSize ?? defaultFormatSize
|
|
117
|
+
|
|
118
|
+
log(`\n🧹 Prune candidates — ${plan.candidates.length} repo(s), ${fmt(plan.totalSize)} total:\n`)
|
|
119
|
+
for (const c of plan.candidates) {
|
|
120
|
+
log(` ${c.repoRel} (${fmt(c.size)})`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const results: PruneResult[] = []
|
|
124
|
+
let deleted = 0
|
|
125
|
+
let failed = 0
|
|
126
|
+
|
|
127
|
+
for (const c of plan.candidates) {
|
|
128
|
+
try {
|
|
129
|
+
del(c.repoPath)
|
|
130
|
+
log(` 🗑️ Deleted: ${c.repoRel}`)
|
|
131
|
+
results.push({ repoRel: c.repoRel, deleted: true })
|
|
132
|
+
deleted++
|
|
133
|
+
} catch (err: any) {
|
|
134
|
+
log(` ❌ Failed to delete ${c.repoRel}: ${err.message}`)
|
|
135
|
+
results.push({ repoRel: c.repoRel, deleted: false, error: err.message })
|
|
136
|
+
failed++
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
log(`\n📦 Prune complete: ${deleted} deleted, ${failed} failed`)
|
|
141
|
+
return results
|
|
142
|
+
}
|