@lythos/cold-pool 0.9.47 → 0.9.49

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.47",
3
+ "version": "0.9.49",
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/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { join } from 'node:path'
13
13
  import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
14
14
  import { buildPrunePlan, executePrunePlan } from './prune-plan.js'
15
15
  import { parseLocator } from './parse-locator.js'
16
+ import type { ReconcileDesiredState } from './reconcile-plan.js'
16
17
 
17
18
  const CMD = 'cold-pool'
18
19
 
@@ -111,12 +112,7 @@ async function main(): Promise<void> {
111
112
  // ── validate (plan-only, no --apply) ────────────────────────
112
113
  case 'validate': {
113
114
  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 validate --lock ./skill-deck.lock')
118
- process.exit(1)
119
- }
115
+ const lockPath = lockIdx >= 0 ? args[lockIdx + 1] : './skill-deck.lock'
120
116
 
121
117
  if (!existsSync(lockPath)) {
122
118
  console.error(`❌ Lock file not found: ${lockPath}`)
@@ -128,11 +124,21 @@ async function main(): Promise<void> {
128
124
  lock = JSON.parse(readFileSync(lockPath, 'utf-8'))
129
125
  } catch (e: any) {
130
126
  console.error(`❌ Failed to parse lock file: ${e.message}`)
127
+ console.error('')
128
+ console.error('This usually means the lock file is corrupt or was written by an older version.')
129
+ console.error('To fix:')
130
+ console.error(' deck link # regenerate the lock file')
131
+ console.error(' cold-pool validate # retry after lock is rebuilt')
131
132
  process.exit(1)
132
133
  }
133
134
 
134
135
  if (!lock.skills || !Array.isArray(lock.skills)) {
135
- console.error('❌ Lock file has no "skills" array. Run deck link first.')
136
+ console.error('❌ Lock file has no "skills" array.')
137
+ console.error('')
138
+ console.error('The lock file is missing the expected structure. This can happen after a')
139
+ console.error('schema migration or if the file was manually edited.')
140
+ console.error('To fix:')
141
+ console.error(' deck link # regenerate with current schema')
136
142
  process.exit(1)
137
143
  }
138
144
 
@@ -165,9 +171,15 @@ async function main(): Promise<void> {
165
171
  const declaredSources = new Set(lock.skills.map((s: any) => s.source))
166
172
  for (const repoPath of allRepos) {
167
173
  const repoRel = repoPath.slice(coldPoolPath.length + 1)
168
- const isReferenced = [...declaredSources].some(
169
- (src) => src === repoRel || src.startsWith(repoRel + '/'),
170
- )
174
+ // Compare as (host, owner, repo) tuple to avoid prefix collisions
175
+ const repoParts = repoRel.split('/')
176
+ const isReferenced = [...declaredSources].some((src) => {
177
+ const srcParts = src.split('/')
178
+ if (repoParts.length >= 3 && srcParts.length >= 3) {
179
+ return repoParts[0] === srcParts[0] && repoParts[1] === srcParts[1] && repoParts[2] === srcParts[2]
180
+ }
181
+ return src === repoRel
182
+ })
171
183
  if (!isReferenced) {
172
184
  extra.push(repoRel)
173
185
  }
@@ -212,5 +224,12 @@ async function main(): Promise<void> {
212
224
 
213
225
  main().catch((e: Error) => {
214
226
  console.error(`❌ ${e.message}`)
227
+ console.error('')
228
+ console.error('If this is a configuration issue:')
229
+ console.error(' cold-pool validate --lock ./skill-deck.lock # check lock consistency')
230
+ console.error('If you suspect a bug:')
231
+ console.error(' cold-pool prune --dry-run # safe diagnostic, no deletes')
232
+ console.error('For full help:')
233
+ console.error(' cold-pool --help')
215
234
  process.exit(1)
216
235
  })
package/src/cold-pool.ts CHANGED
@@ -43,6 +43,10 @@ export interface ListPlan {
43
43
  export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
44
44
  const plan: ListPlanEntry[] = []
45
45
  const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
46
+ // Pre-compute: O(1) lookup for "does this dir have a SKILL.md?"
47
+ const skillMdPaths = new Set(
48
+ allEntries.filter(e => !e.isDirectory && e.relPath.endsWith('/SKILL.md')).map(e => e.relPath)
49
+ )
46
50
 
47
51
  function isTerminal(relPath: string): boolean {
48
52
  const prefix = relPath + '/'
@@ -53,7 +57,7 @@ export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPla
53
57
  }
54
58
 
55
59
  function hasSkillMd(dirRel: string): boolean {
56
- return allEntries.some(e => e.relPath === `${dirRel}/SKILL.md` && !e.isDirectory)
60
+ return skillMdPaths.has(`${dirRel}/SKILL.md`)
57
61
  }
58
62
 
59
63
  // Phase 1: Process terminal dirs (backward compat, but only WITH SKILL.md)
package/src/fetch-plan.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * `executeFetchPlan` will refuse to clone (status: 'failed').
11
11
  */
12
12
  import { existsSync } from 'node:fs'
13
+ import { execFileSync } from 'node:child_process'
13
14
  import type { ColdPool } from './cold-pool.js'
14
15
  import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
15
16
  import { gitClone } from './git-io.js'
@@ -20,15 +21,16 @@ export function buildFetchPlan(
20
21
  opts?: { ref?: string },
21
22
  ): FetchPlan {
22
23
  const targetDir = pool.resolveDir(locator)
24
+ const protocol = process.env.LYTHOS_GIT_PROTOCOL || 'https'
23
25
  const cloneUrl = locator.isLocalhost
24
26
  ? ''
25
- : `https://${locator.host}/${locator.owner}/${locator.repo}.git`
27
+ : `${protocol}://${locator.host}/${locator.owner}/${locator.repo}.git`
26
28
 
27
29
  return {
28
30
  locator,
29
31
  cloneUrl,
30
32
  targetDir,
31
- ref: opts?.ref,
33
+ ref: opts?.ref ?? locator.ref, // explicit opts override locator's #ref
32
34
  alreadyExists: existsSync(targetDir),
33
35
  }
34
36
  }
@@ -47,6 +49,19 @@ export function executeFetchPlan(plan: FetchPlan, io?: FetchIO): FetchResult {
47
49
  }
48
50
 
49
51
  if (exists(plan.targetDir)) {
52
+ if (plan.ref) {
53
+ if (plan.ref.startsWith('-')) {
54
+ log(`⚠️ Ref "${plan.ref}" starts with dash — refusing to avoid git option injection`)
55
+ } else {
56
+ try {
57
+ log(`🔄 checking out ${plan.ref} in ${plan.targetDir}`)
58
+ execFileSync('git', ['-C', plan.targetDir, 'fetch', '--depth', '1', 'origin', plan.ref], { stdio: 'pipe' })
59
+ execFileSync('git', ['-C', plan.targetDir, 'checkout', plan.ref], { stdio: 'pipe' })
60
+ } catch {
61
+ log(`⚠️ Could not checkout ${plan.ref} — using current HEAD`)
62
+ }
63
+ }
64
+ }
50
65
  log(`✓ already present: ${plan.targetDir}`)
51
66
  return {
52
67
  status: 'already-present',
package/src/git-io.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * side-effects. Direct `execFileSync('git', ...)` calls in other
8
8
  * packages are an anti-pattern (controller bypassing service to DAO).
9
9
  */
10
- import { execFileSync, execSync } from 'node:child_process'
10
+ import { execFileSync } from 'node:child_process'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { dirname, resolve } from 'node:path'
13
13
 
@@ -18,6 +18,8 @@ export interface GitCloneOptions {
18
18
  ref?: string
19
19
  /** stdio mode for the spawned git process. Default 'pipe' (capture). */
20
20
  stdio?: 'pipe' | 'inherit' | 'ignore'
21
+ /** Timeout in ms for clone + checkout. Default 120000 (2 min). */
22
+ timeout?: number
21
23
  }
22
24
 
23
25
  export function gitClone(url: string, dir: string, opts?: GitCloneOptions): void {
@@ -27,10 +29,11 @@ export function gitClone(url: string, dir: string, opts?: GitCloneOptions): void
27
29
  args.push('--depth', String(depth))
28
30
  }
29
31
  args.push(url, dir)
30
- execFileSync('git', args, { stdio: opts?.stdio ?? 'pipe' })
32
+ const timeout = opts?.timeout ?? 120_000
33
+ execFileSync('git', args, { stdio: opts?.stdio ?? 'pipe', timeout })
31
34
 
32
35
  if (opts?.ref && opts.ref !== 'HEAD') {
33
- execFileSync('git', ['checkout', opts.ref], { cwd: dir, stdio: opts?.stdio ?? 'pipe' })
36
+ execFileSync('git', ['checkout', opts.ref], { cwd: dir, stdio: opts?.stdio ?? 'pipe', timeout })
34
37
  }
35
38
  }
36
39
 
@@ -41,7 +44,7 @@ export interface GitPullResult {
41
44
 
42
45
  export function gitPull(dir: string, timeoutMs: number = 30000): GitPullResult {
43
46
  try {
44
- const output = execSync('git pull', {
47
+ const output = execFileSync('git', ['pull'], {
45
48
  cwd: dir,
46
49
  encoding: 'utf-8',
47
50
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -326,6 +326,7 @@ export class MetadataDB extends SqliteDb {
326
326
  deckPath: string,
327
327
  declaredSkills: Array<{ locator: string; alias: string | null }>,
328
328
  ): void {
329
+ const now = this.now()
329
330
  this.db.transaction(() => {
330
331
  // Get current refs for this deck (any state)
331
332
  const currentRefs = this.queryAll<{ skill_locator: string; state: string | null }>(
@@ -344,7 +345,7 @@ export class MetadataDB extends SqliteDb {
344
345
  this.exec(
345
346
  `UPDATE deck_refs SET state = 'removed', removed_at = $now
346
347
  WHERE deck_path = $deck AND skill_locator = $locator`,
347
- { $deck: deckPath, $locator: ref.skill_locator, $now: this.now() },
348
+ { $deck: deckPath, $locator: ref.skill_locator, $now: now },
348
349
  )
349
350
  }
350
351
  }
@@ -355,7 +356,7 @@ export class MetadataDB extends SqliteDb {
355
356
  VALUES ($locator, $deck, $alias, 'linked', $now, NULL)
356
357
  `)
357
358
  for (const skill of declaredSkills) {
358
- insert.run({ $locator: skill.locator, $deck: deckPath, $alias: skill.alias, $now: this.now() })
359
+ insert.run({ $locator: skill.locator, $deck: deckPath, $alias: skill.alias, $now: now })
359
360
  }
360
361
  insert.finalize()
361
362
  })()
@@ -11,6 +11,7 @@ describe('parseLocator — accepted forms', () => {
11
11
  repo: 'skills',
12
12
  skill: 'skills/pdf',
13
13
  isLocalhost: false,
14
+ ref: null,
14
15
  })
15
16
  })
16
17
 
@@ -35,6 +36,7 @@ describe('parseLocator — accepted forms', () => {
35
36
  repo: 'design-doc-mermaid',
36
37
  skill: null,
37
38
  isLocalhost: false,
39
+ ref: null,
38
40
  })
39
41
  })
40
42
 
@@ -52,6 +54,7 @@ describe('parseLocator — accepted forms', () => {
52
54
  repo: 'my-skill',
53
55
  skill: null,
54
56
  isLocalhost: true,
57
+ ref: null,
55
58
  })
56
59
  })
57
60
 
@@ -99,6 +102,7 @@ describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)',
99
102
  repo: 'skills',
100
103
  skill: 'my-skill',
101
104
  isLocalhost: true,
105
+ ref: null,
102
106
  })
103
107
  })
104
108
 
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Three accepted forms (everything else returns null):
5
5
  * - `host.tld/owner/repo[/skill]` — remote skill
6
+ * - `host.tld/owner/repo[/skill]#ref` — remote skill at branch/tag/commit
6
7
  * - `host.tld/owner/repo` — remote standalone (skill = null)
7
8
  * - `localhost/owner/repo[/skill]` — local skill, same shape as remote
8
9
  *
@@ -13,6 +14,9 @@
13
14
  *
14
15
  * Single-name `localhost/<name>` is rejected — that's a post-compaction
15
16
  * agent invention, not the canonical form.
17
+ *
18
+ * `#ref` suffix (branch/tag/commit) is compatible with skills.sh's
19
+ * `parseFragmentRef`. The ref is passed to gitClone for checkout.
16
20
  */
17
21
  import type { Locator } from './types.js'
18
22
 
@@ -20,7 +24,22 @@ export function parseLocator(input: string): Locator | null {
20
24
  const trimmed = input.trim()
21
25
  if (!trimmed) return null
22
26
 
23
- const parts = trimmed.split('/').filter(Boolean)
27
+ // Extract #ref suffix (branch/tag/commit)
28
+ let ref: string | null = null
29
+ let pathPart = trimmed
30
+ const hashIdx = trimmed.indexOf('#')
31
+ if (hashIdx >= 0) {
32
+ pathPart = trimmed.slice(0, hashIdx)
33
+ ref = trimmed.slice(hashIdx + 1) || null
34
+ // Reject refs that look like git option injection or path traversal
35
+ if (ref && (ref.startsWith('-') || ref.includes('..'))) {
36
+ return null
37
+ }
38
+ }
39
+
40
+ const parts = pathPart.split('/')
41
+ // Reject empty segments (double slashes) and path traversal
42
+ if (parts.some(p => p === '' || p === '..' || p === '.')) return null
24
43
  // Need at least host/owner/repo (3 segments) for any FQ form
25
44
  if (parts.length < 3) return null
26
45
 
@@ -34,6 +53,7 @@ export function parseLocator(input: string): Locator | null {
34
53
  owner: parts[1],
35
54
  repo: parts[2],
36
55
  skill: parts.length > 3 ? parts.slice(3).join('/') : null,
56
+ ref,
37
57
  isLocalhost,
38
58
  }
39
59
  }
@@ -41,5 +61,6 @@ export function parseLocator(input: string): Locator | null {
41
61
  /** Recompose an FQ locator string from a parsed `Locator`. */
42
62
  export function formatLocator(locator: Locator): string {
43
63
  const base = `${locator.host}/${locator.owner}/${locator.repo}`
44
- return locator.skill ? `${base}/${locator.skill}` : base
64
+ const withSkill = locator.skill ? `${base}/${locator.skill}` : base
65
+ return locator.ref ? `${withSkill}#${locator.ref}` : withSkill
45
66
  }
package/src/prune-plan.ts CHANGED
@@ -54,6 +54,7 @@ function calculateDirSize(dir: string): number {
54
54
  try {
55
55
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
56
56
  const p = join(dir, entry.name)
57
+ if (entry.isSymbolicLink()) continue
57
58
  if (entry.isDirectory()) {
58
59
  total += calculateDirSize(p)
59
60
  } else if (entry.isFile()) {
@@ -35,7 +35,7 @@ export interface ReconcileEntry {
35
35
  export interface ReconcilePlan {
36
36
  /** Skills whose repo dir doesn't exist in the cold pool. */
37
37
  missing: ReconcileEntry[]
38
- /** Skills whose repo exists but git HEAD doesn't match the metadata record. */
38
+ /** Skills whose repo was previously fetched (metadata record exists) but HEAD hasn't been verified against upstream. The async executor must git-fetch to determine if they're actually behind. */
39
39
  behind: ReconcileEntry[]
40
40
  /** Repos in the cold pool not referenced by any skill in the desired state. */
41
41
  extra: ReconcileEntry[]
package/src/types.ts CHANGED
@@ -22,6 +22,7 @@ export interface Locator {
22
22
  readonly owner: string
23
23
  readonly repo: string
24
24
  readonly skill: string | null
25
+ readonly ref: string | null // optional branch/tag/commit (#ref suffix)
25
26
  readonly isLocalhost: boolean
26
27
  }
27
28
 
@@ -100,7 +100,19 @@ export async function executeValidationPlan(
100
100
  io?.fetch,
101
101
  )
102
102
 
103
- io?.log?.(`tree fetch: ${tree.status} (http ${tree.httpStatus})`)
103
+ io?.log?.(`tree fetch: ${tree.status} (http ${tree.httpStatus})${tree.truncated ? ' ⚠️ truncated (incomplete)' : ''}`)
104
+
105
+ if (tree.truncated && tree.status === 'ok') {
106
+ return {
107
+ status: 'incomplete',
108
+ items: [],
109
+ fixes: [{
110
+ action: 'retry-later',
111
+ confidence: 0.9,
112
+ message: 'GitHub Tree API returned truncated result (>7MB or >100k entries). Skill discovery may be incomplete — retry with non-recursive paging.',
113
+ }],
114
+ }
115
+ }
104
116
 
105
117
  if (tree.status === 'not-found') {
106
118
  fixes.push({