@agent-facets/core 0.1.2 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @agent-facets/core
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - [`098fd08`](https://github.com/agent-facets/facets/commit/098fd08bf5d9970babc5c57bee6a155bffcecd97) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Better CLI parameter validation
8
+
9
+ - [`5262cbe`](https://github.com/agent-facets/facets/commit/5262cbe66df02c625430309878e6061ccde183de) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Fix publishing by properly categorizing dev dependencies
10
+
11
+ - [`d3b9439`](https://github.com/agent-facets/facets/commit/d3b9439466e0eb65687901426e2ebd6c5a333c60) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Use better github attribution for changesets
12
+
3
13
  ## 0.1.2
4
14
 
5
15
  ### Patch Changes
package/package.json CHANGED
@@ -5,25 +5,28 @@
5
5
  "url": "https://github.com/agent-facets/facets",
6
6
  "directory": "packages/core"
7
7
  },
8
- "version": "0.1.2",
8
+ "version": "0.1.3",
9
9
  "type": "module",
10
10
  "exports": {
11
11
  ".": "./src/index.ts"
12
12
  },
13
13
  "scripts": {
14
+ "prepack": "bun ../../scripts/prepack.ts",
15
+ "postpack": "bun ../../scripts/postpack.ts",
14
16
  "types": "tsc --noEmit",
15
17
  "test": "bun test"
16
18
  },
17
19
  "dependencies": {
18
20
  "arktype": "2.1.29",
19
21
  "comment-json": "^4.2.5",
20
- "nanotar": "0.3.0"
22
+ "nanotar": "0.3.0",
23
+ "yaml": "2.8.3"
21
24
  },
22
25
  "devDependencies": {
23
26
  "@types/bun": "1.3.10"
24
27
  },
25
28
  "peerDependencies": {
26
- "typescript": "^5"
29
+ "typescript": "^5 || ^6"
27
30
  },
28
31
  "publishConfig": {
29
32
  "access": "public",
@@ -206,7 +206,7 @@ describe('validatePlatformConfigs', () => {
206
206
  describe('runBuildPipeline', () => {
207
207
  test('successful build with valid facet', async () => {
208
208
  const dir = await createFixtureDir('valid-build')
209
- await Bun.write(join(dir, 'skills/example.md'), '# Example skill')
209
+ await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Example skill')
210
210
  await Bun.write(
211
211
  join(dir, 'facet.json'),
212
212
  JSON.stringify({
@@ -230,8 +230,8 @@ describe('runBuildPipeline', () => {
230
230
  expect(result.archiveFilename).toBe('test-facet-1.0.0.facet')
231
231
  expect(result.archiveBytes.length).toBeGreaterThan(0)
232
232
  expect(Object.keys(result.assetHashes)).toContain('facet.json')
233
- expect(Object.keys(result.assetHashes)).toContain('skills/example.md')
234
- expect(result.assetHashes['skills/example.md']).toMatchInlineSnapshot(
233
+ expect(Object.keys(result.assetHashes)).toContain('skills/example/SKILL.md')
234
+ expect(result.assetHashes['skills/example/SKILL.md']).toMatchInlineSnapshot(
235
235
  `"sha256:ded8057927e03783371d0d929e4a6e92da66eb9dd164377ad6845a5a1c0cb5ba"`,
236
236
  )
237
237
  expect(result.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
@@ -263,13 +263,13 @@ describe('runBuildPipeline', () => {
263
263
  expect(result.ok).toBe(false)
264
264
  if (!result.ok) {
265
265
  expect(result.errors[0]?.path).toBe('skills.example')
266
- expect(result.errors[0]?.message).toContain('skills/example.md')
266
+ expect(result.errors[0]?.message).toContain('skills/example/SKILL.md')
267
267
  }
268
268
  })
269
269
 
270
270
  test('build succeeds with cross-type name sharing', async () => {
271
271
  const dir = await createFixtureDir('cross-type')
272
- await Bun.write(join(dir, 'skills/review.md'), '# Review skill')
272
+ await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review skill')
273
273
  await Bun.write(join(dir, 'commands/review.md'), '# Review command')
274
274
  await Bun.write(
275
275
  join(dir, 'facet.json'),
@@ -291,8 +291,8 @@ describe('runBuildPipeline', () => {
291
291
 
292
292
  test('build with all asset types includes all hashes', async () => {
293
293
  const dir = await createFixtureDir('all-types')
294
- await Bun.write(join(dir, 'skills/alpha.md'), '# Alpha skill')
295
- await Bun.write(join(dir, 'skills/beta.md'), '# Beta skill')
294
+ await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# Alpha skill')
295
+ await Bun.write(join(dir, 'skills/beta/SKILL.md'), '# Beta skill')
296
296
  await Bun.write(join(dir, 'agents/helper.md'), '# Helper agent')
297
297
  await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy command')
298
298
  await Bun.write(
@@ -322,15 +322,15 @@ describe('runBuildPipeline', () => {
322
322
  'agents/helper.md',
323
323
  'commands/deploy.md',
324
324
  'facet.json',
325
- 'skills/alpha.md',
326
- 'skills/beta.md',
325
+ 'skills/alpha/SKILL.md',
326
+ 'skills/beta/SKILL.md',
327
327
  ])
328
328
  }
329
329
  })
330
330
 
331
331
  test('build fails on malformed compact facets entry', async () => {
332
332
  const dir = await createFixtureDir('bad-facets')
333
- await Bun.write(join(dir, 'skills/x.md'), '# Skill')
333
+ await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
334
334
  await Bun.write(
335
335
  join(dir, 'facet.json'),
336
336
  JSON.stringify({
@@ -351,12 +351,89 @@ describe('runBuildPipeline', () => {
351
351
  })
352
352
  })
353
353
 
354
+ // --- Content validation ---
355
+
356
+ describe('content validation', () => {
357
+ test('build fails on file with YAML front matter', async () => {
358
+ const dir = await createFixtureDir('front-matter')
359
+ await Bun.write(
360
+ join(dir, 'skills/review/SKILL.md'),
361
+ '---\nname: Review\ndescription: A review skill\n---\n# Review\nReview all code.',
362
+ )
363
+ await Bun.write(
364
+ join(dir, 'facet.json'),
365
+ JSON.stringify({
366
+ name: 'test-facet',
367
+ version: '1.0.0',
368
+ skills: {
369
+ review: { description: 'A review skill' },
370
+ },
371
+ }),
372
+ )
373
+
374
+ const result = await runBuildPipeline(dir)
375
+ expect(result.ok).toBe(false)
376
+ if (!result.ok) {
377
+ expect(result.errors).toHaveLength(1)
378
+ expect(result.errors[0]?.path).toBe('skills.review')
379
+ expect(result.errors[0]?.message).toContain('front matter')
380
+ expect(result.errors[0]?.message).toContain('skills/review/SKILL.md')
381
+ }
382
+ })
383
+
384
+ test('build fails on empty content file', async () => {
385
+ const dir = await createFixtureDir('empty-file')
386
+ await Bun.write(join(dir, 'skills/empty/SKILL.md'), '')
387
+ await Bun.write(
388
+ join(dir, 'facet.json'),
389
+ JSON.stringify({
390
+ name: 'test-facet',
391
+ version: '1.0.0',
392
+ skills: {
393
+ empty: { description: 'An empty skill' },
394
+ },
395
+ }),
396
+ )
397
+
398
+ const result = await runBuildPipeline(dir)
399
+ expect(result.ok).toBe(false)
400
+ if (!result.ok) {
401
+ expect(result.errors).toHaveLength(1)
402
+ expect(result.errors[0]?.path).toBe('skills.empty')
403
+ expect(result.errors[0]?.message).toContain('empty')
404
+ }
405
+ })
406
+
407
+ test('build fails on whitespace-only content file', async () => {
408
+ const dir = await createFixtureDir('whitespace-file')
409
+ await Bun.write(join(dir, 'agents/blank.md'), ' \n\n \n')
410
+ await Bun.write(
411
+ join(dir, 'facet.json'),
412
+ JSON.stringify({
413
+ name: 'test-facet',
414
+ version: '1.0.0',
415
+ agents: {
416
+ blank: { description: 'A blank agent' },
417
+ },
418
+ }),
419
+ )
420
+
421
+ const result = await runBuildPipeline(dir)
422
+ expect(result.ok).toBe(false)
423
+ if (!result.ok) {
424
+ expect(result.errors).toHaveLength(1)
425
+ expect(result.errors[0]?.path).toBe('agents.blank')
426
+ expect(result.errors[0]?.message).toContain('empty')
427
+ }
428
+ })
429
+ })
430
+
354
431
  // --- Build output generation ---
355
432
 
356
433
  describe('writeBuildOutput', () => {
357
434
  test('writes archive and build manifest to dist/', async () => {
358
435
  const dir = await createFixtureDir('write-output')
359
- await Bun.write(join(dir, 'skills/example.md'), '# Resolved content')
436
+ await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Resolved content')
360
437
  await Bun.write(
361
438
  join(dir, 'facet.json'),
362
439
  JSON.stringify({
@@ -385,7 +462,7 @@ describe('writeBuildOutput', () => {
385
462
  expect(manifest.archive).toBe('test-facet-1.0.0.facet')
386
463
  expect(manifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
387
464
  expect(manifest.assets['facet.json']).toMatch(/^sha256:[a-f0-9]{64}$/)
388
- expect(manifest.assets['skills/example.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
465
+ expect(manifest.assets['skills/example/SKILL.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
389
466
 
390
467
  // No loose files
391
468
  const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists()
@@ -394,7 +471,7 @@ describe('writeBuildOutput', () => {
394
471
 
395
472
  test('cleans previous dist/ before writing', async () => {
396
473
  const dir = await createFixtureDir('clean-dist')
397
- await Bun.write(join(dir, 'skills/x.md'), '# Skill')
474
+ await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
398
475
  await Bun.write(
399
476
  join(dir, 'facet.json'),
400
477
  JSON.stringify({
@@ -63,7 +63,7 @@ describe('collectArchiveEntries', () => {
63
63
 
64
64
  expect(entries).toHaveLength(4)
65
65
  expect(entries.map((e) => e.path)).toContain('facet.json')
66
- expect(entries.map((e) => e.path)).toContain('skills/review.md')
66
+ expect(entries.map((e) => e.path)).toContain('skills/review/SKILL.md')
67
67
  expect(entries.map((e) => e.path)).toContain('agents/helper.md')
68
68
  expect(entries.map((e) => e.path)).toContain('commands/deploy.md')
69
69
  })
@@ -84,7 +84,7 @@ describe('collectArchiveEntries', () => {
84
84
  const entries = collectArchiveEntries(resolved, 'manifest content')
85
85
  const paths = entries.map((e) => e.path)
86
86
 
87
- expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill.md', 'skills/z-skill.md'])
87
+ expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill/SKILL.md', 'skills/z-skill/SKILL.md'])
88
88
  })
89
89
 
90
90
  test('handles manifest with no optional asset types', () => {
@@ -105,7 +105,7 @@ describe('computeAssetHashes', () => {
105
105
  test('returns correct hash for each entry', () => {
106
106
  const entries = [
107
107
  { path: 'facet.json', content: '{"name":"test"}' },
108
- { path: 'skills/review.md', content: '# Review' },
108
+ { path: 'skills/review/SKILL.md', content: '# Review' },
109
109
  ]
110
110
 
111
111
  const hashes = computeAssetHashes(entries)
@@ -114,7 +114,7 @@ describe('computeAssetHashes', () => {
114
114
  expect(hashes['facet.json']).toMatchInlineSnapshot(
115
115
  `"sha256:7d9fd2051fc32b32feab10946fab6bb91426ab7e39aa5439289ed892864aa91d"`,
116
116
  )
117
- expect(hashes['skills/review.md']).toMatchInlineSnapshot(
117
+ expect(hashes['skills/review/SKILL.md']).toMatchInlineSnapshot(
118
118
  `"sha256:f1a9d9d60fba2e67d82d788760d147d95461a58456411e205bf33a6dbdc3497f"`,
119
119
  )
120
120
  })
@@ -134,7 +134,7 @@ describe('assembleTar', () => {
134
134
  test('produces a valid tar archive', () => {
135
135
  const entries = [
136
136
  { path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
137
- { path: 'skills/review.md', content: '# Review skill' },
137
+ { path: 'skills/review/SKILL.md', content: '# Review skill' },
138
138
  ]
139
139
 
140
140
  const tar = assembleTar(entries)
@@ -147,7 +147,7 @@ describe('assembleTar', () => {
147
147
 
148
148
  const names = parsed.map((f) => f.name)
149
149
  expect(names).toContain('facet.json')
150
- expect(names).toContain('skills/review.md')
150
+ expect(names).toContain('skills/review/SKILL.md')
151
151
  })
152
152
 
153
153
  test('tar contains correct file contents', () => {
@@ -205,7 +205,7 @@ describe('compressArchive', () => {
205
205
  test('compressed archive can be decompressed to recover original tar', async () => {
206
206
  const entries = [
207
207
  { path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
208
- { path: 'skills/review.md', content: '# Review skill' },
208
+ { path: 'skills/review/SKILL.md', content: '# Review skill' },
209
209
  ]
210
210
 
211
211
  const tar = assembleTar(entries)
@@ -220,7 +220,7 @@ describe('compressArchive', () => {
220
220
 
221
221
  const names = parsed.map((f) => f.name)
222
222
  expect(names).toContain('facet.json')
223
- expect(names).toContain('skills/review.md')
224
- expect(parsed.find((f) => f.name === 'skills/review.md')?.text).toBe('# Review skill')
223
+ expect(names).toContain('skills/review/SKILL.md')
224
+ expect(parsed.find((f) => f.name === 'skills/review/SKILL.md')?.text).toBe('# Review skill')
225
225
  })
226
226
  })
@@ -0,0 +1,190 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { writeManifest } from '../edit/manifest-writer.ts'
6
+ import { reconcile } from '../edit/reconcile.ts'
7
+ import type { DiscoveredAsset } from '../edit/scanner.ts'
8
+ import { scanAssets } from '../edit/scanner.ts'
9
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
10
+
11
+ async function createFixtureDir(name: string): Promise<string> {
12
+ const dir = join(tmpdir(), `facets-edit-test-${name}-${Date.now()}`)
13
+ await mkdir(dir, { recursive: true })
14
+ return dir
15
+ }
16
+
17
+ // --- Scanner ---
18
+
19
+ describe('scanAssets', () => {
20
+ test('discovers skills in directory convention', async () => {
21
+ const dir = await createFixtureDir('scan-skills')
22
+ await mkdir(join(dir, 'skills/review'), { recursive: true })
23
+ await mkdir(join(dir, 'skills/code-fix'), { recursive: true })
24
+ await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review')
25
+ await Bun.write(join(dir, 'skills/code-fix/SKILL.md'), '# Code Fix')
26
+
27
+ const assets = await scanAssets(dir)
28
+ expect(assets.filter((a) => a.type === 'skills')).toHaveLength(2)
29
+ expect(assets.find((a) => a.name === 'review')).toBeDefined()
30
+ expect(assets.find((a) => a.name === 'code-fix')).toBeDefined()
31
+ })
32
+
33
+ test('discovers agents and commands in flat convention', async () => {
34
+ const dir = await createFixtureDir('scan-flat')
35
+ await mkdir(join(dir, 'agents'), { recursive: true })
36
+ await mkdir(join(dir, 'commands'), { recursive: true })
37
+ await Bun.write(join(dir, 'agents/reviewer.md'), '# Reviewer')
38
+ await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy')
39
+
40
+ const assets = await scanAssets(dir)
41
+ expect(assets.find((a) => a.type === 'agents' && a.name === 'reviewer')).toBeDefined()
42
+ expect(assets.find((a) => a.type === 'commands' && a.name === 'deploy')).toBeDefined()
43
+ })
44
+
45
+ test('skips non-kebab-case names', async () => {
46
+ const dir = await createFixtureDir('scan-skip')
47
+ await mkdir(join(dir, 'skills/InvalidName'), { recursive: true })
48
+ await mkdir(join(dir, 'agents'), { recursive: true })
49
+ await Bun.write(join(dir, 'skills/InvalidName/SKILL.md'), '# Bad')
50
+ await Bun.write(join(dir, 'agents/NOT_VALID.md'), '# Bad')
51
+
52
+ const assets = await scanAssets(dir)
53
+ expect(assets).toHaveLength(0)
54
+ })
55
+
56
+ test('returns empty for directory with no assets', async () => {
57
+ const dir = await createFixtureDir('scan-empty')
58
+ const assets = await scanAssets(dir)
59
+ expect(assets).toHaveLength(0)
60
+ })
61
+
62
+ test('returns sorted results', async () => {
63
+ const dir = await createFixtureDir('scan-sorted')
64
+ await mkdir(join(dir, 'skills/zebra'), { recursive: true })
65
+ await mkdir(join(dir, 'agents'), { recursive: true })
66
+ await mkdir(join(dir, 'skills/alpha'), { recursive: true })
67
+ await Bun.write(join(dir, 'skills/zebra/SKILL.md'), '# Z')
68
+ await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# A')
69
+ await Bun.write(join(dir, 'agents/beta.md'), '# B')
70
+
71
+ const assets = await scanAssets(dir)
72
+ const names = assets.map((a) => `${a.type}:${a.name}`)
73
+ expect(names).toEqual(['agents:beta', 'skills:alpha', 'skills:zebra'])
74
+ })
75
+ })
76
+
77
+ // --- Reconciliation ---
78
+
79
+ describe('reconcile', () => {
80
+ test('identifies additions (on disk, not in manifest)', () => {
81
+ const manifest: FacetManifest = {
82
+ name: 'test',
83
+ version: '1.0.0',
84
+ skills: { review: { description: 'Review skill' } },
85
+ }
86
+ const discovered: DiscoveredAsset[] = [
87
+ { type: 'skills', name: 'review', path: 'skills/review/SKILL.md' },
88
+ { type: 'skills', name: 'new-skill', path: 'skills/new-skill/SKILL.md' },
89
+ { type: 'agents', name: 'helper', path: 'agents/helper.md' },
90
+ ]
91
+
92
+ const result = reconcile(manifest, discovered)
93
+ expect(result.additions).toHaveLength(2)
94
+ expect(result.additions.find((a) => a.name === 'new-skill')).toBeDefined()
95
+ expect(result.additions.find((a) => a.name === 'helper')).toBeDefined()
96
+ })
97
+
98
+ test('identifies missing files (in manifest, not on disk)', () => {
99
+ const manifest: FacetManifest = {
100
+ name: 'test',
101
+ version: '1.0.0',
102
+ skills: {
103
+ review: { description: 'Review' },
104
+ gone: { description: 'Gone' },
105
+ },
106
+ }
107
+ const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'review', path: 'skills/review/SKILL.md' }]
108
+
109
+ const result = reconcile(manifest, discovered)
110
+ expect(result.missing).toHaveLength(1)
111
+ expect(result.missing[0]?.name).toBe('gone')
112
+ expect(result.missing[0]?.expectedPath).toBe('skills/gone/SKILL.md')
113
+ })
114
+
115
+ test('identifies matched assets', () => {
116
+ const manifest: FacetManifest = {
117
+ name: 'test',
118
+ version: '1.0.0',
119
+ agents: { reviewer: { description: 'Review agent' } },
120
+ }
121
+ const discovered: DiscoveredAsset[] = [{ type: 'agents', name: 'reviewer', path: 'agents/reviewer.md' }]
122
+
123
+ const result = reconcile(manifest, discovered)
124
+ expect(result.matched).toHaveLength(1)
125
+ expect(result.matched[0]?.name).toBe('reviewer')
126
+ expect(result.additions).toHaveLength(0)
127
+ expect(result.missing).toHaveLength(0)
128
+ })
129
+
130
+ test('handles manifest with no assets', () => {
131
+ const manifest: FacetManifest = {
132
+ name: 'test',
133
+ version: '1.0.0',
134
+ facets: ['base@1.0.0'],
135
+ }
136
+ const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'extra', path: 'skills/extra/SKILL.md' }]
137
+
138
+ const result = reconcile(manifest, discovered)
139
+ expect(result.additions).toHaveLength(1)
140
+ expect(result.missing).toHaveLength(0)
141
+ expect(result.matched).toHaveLength(0)
142
+ })
143
+
144
+ test('missing agent has flat file expected path', () => {
145
+ const manifest: FacetManifest = {
146
+ name: 'test',
147
+ version: '1.0.0',
148
+ agents: { gone: { description: 'Gone' } },
149
+ }
150
+
151
+ const result = reconcile(manifest, [])
152
+ expect(result.missing[0]?.expectedPath).toBe('agents/gone.md')
153
+ })
154
+ })
155
+
156
+ // --- Manifest Writer ---
157
+
158
+ describe('writeManifest', () => {
159
+ test('writes valid JSON to facet.json', async () => {
160
+ const dir = await createFixtureDir('write-manifest')
161
+ const manifest: FacetManifest = {
162
+ name: 'test-facet',
163
+ version: '1.0.0',
164
+ skills: { review: { description: 'A review skill' } },
165
+ }
166
+
167
+ await writeManifest(manifest, dir)
168
+
169
+ const content = await Bun.file(join(dir, 'facet.json')).text()
170
+ const parsed = JSON.parse(content)
171
+ expect(parsed.name).toBe('test-facet')
172
+ expect(parsed.version).toBe('1.0.0')
173
+ expect(parsed.skills.review.description).toBe('A review skill')
174
+ })
175
+
176
+ test('output is 2-space indented', async () => {
177
+ const dir = await createFixtureDir('write-indent')
178
+ const manifest: FacetManifest = {
179
+ name: 'test',
180
+ version: '1.0.0',
181
+ skills: { x: { description: 'X' } },
182
+ }
183
+
184
+ await writeManifest(manifest, dir)
185
+
186
+ const content = await Bun.file(join(dir, 'facet.json')).text()
187
+ // 2-space indent means lines like ' "name": "test"'
188
+ expect(content).toContain(' "name"')
189
+ })
190
+ })
@@ -174,7 +174,7 @@ describe('loadManifest', () => {
174
174
  describe('resolvePrompts', () => {
175
175
  test('prompt content is resolved from conventional file paths', async () => {
176
176
  const dir = await createFixtureDir('resolve-convention')
177
- await writeFixture(dir, 'skills/review.md', '# Code Review\nReview all code.')
177
+ await writeFixture(dir, 'skills/review/SKILL.md', '# Code Review\nReview all code.')
178
178
  await writeFixture(dir, 'agents/reviewer.md', '# Reviewer\nReview this code.')
179
179
  await writeFixture(dir, 'commands/deploy.md', '# Deploy\nDeploy the code.')
180
180
 
@@ -242,7 +242,7 @@ describe('resolvePrompts', () => {
242
242
 
243
243
  test('manifest without agents or commands resolves successfully', async () => {
244
244
  const dir = await createFixtureDir('resolve-skills-only')
245
- await writeFixture(dir, 'skills/x.md', '# Skill X\nDo x.')
245
+ await writeFixture(dir, 'skills/x/SKILL.md', '# Skill X\nDo x.')
246
246
 
247
247
  const manifest = {
248
248
  name: 'test',
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { type } from 'arktype'
3
- import { checkFacetManifestConstraints, type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
3
+ import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
4
4
 
5
5
  // --- Valid manifests ---
6
6
 
@@ -88,9 +88,6 @@ describe('FacetManifestSchema — valid manifests', () => {
88
88
  }
89
89
  const result = FacetManifestSchema(input)
90
90
  expect(result).not.toBeInstanceOf(type.errors)
91
- const data = result as FacetManifest
92
- const errors = checkFacetManifestConstraints(data)
93
- expect(errors).toHaveLength(0)
94
91
  })
95
92
  })
96
93
 
@@ -137,38 +134,29 @@ describe('FacetManifestSchema — invalid manifests', () => {
137
134
  const errors = result as InstanceType<typeof type.errors>
138
135
  expect(errors.some((e) => e.path.includes('bad'))).toBe(true)
139
136
  })
140
- })
141
-
142
- // --- Business-rule constraints ---
143
137
 
144
- describe('checkFacetManifestConstraints', () => {
145
- test('no text assets → error', () => {
138
+ test('no text assets → schema error', () => {
146
139
  const input = {
147
140
  name: 'empty',
148
141
  version: '1.0.0',
149
142
  servers: { jira: '1.0.0' },
150
143
  }
151
144
  const result = FacetManifestSchema(input)
152
- expect(result).not.toBeInstanceOf(type.errors)
153
- const errors = checkFacetManifestConstraints(result as FacetManifest)
154
- expect(errors).toHaveLength(1)
155
- const firstError = errors[0]
156
- expect(firstError).toBeDefined()
157
- expect(firstError?.message).toContain('at least one text asset')
145
+ expect(result).toBeInstanceOf(type.errors)
146
+ const errors = result as InstanceType<typeof type.errors>
147
+ expect(errors.some((e) => e.message.includes('at least one text asset'))).toBe(true)
158
148
  })
159
149
 
160
- test('selective facets entry with no asset selection → error', () => {
150
+ test('selective facets entry with no asset selection → schema error', () => {
161
151
  const input = {
162
152
  name: 'bad-selective',
163
153
  version: '1.0.0',
164
154
  facets: [{ name: 'other', version: '1.0.0' }],
165
155
  }
166
156
  const result = FacetManifestSchema(input)
167
- expect(result).not.toBeInstanceOf(type.errors)
168
- const errors = checkFacetManifestConstraints(result as FacetManifest)
169
- const selectiveError = errors.find((e) => e.message.includes('at least one asset type'))
170
- expect(selectiveError).toBeDefined()
171
- expect(selectiveError?.path).toBe('facets[0]')
157
+ expect(result).toBeInstanceOf(type.errors)
158
+ const errors = result as InstanceType<typeof type.errors>
159
+ expect(errors.some((e) => e.message.includes('at least one asset type'))).toBe(true)
172
160
  })
173
161
  })
174
162
 
@@ -27,7 +27,7 @@ export function collectArchiveEntries(resolved: ResolvedFacetManifest, manifestC
27
27
 
28
28
  if (resolved.skills) {
29
29
  for (const [name, skill] of Object.entries(resolved.skills)) {
30
- entries.push({ path: `skills/${name}.md`, content: skill.prompt })
30
+ entries.push({ path: `skills/${name}/SKILL.md`, content: skill.prompt })
31
31
  }
32
32
  }
33
33
 
@@ -9,11 +9,12 @@ import {
9
9
  computeContentHash,
10
10
  } from './content-hash.ts'
11
11
  import { detectNamingCollisions } from './detect-collisions.ts'
12
+ import { validateContentFiles } from './validate-content.ts'
12
13
  import { validateCompactFacets } from './validate-facets.ts'
13
14
  import { validatePlatformConfigs } from './validate-platforms.ts'
14
15
 
15
16
  export interface BuildProgress {
16
- stage: string
17
+ stage: BuildStage
17
18
  status: 'running' | 'done' | 'failed'
18
19
  }
19
20
 
@@ -33,19 +34,33 @@ export interface BuildFailure {
33
34
  warnings: string[]
34
35
  }
35
36
 
37
+ /** Stage names emitted by the build pipeline via the onProgress callback. */
38
+ export const BUILD_STAGES = [
39
+ 'Parsing manifest',
40
+ 'Resolving prompts',
41
+ 'Validating assets',
42
+ 'Checking collisions',
43
+ 'Validating platforms',
44
+ 'Assembling archive',
45
+ 'Writing output',
46
+ ] as const
47
+
48
+ export type BuildStage = (typeof BUILD_STAGES)[number]
49
+
36
50
  /**
37
51
  * Runs the full build pipeline:
38
- * 1. Load manifest — read the facet manifest, parse JSON, validate schema, check constraints
39
- * 2. Resolve prompts — read prompt files at conventional paths for skills, agents, commands (also verifies files exist)
40
- * 3. Validate compact facets format check name@version pattern
41
- * 4. Detect naming collisions — fail if same name used within an asset type
42
- * 5. Validate platform config — check known platform schemas, warn on unknown
43
- * 6. Assemble archive — collect entries, compute per-asset hashes, build deterministic tar, compute integrity hash, compress for delivery
52
+ * 1. Parse manifest — read facet.json, parse JSON, validate schema, check constraints
53
+ * 2. Resolve prompts — read prompt files at conventional paths (also verifies files exist)
54
+ * 3. Validate content no front matter, no empty files
55
+ * 4. Check collisions — fail if same name used within an asset type
56
+ * 5. Validate platforms — check known platform schemas, warn on unknown
57
+ * 6. Assemble archive — collect entries, compute hashes, build tar, compress
44
58
  *
45
59
  * Returns the resolved manifest and archive data on success, or collected errors on failure.
46
60
  * Warnings are returned in both cases.
47
61
  *
48
62
  * An optional `onProgress` callback receives stage updates for UI display.
63
+ * The 'Writing output' stage is emitted by name but handled by the caller (BuildView).
49
64
  */
50
65
  export async function runBuildPipeline(
51
66
  rootDir: string,
@@ -53,46 +68,64 @@ export async function runBuildPipeline(
53
68
  ): Promise<BuildResult | BuildFailure> {
54
69
  const warnings: string[] = []
55
70
 
56
- // Stage 1: Load manifest
57
- onProgress?.({ stage: 'Validating manifest', status: 'running' })
71
+ // Stage 1: Parse manifest
72
+ onProgress?.({ stage: 'Parsing manifest', status: 'running' })
58
73
 
59
74
  const loadResult = await loadManifest(rootDir)
60
75
  if (!loadResult.ok) {
61
- onProgress?.({ stage: 'Validating manifest', status: 'failed' })
76
+ onProgress?.({ stage: 'Parsing manifest', status: 'failed' })
62
77
  return { ok: false, errors: loadResult.errors, warnings }
63
78
  }
64
79
  const manifest = loadResult.data
65
80
 
81
+ onProgress?.({ stage: 'Parsing manifest', status: 'done' })
82
+
66
83
  // Stage 2: Resolve prompts (also serves as file existence verification)
84
+ onProgress?.({ stage: 'Resolving prompts', status: 'running' })
85
+
67
86
  const resolveResult = await resolvePrompts(manifest, rootDir)
68
87
  if (!resolveResult.ok) {
69
- onProgress?.({ stage: 'Validating manifest', status: 'failed' })
88
+ onProgress?.({ stage: 'Resolving prompts', status: 'failed' })
70
89
  return { ok: false, errors: resolveResult.errors, warnings }
71
90
  }
72
91
 
73
- // Stage 3: Validate compact facets format
74
- const facetsErrors = validateCompactFacets(manifest)
75
- if (facetsErrors.length > 0) {
76
- onProgress?.({ stage: 'Validating manifest', status: 'failed' })
77
- return { ok: false, errors: facetsErrors, warnings }
92
+ onProgress?.({ stage: 'Resolving prompts', status: 'done' })
93
+
94
+ // Stage 3: Validate assets (no front matter, no empty files)
95
+ onProgress?.({ stage: 'Validating assets', status: 'running' })
96
+
97
+ const contentErrors = validateContentFiles(resolveResult.data)
98
+ if (contentErrors.length > 0) {
99
+ onProgress?.({ stage: 'Validating assets', status: 'failed' })
100
+ return { ok: false, errors: contentErrors, warnings }
78
101
  }
79
102
 
80
- // Stage 4: Detect naming collisions
103
+ onProgress?.({ stage: 'Validating assets', status: 'done' })
104
+
105
+ // Stage 4: Check naming collisions
106
+ onProgress?.({ stage: 'Checking collisions', status: 'running' })
107
+
81
108
  const collisionErrors = detectNamingCollisions(manifest)
82
- if (collisionErrors.length > 0) {
83
- onProgress?.({ stage: 'Validating manifest', status: 'failed' })
84
- return { ok: false, errors: collisionErrors, warnings }
109
+ const facetsErrors = validateCompactFacets(manifest)
110
+ const checkErrors = [...collisionErrors, ...facetsErrors]
111
+ if (checkErrors.length > 0) {
112
+ onProgress?.({ stage: 'Checking collisions', status: 'failed' })
113
+ return { ok: false, errors: checkErrors, warnings }
85
114
  }
86
115
 
116
+ onProgress?.({ stage: 'Checking collisions', status: 'done' })
117
+
87
118
  // Stage 5: Validate platform config
119
+ onProgress?.({ stage: 'Validating platforms', status: 'running' })
120
+
88
121
  const platformResult = validatePlatformConfigs(manifest)
89
122
  if (platformResult.errors.length > 0) {
90
- onProgress?.({ stage: 'Validating manifest', status: 'failed' })
123
+ onProgress?.({ stage: 'Validating platforms', status: 'failed' })
91
124
  return { ok: false, errors: platformResult.errors, warnings: [...warnings, ...platformResult.warnings] }
92
125
  }
93
126
  warnings.push(...platformResult.warnings)
94
127
 
95
- onProgress?.({ stage: 'Validating manifest', status: 'done' })
128
+ onProgress?.({ stage: 'Validating platforms', status: 'done' })
96
129
 
97
130
  // Stage 6: Assemble archive, compute content hashes, and compress for delivery
98
131
  onProgress?.({ stage: 'Assembling archive', status: 'running' })
@@ -0,0 +1,50 @@
1
+ import { hasFrontMatter } from '../front-matter.ts'
2
+ import type { ResolvedFacetManifest } from '../loaders/facet.ts'
3
+ import type { ValidationError } from '../types.ts'
4
+
5
+ /**
6
+ * Validates resolved prompt content for all assets:
7
+ * - No YAML front matter (manifest is the single source of truth for metadata)
8
+ * - No empty files (zero bytes or whitespace only)
9
+ *
10
+ * Returns an array of validation errors, one per offending file.
11
+ */
12
+ export function validateContentFiles(resolved: ResolvedFacetManifest): ValidationError[] {
13
+ const errors: ValidationError[] = []
14
+
15
+ const assetTypes = [
16
+ { type: 'skills', assets: resolved.skills },
17
+ { type: 'agents', assets: resolved.agents },
18
+ { type: 'commands', assets: resolved.commands },
19
+ ] as const
20
+
21
+ for (const { type, assets } of assetTypes) {
22
+ if (!assets) continue
23
+ for (const [name, asset] of Object.entries(assets)) {
24
+ const relativePath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
25
+
26
+ // Check for empty content
27
+ if (asset.prompt.trim().length === 0) {
28
+ errors.push({
29
+ path: `${type}.${name}`,
30
+ message: `File is empty: ${relativePath}. Content files must contain prompt content.`,
31
+ expected: 'non-empty content',
32
+ actual: 'empty or whitespace only',
33
+ })
34
+ continue // Skip front matter check on empty files
35
+ }
36
+
37
+ // Check for YAML front matter
38
+ if (hasFrontMatter(asset.prompt)) {
39
+ errors.push({
40
+ path: `${type}.${name}`,
41
+ message: `File contains YAML front matter: ${relativePath}. The manifest is the source of truth for metadata — use \`facet edit\` to reconcile.`,
42
+ expected: 'no front matter',
43
+ actual: 'front matter detected',
44
+ })
45
+ }
46
+ }
47
+ }
48
+
49
+ return errors
50
+ }
@@ -0,0 +1,12 @@
1
+ import { join } from 'node:path'
2
+ import { FACET_MANIFEST_FILE } from '../loaders/facet.ts'
3
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
4
+
5
+ /**
6
+ * Writes a facet manifest to disk as `facet.json`.
7
+ * Uses `JSON.stringify(data, null, 2)` per ADR-006.
8
+ */
9
+ export async function writeManifest(manifest: FacetManifest, rootDir: string): Promise<void> {
10
+ const path = join(rootDir, FACET_MANIFEST_FILE)
11
+ await Bun.write(path, JSON.stringify(manifest, null, 2))
12
+ }
@@ -0,0 +1,77 @@
1
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
2
+ import type { AssetType, DiscoveredAsset } from './scanner.ts'
3
+
4
+ export interface MissingAsset {
5
+ type: AssetType
6
+ name: string
7
+ /** The path where the file was expected (e.g., 'skills/review/SKILL.md') */
8
+ expectedPath: string
9
+ }
10
+
11
+ export interface MatchedAsset {
12
+ type: AssetType
13
+ name: string
14
+ /** Relative path from the facet root */
15
+ path: string
16
+ }
17
+
18
+ export interface ReconciliationResult {
19
+ /** Assets found on disk but not in the manifest */
20
+ additions: DiscoveredAsset[]
21
+ /** Assets in the manifest but missing from disk */
22
+ missing: MissingAsset[]
23
+ /** Assets that exist in both the manifest and on disk */
24
+ matched: MatchedAsset[]
25
+ }
26
+
27
+ /**
28
+ * Compares discovered assets on disk against manifest entries.
29
+ * Produces lists of additions (on disk, not in manifest),
30
+ * missing files (in manifest, not on disk), and matched assets.
31
+ */
32
+ export function reconcile(manifest: FacetManifest, discovered: DiscoveredAsset[]): ReconciliationResult {
33
+ const additions: DiscoveredAsset[] = []
34
+ const missing: MissingAsset[] = []
35
+ const matched: MatchedAsset[] = []
36
+
37
+ // Build a set of discovered assets keyed by type:name
38
+ const discoveredMap = new Map<string, DiscoveredAsset>()
39
+ for (const asset of discovered) {
40
+ discoveredMap.set(`${asset.type}:${asset.name}`, asset)
41
+ }
42
+
43
+ // Check manifest entries against discovered assets
44
+ const assetSections: Array<{ type: AssetType; entries: Record<string, unknown> | undefined }> = [
45
+ { type: 'skills', entries: manifest.skills },
46
+ { type: 'agents', entries: manifest.agents },
47
+ { type: 'commands', entries: manifest.commands },
48
+ ]
49
+
50
+ const manifestKeys = new Set<string>()
51
+
52
+ for (const { type, entries } of assetSections) {
53
+ if (!entries) continue
54
+ for (const name of Object.keys(entries)) {
55
+ const key = `${type}:${name}`
56
+ manifestKeys.add(key)
57
+
58
+ const onDisk = discoveredMap.get(key)
59
+ if (onDisk) {
60
+ matched.push({ type, name, path: onDisk.path })
61
+ } else {
62
+ const expectedPath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
63
+ missing.push({ type, name, expectedPath })
64
+ }
65
+ }
66
+ }
67
+
68
+ // Check discovered assets not in manifest
69
+ for (const asset of discovered) {
70
+ const key = `${asset.type}:${asset.name}`
71
+ if (!manifestKeys.has(key)) {
72
+ additions.push(asset)
73
+ }
74
+ }
75
+
76
+ return { additions, missing, matched }
77
+ }
@@ -0,0 +1,57 @@
1
+ import { Glob } from 'bun'
2
+
3
+ /** Kebab-case pattern for valid asset names. */
4
+ export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
5
+
6
+ export type AssetType = 'skills' | 'agents' | 'commands'
7
+
8
+ export interface DiscoveredAsset {
9
+ type: AssetType
10
+ name: string
11
+ /** Relative path from the facet root (e.g., 'skills/review/SKILL.md') */
12
+ path: string
13
+ }
14
+
15
+ /**
16
+ * Scans a facet directory for content files and returns discovered assets.
17
+ *
18
+ * Skills use the directory convention: `skills/<name>/SKILL.md`
19
+ * Agents use the flat convention: `agents/<name>.md`
20
+ * Commands use the flat convention: `commands/<name>.md`
21
+ *
22
+ * Only assets with valid kebab-case names are returned.
23
+ */
24
+ export async function scanAssets(rootDir: string): Promise<DiscoveredAsset[]> {
25
+ const assets: DiscoveredAsset[] = []
26
+
27
+ // Scan skills: skills/*/SKILL.md
28
+ const skillGlob = new Glob('skills/*/SKILL.md')
29
+ for await (const match of skillGlob.scan({ cwd: rootDir, onlyFiles: true })) {
30
+ // match is e.g. 'skills/review/SKILL.md'
31
+ const parts = match.split('/')
32
+ const name = parts[1]
33
+ if (name && KEBAB_CASE.test(name)) {
34
+ assets.push({ type: 'skills', name, path: match })
35
+ }
36
+ }
37
+
38
+ // Scan agents: agents/*.md
39
+ const agentGlob = new Glob('agents/*.md')
40
+ for await (const match of agentGlob.scan({ cwd: rootDir, onlyFiles: true })) {
41
+ const name = match.replace('agents/', '').replace('.md', '')
42
+ if (KEBAB_CASE.test(name)) {
43
+ assets.push({ type: 'agents', name, path: match })
44
+ }
45
+ }
46
+
47
+ // Scan commands: commands/*.md
48
+ const commandGlob = new Glob('commands/*.md')
49
+ for await (const match of commandGlob.scan({ cwd: rootDir, onlyFiles: true })) {
50
+ const name = match.replace('commands/', '').replace('.md', '')
51
+ if (KEBAB_CASE.test(name)) {
52
+ assets.push({ type: 'commands', name, path: match })
53
+ }
54
+ }
55
+
56
+ return assets.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name))
57
+ }
@@ -0,0 +1,58 @@
1
+ import { parse as parseYaml } from 'yaml'
2
+
3
+ /**
4
+ * Matches YAML front matter: opening `---`, YAML content, closing `---`.
5
+ * Anchored to start of string. Handles optional trailing content.
6
+ */
7
+ const FRONT_MATTER_RE = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/
8
+
9
+ /** Matches empty front matter: `---\n---` with optional trailing content. */
10
+ const EMPTY_FRONT_MATTER_RE = /^---\n---(?:\n([\s\S]*))?$/
11
+
12
+ /** Normalize BOM and line endings to LF. */
13
+ function normalize(raw: string): string {
14
+ return raw
15
+ .replace(/^\uFEFF/, '')
16
+ .replace(/\r\n/g, '\n')
17
+ .replace(/\r/g, '\n')
18
+ }
19
+
20
+ /** Returns true if the string contains YAML front matter. */
21
+ export function hasFrontMatter(raw: string): boolean {
22
+ const input = normalize(raw)
23
+ return FRONT_MATTER_RE.test(input) || EMPTY_FRONT_MATTER_RE.test(input)
24
+ }
25
+
26
+ export interface FrontMatterResult<T = Record<string, unknown>> {
27
+ /** Parsed YAML attributes. Empty object if no front matter or parse failure. */
28
+ data: T
29
+ /** Markdown body with front matter stripped. Original content if no front matter. */
30
+ content: string
31
+ }
32
+
33
+ /**
34
+ * Extracts YAML front matter attributes and clean body from a string.
35
+ * Returns the original content unchanged if no front matter is found.
36
+ * Treats YAML parse failures as "no front matter" (returns empty data + original content).
37
+ */
38
+ export function extractFrontMatter<T = Record<string, unknown>>(raw: string): FrontMatterResult<T> {
39
+ const input = normalize(raw)
40
+
41
+ const emptyMatch = input.match(EMPTY_FRONT_MATTER_RE)
42
+ if (emptyMatch) {
43
+ return { data: {} as T, content: emptyMatch[1] ?? '' }
44
+ }
45
+
46
+ const match = input.match(FRONT_MATTER_RE)
47
+ if (!match) {
48
+ return { data: {} as T, content: raw }
49
+ }
50
+
51
+ try {
52
+ const yaml = match[1] ?? ''
53
+ return { data: (parseYaml(yaml) ?? {}) as T, content: match[2] ?? '' }
54
+ } catch {
55
+ // Malformed YAML — treat as no front matter
56
+ return { data: {} as T, content: raw }
57
+ }
58
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  // types
2
-
3
2
  export type { ArchiveEntry } from './build/content-hash.ts'
4
3
  export {
5
4
  assembleTar,
@@ -9,13 +8,22 @@ export {
9
8
  computeContentHash,
10
9
  } from './build/content-hash.ts'
11
10
  export { detectNamingCollisions } from './build/detect-collisions.ts'
12
- export type { BuildFailure, BuildProgress, BuildResult } from './build/pipeline.ts'
11
+ export type { BuildFailure, BuildProgress, BuildResult, BuildStage } from './build/pipeline.ts'
13
12
  // build pipeline
14
- export { runBuildPipeline } from './build/pipeline.ts'
13
+ export { BUILD_STAGES, runBuildPipeline } from './build/pipeline.ts'
15
14
  export { validateCompactFacets } from './build/validate-facets.ts'
16
15
  export type { PlatformValidationResult } from './build/validate-platforms.ts'
17
16
  export { validatePlatformConfigs } from './build/validate-platforms.ts'
18
17
  export { writeBuildOutput } from './build/write-output.ts'
18
+ export { writeManifest } from './edit/manifest-writer.ts'
19
+ export type { MatchedAsset, MissingAsset, ReconciliationResult } from './edit/reconcile.ts'
20
+ export { reconcile } from './edit/reconcile.ts'
21
+ // edit
22
+ export type { AssetType, DiscoveredAsset } from './edit/scanner.ts'
23
+ export { KEBAB_CASE, scanAssets } from './edit/scanner.ts'
24
+ // front matter
25
+ export type { FrontMatterResult } from './front-matter.ts'
26
+ export { extractFrontMatter, hasFrontMatter } from './front-matter.ts'
19
27
  export type { ResolvedFacetManifest } from './loaders/facet.ts'
20
28
  // loaders
21
29
  export { FACET_MANIFEST_FILE, loadManifest, resolvePrompts } from './loaders/facet.ts'
@@ -24,10 +32,7 @@ export type { BuildManifest } from './schemas/build-manifest.ts'
24
32
  export { BuildManifestSchema } from './schemas/build-manifest.ts'
25
33
  export type { FacetManifest } from './schemas/facet-manifest.ts'
26
34
  // schemas
27
- export {
28
- checkFacetManifestConstraints,
29
- FacetManifestSchema,
30
- } from './schemas/facet-manifest.ts'
35
+ export { FacetManifestSchema } from './schemas/facet-manifest.ts'
31
36
  export type { Lockfile } from './schemas/lockfile.ts'
32
37
  export { LockfileSchema } from './schemas/lockfile.ts'
33
38
  export type { ServerManifest } from './schemas/server-manifest.ts'
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { type } from 'arktype'
3
- import { checkFacetManifestConstraints, type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
3
+ import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
4
4
  import type { Result, ValidationError } from '../types.ts'
5
5
  import { mapArkErrors, parseJson, readFile } from './validate.ts'
6
6
 
@@ -34,18 +34,13 @@ export async function loadManifest(dir: string): Promise<Result<FacetManifest>>
34
34
  return { ok: false, errors: mapArkErrors(validated) }
35
35
  }
36
36
 
37
- // Phase 3: Business-rule constraints
38
- const constraintErrors = checkFacetManifestConstraints(validated)
39
- if (constraintErrors.length > 0) {
40
- return { ok: false, errors: constraintErrors }
41
- }
42
-
43
37
  return { ok: true, data: validated }
44
38
  }
45
39
 
46
40
  /**
47
41
  * A manifest with all prompts resolved to their string content.
48
- * File paths are derived from convention: `<type>/<name>.md`.
42
+ * File paths are derived from convention: skills use `skills/<name>/SKILL.md`,
43
+ * agents use `agents/<name>.md`, commands use `commands/<name>.md`.
49
44
  */
50
45
  export interface ResolvedFacetManifest {
51
46
  name: string
@@ -83,8 +78,9 @@ export interface ResolvedFacetManifest {
83
78
  * Resolves prompt content for all skills, agents, and commands by reading
84
79
  * files at conventional paths relative to the facet root directory.
85
80
  *
86
- * The convention is `<type>/<name>.md` for example, a skill named
87
- * "code-review" resolves to `skills/code-review.md`.
81
+ * Skills use the Agent Skills directory convention: a skill named "code-review"
82
+ * resolves to `skills/code-review/SKILL.md`. Agents and commands use the flat
83
+ * file convention: `agents/<name>.md` and `commands/<name>.md`.
88
84
  *
89
85
  * This also serves as file existence verification for all three asset types —
90
86
  * if an expected file doesn't exist, resolution fails with an error identifying
@@ -96,7 +92,7 @@ export interface ResolvedFacetManifest {
96
92
  export async function resolvePrompts(manifest: FacetManifest, rootDir: string): Promise<Result<ResolvedFacetManifest>> {
97
93
  const errors: ValidationError[] = []
98
94
 
99
- // Resolve skill prompts from skills/<name>.md
95
+ // Resolve skill prompts from skills/<name>/SKILL.md
100
96
  let resolvedSkills: ResolvedFacetManifest['skills'] | undefined
101
97
  if (manifest.skills) {
102
98
  resolvedSkills = {}
@@ -158,11 +154,13 @@ export async function resolvePrompts(manifest: FacetManifest, rootDir: string):
158
154
  }
159
155
 
160
156
  /**
161
- * Resolves prompt content for a single asset by reading <type>/<name>.md.
157
+ * Resolves prompt content for a single asset by reading the file at its
158
+ * conventional path. Skills use `skills/<name>/SKILL.md` (Agent Skills
159
+ * directory convention). Agents and commands use `<type>/<name>.md`.
162
160
  * Returns the file content as a string, or a ValidationError if the file doesn't exist.
163
161
  */
164
162
  async function resolveAssetPrompt(assetType: string, name: string, rootDir: string): Promise<string | ValidationError> {
165
- const relativePath = `${assetType}/${name}.md`
163
+ const relativePath = assetType === 'skills' ? `${assetType}/${name}/SKILL.md` : `${assetType}/${name}.md`
166
164
  const filePath = join(rootDir, relativePath)
167
165
  const file = Bun.file(filePath)
168
166
  const exists = await file.exists()
@@ -8,9 +8,11 @@ import type { ValidationError } from '../types.ts'
8
8
  export function mapArkErrors(errors: InstanceType<typeof type.errors>): ValidationError[] {
9
9
  return errors.map((err) => ({
10
10
  path: err.path.join('.'),
11
- message: err.message,
11
+ // For predicate errors (.narrow()), err.message includes the full data object.
12
+ // Use err.expected directly — it's our clean sentence from ctx.mustBe().
13
+ message: err.code === 'predicate' ? (err.expected ?? err.message) : err.message,
12
14
  expected: err.expected ?? 'unknown',
13
- actual: err.actual ?? 'unknown',
15
+ actual: err.code === 'predicate' ? 'constraint not met' : (err.actual ?? 'unknown'),
14
16
  }))
15
17
  }
16
18
 
@@ -2,7 +2,7 @@ import { type } from 'arktype'
2
2
 
3
3
  // --- Sub-schemas ---
4
4
 
5
- /** Skill descriptor — description is required, prompt resolved from skills/<name>.md */
5
+ /** Skill descriptor — description is required, prompt resolved from skills/<name>/SKILL.md */
6
6
  const SkillDescriptor = type({
7
7
  description: 'string',
8
8
  'platforms?': type.Record('string', 'unknown'),
@@ -37,9 +37,11 @@ const ServerReference = type('string').or({ image: 'string' })
37
37
  // --- Main schema ---
38
38
 
39
39
  /**
40
- * The structural schema for the facet manifest — validates shape only.
41
- * Custom constraints (at least one text asset, selective entry must select at least one type)
42
- * are checked post-validation by checkFacetManifestConstraints().
40
+ * The facet manifest schema — validates structure and business constraints.
41
+ *
42
+ * Structural validation covers field types and shapes. Narrow constraints enforce:
43
+ * 1. At least one text asset (skills, agents, commands, or facets) must be present
44
+ * 2. Selective facets entries must include at least one asset type selection
43
45
  */
44
46
  export const FacetManifestSchema = type({
45
47
  name: 'string',
@@ -51,63 +53,35 @@ export const FacetManifestSchema = type({
51
53
  'commands?': type.Record('string', CommandDescriptor),
52
54
  'facets?': FacetsEntry.array(),
53
55
  'servers?': type.Record('string', ServerReference),
54
- })
55
-
56
- /** Inferred TypeScript type for a validated facet manifest */
57
- export type FacetManifest = typeof FacetManifestSchema.infer
58
-
59
- // --- Custom validation ---
60
-
61
- export interface FacetManifestError {
62
- path: string
63
- message: string
64
- expected: string
65
- actual: string
66
- }
67
-
68
- /**
69
- * Checks business-rule constraints that ArkType's structural validation cannot express:
70
- * 1. At least one text asset must be present (skills, agents, commands, or facets)
71
- * 2. Selective facets entries must include at least one asset type
72
- */
73
- export function checkFacetManifestConstraints(manifest: FacetManifest): FacetManifestError[] {
74
- const errors: FacetManifestError[] = []
75
-
56
+ }).narrow((data, ctx) => {
76
57
  // Constraint 1: at least one text asset
77
- const hasSkills = manifest.skills && Object.keys(manifest.skills).length > 0
78
- const hasAgents = manifest.agents && Object.keys(manifest.agents).length > 0
79
- const hasCommands = manifest.commands && Object.keys(manifest.commands).length > 0
80
- const hasFacets = manifest.facets && manifest.facets.length > 0
58
+ const hasSkills = data.skills && Object.keys(data.skills).length > 0
59
+ const hasAgents = data.agents && Object.keys(data.agents).length > 0
60
+ const hasCommands = data.commands && Object.keys(data.commands).length > 0
61
+ const hasFacets = data.facets && data.facets.length > 0
81
62
 
82
63
  if (!hasSkills && !hasAgents && !hasCommands && !hasFacets) {
83
- errors.push({
84
- path: '',
85
- message: 'Manifest must include at least one text asset (skills, agents, commands, or facets)',
86
- expected: 'at least one of: skills, agents, commands, facets',
87
- actual: 'none present',
88
- })
64
+ ctx.mustBe('Manifest must include at least one text asset (skills, agents, commands, or facets)')
89
65
  }
90
66
 
91
67
  // Constraint 2: selective facets entries must select at least one asset type
92
- if (manifest.facets) {
93
- for (let i = 0; i < manifest.facets.length; i++) {
94
- const entry = manifest.facets[i]
68
+ if (data.facets) {
69
+ for (let i = 0; i < data.facets.length; i++) {
70
+ const entry = data.facets[i]
95
71
  if (typeof entry === 'object') {
96
72
  const hasSelectedSkills = entry.skills && entry.skills.length > 0
97
73
  const hasSelectedAgents = entry.agents && entry.agents.length > 0
98
74
  const hasSelectedCommands = entry.commands && entry.commands.length > 0
99
75
 
100
76
  if (!hasSelectedSkills && !hasSelectedAgents && !hasSelectedCommands) {
101
- errors.push({
102
- path: `facets[${i}]`,
103
- message: 'Selective facets entry must include at least one asset type (skills, agents, or commands)',
104
- expected: 'at least one of: skills, agents, commands',
105
- actual: 'none selected',
106
- })
77
+ ctx.mustBe('Selective facets entry must include at least one asset type (skills, agents, or commands)')
107
78
  }
108
79
  }
109
80
  }
110
81
  }
111
82
 
112
- return errors
113
- }
83
+ return true
84
+ })
85
+
86
+ /** Inferred TypeScript type for a validated facet manifest */
87
+ export type FacetManifest = typeof FacetManifestSchema.infer