@lythos/cold-pool 0.9.47 → 0.9.48

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.48",
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
@@ -20,9 +20,10 @@ export function buildFetchPlan(
20
20
  opts?: { ref?: string },
21
21
  ): FetchPlan {
22
22
  const targetDir = pool.resolveDir(locator)
23
+ const protocol = process.env.LYTHOS_GIT_PROTOCOL || 'https'
23
24
  const cloneUrl = locator.isLocalhost
24
25
  ? ''
25
- : `https://${locator.host}/${locator.owner}/${locator.repo}.git`
26
+ : `${protocol}://${locator.host}/${locator.owner}/${locator.repo}.git`
26
27
 
27
28
  return {
28
29
  locator,
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
  })()
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[]
@@ -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({