@lythos/cold-pool 0.9.31 → 0.9.32

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Cold pool service layer for the lythoskill ecosystem.
4
4
 
5
- > Status: scaffold (0.9.x). Public API will stabilize at 0.10.0.
5
+ > Status: 0.9.32 public API. Reconciliation (reconcile plan) is functional; execute convergence WIP.
6
6
 
7
7
  ## What this is
8
8
 
@@ -16,12 +16,14 @@ deck / curator / arena consume it instead of running `git clone` themselves.
16
16
  Three layers, sharing the project's `intent → plan → execute` pattern
17
17
  (`cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md`):
18
18
 
19
- - **Resource layer** — `ColdPool` class holds path, metadata index, reconcile
20
- entry. Read-only accessors.
19
+ - **Resource layer** — `ColdPool` class holds path, metadata index (MetadataDB),
20
+ reconcile entry. Read-only accessors: `resolveDir()`, `has()`, `list()`.
21
21
  - **Plan layer** — `buildFetchPlan(coldPool, locator) → FetchPlan`,
22
- `buildValidationPlan(coldPool, locator) → ValidationReport`. Pure data,
22
+ `buildValidationPlan(coldPool, locator) → ValidationReport`,
23
+ `buildReconcilePlan(coldPool, desired) → ReconcilePlan`. Pure data,
23
24
  no side effects, dry-run printable.
24
- - **Execute layer** — `executeFetchPlan(plan, io: FetchIO)`. IO is
25
+ - **Execute layer** — `executeFetchPlan(plan, io: FetchIO)`,
26
+ `executeReconcilePlan(plan, io: ReconcileIO)`. IO is
25
27
  injectable; defaults to real git operations. Tests swap mocks.
26
28
 
27
29
  ## Locator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lythos/cold-pool",
3
- "version": "0.9.31",
3
+ "version": "0.9.32",
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",
package/src/index.ts CHANGED
@@ -38,3 +38,13 @@ export { gitClone, gitPull, detectGitRoot } from './git-io.js'
38
38
  export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
39
39
 
40
40
  export { getRepoHeadRef, getSkillBlobHash, getSkillTreeHash, hashSkillMd } from './git-hash.js'
