@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 +1 -1
- package/src/cli.ts +29 -10
- package/src/cold-pool.ts +5 -1
- package/src/fetch-plan.ts +17 -2
- package/src/git-io.ts +7 -4
- package/src/metadata-db.ts +3 -2
- package/src/parse-locator.test.ts +4 -0
- package/src/parse-locator.ts +23 -2
- package/src/prune-plan.ts +1 -0
- package/src/reconcile-plan.ts +1 -1
- package/src/types.ts +1 -0
- package/src/validate-plan.ts +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.9.
|
|
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] :
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
47
|
+
const output = execFileSync('git', ['pull'], {
|
|
45
48
|
cwd: dir,
|
|
46
49
|
encoding: 'utf-8',
|
|
47
50
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/src/metadata-db.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
package/src/parse-locator.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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()) {
|
package/src/reconcile-plan.ts
CHANGED
|
@@ -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
|
|
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
package/src/validate-plan.ts
CHANGED
|
@@ -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({
|