@lythos/cold-pool 0.14.3 → 0.14.4

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
@@ -31,7 +31,7 @@ Three layers, sharing the project's `intent → plan → execute` pattern
31
31
  Per `ADR-20260502012643244`, locators are FQ-only:
32
32
 
33
33
  - `host.tld/owner/repo[/skill]` — remote skill (monorepo, flat, or arbitrary subdir)
34
- - `localhost/<name>` — local-only skill, no remote origin
34
+ - `localhost/me/<skill>` — local-only skill, no remote origin (host/owner/repo aligned)
35
35
 
36
36
  Bare names and `owner/repo` shorthand are rejected — `parseLocator` returns null.
37
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/cold-pool",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
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",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
44
44
  "dependencies": {
45
- "@lythos/infra": "^0.14.3",
45
+ "@lythos/infra": "^0.14.4",
46
46
  "simple-git": "^3.36.0"
47
47
  },
48
48
  "engines": {
package/src/cold-pool.ts CHANGED
@@ -40,6 +40,7 @@ export interface ListPlan {
40
40
  * directory. Terminal-depth heuristics alone miss real-world layouts
41
41
  * like monorepos with subdirs, multi-skill repos, and mixed-depth clones.
42
42
  */
43
+ /** Pure plan builder — IO (readdirSync) done by caller, results injected as allEntries. */
43
44
  export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
44
45
  const plan: ListPlanEntry[] = []
45
46
  const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
package/src/fetch-plan.ts CHANGED
@@ -16,6 +16,7 @@ import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
16
16
  import { gitClone } from './git-io.js'
17
17
  import { getMirror, rewriteUrl } from './mirror.js'
18
18
 
19
+ /** Plan builder — IO via ColdPool parameter (pool.has, pool.resolveDir). Execute: executeFetchPlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
19
20
  export function buildFetchPlan(
20
21
  pool: ColdPool,
21
22
  locator: Locator,
@@ -110,7 +110,7 @@ describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)',
110
110
  expect(parseLocator('localhost')).toBeNull()
111
111
  })
112
112
 
113
- test('localhost/<name> (single name, missing repo) is rejected — that was the post-compaction agent invention', () => {
113
+ test('localhost/<name> (2 segments) is rejected — use localhost/me/<skill> for quick local form', () => {
114
114
  expect(parseLocator('localhost/my-skill')).toBeNull()
115
115
  })
116
116
  })
@@ -1,19 +1,18 @@
1
1
  /**
2
2
  * FQ-only locator parser, per ADR-20260502012643244.
3
3
  *
4
- * Three accepted forms (everything else returns null):
4
+ * Accepted forms (everything else returns null):
5
5
  * - `host.tld/owner/repo[/skill]` — remote skill
6
6
  * - `host.tld/owner/repo[/skill]#ref` — remote skill at branch/tag/commit
7
7
  * - `host.tld/owner/repo` — remote standalone (skill = null)
8
- * - `localhost/owner/repo[/skill]` — local skill, same shape as remote
8
+ * - `localhost/me/<skill>` — local skill, full form (host/owner/repo aligned)
9
9
  *
10
10
  * The locator is a path. Appending it to the cold-pool base dir gives an
11
- * existing directory; SKILL.md inside is the skill content. No special-case
12
- * for localhost — only difference is `host === 'localhost'` signals "no
13
- * remote, no clone, no refresh".
11
+ * existing directory; SKILL.md inside is the skill content. For localhost,
12
+ * `host === 'localhost'` signals "no remote, no clone, no refresh".
14
13
  *
15
- * Single-name `localhost/<name>` is rejected that's a post-compaction
16
- * agent invention, not the canonical form.
14
+ * localhost follows the same host/owner/repo shape as remote locators —
15
+ * `me` is the conventional owner for personal skills.
17
16
  *
18
17
  * `#ref` suffix (branch/tag/commit) is compatible with skills.sh's
19
18
  * `parseFragmentRef`. The ref is passed to gitClone for checkout.
@@ -41,7 +40,8 @@ export function parseLocator(input: string): Locator | null {
41
40
  const parts = pathPart.split('/')
42
41
  // Reject empty segments (double slashes) and path traversal
43
42
  if (parts.some(p => p === '' || p === '..' || p === '.')) return null
44
- // Need at least host/owner/repo (3 segments) for any FQ form
43
+ // Need at least host/owner/repo (3 segments) for any FQ form.
44
+ // For localhost quick form, use: localhost/me/<skill-name>
45
45
  if (parts.length < 3) return null
46
46
 
47
47
  // host segment: must be `localhost` (literal) or contain a `.` (host.tld)
@@ -0,0 +1,124 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { buildPrunePlan, executePrunePlan } from './prune-plan'
6
+ import { ColdPool } from './cold-pool'
7
+
8
+ function seedPool(): { root: string; pool: ColdPool } {
9
+ const root = mkdtempSync(join(tmpdir(), 'prune-test-'))
10
+ const pool = new ColdPool(root)
11
+
12
+ // Repo A: referenced → should NOT be pruned
13
+ const repoA = join(root, 'github.com', 'org', 'repo-a')
14
+ mkdirSync(repoA, { recursive: true })
15
+ writeFileSync(join(repoA, 'SKILL.md'), '# Skill A')
16
+
17
+ // Repo B: NOT referenced → SHOULD be pruned
18
+ const repoB = join(root, 'github.com', 'org', 'repo-b')
19
+ mkdirSync(repoB, { recursive: true })
20
+ writeFileSync(join(repoB, 'SKILL.md'), '# Skill B')
21
+
22
+ // Repo C: referenced → should NOT be pruned
23
+ const repoC = join(root, 'github.com', 'org', 'repo-c')
24
+ mkdirSync(repoC, { recursive: true })
25
+ writeFileSync(join(repoC, 'SKILL.md'), '# Skill C')
26
+
27
+ // Seed metadata: A and C are active, B is not
28
+ pool.metadata.reconcileDeckReferences('/tmp/test-deck', [
29
+ { locator: 'github.com/org/repo-a', alias: 'a' },
30
+ { locator: 'github.com/org/repo-c', alias: 'c' },
31
+ ])
32
+
33
+ return { root, pool }
34
+ }
35
+
36
+ describe('buildPrunePlan — plan-mode (IO via ColdPool + test filesystem)', () => {
37
+ let root: string
38
+ let pool: ColdPool
39
+
40
+ beforeEach(() => {
41
+ const p = seedPool()
42
+ root = p.root
43
+ pool = p.pool
44
+ })
45
+
46
+ afterEach(() => {
47
+ try { pool.metadata.close() } catch {}
48
+ rmSync(root, { recursive: true, force: true })
49
+ })
50
+
51
+ test('identifies unreferenced repos as prune candidates', () => {
52
+ const plan = buildPrunePlan(root)
53
+ expect(plan.candidates.length).toBe(1)
54
+ expect(plan.candidates[0].repoRel).toBe('github.com/org/repo-b')
55
+ })
56
+
57
+ test('does NOT flag actively referenced repos', () => {
58
+ const plan = buildPrunePlan(root)
59
+ const rels = plan.candidates.map(c => c.repoRel)
60
+ expect(rels).not.toContain('github.com/org/repo-a')
61
+ expect(rels).not.toContain('github.com/org/repo-c')
62
+ })
63
+
64
+ test('empty cold pool → empty plan', () => {
65
+ const emptyDir = mkdtempSync(join(tmpdir(), 'prune-empty-'))
66
+ const plan = buildPrunePlan(emptyDir)
67
+ expect(plan.candidates).toEqual([])
68
+ expect(plan.totalSize).toBe(0)
69
+ rmSync(emptyDir, { recursive: true, force: true })
70
+ })
71
+
72
+ test('plan includes coldPoolPath and totalSize', () => {
73
+ const plan = buildPrunePlan(root)
74
+ expect(plan.coldPoolPath).toBe(root)
75
+ expect(plan.totalSize).toBeGreaterThan(0)
76
+ })
77
+ })
78
+
79
+ describe('executePrunePlan — plan-mode (IO injected)', () => {
80
+ test('calls delete for each candidate', () => {
81
+ const deleted: string[] = []
82
+ const plan = {
83
+ coldPoolPath: '/pool',
84
+ candidates: [
85
+ { repoPath: '/pool/gh/org/a', repoRel: 'gh/org/a', size: 1024 },
86
+ { repoPath: '/pool/gh/org/b', repoRel: 'gh/org/b', size: 2048 },
87
+ ],
88
+ totalSize: 3072,
89
+ }
90
+ const results = executePrunePlan(plan, {
91
+ delete: (path) => { deleted.push(path) },
92
+ log: () => {},
93
+ })
94
+ expect(deleted).toEqual(['/pool/gh/org/a', '/pool/gh/org/b'])
95
+ expect(results).toHaveLength(2)
96
+ expect(results[0].deleted).toBe(true)
97
+ expect(results[1].deleted).toBe(true)
98
+ })
99
+
100
+ test('logs count and total size', () => {
101
+ const logs: string[] = []
102
+ executePrunePlan({
103
+ coldPoolPath: '/pool',
104
+ candidates: [{ repoPath: '/pool/x', repoRel: 'x', size: 500 }],
105
+ totalSize: 500,
106
+ }, {
107
+ delete: () => {},
108
+ log: (msg) => logs.push(msg),
109
+ })
110
+ const joined = logs.join(' ')
111
+ expect(joined).toContain('1 repo')
112
+ expect(joined).toContain('500')
113
+ })
114
+
115
+ test('skips delete when candidates array empty', () => {
116
+ let called = false
117
+ const results = executePrunePlan(
118
+ { coldPoolPath: '/pool', candidates: [], totalSize: 0 },
119
+ { delete: () => { called = true }, log: () => {} },
120
+ )
121
+ expect(called).toBe(false)
122
+ expect(results).toEqual([])
123
+ })
124
+ })
package/src/prune-plan.ts CHANGED
@@ -82,6 +82,7 @@ function defaultFormatSize(bytes: number): string {
82
82
  * 2. Queries metadata DB for all active locators (state = added/linked/NULL).
83
83
  * 3. A repo is prunable if no active locator references it.
84
84
  */
85
+ /** Plan builder — IO via ColdPool (findSkillDirectories, metadata queries). Execute: executePrunePlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
85
86
  export function buildPrunePlan(coldPoolPath: string): PrunePlan {
86
87
  const pool = new ColdPool(coldPoolPath)
87
88
  const allRepos = pool.findSkillDirectories()
@@ -66,6 +66,7 @@ function extractRepo(source: string): RepoKey | null {
66
66
 
67
67
  // ── Plan builder ───────────────────────────────────────────────────────────
68
68
 
69
+ /** Plan builder — IO via ColdPool parameter (list, has, metadata). Execute: executeReconcilePlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
69
70
  export function buildReconcilePlan(
70
71
  coldPool: ColdPool,
71
72
  desired: ReconcileDesiredState,
package/src/types.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  * - `host.tld/owner/repo` (remote standalone — repo root has SKILL.md, skill = null)
12
12
  * - `localhost/owner/repo[/skill]` (no remote — same shape, host literal `localhost`)
13
13
  *
14
- * Bare names, shorthand `owner/repo`, and `localhost/<name>` (no owner/repo)
15
- * are rejected by `parseLocator`. The locator is a path: appending to coldPool
16
- * yields `<coldPool>/<host>/<owner>/<repo>[/skill]/SKILL.md`. The only thing
14
+ * Bare names and shorthand `owner/repo` are rejected by `parseLocator`.
15
+ * localhost follows host/owner/repo shape: `localhost/me/<skill>`.
16
+ * The locator is a path: appending to coldPool yields
17
+ * `<coldPool>/<host>/<owner>/<repo>[/skill]/SKILL.md`. The only thing
17
18
  * `isLocalhost` controls is "no remote operations" (no clone, no pull, no fetch).
18
19
  */
19
20
  export interface Locator {
@@ -32,6 +32,7 @@ export interface ValidationIO {
32
32
 
33
33
  const DEFAULT_CHECKS: ValidationCheck[] = ['syntax', 'remote', 'path']
34
34
 
35
+ /** Pure plan builder. Execute: executeValidationPlan(plan, io?) in same file — IO injected via ValidationIO. */
35
36
  export function buildValidationPlan(
36
37
  rawInput: string,
37
38
  opts?: { checks?: ReadonlyArray<ValidationCheck>; ref?: string },
@@ -55,7 +56,7 @@ export async function executeValidationPlan(
55
56
  fixes.push({
56
57
  action: 'update-locator',
57
58
  confidence: 0.5,
58
- message: 'Locator must be FQ: host.tld/owner/repo[/skill] or localhost/<name>. Bare names are rejected per ADR-20260502012643244.',
59
+ message: 'Locator must be FQ: host.tld/owner/repo[/skill] or localhost/me/<skill>. Bare names are rejected per ADR-20260502012643244.',
59
60
  })
60
61
  return {
61
62
  status: 'invalid',