@lythos/cold-pool 0.9.24

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lythos-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @lythos/cold-pool
2
+
3
+ Cold pool service layer for the lythoskill ecosystem.
4
+
5
+ > Status: scaffold (0.9.x). Public API will stabilize at 0.10.0.
6
+
7
+ ## What this is
8
+
9
+ A dedicated resource-holder package for the cold pool — the local cache of
10
+ skill repositories at `~/.agents/skill-repos/` (configurable). This package
11
+ is the **only** layer in the ecosystem that holds git side-effects.
12
+ deck / curator / arena consume it instead of running `git clone` themselves.
13
+
14
+ ## Architecture
15
+
16
+ Three layers, sharing the project's `intent → plan → execute` pattern
17
+ (`cortex/wiki/01-patterns/2026-05-04-intent-plan-execute-fractal-architecture-pattern.md`):
18
+
19
+ - **Resource layer** — `ColdPool` class holds path, metadata index, reconcile
20
+ entry. Read-only accessors.
21
+ - **Plan layer** — `buildFetchPlan(coldPool, locator) → FetchPlan`,
22
+ `buildValidationPlan(coldPool, locator) → ValidationReport`. Pure data,
23
+ no side effects, dry-run printable.
24
+ - **Execute layer** — `executeFetchPlan(plan, io: FetchIO)`. IO is
25
+ injectable; defaults to real git operations. Tests swap mocks.
26
+
27
+ ## Locator
28
+
29
+ Per `ADR-20260502012643244`, locators are FQ-only:
30
+
31
+ - `host.tld/owner/repo[/skill]` — remote skill (monorepo, flat, or arbitrary subdir)
32
+ - `localhost/<name>` — local-only skill, no remote origin
33
+
34
+ Bare names and `owner/repo` shorthand are rejected — `parseLocator` returns null.
35
+
36
+ ## Granularity boundary
37
+
38
+ `skill-deck.lock` is **working-set granularity** (per-skill in `.claude/skills/`).
39
+ The cold pool reconciliation runs at **repo+ref granularity** (per cloned
40
+ repository at a specific commit). The two are not conflated. See
41
+ `ADR-20260507021957847`.
42
+
43
+ ## Package status
44
+
45
+ This is internal monorepo infrastructure during the 0.9.x line. Public
46
+ npm publish + stable API targeted for 0.10.0. See
47
+ `EPIC-20260507020846020` for the rollout plan.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@lythos/cold-pool",
3
+ "version": "0.9.24",
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
+ "keywords": [
6
+ "ai-agent",
7
+ "skill",
8
+ "claude-code",
9
+ "agent-skills",
10
+ "llm-tooling",
11
+ "lythoskill",
12
+ "cold-pool"
13
+ ],
14
+ "author": "lythos-labs",
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "type": "module",
20
+ "main": "src/index.ts",
21
+ "types": "src/index.ts",
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/lythos-labs/lythoskill.git",
30
+ "directory": "packages/lythoskill-cold-pool"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/lythos-labs/lythoskill/issues"
34
+ },
35
+ "homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
36
+ "engines": {
37
+ "bun": ">=1.0.0"
38
+ }
39
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool'
6
+ import { parseLocator } from './parse-locator'
7
+
8
+ describe('ColdPool — constructor', () => {
9
+ test('default path when none given', () => {
10
+ const pool = new ColdPool()
11
+ expect(pool.path).toBe(DEFAULT_COLD_POOL_PATH)
12
+ })
13
+
14
+ test('custom path is stored verbatim', () => {
15
+ const pool = new ColdPool('/tmp/custom-pool')
16
+ expect(pool.path).toBe('/tmp/custom-pool')
17
+ })
18
+ })
19
+
20
+ describe('ColdPool.resolveDir — pure path computation', () => {
21
+ const pool = new ColdPool('/cold')
22
+
23
+ test('host/owner/repo', () => {
24
+ const loc = parseLocator('github.com/anthropics/skills')!
25
+ expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
26
+ })
27
+
28
+ test('host/owner/repo/skill — skill subpath does NOT extend the dir', () => {
29
+ // resolveDir returns repo dir, not skill dir. Skill subpath is for findSkillDir later.
30
+ const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
31
+ expect(pool.resolveDir(loc)).toBe('/cold/github.com/anthropics/skills')
32
+ })
33
+
34
+ test('localhost form — uniform <pool>/<host>/<owner>/<repo>, no special-case', () => {
35
+ const loc = parseLocator('localhost/me/my-skill')!
36
+ expect(pool.resolveDir(loc)).toBe('/cold/localhost/me/my-skill')
37
+ })
38
+ })
39
+
40
+ describe('ColdPool — fs-backed read accessors', () => {
41
+ // Build a small fake cold pool on disk per the uniform layout:
42
+ // `<pool>/<host>/<owner>/<repo>/SKILL.md` for ALL hosts including localhost.
43
+ // "Directory layers = FQ locator segments."
44
+ const root = mkdtempSync(join(tmpdir(), 'cold-pool-test-'))
45
+ mkdirSync(join(root, 'github.com/owner/repo-a'), { recursive: true })
46
+ mkdirSync(join(root, 'github.com/owner/repo-b'), { recursive: true })
47
+ mkdirSync(join(root, 'localhost/me/skill-x'), { recursive: true })
48
+ writeFileSync(join(root, 'localhost/me/skill-x/SKILL.md'), '# x')
49
+ // Hidden dir should be skipped
50
+ mkdirSync(join(root, '.git'), { recursive: true })
51
+
52
+ const pool = new ColdPool(root)
53
+
54
+ test('has() returns true for existing repo', () => {
55
+ const loc = parseLocator('github.com/owner/repo-a')!
56
+ expect(pool.has(loc)).toBe(true)
57
+ })
58
+
59
+ test('has() returns false for missing repo', () => {
60
+ const loc = parseLocator('github.com/owner/missing')!
61
+ expect(pool.has(loc)).toBe(false)
62
+ })
63
+
64
+ test('has() works for localhost form (uniform <host>/<owner>/<repo>)', () => {
65
+ const loc = parseLocator('localhost/me/skill-x')!
66
+ expect(pool.has(loc)).toBe(true)
67
+ })
68
+
69
+ test('list() enumerates uniform host/owner/repo across all hosts, skips hidden', () => {
70
+ const entries = pool.list().sort()
71
+ expect(entries).toEqual([
72
+ join(root, 'github.com/owner/repo-a'),
73
+ join(root, 'github.com/owner/repo-b'),
74
+ join(root, 'localhost/me/skill-x'),
75
+ ].sort())
76
+ })
77
+
78
+ test('list() includes legacy drift entries (depth-2 SKILL.md, missing repo level) for cleanup awareness', () => {
79
+ const driftRoot = mkdtempSync(join(tmpdir(), 'cold-pool-drift-'))
80
+ mkdirSync(join(driftRoot, 'github.com/o/r'), { recursive: true })
81
+ mkdirSync(join(driftRoot, 'localhost/me/canonical'), { recursive: true })
82
+ // Legacy drift A: top-level dir with SKILL.md (post-compaction agent invention,
83
+ // bare-name "skill-a" hack)
84
+ mkdirSync(join(driftRoot, 'legacy-toplevel'), { recursive: true })
85
+ writeFileSync(join(driftRoot, 'legacy-toplevel/SKILL.md'), '# legacy A')
86
+ // Legacy drift B: <localhost>/<name>/SKILL.md (depth-2 missing repo level,
87
+ // earlier `localhost/<name>` form before owner/repo became required)
88
+ mkdirSync(join(driftRoot, 'localhost/legacy-name'), { recursive: true })
89
+ writeFileSync(join(driftRoot, 'localhost/legacy-name/SKILL.md'), '# legacy B')
90
+
91
+ const driftPool = new ColdPool(driftRoot)
92
+ const entries = driftPool.list().sort()
93
+ expect(entries).toContain(join(driftRoot, 'legacy-toplevel'))
94
+ expect(entries).toContain(join(driftRoot, 'localhost/legacy-name'))
95
+ })
96
+
97
+ test('list() returns [] when path does not exist', () => {
98
+ const empty = new ColdPool('/no/such/path')
99
+ expect(empty.list()).toEqual([])
100
+ })
101
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ColdPool — dedicated resource holder.
3
+ *
4
+ * Owns the cold-pool path, exposes read-only accessors. Operations
5
+ * (fetch, validate, reconcile) are external functions that take a
6
+ * ColdPool and return Plan-shaped data, per the intent/plan/execute
7
+ * pattern.
8
+ */
9
+ import { existsSync, readdirSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import type { Locator } from './types.js'
13
+
14
+ export const DEFAULT_COLD_POOL_PATH = process.env.LYTHOS_COLD_POOL
15
+ ?? join(homedir(), '.agents/skill-repos')
16
+
17
+ export class ColdPool {
18
+ readonly path: string
19
+
20
+ constructor(coldPoolPath?: string) {
21
+ this.path = coldPoolPath ?? DEFAULT_COLD_POOL_PATH
22
+ }
23
+
24
+ /**
25
+ * Compute the cold-pool directory for a locator. No fs check.
26
+ *
27
+ * Layout invariant: `<pool>/<host>/<owner>/<repo>` for ALL locators
28
+ * including localhost. No special-case branching — "directory layers
29
+ * = FQ locator segments" (per user 2026-05-07). Skill subpath
30
+ * extends within the repo dir (resolveDir returns the repo dir).
31
+ */
32
+ resolveDir(locator: Locator): string {
33
+ return join(this.path, locator.host, locator.owner, locator.repo)
34
+ }
35
+
36
+ /** Whether a locator's repo directory exists in the pool. */
37
+ has(locator: Locator): boolean {
38
+ return existsSync(this.resolveDir(locator))
39
+ }
40
+
41
+ /**
42
+ * Enumerate cold-pool entries.
43
+ *
44
+ * Uniform layout: `<pool>/<host>/<owner>/<repo>`. localhost is just
45
+ * another host. No localhost special-case.
46
+ *
47
+ * Legacy drift: `<pool>/<x>/SKILL.md` (depth 2 with SKILL.md) or
48
+ * `<pool>/localhost/<name>/SKILL.md` (depth 3 with SKILL.md, missing
49
+ * owner/repo) are non-canonical state from older agents that bypassed
50
+ * FQ-only enforcement. Surface them so prune can offer cleanup.
51
+ */
52
+ list(): string[] {
53
+ if (!existsSync(this.path)) return []
54
+ const repos: string[] = []
55
+
56
+ for (const host of readdirSync(this.path, { withFileTypes: true })) {
57
+ if (!host.isDirectory() || host.name.startsWith('.')) continue
58
+ const hostPath = join(this.path, host.name)
59
+
60
+ // Legacy drift: top-level dir with SKILL.md (not canonical 3-segment)
61
+ if (existsSync(join(hostPath, 'SKILL.md'))) {
62
+ repos.push(hostPath)
63
+ continue
64
+ }
65
+
66
+ // Canonical: <host>/<owner>/<repo>
67
+ for (const owner of readdirSync(hostPath, { withFileTypes: true })) {
68
+ if (!owner.isDirectory() || owner.name.startsWith('.')) continue
69
+ const ownerPath = join(hostPath, owner.name)
70
+
71
+ // Legacy drift: <host>/<x>/SKILL.md (depth 2 missing repo level —
72
+ // typically `localhost/<name>/SKILL.md` from older agents)
73
+ if (existsSync(join(ownerPath, 'SKILL.md'))) {
74
+ repos.push(ownerPath)
75
+ continue
76
+ }
77
+
78
+ for (const repo of readdirSync(ownerPath, { withFileTypes: true })) {
79
+ if (!repo.isDirectory() || repo.name.startsWith('.')) continue
80
+ repos.push(join(ownerPath, repo.name))
81
+ }
82
+ }
83
+ }
84
+
85
+ return repos
86
+ }
87
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, mkdirSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { ColdPool } from './cold-pool'
6
+ import { parseLocator } from './parse-locator'
7
+ import { buildFetchPlan, executeFetchPlan } from './fetch-plan'
8
+ import type { FetchIO } from './types'
9
+
10
+ describe('buildFetchPlan', () => {
11
+ test('builds targetDir + cloneUrl from locator', () => {
12
+ const pool = new ColdPool('/cold')
13
+ const loc = parseLocator('github.com/anthropics/skills/skills/pdf')!
14
+ const plan = buildFetchPlan(pool, loc)
15
+ expect(plan.targetDir).toBe('/cold/github.com/anthropics/skills')
16
+ expect(plan.cloneUrl).toBe('https://github.com/anthropics/skills.git')
17
+ expect(plan.alreadyExists).toBe(false)
18
+ })
19
+
20
+ test('alreadyExists reflects fs presence', () => {
21
+ const root = mkdtempSync(join(tmpdir(), 'fetch-plan-test-'))
22
+ mkdirSync(join(root, 'github.com/owner/repo'), { recursive: true })
23
+ const pool = new ColdPool(root)
24
+ const loc = parseLocator('github.com/owner/repo')!
25
+ const plan = buildFetchPlan(pool, loc)
26
+ expect(plan.alreadyExists).toBe(true)
27
+ })
28
+
29
+ test('localhost locator gets empty cloneUrl + uniform <host>/<owner>/<repo> path', () => {
30
+ const pool = new ColdPool('/cold')
31
+ const loc = parseLocator('localhost/me/my-skill')!
32
+ const plan = buildFetchPlan(pool, loc)
33
+ expect(plan.cloneUrl).toBe('')
34
+ expect(plan.targetDir).toBe('/cold/localhost/me/my-skill')
35
+ })
36
+
37
+ test('passes ref through', () => {
38
+ const pool = new ColdPool('/cold')
39
+ const loc = parseLocator('github.com/o/r')!
40
+ const plan = buildFetchPlan(pool, loc, { ref: 'v1.2.3' })
41
+ expect(plan.ref).toBe('v1.2.3')
42
+ })
43
+ })
44
+
45
+ describe('executeFetchPlan', () => {
46
+ test('alreadyExists path → already-present, no clone called', () => {
47
+ const pool = new ColdPool('/cold')
48
+ const loc = parseLocator('github.com/o/r')!
49
+ const plan = { ...buildFetchPlan(pool, loc), alreadyExists: true }
50
+ let cloneCalls = 0
51
+ const io: FetchIO = {
52
+ gitClone: () => { cloneCalls++ },
53
+ exists: () => true,
54
+ }
55
+ const result = executeFetchPlan(plan, io)
56
+ expect(result.status).toBe('already-present')
57
+ expect(cloneCalls).toBe(0)
58
+ })
59
+
60
+ test('clones when target absent', () => {
61
+ const pool = new ColdPool('/cold')
62
+ const loc = parseLocator('github.com/o/r')!
63
+ const plan = buildFetchPlan(pool, loc)
64
+ const cloneArgs: Array<{ url: string; dir: string; opts: unknown }> = []
65
+ const io: FetchIO = {
66
+ gitClone: (url, dir, opts) => { cloneArgs.push({ url, dir, opts }) },
67
+ exists: () => false,
68
+ }
69
+ const result = executeFetchPlan(plan, io)
70
+ expect(result.status).toBe('cloned')
71
+ expect(cloneArgs.length).toBe(1)
72
+ expect(cloneArgs[0].url).toBe('https://github.com/o/r.git')
73
+ expect(cloneArgs[0].dir).toBe('/cold/github.com/o/r')
74
+ })
75
+
76
+ test('clone error → failed result with message', () => {
77
+ const pool = new ColdPool('/cold')
78
+ const loc = parseLocator('github.com/o/r')!
79
+ const plan = buildFetchPlan(pool, loc)
80
+ const io: FetchIO = {
81
+ gitClone: () => { throw new Error('boom') },
82
+ exists: () => false,
83
+ }
84
+ const result = executeFetchPlan(plan, io)
85
+ expect(result.status).toBe('failed')
86
+ expect(result.message).toContain('boom')
87
+ })
88
+
89
+ test('localhost locator refuses to fetch', () => {
90
+ const pool = new ColdPool('/cold')
91
+ const loc = parseLocator('localhost/me/x')!
92
+ const plan = buildFetchPlan(pool, loc)
93
+ let cloneCalls = 0
94
+ const io: FetchIO = {
95
+ gitClone: () => { cloneCalls++ },
96
+ exists: () => false,
97
+ }
98
+ const result = executeFetchPlan(plan, io)
99
+ expect(result.status).toBe('failed')
100
+ expect(result.message).toContain('localhost')
101
+ expect(cloneCalls).toBe(0)
102
+ })
103
+
104
+ test('log IO is invoked', () => {
105
+ const pool = new ColdPool('/cold')
106
+ const loc = parseLocator('github.com/o/r')!
107
+ const plan = buildFetchPlan(pool, loc)
108
+ const lines: string[] = []
109
+ const io: FetchIO = {
110
+ gitClone: () => {},
111
+ exists: () => false,
112
+ log: (m) => lines.push(m),
113
+ }
114
+ executeFetchPlan(plan, io)
115
+ expect(lines.some((l) => l.includes('cloning'))).toBe(true)
116
+ })
117
+ })
@@ -0,0 +1,72 @@
1
+ /**
2
+ * FetchPlan + executor.
3
+ *
4
+ * Per-locator fetch primitive. `buildFetchPlan` is pure (computes
5
+ * targetDir + cloneUrl, peeks fs for `alreadyExists`). `executeFetchPlan`
6
+ * does the side-effecting `git clone` via injected IO.
7
+ *
8
+ * Localhost locators are not fetchable (no remote) — `buildFetchPlan`
9
+ * returns a plan with `alreadyExists: pool.has(locator)` but
10
+ * `executeFetchPlan` will refuse to clone (status: 'failed').
11
+ */
12
+ import { existsSync } from 'node:fs'
13
+ import type { ColdPool } from './cold-pool.js'
14
+ import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
15
+ import { gitClone } from './git-io.js'
16
+
17
+ export function buildFetchPlan(
18
+ pool: ColdPool,
19
+ locator: Locator,
20
+ opts?: { ref?: string },
21
+ ): FetchPlan {
22
+ const targetDir = pool.resolveDir(locator)
23
+ const cloneUrl = locator.isLocalhost
24
+ ? ''
25
+ : `https://${locator.host}/${locator.owner}/${locator.repo}.git`
26
+
27
+ return {
28
+ locator,
29
+ cloneUrl,
30
+ targetDir,
31
+ ref: opts?.ref,
32
+ alreadyExists: existsSync(targetDir),
33
+ }
34
+ }
35
+
36
+ export function executeFetchPlan(plan: FetchPlan, io?: FetchIO): FetchResult {
37
+ const log = io?.log ?? (() => {})
38
+ const exists = io?.exists ?? existsSync
39
+ const cloneFn = io?.gitClone ?? gitClone
40
+
41
+ if (plan.locator.isLocalhost) {
42
+ return {
43
+ status: 'failed',
44
+ targetDir: plan.targetDir,
45
+ message: 'localhost locators have no remote; nothing to fetch',
46
+ }
47
+ }
48
+
49
+ if (exists(plan.targetDir)) {
50
+ log(`✓ already present: ${plan.targetDir}`)
51
+ return {
52
+ status: 'already-present',
53
+ targetDir: plan.targetDir,
54
+ }
55
+ }
56
+
57
+ log(`📦 cloning ${plan.cloneUrl} → ${plan.targetDir}`)
58
+ try {
59
+ cloneFn(plan.cloneUrl, plan.targetDir, { depth: 1, ref: plan.ref })
60
+ return {
61
+ status: 'cloned',
62
+ targetDir: plan.targetDir,
63
+ }
64
+ } catch (err: unknown) {
65
+ const e = err as { message?: string }
66
+ return {
67
+ status: 'failed',
68
+ targetDir: plan.targetDir,
69
+ message: e.message ?? 'git clone failed',
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { detectGitRoot } from './git-io'
6
+
7
+ // Note: gitClone() and gitPull() wrap external `git` invocations and are
8
+ // tested indirectly via executeFetchPlan + integration scenarios. Direct
9
+ // tests would require a fixture remote and slow the CI loop. detectGitRoot
10
+ // is pure fs traversal and is fully tested here.
11
+
12
+ describe('detectGitRoot', () => {
13
+ const root = mkdtempSync(join(tmpdir(), 'git-root-test-'))
14
+ const repoDir = join(root, 'pool/host/owner/repo')
15
+ const subDir = join(repoDir, 'src/deep')
16
+ mkdirSync(subDir, { recursive: true })
17
+ writeFileSync(join(repoDir, '.git'), '') // .git can be a file (worktree marker) or dir; existsSync covers both
18
+ // Sibling dir without .git
19
+ const orphanDir = join(root, 'pool/orphan')
20
+ mkdirSync(orphanDir, { recursive: true })
21
+
22
+ test('finds .git at the same dir', () => {
23
+ const r = detectGitRoot(repoDir)
24
+ expect(r.gitRoot).toBe(resolve(repoDir))
25
+ })
26
+
27
+ test('walks up to find .git', () => {
28
+ const r = detectGitRoot(subDir)
29
+ expect(r.gitRoot).toBe(resolve(repoDir))
30
+ })
31
+
32
+ test('returns not-found when no .git in walk', () => {
33
+ const r = detectGitRoot(orphanDir)
34
+ expect(r.gitRoot).toBeNull()
35
+ expect(r.reason).toBe('not-found')
36
+ })
37
+
38
+ test('respects coldPool boundary — outside-cold-pool when crossing out', () => {
39
+ // Fresh tmpdir to ensure no .git anywhere on the path back to /
40
+ const isolated = mkdtempSync(join(tmpdir(), 'git-root-isolated-'))
41
+ const inner = join(isolated, 'inner')
42
+ const coldPool = join(isolated, 'pool')
43
+ mkdirSync(inner, { recursive: true })
44
+ mkdirSync(coldPool, { recursive: true })
45
+ const r = detectGitRoot(inner, coldPool)
46
+ expect(r.gitRoot).toBeNull()
47
+ expect(r.reason).toBe('outside-cold-pool')
48
+ })
49
+
50
+ test('coldPool given, walking up within cold pool finds .git', () => {
51
+ const r = detectGitRoot(subDir, join(root, 'pool'))
52
+ expect(r.gitRoot).toBe(resolve(repoDir))
53
+ })
54
+ })
package/src/git-io.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Git IO primitives — the only place in the lythoskill ecosystem that
3
+ * runs `git clone` / `git pull` directly. Consumers (deck/curator/arena)
4
+ * must go through these (or through `executeFetchPlan` which uses them).
5
+ *
6
+ * Per ADR-20260507021957847: cold-pool is the dedicated holder of git
7
+ * side-effects. Direct `execFileSync('git', ...)` calls in other
8
+ * packages are an anti-pattern (controller bypassing service to DAO).
9
+ */
10
+ import { execFileSync, execSync } from 'node:child_process'
11
+ import { existsSync } from 'node:fs'
12
+ import { dirname, resolve } from 'node:path'
13
+
14
+ export interface GitCloneOptions {
15
+ /** Default 1 (shallow). Set to 0 to disable depth (full history). */
16
+ depth?: number
17
+ /** Branch/tag/commit to checkout after clone. Implies a follow-up `git checkout` if not HEAD. */
18
+ ref?: string
19
+ /** stdio mode for the spawned git process. Default 'pipe' (capture). */
20
+ stdio?: 'pipe' | 'inherit' | 'ignore'
21
+ }
22
+
23
+ export function gitClone(url: string, dir: string, opts?: GitCloneOptions): void {
24
+ const args = ['clone']
25
+ const depth = opts?.depth ?? 1
26
+ if (depth > 0) {
27
+ args.push('--depth', String(depth))
28
+ }
29
+ args.push(url, dir)
30
+ execFileSync('git', args, { stdio: opts?.stdio ?? 'pipe' })
31
+
32
+ if (opts?.ref && opts.ref !== 'HEAD') {
33
+ execFileSync('git', ['checkout', opts.ref], { cwd: dir, stdio: opts?.stdio ?? 'pipe' })
34
+ }
35
+ }
36
+
37
+ export interface GitPullResult {
38
+ status: 'updated' | 'up-to-date' | 'failed'
39
+ message: string
40
+ }
41
+
42
+ export function gitPull(dir: string, timeoutMs: number = 30000): GitPullResult {
43
+ try {
44
+ const output = execSync('git pull', {
45
+ cwd: dir,
46
+ encoding: 'utf-8',
47
+ stdio: ['pipe', 'pipe', 'pipe'],
48
+ timeout: timeoutMs,
49
+ }).trim()
50
+
51
+ if (output.includes('Already up to date') || output.includes('Already up-to-date')) {
52
+ return { status: 'up-to-date', message: output }
53
+ }
54
+ return { status: 'updated', message: output }
55
+ } catch (err: unknown) {
56
+ const e = err as { stderr?: { toString: () => string }; message?: string }
57
+ const stderr = e.stderr?.toString() || e.message || ''
58
+ return { status: 'failed', message: stderr.trim() }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Walk up from `dir` looking for a directory containing `.git/`. Stops
64
+ * walking when it crosses outside `coldPool` (if given) — keeps the
65
+ * search scoped to the cold-pool's own git roots.
66
+ */
67
+ export interface GitRootResult {
68
+ gitRoot: string | null
69
+ reason?: 'not-found' | 'outside-cold-pool'
70
+ }
71
+
72
+ export function detectGitRoot(dir: string, coldPool?: string): GitRootResult {
73
+ const absDir = resolve(dir)
74
+ const absColdPool = coldPool ? resolve(coldPool) : null
75
+
76
+ let cur = absDir
77
+ while (true) {
78
+ if (absColdPool && !cur.startsWith(absColdPool)) {
79
+ return { gitRoot: null, reason: 'outside-cold-pool' }
80
+ }
81
+ if (existsSync(`${cur}/.git`)) {
82
+ return { gitRoot: cur }
83
+ }
84
+ const parent = dirname(cur)
85
+ if (parent === cur) {
86
+ return { gitRoot: null, reason: 'not-found' }
87
+ }
88
+ cur = parent
89
+ }
90
+ }