@lythos/cold-pool 0.14.2 → 0.14.4
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/README.md +1 -1
- package/package.json +2 -2
- package/src/cli.ts +10 -0
- package/src/cold-pool.ts +1 -0
- package/src/fetch-plan.ts +1 -0
- package/src/metadata-db.test.ts +56 -0
- package/src/metadata-db.ts +90 -1
- package/src/parse-locator.test.ts +1 -1
- package/src/parse-locator.ts +8 -8
- package/src/prune-plan.test.ts +124 -0
- package/src/prune-plan.ts +1 -0
- package/src/reconcile-plan.ts +1 -0
- package/src/types.ts +4 -3
- package/src/validate-plan.ts +2 -1
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Three layers, sharing the project's `intent → plan → execute` pattern
|
|
|
31
31
|
Per `ADR-20260502012643244`, locators are FQ-only:
|
|
32
32
|
|
|
33
33
|
- `host.tld/owner/repo[/skill]` — remote skill (monorepo, flat, or arbitrary subdir)
|
|
34
|
-
- `localhost/<
|
|
34
|
+
- `localhost/me/<skill>` — local-only skill, no remote origin (host/owner/repo aligned)
|
|
35
35
|
|
|
36
36
|
Bare names and `owner/repo` shorthand are rejected — `parseLocator` returns null.
|
|
37
37
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@lythos/infra": "^0.14.
|
|
45
|
+
"@lythos/infra": "^0.14.4",
|
|
46
46
|
"simple-git": "^3.36.0"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/src/cli.ts
CHANGED
|
@@ -185,9 +185,19 @@ async function main(): Promise<void> {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Metadata integrity check
|
|
189
|
+
const integrity = pool.metadata.validateIntegrity()
|
|
190
|
+
|
|
188
191
|
pool.metadata.close()
|
|
189
192
|
|
|
190
193
|
// Report
|
|
194
|
+
if (!integrity.ok) {
|
|
195
|
+
if (integrity.stored === null) {
|
|
196
|
+
console.log(`ℹ️ Metadata fingerprint not yet stored — first reconcile will create it.`)
|
|
197
|
+
} else {
|
|
198
|
+
console.log(`⚠️ Metadata integrity: ${integrity.message}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
191
201
|
console.log(`\n📊 Validate Report`)
|
|
192
202
|
console.log(` Cold pool: ${coldPoolPath}`)
|
|
193
203
|
console.log(` Skills declared: ${lock.skills.length}`)
|
package/src/cold-pool.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface ListPlan {
|
|
|
40
40
|
* directory. Terminal-depth heuristics alone miss real-world layouts
|
|
41
41
|
* like monorepos with subdirs, multi-skill repos, and mixed-depth clones.
|
|
42
42
|
*/
|
|
43
|
+
/** Pure plan builder — IO (readdirSync) done by caller, results injected as allEntries. */
|
|
43
44
|
export function buildListPlan(rootPath: string, allEntries: DirEntry[]): ListPlan {
|
|
44
45
|
const plan: ListPlanEntry[] = []
|
|
45
46
|
const dirSet = new Set(allEntries.filter(e => e.isDirectory).map(e => e.relPath))
|
package/src/fetch-plan.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { Locator, FetchPlan, FetchResult, FetchIO } from './types.js'
|
|
|
16
16
|
import { gitClone } from './git-io.js'
|
|
17
17
|
import { getMirror, rewriteUrl } from './mirror.js'
|
|
18
18
|
|
|
19
|
+
/** Plan builder — IO via ColdPool parameter (pool.has, pool.resolveDir). Execute: executeFetchPlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
|
|
19
20
|
export function buildFetchPlan(
|
|
20
21
|
pool: ColdPool,
|
|
21
22
|
locator: Locator,
|
package/src/metadata-db.test.ts
CHANGED
|
@@ -155,4 +155,60 @@ describe('MetadataDB', () => {
|
|
|
155
155
|
db2.close()
|
|
156
156
|
})
|
|
157
157
|
})
|
|
158
|
+
|
|
159
|
+
describe('integrity fingerprint', () => {
|
|
160
|
+
it('creates fingerprint after reconcileDeckReferences', () => {
|
|
161
|
+
db.reconcileDeckReferences('/tmp/test-deck', [
|
|
162
|
+
{ locator: 'github.com/a/b/skill-x', alias: 'x' },
|
|
163
|
+
])
|
|
164
|
+
const result = db.validateIntegrity()
|
|
165
|
+
expect(result.ok).toBe(true)
|
|
166
|
+
expect(result.stored).not.toBeNull()
|
|
167
|
+
expect(result.computed).toBe(result.stored!)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('detects data tampering (corrupted active locators)', () => {
|
|
171
|
+
db.reconcileDeckReferences('/tmp/test-deck', [
|
|
172
|
+
{ locator: 'github.com/a/b/skill-x', alias: 'x' },
|
|
173
|
+
])
|
|
174
|
+
expect(db.validateIntegrity().ok).toBe(true)
|
|
175
|
+
|
|
176
|
+
// Tamper: remove a locator directly via SQL
|
|
177
|
+
db['db'].exec(`DELETE FROM deck_refs`)
|
|
178
|
+
const result = db.validateIntegrity()
|
|
179
|
+
expect(result.ok).toBe(false)
|
|
180
|
+
expect(result.message).toContain('mismatch')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('detects swapped DB (different schema version)', () => {
|
|
184
|
+
db.reconcileDeckReferences('/tmp/test-deck', [
|
|
185
|
+
{ locator: 'github.com/a/b/skill-x', alias: 'x' },
|
|
186
|
+
])
|
|
187
|
+
expect(db.validateIntegrity().ok).toBe(true)
|
|
188
|
+
|
|
189
|
+
// Tamper: bump schema version in fingerprint record without migration
|
|
190
|
+
db['db'].exec(`UPDATE _meta_fingerprint SET schema_version = 99`)
|
|
191
|
+
const result = db.validateIntegrity()
|
|
192
|
+
expect(result.ok).toBe(false)
|
|
193
|
+
expect(result.message).toContain('Schema version')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('returns ok when fingerprint is consistent', () => {
|
|
197
|
+
db.reconcileDeckReferences('/tmp/deck-1', [
|
|
198
|
+
{ locator: 'github.com/x/y/skill-a', alias: 'a' },
|
|
199
|
+
{ locator: 'github.com/x/y/skill-b', alias: 'b' },
|
|
200
|
+
])
|
|
201
|
+
const r1 = db.validateIntegrity()
|
|
202
|
+
expect(r1.ok).toBe(true)
|
|
203
|
+
|
|
204
|
+
// Second reconcile with same data — fingerprint recomputed, still ok
|
|
205
|
+
db.reconcileDeckReferences('/tmp/deck-1', [
|
|
206
|
+
{ locator: 'github.com/x/y/skill-a', alias: 'a' },
|
|
207
|
+
{ locator: 'github.com/x/y/skill-b', alias: 'b' },
|
|
208
|
+
])
|
|
209
|
+
const r2 = db.validateIntegrity()
|
|
210
|
+
expect(r2.ok).toBe(true)
|
|
211
|
+
expect(r2.stored).toBe(r1.computed) // stored should now be latest
|
|
212
|
+
})
|
|
213
|
+
})
|
|
158
214
|
})
|
package/src/metadata-db.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { SqliteDb } from '@lythos/infra'
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
24
25
|
|
|
25
26
|
export type DeckRefState = 'added' | 'linked' | 'removed'
|
|
26
27
|
|
|
@@ -52,7 +53,7 @@ export interface DeckReference {
|
|
|
52
53
|
removedAt: string | null
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
const CURRENT_SCHEMA =
|
|
56
|
+
const CURRENT_SCHEMA = 7
|
|
56
57
|
|
|
57
58
|
export class MetadataDB extends SqliteDb {
|
|
58
59
|
constructor(dbPath: string) {
|
|
@@ -103,6 +104,16 @@ export class MetadataDB extends SqliteDb {
|
|
|
103
104
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_deck_refs_state ON deck_refs(state)`)
|
|
104
105
|
this.exec(`CREATE INDEX IF NOT EXISTS idx_skills_repo ON skills(host, owner, repo)`)
|
|
105
106
|
|
|
107
|
+
this.exec(`
|
|
108
|
+
CREATE TABLE IF NOT EXISTS _meta_fingerprint (
|
|
109
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
110
|
+
fingerprint TEXT NOT NULL,
|
|
111
|
+
computed_at TEXT NOT NULL,
|
|
112
|
+
schema_version INTEGER NOT NULL,
|
|
113
|
+
active_locator_count INTEGER NOT NULL
|
|
114
|
+
)
|
|
115
|
+
`)
|
|
116
|
+
|
|
106
117
|
// Schema migrations
|
|
107
118
|
this.migrateSchema(CURRENT_SCHEMA, [
|
|
108
119
|
{ version: 1, sql: `CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)` },
|
|
@@ -126,6 +137,18 @@ export class MetadataDB extends SqliteDb {
|
|
|
126
137
|
version: 6,
|
|
127
138
|
sql: `ALTER TABLE deck_refs ADD COLUMN mode TEXT DEFAULT 'symlink'`,
|
|
128
139
|
},
|
|
140
|
+
{
|
|
141
|
+
version: 7,
|
|
142
|
+
sql: `
|
|
143
|
+
CREATE TABLE IF NOT EXISTS _meta_fingerprint (
|
|
144
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
145
|
+
fingerprint TEXT NOT NULL,
|
|
146
|
+
computed_at TEXT NOT NULL,
|
|
147
|
+
schema_version INTEGER NOT NULL,
|
|
148
|
+
active_locator_count INTEGER NOT NULL
|
|
149
|
+
)
|
|
150
|
+
`,
|
|
151
|
+
},
|
|
129
152
|
])
|
|
130
153
|
}
|
|
131
154
|
|
|
@@ -360,5 +383,71 @@ export class MetadataDB extends SqliteDb {
|
|
|
360
383
|
}
|
|
361
384
|
insert.finalize()
|
|
362
385
|
})()
|
|
386
|
+
|
|
387
|
+
this.computeAndStoreFingerprint()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── Integrity fingerprint ─────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
private computeAndStoreFingerprint(): void {
|
|
393
|
+
const activeLocators = this.getAllActiveLocators().sort().join('\n')
|
|
394
|
+
const repoCount = (this.db.query('SELECT COUNT(*) as cnt FROM repos').get() as { cnt: number } | null)?.cnt ?? 0
|
|
395
|
+
const skillCount = (this.db.query('SELECT COUNT(*) as cnt FROM skills').get() as { cnt: number } | null)?.cnt ?? 0
|
|
396
|
+
const refCount = (this.db.query('SELECT COUNT(*) as cnt FROM deck_refs').get() as { cnt: number } | null)?.cnt ?? 0
|
|
397
|
+
const alc = activeLocators ? activeLocators.split('\n').length : 0
|
|
398
|
+
const payload = `${activeLocators}\n${CURRENT_SCHEMA}\n${repoCount}\n${skillCount}\n${refCount}`
|
|
399
|
+
const fingerprint = createHash('sha256').update(payload).digest('hex')
|
|
400
|
+
|
|
401
|
+
this.exec(
|
|
402
|
+
`INSERT OR REPLACE INTO _meta_fingerprint (id, fingerprint, computed_at, schema_version, active_locator_count)
|
|
403
|
+
VALUES (1, $fp, $now, $sv, $alc)`,
|
|
404
|
+
{ $fp: fingerprint, $now: this.now(), $sv: CURRENT_SCHEMA, $alc: alc },
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Recompute the fingerprint and compare with stored value.
|
|
410
|
+
* Returns { ok: true } if match, { ok: false, message } with repair hint if mismatch.
|
|
411
|
+
*/
|
|
412
|
+
validateIntegrity(): { ok: boolean; stored: string | null; computed: string; message: string } {
|
|
413
|
+
const row = this.db
|
|
414
|
+
.query(`SELECT fingerprint, computed_at, schema_version FROM _meta_fingerprint WHERE id = 1`)
|
|
415
|
+
.get() as { fingerprint: string; computed_at: string; schema_version: number } | null
|
|
416
|
+
|
|
417
|
+
const activeLocators = this.getAllActiveLocators().sort().join('\n')
|
|
418
|
+
const repoCount = (this.db.query('SELECT COUNT(*) as cnt FROM repos').get() as { cnt: number } | null)?.cnt ?? 0
|
|
419
|
+
const skillCount = (this.db.query('SELECT COUNT(*) as cnt FROM skills').get() as { cnt: number } | null)?.cnt ?? 0
|
|
420
|
+
const refCount = (this.db.query('SELECT COUNT(*) as cnt FROM deck_refs').get() as { cnt: number } | null)?.cnt ?? 0
|
|
421
|
+
const payload = `${activeLocators}\n${CURRENT_SCHEMA}\n${repoCount}\n${skillCount}\n${refCount}`
|
|
422
|
+
const computed = createHash('sha256').update(payload).digest('hex')
|
|
423
|
+
|
|
424
|
+
if (!row) {
|
|
425
|
+
return {
|
|
426
|
+
ok: false,
|
|
427
|
+
stored: null,
|
|
428
|
+
computed,
|
|
429
|
+
message: 'No fingerprint stored. DB may be uninitialized — run deck link to rebuild.',
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (row.schema_version !== CURRENT_SCHEMA) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
stored: row.fingerprint,
|
|
437
|
+
computed,
|
|
438
|
+
message: `Schema version mismatch: stored ${row.schema_version}, current ${CURRENT_SCHEMA}. Run deck link to rebuild metadata.`,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (row.fingerprint !== computed) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
stored: row.fingerprint,
|
|
446
|
+
computed,
|
|
447
|
+
message: 'Fingerprint mismatch — metadata DB may have been swapped, corrupted, or desynchronized from cold pool. Run deck link to rebuild.',
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { ok: true, stored: row.fingerprint, computed, message: 'Integrity OK' }
|
|
363
452
|
}
|
|
364
453
|
}
|
|
@@ -110,7 +110,7 @@ describe('parseLocator — rejected forms (per ADR-20260502012643244 FQ-only)',
|
|
|
110
110
|
expect(parseLocator('localhost')).toBeNull()
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
test('localhost/<name> (
|
|
113
|
+
test('localhost/<name> (2 segments) is rejected — use localhost/me/<skill> for quick local form', () => {
|
|
114
114
|
expect(parseLocator('localhost/my-skill')).toBeNull()
|
|
115
115
|
})
|
|
116
116
|
})
|
package/src/parse-locator.ts
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FQ-only locator parser, per ADR-20260502012643244.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Accepted forms (everything else returns null):
|
|
5
5
|
* - `host.tld/owner/repo[/skill]` — remote skill
|
|
6
6
|
* - `host.tld/owner/repo[/skill]#ref` — remote skill at branch/tag/commit
|
|
7
7
|
* - `host.tld/owner/repo` — remote standalone (skill = null)
|
|
8
|
-
* - `localhost/
|
|
8
|
+
* - `localhost/me/<skill>` — local skill, full form (host/owner/repo aligned)
|
|
9
9
|
*
|
|
10
10
|
* The locator is a path. Appending it to the cold-pool base dir gives an
|
|
11
|
-
* existing directory; SKILL.md inside is the skill content.
|
|
12
|
-
*
|
|
13
|
-
* remote, no clone, no refresh".
|
|
11
|
+
* existing directory; SKILL.md inside is the skill content. For localhost,
|
|
12
|
+
* `host === 'localhost'` signals "no remote, no clone, no refresh".
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* localhost follows the same host/owner/repo shape as remote locators —
|
|
15
|
+
* `me` is the conventional owner for personal skills.
|
|
17
16
|
*
|
|
18
17
|
* `#ref` suffix (branch/tag/commit) is compatible with skills.sh's
|
|
19
18
|
* `parseFragmentRef`. The ref is passed to gitClone for checkout.
|
|
@@ -41,7 +40,8 @@ export function parseLocator(input: string): Locator | null {
|
|
|
41
40
|
const parts = pathPart.split('/')
|
|
42
41
|
// Reject empty segments (double slashes) and path traversal
|
|
43
42
|
if (parts.some(p => p === '' || p === '..' || p === '.')) return null
|
|
44
|
-
// Need at least host/owner/repo (3 segments) for any FQ form
|
|
43
|
+
// Need at least host/owner/repo (3 segments) for any FQ form.
|
|
44
|
+
// For localhost quick form, use: localhost/me/<skill-name>
|
|
45
45
|
if (parts.length < 3) return null
|
|
46
46
|
|
|
47
47
|
// host segment: must be `localhost` (literal) or contain a `.` (host.tld)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { buildPrunePlan, executePrunePlan } from './prune-plan'
|
|
6
|
+
import { ColdPool } from './cold-pool'
|
|
7
|
+
|
|
8
|
+
function seedPool(): { root: string; pool: ColdPool } {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'prune-test-'))
|
|
10
|
+
const pool = new ColdPool(root)
|
|
11
|
+
|
|
12
|
+
// Repo A: referenced → should NOT be pruned
|
|
13
|
+
const repoA = join(root, 'github.com', 'org', 'repo-a')
|
|
14
|
+
mkdirSync(repoA, { recursive: true })
|
|
15
|
+
writeFileSync(join(repoA, 'SKILL.md'), '# Skill A')
|
|
16
|
+
|
|
17
|
+
// Repo B: NOT referenced → SHOULD be pruned
|
|
18
|
+
const repoB = join(root, 'github.com', 'org', 'repo-b')
|
|
19
|
+
mkdirSync(repoB, { recursive: true })
|
|
20
|
+
writeFileSync(join(repoB, 'SKILL.md'), '# Skill B')
|
|
21
|
+
|
|
22
|
+
// Repo C: referenced → should NOT be pruned
|
|
23
|
+
const repoC = join(root, 'github.com', 'org', 'repo-c')
|
|
24
|
+
mkdirSync(repoC, { recursive: true })
|
|
25
|
+
writeFileSync(join(repoC, 'SKILL.md'), '# Skill C')
|
|
26
|
+
|
|
27
|
+
// Seed metadata: A and C are active, B is not
|
|
28
|
+
pool.metadata.reconcileDeckReferences('/tmp/test-deck', [
|
|
29
|
+
{ locator: 'github.com/org/repo-a', alias: 'a' },
|
|
30
|
+
{ locator: 'github.com/org/repo-c', alias: 'c' },
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
return { root, pool }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('buildPrunePlan — plan-mode (IO via ColdPool + test filesystem)', () => {
|
|
37
|
+
let root: string
|
|
38
|
+
let pool: ColdPool
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
const p = seedPool()
|
|
42
|
+
root = p.root
|
|
43
|
+
pool = p.pool
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
try { pool.metadata.close() } catch {}
|
|
48
|
+
rmSync(root, { recursive: true, force: true })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('identifies unreferenced repos as prune candidates', () => {
|
|
52
|
+
const plan = buildPrunePlan(root)
|
|
53
|
+
expect(plan.candidates.length).toBe(1)
|
|
54
|
+
expect(plan.candidates[0].repoRel).toBe('github.com/org/repo-b')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does NOT flag actively referenced repos', () => {
|
|
58
|
+
const plan = buildPrunePlan(root)
|
|
59
|
+
const rels = plan.candidates.map(c => c.repoRel)
|
|
60
|
+
expect(rels).not.toContain('github.com/org/repo-a')
|
|
61
|
+
expect(rels).not.toContain('github.com/org/repo-c')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('empty cold pool → empty plan', () => {
|
|
65
|
+
const emptyDir = mkdtempSync(join(tmpdir(), 'prune-empty-'))
|
|
66
|
+
const plan = buildPrunePlan(emptyDir)
|
|
67
|
+
expect(plan.candidates).toEqual([])
|
|
68
|
+
expect(plan.totalSize).toBe(0)
|
|
69
|
+
rmSync(emptyDir, { recursive: true, force: true })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('plan includes coldPoolPath and totalSize', () => {
|
|
73
|
+
const plan = buildPrunePlan(root)
|
|
74
|
+
expect(plan.coldPoolPath).toBe(root)
|
|
75
|
+
expect(plan.totalSize).toBeGreaterThan(0)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('executePrunePlan — plan-mode (IO injected)', () => {
|
|
80
|
+
test('calls delete for each candidate', () => {
|
|
81
|
+
const deleted: string[] = []
|
|
82
|
+
const plan = {
|
|
83
|
+
coldPoolPath: '/pool',
|
|
84
|
+
candidates: [
|
|
85
|
+
{ repoPath: '/pool/gh/org/a', repoRel: 'gh/org/a', size: 1024 },
|
|
86
|
+
{ repoPath: '/pool/gh/org/b', repoRel: 'gh/org/b', size: 2048 },
|
|
87
|
+
],
|
|
88
|
+
totalSize: 3072,
|
|
89
|
+
}
|
|
90
|
+
const results = executePrunePlan(plan, {
|
|
91
|
+
delete: (path) => { deleted.push(path) },
|
|
92
|
+
log: () => {},
|
|
93
|
+
})
|
|
94
|
+
expect(deleted).toEqual(['/pool/gh/org/a', '/pool/gh/org/b'])
|
|
95
|
+
expect(results).toHaveLength(2)
|
|
96
|
+
expect(results[0].deleted).toBe(true)
|
|
97
|
+
expect(results[1].deleted).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('logs count and total size', () => {
|
|
101
|
+
const logs: string[] = []
|
|
102
|
+
executePrunePlan({
|
|
103
|
+
coldPoolPath: '/pool',
|
|
104
|
+
candidates: [{ repoPath: '/pool/x', repoRel: 'x', size: 500 }],
|
|
105
|
+
totalSize: 500,
|
|
106
|
+
}, {
|
|
107
|
+
delete: () => {},
|
|
108
|
+
log: (msg) => logs.push(msg),
|
|
109
|
+
})
|
|
110
|
+
const joined = logs.join(' ')
|
|
111
|
+
expect(joined).toContain('1 repo')
|
|
112
|
+
expect(joined).toContain('500')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('skips delete when candidates array empty', () => {
|
|
116
|
+
let called = false
|
|
117
|
+
const results = executePrunePlan(
|
|
118
|
+
{ coldPoolPath: '/pool', candidates: [], totalSize: 0 },
|
|
119
|
+
{ delete: () => { called = true }, log: () => {} },
|
|
120
|
+
)
|
|
121
|
+
expect(called).toBe(false)
|
|
122
|
+
expect(results).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
})
|
package/src/prune-plan.ts
CHANGED
|
@@ -82,6 +82,7 @@ function defaultFormatSize(bytes: number): string {
|
|
|
82
82
|
* 2. Queries metadata DB for all active locators (state = added/linked/NULL).
|
|
83
83
|
* 3. A repo is prunable if no active locator references it.
|
|
84
84
|
*/
|
|
85
|
+
/** Plan builder — IO via ColdPool (findSkillDirectories, metadata queries). Execute: executePrunePlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
|
|
85
86
|
export function buildPrunePlan(coldPoolPath: string): PrunePlan {
|
|
86
87
|
const pool = new ColdPool(coldPoolPath)
|
|
87
88
|
const allRepos = pool.findSkillDirectories()
|
package/src/reconcile-plan.ts
CHANGED
|
@@ -66,6 +66,7 @@ function extractRepo(source: string): RepoKey | null {
|
|
|
66
66
|
|
|
67
67
|
// ── Plan builder ───────────────────────────────────────────────────────────
|
|
68
68
|
|
|
69
|
+
/** Plan builder — IO via ColdPool parameter (list, has, metadata). Execute: executeReconcilePlan(plan, io?) in same file. Mock ColdPool for plan-mode tests. */
|
|
69
70
|
export function buildReconcilePlan(
|
|
70
71
|
coldPool: ColdPool,
|
|
71
72
|
desired: ReconcileDesiredState,
|
package/src/types.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* - `host.tld/owner/repo` (remote standalone — repo root has SKILL.md, skill = null)
|
|
12
12
|
* - `localhost/owner/repo[/skill]` (no remote — same shape, host literal `localhost`)
|
|
13
13
|
*
|
|
14
|
-
* Bare names
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Bare names and shorthand `owner/repo` are rejected by `parseLocator`.
|
|
15
|
+
* localhost follows host/owner/repo shape: `localhost/me/<skill>`.
|
|
16
|
+
* The locator is a path: appending to coldPool yields
|
|
17
|
+
* `<coldPool>/<host>/<owner>/<repo>[/skill]/SKILL.md`. The only thing
|
|
17
18
|
* `isLocalhost` controls is "no remote operations" (no clone, no pull, no fetch).
|
|
18
19
|
*/
|
|
19
20
|
export interface Locator {
|
package/src/validate-plan.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface ValidationIO {
|
|
|
32
32
|
|
|
33
33
|
const DEFAULT_CHECKS: ValidationCheck[] = ['syntax', 'remote', 'path']
|
|
34
34
|
|
|
35
|
+
/** Pure plan builder. Execute: executeValidationPlan(plan, io?) in same file — IO injected via ValidationIO. */
|
|
35
36
|
export function buildValidationPlan(
|
|
36
37
|
rawInput: string,
|
|
37
38
|
opts?: { checks?: ReadonlyArray<ValidationCheck>; ref?: string },
|
|
@@ -55,7 +56,7 @@ export async function executeValidationPlan(
|
|
|
55
56
|
fixes.push({
|
|
56
57
|
action: 'update-locator',
|
|
57
58
|
confidence: 0.5,
|
|
58
|
-
message: 'Locator must be FQ: host.tld/owner/repo[/skill] or localhost/<
|
|
59
|
+
message: 'Locator must be FQ: host.tld/owner/repo[/skill] or localhost/me/<skill>. Bare names are rejected per ADR-20260502012643244.',
|
|
59
60
|
})
|
|
60
61
|
return {
|
|
61
62
|
status: 'invalid',
|