41
+
42
+ export type {
43
+ DesiredSkill,
44
+ ReconcileDesiredState,
45
+ ReconcileEntry,
46
+ ReconcilePlan,
47
+ ReconcileResult,
48
+ ReconcileIO,
49
+ } from './reconcile-plan.js'
50
+ export { buildReconcilePlan, executeReconcilePlan } from './reconcile-plan.js'
@@ -0,0 +1,236 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { ColdPool } from './cold-pool'
6
+ import { buildReconcilePlan, executeReconcilePlan, type ReconcileDesiredState } from './reconcile-plan'
7
+
8
+ function makePool(): { root: string; pool: ColdPool } {
9
+ const root = mkdtempSync(join(tmpdir(), 'reconcile-test-'))
10
+ const pool = new ColdPool(root)
11
+ return { root, pool }
12
+ }
13
+
14
+ function seedRepo(root: string, host: string, owner: string, repo: string) {
15
+ const dir = join(root, host, owner, repo)
16
+ mkdirSync(dir, { recursive: true })
17
+ writeFileSync(join(dir, 'SKILL.md'), '# Test Skill')
18
+ // Seed metadata with a fake HEAD ref
19
+ return dir
20
+ }
21
+
22
+ describe('buildReconcilePlan — pure classification', () => {
23
+ let root: string
24
+ let pool: ColdPool
25
+
26
+ beforeEach(() => {
27
+ const p = makePool()
28
+ root = p.root
29
+ pool = p.pool
30
+ })
31
+
32
+ afterEach(() => {
33
+ pool.metadata.close()
34
+ rmSync(root, { recursive: true, force: true })
35
+ })
36
+
37
+ test('empty desired + empty cold pool → all empty', () => {
38
+ const desired: ReconcileDesiredState = { deckPath: '/project/skill-deck.toml', skills: [] }
39
+ const plan = buildReconcilePlan(pool, desired)
40
+ expect(plan.missing).toEqual([])
41
+ expect(plan.behind).toEqual([])
42
+ expect(plan.extra).toEqual([])
43
+ })
44
+
45
+ test('desired skill missing from cold pool → reported as missing', () => {
46
+ const desired: ReconcileDesiredState = {
47
+ deckPath: '/project/skill-deck.toml',
48
+ skills: [{ locator: 'github.com/lythos-labs/tdd', alias: 'tdd' }],
49
+ }
50
+ const plan = buildReconcilePlan(pool, desired)
51
+ expect(plan.missing.length).toBe(1)
52
+ expect(plan.missing[0].host).toBe('github.com')
53
+ expect(plan.missing[0].owner).toBe('lythos-labs')
54
+ expect(plan.missing[0].repo).toBe('tdd')
55
+ expect(plan.missing[0].aliases).toEqual(['tdd'])
56
+ expect(plan.behind).toEqual([])
57
+ expect(plan.extra).toEqual([])
58
+ })
59
+
60
+ test('desired skill exists in cold pool with metadata → reported as behind', () => {
61
+ seedRepo(root, 'github.com', 'owner', 'repo-a')
62
+ pool.metadata.recordRepoRef('github.com', 'owner', 'repo-a', 'abc123def')
63
+
64
+ const desired: ReconcileDesiredState = {
65
+ deckPath: '/project/skill-deck.toml',
66
+ skills: [{ locator: 'github.com/owner/repo-a', alias: 'skill-a' }],
67
+ }
68
+ const plan = buildReconcilePlan(pool, desired)
69
+ expect(plan.missing).toEqual([])
70
+ expect(plan.behind.length).toBe(1)
71
+ expect(plan.behind[0].repo).toBe('repo-a')
72
+ expect(plan.behind[0].reason).toContain('abc123d')
73
+ expect(plan.extra).toEqual([])
74
+ })
75
+
76
+ test('desired skill exists but no metadata → not flagged (manual clone)', () => {
77
+ seedRepo(root, 'github.com', 'owner', 'repo-b')
78
+
79
+ const desired: ReconcileDesiredState = {
80
+ deckPath: '/project/skill-deck.toml',
81
+ skills: [{ locator: 'github.com/owner/repo-b', alias: 'skill-b' }],
82
+ }
83
+ const plan = buildReconcilePlan(pool, desired)
84
+ expect(plan.missing).toEqual([])
85
+ expect(plan.behind).toEqual([])
86
+ expect(plan.extra).toEqual([])
87
+ })
88
+
89
+ test('monorepo skill path → extracts correct repo root', () => {
90
+ seedRepo(root, 'github.com', 'owner', 'monorepo')
91
+ pool.metadata.recordRepoRef('github.com', 'owner', 'monorepo', 'def456')
92
+
93
+ const desired: ReconcileDesiredState = {
94
+ deckPath: '/project/skill-deck.toml',
95
+ skills: [{ locator: 'github.com/owner/monorepo/skills/foo', alias: 'foo' }],
96
+ }
97
+ const plan = buildReconcilePlan(pool, desired)
98
+ expect(plan.missing).toEqual([])
99
+ expect(plan.behind.length).toBe(1)
100
+ expect(plan.behind[0].repo).toBe('monorepo')
101
+ })
102
+
103
+ test('extra repo in cold pool not in desired → reported as extra', () => {
104
+ seedRepo(root, 'github.com', 'someone', 'orphan-repo')
105
+
106
+ const desired: ReconcileDesiredState = {
107
+ deckPath: '/project/skill-deck.toml',
108
+ skills: [{ locator: 'github.com/owner/repo-a', alias: 'a' }],
109
+ }
110
+ const plan = buildReconcilePlan(pool, desired)
111
+ expect(plan.missing.length).toBe(1) // repo-a is missing
112
+ expect(plan.extra.length).toBe(1)
113
+ expect(plan.extra[0].repo).toBe('orphan-repo')
114
+ })
115
+
116
+ test('multiple skills in same repo → single repo entry', () => {
117
+ seedRepo(root, 'github.com', 'owner', 'shared-repo')
118
+ pool.metadata.recordRepoRef('github.com', 'owner', 'shared-repo', 'ghi789')
119
+
120
+ const desired: ReconcileDesiredState = {
121
+ deckPath: '/project/skill-deck.toml',
122
+ skills: [
123
+ { locator: 'github.com/owner/shared-repo/skills/a', alias: 'alpha' },
124
+ { locator: 'github.com/owner/shared-repo/skills/b', alias: 'beta' },
125
+ ],
126
+ }
127
+ const plan = buildReconcilePlan(pool, desired)
128
+ expect(plan.missing).toEqual([])
129
+ expect(plan.behind.length).toBe(1)
130
+ expect(plan.behind[0].aliases).toEqual(['alpha', 'beta'])
131
+ })
132
+
133
+ test('localhost skill in desired, exists in pool', () => {
134
+ seedRepo(root, 'localhost', 'me', 'local-skill')
135
+
136
+ const desired: ReconcileDesiredState = {
137
+ deckPath: '/project/skill-deck.toml',
138
+ skills: [{ locator: 'localhost/me/local-skill', alias: 'local' }],
139
+ }
140
+ const plan = buildReconcilePlan(pool, desired)
141
+ // localhost: metadata not auto-recorded (user-managed), so no behind
142
+ expect(plan.missing).toEqual([])
143
+ expect(plan.behind).toEqual([])
144
+ expect(plan.extra).toEqual([])
145
+ })
146
+
147
+ test('unrecognized locator format → reported as missing', () => {
148
+ const desired: ReconcileDesiredState = {
149
+ deckPath: '/project/skill-deck.toml',
150
+ skills: [{ locator: 'bare-name', alias: 'bare' }],
151
+ }
152
+ const plan = buildReconcilePlan(pool, desired)
153
+ expect(plan.missing.length).toBe(1)
154
+ expect(plan.missing[0].reason).toContain('Unrecognized')
155
+ })
156
+
157
+ test('mixed: missing + behind + extra all populated', () => {
158
+ // Seed two repos that exist
159
+ seedRepo(root, 'github.com', 'owner', 'exists-behind')
160
+ pool.metadata.recordRepoRef('github.com', 'owner', 'exists-behind', 'abc')
161
+ seedRepo(root, 'github.com', 'owner', 'exists-clean')
162
+
163
+ // Seed an orphan
164
+ seedRepo(root, 'github.com', 'someone', 'orphan')
165
+
166
+ const desired: ReconcileDesiredState = {
167
+ deckPath: '/project/skill-deck.toml',
168
+ skills: [
169
+ { locator: 'github.com/owner/exists-behind', alias: 'behind' },
170
+ { locator: 'github.com/owner/exists-clean', alias: 'clean' },
171
+ { locator: 'github.com/owner/missing-repo', alias: 'missing' },
172
+ ],
173
+ }
174
+ const plan = buildReconcilePlan(pool, desired)
175
+
176
+ expect(plan.missing.length).toBe(1)
177
+ expect(plan.missing[0].repo).toBe('missing-repo')
178
+
179
+ expect(plan.behind.length).toBe(1)
180
+ expect(plan.behind[0].repo).toBe('exists-behind')
181
+
182
+ expect(plan.extra.length).toBe(1)
183
+ expect(plan.extra[0].repo).toBe('orphan')
184
+ })
185
+ })
186
+
187
+ describe('executeReconcilePlan — plan-first reporting', () => {
188
+ test('empty plan → all zero count', () => {
189
+ const results = executeReconcilePlan({ missing: [], behind: [], extra: [] })
190
+ expect(results).toEqual([])
191
+ })
192
+
193
+ test('missing entry → failed status', () => {
194
+ const plan = {
195
+ missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'Not found', aliases: ['r'] }],
196
+ behind: [],
197
+ extra: [],
198
+ }
199
+ const results = executeReconcilePlan(plan)
200
+ expect(results.length).toBe(1)
201
+ expect(results[0].status).toBe('failed')
202
+ })
203
+
204
+ test('behind entry → skipped status', () => {
205
+ const plan = {
206
+ missing: [],
207
+ behind: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'HEAD mismatch', aliases: ['r'] }],
208
+ extra: [],
209
+ }
210
+ const results = executeReconcilePlan(plan)
211
+ expect(results[0].status).toBe('skipped')
212
+ })
213
+
214
+ test('extra entry → extra-reported status', () => {
215
+ const plan = {
216
+ missing: [],
217
+ behind: [],
218
+ extra: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/pool/github.com/o/r', reason: 'Orphan', aliases: [] }],
219
+ }
220
+ const results = executeReconcilePlan(plan)
221
+ expect(results[0].status).toBe('extra-reported')
222
+ })
223
+
224
+ test('io.log captures output', () => {
225
+ const lines: string[] = []
226
+ const plan = {
227
+ missing: [{ host: 'github.com', owner: 'o', repo: 'r', repoPath: '/p/github.com/o/r', reason: 'X', aliases: ['r'] }],
228
+ behind: [],
229
+ extra: [],
230
+ }
231
+ executeReconcilePlan(plan, { log: (m) => lines.push(m) })
232
+ const report = lines.join('\n')
233
+ expect(report).toContain('Missing: 1')
234
+ expect(report).toContain('❌ Missing')
235
+ })
236
+ })
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Reconcile plan: k8s-style desired vs actual convergence.
3
+ *
4
+ * Per ADR-20260507021957847 and EPIC-20260507191713917.
5
+ * Plan builders are pure (read-only, no mutation). Executors inject IO.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { basename, join, relative } from 'node:path'
10
+ import type { ColdPool } from './cold-pool.js'
11
+ import { parseLocator } from './parse-locator.js'
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────────
14
+
15
+ export interface DesiredSkill {
16
+ locator: string
17
+ alias: string
18
+ }
19
+
20
+ export interface ReconcileDesiredState {
21
+ deckPath: string
22
+ skills: DesiredSkill[]
23
+ }
24
+
25
+ export interface ReconcileEntry {
26
+ host: string
27
+ owner: string
28
+ repo: string
29
+ repoPath: string
30
+ reason: string
31
+ /** The affected skill aliases, if any. */
32
+ aliases: string[]
33
+ }
34
+
35
+ export interface ReconcilePlan {
36
+ /** Skills whose repo dir doesn't exist in the cold pool. */
37
+ missing: ReconcileEntry[]
38
+ /** Skills whose repo exists but git HEAD doesn't match the metadata record. */
39
+ behind: ReconcileEntry[]
40
+ /** Repos in the cold pool not referenced by any skill in the desired state. */
41
+ extra: ReconcileEntry[]
42
+ }
43
+
44
+ // ── Helpers ────────────────────────────────────────────────────────────────
45
+
46
+ interface RepoKey {
47
+ host: string
48
+ owner: string
49
+ repo: string
50
+ }
51
+
52
+ function repoKey(host: string, owner: string, repo: string): string {
53
+ return `${host}/${owner}/${repo}`
54
+ }
55
+
56
+ /**
57
+ * Extract (host, owner, repo) from a source path relative to cold-pool root.
58
+ * Uses parseLocator — the source path is an FQ locator whose first 3 segments
59
+ * give the canonical repo directory.
60
+ */
61
+ function extractRepo(source: string): RepoKey | null {
62
+ const loc = parseLocator(source)
63
+ if (!loc) return null
64
+ return { host: loc.host, owner: loc.owner, repo: loc.repo }
65
+ }
66
+
67
+ // ── Plan builder ───────────────────────────────────────────────────────────
68
+
69
+ export function buildReconcilePlan(
70
+ coldPool: ColdPool,
71
+ desired: ReconcileDesiredState,
72
+ ): ReconcilePlan {
73
+ // 1. Index desired skills by repo
74
+ const desiredRepos = new Map<string, { key: RepoKey; aliases: string[] }>()
75
+ const unrecognized: ReconcileEntry[] = []
76
+
77
+ for (const skill of desired.skills) {
78
+ const repo = extractRepo(skill.locator)
79
+ if (!repo) {
80
+ unrecognized.push({
81
+ host: '', owner: '', repo: skill.locator, repoPath: '',
82
+ reason: `Unrecognized locator format: "${skill.locator}"`,
83
+ aliases: [skill.alias],
84
+ })
85
+ continue
86
+ }
87
+ const k = repoKey(repo.host, repo.owner, repo.repo)
88
+ const existing = desiredRepos.get(k)
89
+ if (existing) {
90
+ existing.aliases.push(skill.alias)
91
+ } else {
92
+ desiredRepos.set(k, { key: repo, aliases: [skill.alias] })
93
+ }
94
+ }
95
+
96
+ // 2. Enumerate cold pool actual state
97
+ const coldPoolDirs = coldPool.list() // absolute paths
98
+ const coldPoolRepos = new Map<string, string>() // repoKey → absolute path
99
+ for (const dir of coldPoolDirs) {
100
+ const rel = relative(coldPool.path, dir)
101
+ const parts = rel.split('/')
102
+ if (parts.length >= 3) {
103
+ const k = repoKey(parts[0], parts[1], parts[2])
104
+ coldPoolRepos.set(k, dir)
105
+ }
106
+ }
107
+
108
+ // 3. Compare: missing + behind
109
+ const missing: ReconcileEntry[] = [...unrecognized]
110
+ const behind: ReconcileEntry[] = []
111
+
112
+ for (const [k, { key, aliases }] of desiredRepos) {
113
+ const dir = coldPoolRepos.get(k)
114
+ if (!dir || !existsSync(dir)) {
115
+ missing.push({
116
+ host: key.host, owner: key.owner, repo: key.repo,
117
+ repoPath: coldPool.resolveDir({ raw: '', host: key.host, owner: key.owner, repo: key.repo, skill: null, isLocalhost: key.host === 'localhost' }),
118
+ reason: 'Repo directory not found in cold pool',
119
+ aliases,
120
+ })
121
+ continue
122
+ }
123
+
124
+ // Check metadata DB for recorded HEAD ref
125
+ const recordedRef = coldPool.metadata.getRepoRef(key.host, key.owner, key.repo)
126
+ if (recordedRef) {
127
+ // Metadata has a record — repo was previously fetched via deck add.
128
+ // We can't check current HEAD here because that requires async git ops.
129
+ // Mark as "needs verification" — the async executor will check.
130
+ behind.push({
131
+ host: key.host, owner: key.owner, repo: key.repo,
132
+ repoPath: dir,
133
+ reason: `Recorded HEAD: ${recordedRef.slice(0, 8)} — verify against current`,
134
+ aliases,
135
+ })
136
+ }
137
+ // If no metadata record, the repo exists but wasn't recorded via deck add.
138
+ // This is fine (e.g. manually cloned). Don't flag.
139
+ }
140
+
141
+ // 4. Compare: extra (in cold pool but not referenced by desired state)
142
+ const extra: ReconcileEntry[] = []
143
+ for (const [k, dir] of coldPoolRepos) {
144
+ if (!desiredRepos.has(k)) {
145
+ const parts = k.split('/')
146
+ extra.push({
147
+ host: parts[0], owner: parts[1], repo: parts[2],
148
+ repoPath: dir,
149
+ reason: 'Not referenced by any skill in the desired state',
150
+ aliases: [],
151
+ })
152
+ }
153
+ }
154
+
155
+ return { missing, behind, extra }
156
+ }
157
+
158
+ // ── Execution (IO layer, injectable for testing) ───────────────────────────
159
+
160
+ export interface ReconcileResult {
161
+ host: string
162
+ owner: string
163
+ repo: string
164
+ status: 'restored' | 'updated' | 'skipped' | 'failed' | 'extra-reported'
165
+ message: string
166
+ }
167
+
168
+ export interface ReconcileIO {
169
+ gitClone?: (url: string, dir: string, opts?: { depth?: number }) => void
170
+ log?: (msg: string) => void
171
+ }
172
+
173
+ export function executeReconcilePlan(
174
+ plan: ReconcilePlan,
175
+ _io?: ReconcileIO,
176
+ ): ReconcileResult[] {
177
+ const log = _io?.log ?? (() => {})
178
+ const results: ReconcileResult[] = []
179
+
180
+ // Report phase — plan-first: show what would happen
181
+ log('\n🔍 Reconcile Plan')
182
+ log(` Missing: ${plan.missing.length} | Behind: ${plan.behind.length} | Extra: ${plan.extra.length}`)
183
+
184
+ for (const entry of plan.missing) {
185
+ const repo = `${entry.host}/${entry.owner}/${entry.repo}`
186
+ log(` ❌ Missing: ${repo} — ${entry.reason}`)
187
+ log(` Affected: ${entry.aliases.join(', ')}`)
188
+ results.push({
189
+ host: entry.host, owner: entry.owner, repo: entry.repo,
190
+ status: 'failed',
191
+ message: `Missing: ${entry.reason}`,
192
+ })
193
+ }
194
+
195
+ for (const entry of plan.behind) {
196
+ const repo = `${entry.host}/${entry.owner}/${entry.repo}`
197
+ log(` ⚠️ Behind: ${repo}`)
198
+ log(` ${entry.reason}`)
199
+ results.push({
200
+ host: entry.host, owner: entry.owner, repo: entry.repo,
201
+ status: 'skipped',
202
+ message: entry.reason,
203
+ })
204
+ }
205
+
206
+ for (const entry of plan.extra) {
207
+ const repo = `${entry.host}/${entry.owner}/${entry.repo}`
208
+ log(` 📦 Extra: ${repo} — ${entry.reason}`)
209
+ results.push({
210
+ host: entry.host, owner: entry.owner, repo: entry.repo,
211
+ status: 'extra-reported',
212
+ message: entry.reason,
213
+ })
214
+ }
215
+
216
+ return results
217
+ }