@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/cold-pool",
3
- "version": "0.9.45",
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
+ })
@@ -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 terminal repo directory.
31
+ * (with relPath), classify every directory containing SKILL.md.
32
32
  *
33
- * Canonical: <pool>/<host>/<owner>/<repo> (3 segments, no SKILL.md mid-tree)
34
- * Legacy: <pool>/<host>/SKILL.md (depth 1no owner/repo)
35
- * <pool>/<host>/<name>/SKILL.md (depth 2 — no repo segment)
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
- // Walk only terminal dirs they are the leaves (repo-level entries)
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
- // Legacy depth-1: <host>/SKILL.md terminal dir with SKILL.md, depth=1
63
- if (segments.length === 1 && hasSkillMd(d)) {
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
- // Legacy depth-2: <host>/<name>/SKILL.md — terminal dir with SKILL.md, depth=2
69
- if (segments.length === 2 && hasSkillMd(d)) {
70
- plan.push({ path: join(rootPath, d), kind: 'legacy-depth2' })
71
- continue
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
- // Canonical: <host>/<owner>/<repo> — terminal dir at depth 3, no SKILL.md mid-tree
75
- if (segments.length === 3) {
76
- // Verify no SKILL.md at intermediate levels (depth 1 or 2)
77
- const parent = segments.slice(0, 2).join('/')
78
- if (!hasSkillMd(segments[0]) && !hasSkillMd(parent)) {
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,
@@ -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 './db-helpers.js'
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 = 2
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
- `DELETE FROM deck_refs WHERE skill_locator = $locator AND deck_path = $deck`,
167
- { $locator: skillLocator, $deck: deckPath },
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(`DELETE FROM deck_refs WHERE deck_path = $deck`, { $deck: deckPath })
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
- getReferencingDecks(skillLocator: string): Array<{ deckPath: string; alias: string | null }> {
176
- const rows = this.queryAll<{ deck_path: string; declared_alias: string | null }>(
177
- `SELECT deck_path, declared_alias FROM deck_refs WHERE skill_locator = $locator`,
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
- // ── Reconcile ────────────────────────────────────────────────
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
- this.exec(`DELETE FROM deck_refs WHERE deck_path = $deck`, { $deck: deckPath })
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
+ }