@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.
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { fetchRepoTree, type FetchFn, type TreeEntry } from './github-tree'
3
+
4
+ function mockFetch(impl: (url: string, init?: RequestInit) => {
5
+ status: number
6
+ body?: unknown
7
+ headers?: Record<string, string>
8
+ error?: Error
9
+ }): FetchFn {
10
+ return async (url, init) => {
11
+ const out = impl(url, init)
12
+ if (out.error) throw out.error
13
+ return new Response(out.body !== undefined ? JSON.stringify(out.body) : null, {
14
+ status: out.status,
15
+ headers: out.headers,
16
+ })
17
+ }
18
+ }
19
+
20
+ describe('fetchRepoTree', () => {
21
+ test('200 → ok with entries', async () => {
22
+ const entries: TreeEntry[] = [
23
+ { path: 'README.md', type: 'blob', sha: 'a' },
24
+ { path: 'skills/pdf/SKILL.md', type: 'blob', sha: 'b' },
25
+ ]
26
+ const fetch = mockFetch((url) => {
27
+ expect(url).toBe('https://api.github.com/repos/anthropics/skills/git/trees/HEAD?recursive=1')
28
+ return { status: 200, body: { tree: entries, truncated: false } }
29
+ })
30
+ const res = await fetchRepoTree('github.com', 'anthropics', 'skills', 'HEAD', fetch)
31
+ expect(res.status).toBe('ok')
32
+ expect(res.entries).toEqual(entries)
33
+ expect(res.httpStatus).toBe(200)
34
+ expect(res.truncated).toBe(false)
35
+ })
36
+
37
+ test('200 with truncated', async () => {
38
+ const fetch = mockFetch(() => ({
39
+ status: 200,
40
+ body: { tree: [], truncated: true },
41
+ }))
42
+ const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
43
+ expect(res.truncated).toBe(true)
44
+ })
45
+
46
+ test('404 → not-found', async () => {
47
+ const fetch = mockFetch(() => ({ status: 404 }))
48
+ const res = await fetchRepoTree('github.com', 'nope', 'nope', 'HEAD', fetch)
49
+ expect(res.status).toBe('not-found')
50
+ expect(res.httpStatus).toBe(404)
51
+ })
52
+
53
+ test('403 with X-RateLimit-Remaining: 0 → rate-limited', async () => {
54
+ const fetch = mockFetch(() => ({
55
+ status: 403,
56
+ headers: { 'X-RateLimit-Remaining': '0' },
57
+ }))
58
+ const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
59
+ expect(res.status).toBe('rate-limited')
60
+ })
61
+
62
+ test('403 without rate-limit headers → private', async () => {
63
+ const fetch = mockFetch(() => ({ status: 403 }))
64
+ const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
65
+ expect(res.status).toBe('private')
66
+ })
67
+
68
+ test('network error → network-error', async () => {
69
+ const fetch = mockFetch(() => ({ status: 0, error: new Error('ENOTFOUND') }))
70
+ const res = await fetchRepoTree('github.com', 'o', 'r', 'HEAD', fetch)
71
+ expect(res.status).toBe('network-error')
72
+ expect(res.message).toContain('ENOTFOUND')
73
+ })
74
+
75
+ test('non-github host → unsupported-host', async () => {
76
+ const fetch = mockFetch(() => ({ status: 200 })) // would be ok if fetched
77
+ const res = await fetchRepoTree('gitlab.com', 'o', 'r', 'HEAD', fetch)
78
+ expect(res.status).toBe('unsupported-host')
79
+ })
80
+
81
+ test('default ref is HEAD', async () => {
82
+ let capturedUrl = ''
83
+ const fetch = mockFetch((url) => {
84
+ capturedUrl = url
85
+ return { status: 200, body: { tree: [] } }
86
+ })
87
+ await fetchRepoTree('github.com', 'o', 'r', undefined, fetch)
88
+ expect(capturedUrl).toContain('/trees/HEAD?recursive=1')
89
+ })
90
+
91
+ test('explicit ref is honored', async () => {
92
+ let capturedUrl = ''
93
+ const fetch = mockFetch((url) => {
94
+ capturedUrl = url
95
+ return { status: 200, body: { tree: [] } }
96
+ })
97
+ await fetchRepoTree('github.com', 'o', 'r', 'v1.2.3', fetch)
98
+ expect(capturedUrl).toContain('/trees/v1.2.3?recursive=1')
99
+ })
100
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Lightweight GitHub Tree API client.
3
+ *
4
+ * No SDK, no auth handling — public repos only via built-in `fetch`.
5
+ * One function: `fetchRepoTree`. Caller passes a fetch implementation
6
+ * for testing; default is `globalThis.fetch`.
7
+ *
8
+ * Endpoint: GET https://api.github.com/repos/{owner}/{repo}/git/trees/{ref}?recursive=1
9
+ * 200 → tree returned (with optional `truncated: true` for >7MB or 100k entries)
10
+ * 404 → repo or ref not found
11
+ * 403 → rate-limited (X-RateLimit-Remaining: 0) or private repo without auth
12
+ */
13
+
14
+ export interface TreeEntry {
15
+ readonly path: string
16
+ readonly type: 'blob' | 'tree' | 'commit'
17
+ readonly sha: string
18
+ readonly size?: number
19
+ }
20
+
21
+ export interface TreeResponse {
22
+ readonly status: 'ok' | 'not-found' | 'rate-limited' | 'private' | 'network-error' | 'unsupported-host'
23
+ readonly entries: ReadonlyArray<TreeEntry>
24
+ readonly truncated: boolean
25
+ readonly httpStatus: number
26
+ readonly message?: string
27
+ }
28
+
29
+ export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>
30
+
31
+ const DEFAULT_REF = 'HEAD'
32
+
33
+ export async function fetchRepoTree(
34
+ host: string,
35
+ owner: string,
36
+ repo: string,
37
+ ref: string = DEFAULT_REF,
38
+ fetchImpl: FetchFn = globalThis.fetch,
39
+ ): Promise<TreeResponse> {
40
+ if (host !== 'github.com') {
41
+ return {
42
+ status: 'unsupported-host',
43
+ entries: [],
44
+ truncated: false,
45
+ httpStatus: 0,
46
+ message: `host '${host}' is not supported by fetchRepoTree (only github.com for now)`,
47
+ }
48
+ }
49
+
50
+ const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`
51
+
52
+ let res: Response
53
+ try {
54
+ res = await fetchImpl(url, {
55
+ headers: {
56
+ Accept: 'application/vnd.github+json',
57
+ 'User-Agent': '@lythos/cold-pool',
58
+ },
59
+ })
60
+ } catch (err) {
61
+ return {
62
+ status: 'network-error',
63
+ entries: [],
64
+ truncated: false,
65
+ httpStatus: 0,
66
+ message: err instanceof Error ? err.message : String(err),
67
+ }
68
+ }
69
+
70
+ if (res.status === 404) {
71
+ return {
72
+ status: 'not-found',
73
+ entries: [],
74
+ truncated: false,
75
+ httpStatus: 404,
76
+ message: `repo '${owner}/${repo}' or ref '${ref}' not found on github.com`,
77
+ }
78
+ }
79
+
80
+ if (res.status === 403) {
81
+ const remaining = res.headers.get('X-RateLimit-Remaining')
82
+ const isRateLimited = remaining === '0'
83
+ return {
84
+ status: isRateLimited ? 'rate-limited' : 'private',
85
+ entries: [],
86
+ truncated: false,
87
+ httpStatus: 403,
88
+ message: isRateLimited
89
+ ? 'github.com api rate limit exceeded for unauthenticated requests'
90
+ : `repo '${owner}/${repo}' is private (or auth required)`,
91
+ }
92
+ }
93
+
94
+ if (!res.ok) {
95
+ return {
96
+ status: 'network-error',
97
+ entries: [],
98
+ truncated: false,
99
+ httpStatus: res.status,
100
+ message: `unexpected http ${res.status}`,
101
+ }
102
+ }
103
+
104
+ const body = await res.json() as { tree?: TreeEntry[]; truncated?: boolean }
105
+ return {
106
+ status: 'ok',
107
+ entries: body.tree ?? [],
108
+ truncated: body.truncated ?? false,
109
+ httpStatus: 200,
110
+ }
111
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @lythos/cold-pool — Cold pool service layer.
3
+ *
4
+ * Resource layer: `ColdPool`
5
+ * Plan layer: `parseLocator`, `buildValidationPlan`, `buildFetchPlan`
6
+ * Execute layer: `executeValidationPlan`, `executeFetchPlan`, git IO primitives
7
+ */
8
+ export type {
9
+ Locator,
10
+ ValidationReport,
11
+ ValidationFindings,
12
+ SuggestedFix,
13
+ FetchPlan,
14
+ FetchResult,
15
+ FetchIO,
16
+ } from './types.js'
17
+
18
+ export { parseLocator, formatLocator } from './parse-locator.js'
19
+ export { ColdPool, DEFAULT_COLD_POOL_PATH } from './cold-pool.js'
20
+
21
+ export type { TreeEntry, TreeResponse, FetchFn } from './github-tree.js'
22
+ export { fetchRepoTree } from './github-tree.js'
23
+
24
+ export type { InferenceResult } from './infer-skill-path.js'
25
+ export { inferSkillPath } from './infer-skill-path.js'
26
+
27
+ export type { ValidationPlan, ValidationCheck, ValidationIO } from './validate-plan.js'
28
+ export { buildValidationPlan, executeValidationPlan } from './validate-plan.js'
29
+
30
+ export type { GitCloneOptions, GitPullResult, GitRootResult } from './git-io.js'
31
+ export { gitClone, gitPull, detectGitRoot } from './git-io.js'
32
+
33
+ export { buildFetchPlan, executeFetchPlan } from './fetch-plan.js'
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { inferSkillPath } from './infer-skill-path'
3
+ import type { TreeEntry } from './github-tree'
4
+
5
+ const blob = (path: string): TreeEntry => ({ path, type: 'blob', sha: 'x' })
6
+ const tree = (path: string): TreeEntry => ({ path, type: 'tree', sha: 'x' })
7
+
8
+ describe('inferSkillPath', () => {
9
+ test('repo-root SKILL.md → "" candidate', () => {
10
+ const r = inferSkillPath([blob('SKILL.md'), blob('README.md')])
11
+ expect(r.candidates).toEqual([''])
12
+ expect(r.exactMatch).toBeNull()
13
+ })
14
+
15
+ test('monorepo with multiple skills', () => {
16
+ const r = inferSkillPath([
17
+ blob('skills/pdf/SKILL.md'),
18
+ blob('skills/pdf/script.py'),
19
+ blob('skills/excel/SKILL.md'),
20
+ tree('skills/empty'),
21
+ ])
22
+ expect(r.candidates.sort()).toEqual(['skills/excel', 'skills/pdf'])
23
+ })
24
+
25
+ test('flat repo (root-level skill dirs)', () => {
26
+ const r = inferSkillPath([
27
+ blob('skill-creator/SKILL.md'),
28
+ blob('competitors-analysis/SKILL.md'),
29
+ blob('README.md'),
30
+ ])
31
+ expect(r.candidates.sort()).toEqual(['competitors-analysis', 'skill-creator'])
32
+ })
33
+
34
+ test('nested monorepo (skills/category/skill)', () => {
35
+ const r = inferSkillPath([
36
+ blob('skills/engineering/tdd/SKILL.md'),
37
+ blob('skills/engineering/diagnose/SKILL.md'),
38
+ ])
39
+ expect(r.candidates.sort()).toEqual(['skills/engineering/diagnose', 'skills/engineering/tdd'])
40
+ })
41
+
42
+ test('exactMatch reflects expectedSubpath when present', () => {
43
+ const r = inferSkillPath(
44
+ [blob('skills/pdf/SKILL.md'), blob('skills/excel/SKILL.md')],
45
+ 'skills/pdf',
46
+ )
47
+ expect(r.exactMatch).toBe('skills/pdf')
48
+ })
49
+
50
+ test('exactMatch is null when expectedSubpath is wrong', () => {
51
+ const r = inferSkillPath(
52
+ [blob('skills/pdf/SKILL.md')],
53
+ 'pdf', // missing the skills/ prefix
54
+ )
55
+ expect(r.exactMatch).toBeNull()
56
+ expect(r.candidates).toEqual(['skills/pdf'])
57
+ })
58
+
59
+ test('tree-type entries are ignored (only blobs counted)', () => {
60
+ const r = inferSkillPath([
61
+ tree('SKILL.md'), // not a real file (tree, not blob)
62
+ blob('skills/x/SKILL.md'),
63
+ ])
64
+ expect(r.candidates).toEqual(['skills/x'])
65
+ })
66
+
67
+ test('empty entries → empty candidates', () => {
68
+ const r = inferSkillPath([])
69
+ expect(r.candidates).toEqual([])
70
+ expect(r.exactMatch).toBeNull()
71
+ })
72
+ })
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure scan of a tree response for SKILL.md candidates.
3
+ *
4
+ * Used by validation when a locator's expected skill subpath does not
5
+ * resolve, or when no skill subpath was given (standalone or ambiguous).
6
+ */
7
+ import type { TreeEntry } from './github-tree.js'
8
+
9
+ export interface InferenceResult {
10
+ /** Directories within the repo that contain a SKILL.md (relative to repo root). Empty string = repo root. */
11
+ readonly candidates: ReadonlyArray<string>
12
+ /** When `expectedSubpath` is given: the matching candidate, if exact. */
13
+ readonly exactMatch: string | null
14
+ }
15
+
16
+ /**
17
+ * Scan a flat tree listing for paths ending in `SKILL.md`. Returns the
18
+ * containing directory paths (relative to repo root). When
19
+ * `expectedSubpath` is given, also reports whether an exact match exists.
20
+ */
21
+ export function inferSkillPath(
22
+ entries: ReadonlyArray<TreeEntry>,
23
+ expectedSubpath?: string | null,
24
+ ): InferenceResult {
25
+ const candidates: string[] = []
26
+ for (const e of entries) {
27
+ if (e.type !== 'blob') continue
28
+ if (e.path === 'SKILL.md') {
29
+ candidates.push('')
30
+ continue
31
+ }
32
+ if (e.path.endsWith('/SKILL.md')) {
33
+ candidates.push(e.path.slice(0, -'/SKILL.md'.length))
34
+ }
35
+ }
36
+
37
+ if (!expectedSubpath) {
38
+ return { candidates, exactMatch: null }
39
+ }
40
+
41
+ const exactMatch = candidates.includes(expectedSubpath) ? expectedSubpath : null
42
+ return { candidates, exactMatch }
43
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { formatLocator, parseLocator } from './parse-locator'
3
+
4
+ describe('parseLocator — accepted forms', () => {
5
+ test('host.tld/owner/repo/skill (monorepo)', () => {
6
+ const loc = parseLocator('github.com/anthropics/skills/skills/pdf')
7
+ expect(loc).toEqual({
8
+ raw: 'github.com/anthropics/skills/skills/pdf',
9
+ host: 'github.com',
10
+ owner: 'anthropics',
11
+ repo: 'skills',
12
+ skill: 'skills/pdf',
13
+ isLocalhost: false,
14
+ })
15
+ })
16
+
17
+ test('host.tld/owner/repo/skill (nested skill subpath)', () => {
18
+ const loc = parseLocator('github.com/mattpocock/skills/skills/engineering/tdd')
19
+ expect(loc?.skill).toBe('skills/engineering/tdd')
20
+ expect(loc?.repo).toBe('skills')
21
+ })
22
+
23
+ test('host.tld/owner/repo/skill (flat repo, single skill segment)', () => {
24
+ const loc = parseLocator('github.com/daymade/claude-code-skills/skill-creator')
25
+ expect(loc?.skill).toBe('skill-creator')
26
+ expect(loc?.repo).toBe('claude-code-skills')
27
+ })
28
+
29
+ test('host.tld/owner/repo (standalone — skill = null)', () => {
30
+ const loc = parseLocator('github.com/SpillwaveSolutions/design-doc-mermaid')
31
+ expect(loc).toEqual({
32
+ raw: 'github.com/SpillwaveSolutions/design-doc-mermaid',
33
+ host: 'github.com',
34
+ owner: 'SpillwaveSolutions',
35
+ repo: 'design-doc-mermaid',
36
+ skill: null,
37
+ isLocalhost: false,
38
+ })
39
+ })
40
+
41
+ test('host.tld/owner/repo/skill (arbitrary subdir name)', () => {
42
+ const loc = parseLocator('github.com/Cocoon-AI/architecture-diagram-generator/architecture-diagram')
43
+ expect(loc?.skill).toBe('architecture-diagram')
44
+ })
45
+
46
+ test('localhost/<owner>/<repo> (canonical local skill, same shape as remote)', () => {
47
+ const loc = parseLocator('localhost/me/my-skill')
48
+ expect(loc).toEqual({
49
+ raw: 'localhost/me/my-skill',
50
+ host: 'localhost',
51
+ owner: 'me',
52
+ repo: 'my-skill',
53
+ skill: null,
54
+ isLocalhost: true,
55
+ })
56
+ })
57
+
58
+ test('non-github host accepted', () => {
59
+ const loc = parseLocator('gitlab.com/owner/repo/skill-x')
60
+ expect(loc?.host).toBe('gitlab.com')
61
+ expect(loc?.skill).toBe('skill-x')
62
+ })
63
+
64
+ test('input is trimmed', () => {
65
+ const loc = parseLocator(' github.com/owner/repo ')
66
+ expect(loc?.repo).toBe('repo')
67
+ })
68
+ })
69
+
70
+ describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)', () => {
71
+ test('empty string', () => {
72
+ expect(parseLocator('')).toBeNull()
73
+ expect(parseLocator(' ')).toBeNull()
74
+ })
75
+
76
+ test('single segment', () => {
77
+ expect(parseLocator('my-skill')).toBeNull()
78
+ })
79
+
80
+ test('bare owner/repo (no host) is rejected — must be FQ', () => {
81
+ expect(parseLocator('daymade/claude-code-skills')).toBeNull()
82
+ expect(parseLocator('owner/repo/skill')).toBeNull()
83
+ })
84
+
85
+ test('host without dot is treated as bare', () => {
86
+ expect(parseLocator('foo/bar/baz')).toBeNull()
87
+ })
88
+
89
+ test('host.tld/owner without repo segment', () => {
90
+ expect(parseLocator('github.com/owner')).toBeNull()
91
+ })
92
+
93
+ test('localhost with multi-segment path (skill subpath beyond owner/repo)', () => {
94
+ const loc = parseLocator('localhost/me/skills/my-skill')
95
+ expect(loc).toEqual({
96
+ raw: 'localhost/me/skills/my-skill',
97
+ host: 'localhost',
98
+ owner: 'me',
99
+ repo: 'skills',
100
+ skill: 'my-skill',
101
+ isLocalhost: true,
102
+ })
103
+ })
104
+
105
+ test('localhost alone (no owner/repo) is rejected', () => {
106
+ expect(parseLocator('localhost')).toBeNull()
107
+ })
108
+
109
+ test('localhost/<name> (single name, missing repo) is rejected — that was the post-compaction agent invention', () => {
110
+ expect(parseLocator('localhost/my-skill')).toBeNull()
111
+ })
112
+ })
113
+
114
+ describe('formatLocator — round-trips', () => {
115
+ test.each([
116
+ 'github.com/anthropics/skills/skills/pdf',
117
+ 'github.com/SpillwaveSolutions/design-doc-mermaid',
118
+ 'github.com/mattpocock/skills/skills/engineering/tdd',
119
+ 'localhost/me/my-skill',
120
+ 'localhost/me/skills/inner-skill',
121
+ ])('round-trip: %s', (raw) => {
122
+ const parsed = parseLocator(raw)!
123
+ expect(formatLocator(parsed)).toBe(raw)
124
+ })
125
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * FQ-only locator parser, per ADR-20260502012643244.
3
+ *
4
+ * Three accepted forms (everything else returns null):
5
+ * - `host.tld/owner/repo[/skill]` — remote skill
6
+ * - `host.tld/owner/repo` — remote standalone (skill = null)
7
+ * - `localhost/owner/repo[/skill]` — local skill, same shape as remote
8
+ *
9
+ * The locator is a path. Appending it to the cold-pool base dir gives an
10
+ * existing directory; SKILL.md inside is the skill content. No special-case
11
+ * for localhost — only difference is `host === 'localhost'` signals "no
12
+ * remote, no clone, no refresh".
13
+ *
14
+ * Single-name `localhost/<name>` is rejected — that's a post-compaction
15
+ * agent invention, not the canonical form.
16
+ */
17
+ import type { Locator } from './types.js'
18
+
19
+ export function parseLocator(input: string): Locator | null {
20
+ const trimmed = input.trim()
21
+ if (!trimmed) return null
22
+
23
+ const parts = trimmed.split('/').filter(Boolean)
24
+ // Need at least host/owner/repo (3 segments) for any FQ form
25
+ if (parts.length < 3) return null
26
+
27
+ // host segment: must be `localhost` (literal) or contain a `.` (host.tld)
28
+ const isLocalhost = parts[0] === 'localhost'
29
+ if (!isLocalhost && !parts[0].includes('.')) return null
30
+
31
+ return {
32
+ raw: input,
33
+ host: parts[0],
34
+ owner: parts[1],
35
+ repo: parts[2],
36
+ skill: parts.length > 3 ? parts.slice(3).join('/') : null,
37
+ isLocalhost,
38
+ }
39
+ }
40
+
41
+ /** Recompose an FQ locator string from a parsed `Locator`. */
42
+ export function formatLocator(locator: Locator): string {
43
+ const base = `${locator.host}/${locator.owner}/${locator.repo}`
44
+ return locator.skill ? `${base}/${locator.skill}` : base
45
+ }
package/src/types.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Core types for @lythos/cold-pool.
3
+ *
4
+ * Plan-shaped data structures for the intent/plan/execute pattern, plus
5
+ * the FQ Locator form per ADR-20260502012643244.
6
+ */
7
+
8
+ /**
9
+ * Parsed FQ locator. Per ADR-20260502012643244, three forms only:
10
+ * - `host.tld/owner/repo[/skill]` (remote)
11
+ * - `host.tld/owner/repo` (remote standalone — repo root has SKILL.md, skill = null)
12
+ * - `localhost/owner/repo[/skill]` (no remote — same shape, host literal `localhost`)
13
+ *
14
+ * Bare names, shorthand `owner/repo`, and `localhost/<name>` (no owner/repo)
15
+ * are rejected by `parseLocator`. The locator is a path: appending to coldPool
16
+ * yields `<coldPool>/<host>/<owner>/<repo>[/skill]/SKILL.md`. The only thing
17
+ * `isLocalhost` controls is "no remote operations" (no clone, no pull, no fetch).
18
+ */
19
+ export interface Locator {
20
+ readonly raw: string
21
+ readonly host: string
22
+ readonly owner: string
23
+ readonly repo: string
24
+ readonly skill: string | null
25
+ readonly isLocalhost: boolean
26
+ }
27
+
28
+ /**
29
+ * Output of `buildValidationPlan`. Pure data.
30
+ *
31
+ * Validation has no separate execute step — the report itself is the plan.
32
+ * Agents and CLI surfaces consume this directly to render structured
33
+ * diagnostics (per ADR-20260507014124191).
34
+ */
35
+ export interface ValidationReport {
36
+ readonly status: 'valid' | 'invalid' | 'ambiguous'
37
+ readonly locator: string
38
+ readonly phase: 'syntax' | 'repo-existence' | 'path-existence' | 'skill-md-existence'
39
+ readonly findings: ValidationFindings
40
+ readonly suggestedFixes: ReadonlyArray<SuggestedFix>
41
+ }
42
+
43
+ export interface ValidationFindings {
44
+ readonly parseError?: string
45
+ readonly repoExists?: boolean
46
+ readonly repoIsPrivate?: boolean
47
+ readonly skillMdFound?: boolean
48
+ /** Subdirectories of the cloned repo that contain a SKILL.md, when path-existence fails. */
49
+ readonly detectedPaths?: ReadonlyArray<string>
50
+ /** HTTP status from the remote check, if any. */
51
+ readonly remoteStatus?: number
52
+ }
53
+
54
+ export interface SuggestedFix {
55
+ readonly action: 'update-locator' | 'web-search' | 'prompt-user'
56
+ /** 0..1 — caller decides whether to act unattended. */
57
+ readonly confidence: number
58
+ readonly message: string
59
+ readonly newLocator?: string
60
+ }
61
+
62
+ /** Per-locator fetch plan. Pure data; no side effects. */
63
+ export interface FetchPlan {
64
+ readonly locator: Locator
65
+ readonly cloneUrl: string
66
+ readonly targetDir: string
67
+ readonly ref?: string
68
+ /** When true, executor should skip the clone (idempotent fetch). */
69
+ readonly alreadyExists: boolean
70
+ }
71
+
72
+ export interface FetchResult {
73
+ readonly status: 'cloned' | 'already-present' | 'failed'
74
+ readonly targetDir: string
75
+ readonly message?: string
76
+ }
77
+
78
+ /** Injectable IO for `executeFetchPlan`. Test swaps in mocks. */
79
+ export interface FetchIO {
80
+ gitClone?: (url: string, dir: string, opts?: { depth?: number; ref?: string }) => void
81
+ exists?: (path: string) => boolean
82
+ log?: (msg: string) => void
83
+ }