@agent-facets/core 0.1.2 → 0.2.0

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.
@@ -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