@cleocode/cant 2026.3.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Heuristic markdown section parser for CANT migration.
3
+ *
4
+ * Splits markdown content into sections by headings and classifies
5
+ * each section by matching against known agent/hook/skill/permission patterns.
6
+ * This is intentionally conservative -- unknown patterns are left as-is.
7
+ */
8
+ import type { ExtractedPermission, ExtractedProperty, MarkdownSection, SectionClassification } from './types';
9
+ /**
10
+ * Parse markdown content into classified sections.
11
+ *
12
+ * Splits on `##` and `###` headings, classifies each section by
13
+ * heuristic pattern matching, and returns structured section data
14
+ * with line numbers for source mapping.
15
+ *
16
+ * @param content - Raw markdown content
17
+ * @returns Array of parsed and classified sections
18
+ */
19
+ export declare function parseMarkdownSections(content: string): MarkdownSection[];
20
+ /**
21
+ * Classify a markdown section based on its heading and content.
22
+ *
23
+ * Uses heuristic matching against known patterns. Returns 'unknown'
24
+ * for sections that cannot be confidently classified.
25
+ *
26
+ * @param section - The section to classify
27
+ * @returns The classification type
28
+ */
29
+ export declare function classifySection(section: MarkdownSection): SectionClassification;
30
+ /**
31
+ * Extract key-value properties from markdown bullet lists.
32
+ *
33
+ * Matches patterns like:
34
+ * - `- **Key**: value`
35
+ * - `- **Key**: value`
36
+ * - `- Key: value`
37
+ *
38
+ * @param lines - Body lines of a section
39
+ * @returns Array of extracted properties
40
+ */
41
+ export declare function extractProperties(lines: string[]): ExtractedProperty[];
42
+ /**
43
+ * Extract permission entries from markdown content.
44
+ *
45
+ * Handles two formats:
46
+ * 1. Structured: `- Tasks: read, write`
47
+ * 2. Prose: `- Read and write tasks`
48
+ *
49
+ * @param lines - Body lines of a permissions section
50
+ * @returns Array of extracted permissions
51
+ */
52
+ export declare function extractPermissions(lines: string[]): ExtractedPermission[];
53
+ /**
54
+ * Normalize a heading into a valid CANT identifier.
55
+ *
56
+ * Converts "Code Review Agent" to "code-review-agent", strips
57
+ * common suffixes like " Agent", lowercases, and replaces
58
+ * non-alphanumeric chars with hyphens.
59
+ *
60
+ * @param heading - The raw heading text
61
+ * @returns A valid CANT identifier
62
+ */
63
+ export declare function headingToIdentifier(heading: string): string;
64
+ /**
65
+ * Map a hook heading to a CAAMP event name.
66
+ *
67
+ * Converts headings like "On Session Start" to "SessionStart".
68
+ * Returns null if the heading does not match a known event.
69
+ *
70
+ * @param heading - The raw heading text
71
+ * @returns The CAAMP PascalCase event name, or null
72
+ */
73
+ export declare function headingToEventName(heading: string): string | null;
74
+ /**
75
+ * Get the full set of CAAMP event names.
76
+ *
77
+ * @returns A read-only set of the 16 canonical CAAMP events
78
+ */
79
+ export declare function getCaampEvents(): ReadonlySet<string>;
@@ -0,0 +1,307 @@
1
+ "use strict";
2
+ /**
3
+ * Heuristic markdown section parser for CANT migration.
4
+ *
5
+ * Splits markdown content into sections by headings and classifies
6
+ * each section by matching against known agent/hook/skill/permission patterns.
7
+ * This is intentionally conservative -- unknown patterns are left as-is.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.parseMarkdownSections = parseMarkdownSections;
11
+ exports.classifySection = classifySection;
12
+ exports.extractProperties = extractProperties;
13
+ exports.extractPermissions = extractPermissions;
14
+ exports.headingToIdentifier = headingToIdentifier;
15
+ exports.headingToEventName = headingToEventName;
16
+ exports.getCaampEvents = getCaampEvents;
17
+ /**
18
+ * Known CAAMP hook event names (PascalCase).
19
+ * Used to identify hook sections from headings like "On Session Start".
20
+ */
21
+ const CAAMP_EVENTS = new Set([
22
+ 'SessionStart',
23
+ 'SessionEnd',
24
+ 'PromptSubmit',
25
+ 'ResponseComplete',
26
+ 'PreToolUse',
27
+ 'PostToolUse',
28
+ 'PostToolUseFailure',
29
+ 'PermissionRequest',
30
+ 'SubagentStart',
31
+ 'SubagentStop',
32
+ 'PreModel',
33
+ 'PostModel',
34
+ 'PreCompact',
35
+ 'PostCompact',
36
+ 'Notification',
37
+ 'ConfigChange',
38
+ ]);
39
+ /**
40
+ * Heading patterns that suggest a hook definition section.
41
+ * Case-insensitive matching against heading text.
42
+ */
43
+ const HOOK_HEADING_PATTERNS = [
44
+ /^on\s+session\s*start/i,
45
+ /^on\s+session\s*end/i,
46
+ /^on\s+prompt\s*submit/i,
47
+ /^on\s+response\s*complete/i,
48
+ /^on\s+pre\s*tool\s*use/i,
49
+ /^on\s+post\s*tool\s*use/i,
50
+ /^on\s+post\s*tool\s*use\s*failure/i,
51
+ /^on\s+permission\s*request/i,
52
+ /^on\s+subagent\s*start/i,
53
+ /^on\s+subagent\s*stop/i,
54
+ /^on\s+pre\s*model/i,
55
+ /^on\s+post\s*model/i,
56
+ /^on\s+pre\s*compact/i,
57
+ /^on\s+post\s*compact/i,
58
+ /^on\s+notification/i,
59
+ /^on\s+config\s*change/i,
60
+ /^when\s+.*\s+start/i,
61
+ /^when\s+.*\s+end/i,
62
+ /^hooks?\b/i,
63
+ ];
64
+ /**
65
+ * Parse markdown content into classified sections.
66
+ *
67
+ * Splits on `##` and `###` headings, classifies each section by
68
+ * heuristic pattern matching, and returns structured section data
69
+ * with line numbers for source mapping.
70
+ *
71
+ * @param content - Raw markdown content
72
+ * @returns Array of parsed and classified sections
73
+ */
74
+ function parseMarkdownSections(content) {
75
+ const lines = content.split('\n');
76
+ const sections = [];
77
+ let currentSection = null;
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const line = lines[i] ?? '';
80
+ const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
81
+ if (headingMatch) {
82
+ // Finalize previous section
83
+ if (currentSection?.heading) {
84
+ finalizeSection(currentSection, i, sections);
85
+ }
86
+ const level = (headingMatch[1] ?? '').length;
87
+ const heading = (headingMatch[2] ?? '').trim();
88
+ currentSection = {
89
+ heading,
90
+ level,
91
+ lineStart: i + 1, // 1-based
92
+ bodyLines: [],
93
+ classification: 'unknown',
94
+ };
95
+ }
96
+ else if (currentSection) {
97
+ currentSection.bodyLines = currentSection.bodyLines ?? [];
98
+ currentSection.bodyLines.push(line);
99
+ }
100
+ }
101
+ // Finalize last section
102
+ if (currentSection?.heading) {
103
+ finalizeSection(currentSection, lines.length, sections);
104
+ }
105
+ return sections;
106
+ }
107
+ /**
108
+ * Finalize a section: set lineEnd, trim trailing blank lines, classify.
109
+ */
110
+ function finalizeSection(section, nextLineIndex, sections) {
111
+ section.lineEnd = nextLineIndex; // 1-based exclusive becomes the end
112
+ // Trim trailing blank lines from body
113
+ while (section.bodyLines.length > 0 &&
114
+ (section.bodyLines[section.bodyLines.length - 1] ?? '').trim() === '') {
115
+ section.bodyLines.pop();
116
+ }
117
+ section.classification = classifySection(section);
118
+ sections.push(section);
119
+ }
120
+ /**
121
+ * Classify a markdown section based on its heading and content.
122
+ *
123
+ * Uses heuristic matching against known patterns. Returns 'unknown'
124
+ * for sections that cannot be confidently classified.
125
+ *
126
+ * @param section - The section to classify
127
+ * @returns The classification type
128
+ */
129
+ function classifySection(section) {
130
+ const heading = section.heading;
131
+ // Agent patterns: "## Agent: X", "## X Agent", "## Code Review Agent"
132
+ if (/\bagent\b/i.test(heading)) {
133
+ return 'agent';
134
+ }
135
+ // Permission patterns: "## Permissions", "### Permissions"
136
+ if (/^permissions?\b/i.test(heading)) {
137
+ return 'permissions';
138
+ }
139
+ // Hook patterns
140
+ for (const pattern of HOOK_HEADING_PATTERNS) {
141
+ if (pattern.test(heading)) {
142
+ return 'hook';
143
+ }
144
+ }
145
+ // Skill patterns: "## Skills", "## Skill: X"
146
+ if (/\bskills?\b/i.test(heading)) {
147
+ return 'skill';
148
+ }
149
+ // Workflow/procedure patterns
150
+ if (/\b(workflow|procedure|deploy|pipeline)\b/i.test(heading)) {
151
+ return 'workflow';
152
+ }
153
+ // Try content-based classification for agent-like sections
154
+ if (hasAgentProperties(section.bodyLines)) {
155
+ return 'agent';
156
+ }
157
+ return 'unknown';
158
+ }
159
+ /**
160
+ * Check if body lines contain agent-like property patterns.
161
+ *
162
+ * Looks for key-value bullet lists with keys like "Model", "Prompt",
163
+ * "Persistence", "Skills", etc.
164
+ */
165
+ function hasAgentProperties(bodyLines) {
166
+ const agentKeys = /\b(model|prompt|persist(ence)?|skills?)\b/i;
167
+ let matchCount = 0;
168
+ for (const line of bodyLines) {
169
+ if (/^[-*]\s+\*?\*?/.test(line) && agentKeys.test(line)) {
170
+ matchCount++;
171
+ }
172
+ }
173
+ // Need at least 2 agent-like properties to classify
174
+ return matchCount >= 2;
175
+ }
176
+ /**
177
+ * Extract key-value properties from markdown bullet lists.
178
+ *
179
+ * Matches patterns like:
180
+ * - `- **Key**: value`
181
+ * - `- **Key**: value`
182
+ * - `- Key: value`
183
+ *
184
+ * @param lines - Body lines of a section
185
+ * @returns Array of extracted properties
186
+ */
187
+ function extractProperties(lines) {
188
+ const properties = [];
189
+ for (const line of lines) {
190
+ // Match: - **Key**: value or - Key: value
191
+ const match = line.match(/^[-*]\s+\*{0,2}([A-Za-z][A-Za-z0-9 _-]*?)\*{0,2}\s*:\s*(.+)$/);
192
+ if (match) {
193
+ const key = (match[1] ?? '').trim().toLowerCase();
194
+ const value = (match[2] ?? '').trim();
195
+ properties.push({ key, value });
196
+ }
197
+ }
198
+ return properties;
199
+ }
200
+ /**
201
+ * Extract permission entries from markdown content.
202
+ *
203
+ * Handles two formats:
204
+ * 1. Structured: `- Tasks: read, write`
205
+ * 2. Prose: `- Read and write tasks`
206
+ *
207
+ * @param lines - Body lines of a permissions section
208
+ * @returns Array of extracted permissions
209
+ */
210
+ function extractPermissions(lines) {
211
+ const permissions = [];
212
+ for (const line of lines) {
213
+ // Format 1: "- Tasks: read, write"
214
+ const structuredMatch = line.match(/^[-*]\s+([A-Za-z]+)\s*:\s*(.+)$/);
215
+ if (structuredMatch) {
216
+ const domain = (structuredMatch[1] ?? '').trim().toLowerCase();
217
+ const rawValues = (structuredMatch[2] ?? '').trim();
218
+ const values = rawValues
219
+ .split(/[,\s]+/)
220
+ .map((v) => v.trim().toLowerCase())
221
+ .filter((v) => ['read', 'write', 'execute'].includes(v));
222
+ if (values.length > 0) {
223
+ permissions.push({ domain, values });
224
+ continue;
225
+ }
226
+ }
227
+ // Format 2: "- Read and write tasks"
228
+ const proseMatch = line.match(/^[-*]\s+(read|write|execute)(?:\s+and\s+(read|write|execute))?\s+(\w+)/i);
229
+ if (proseMatch) {
230
+ const values = [(proseMatch[1] ?? '').toLowerCase()];
231
+ if (proseMatch[2]) {
232
+ values.push(proseMatch[2].toLowerCase());
233
+ }
234
+ const domain = (proseMatch[3] ?? '').toLowerCase();
235
+ permissions.push({ domain, values });
236
+ }
237
+ }
238
+ return permissions;
239
+ }
240
+ /**
241
+ * Normalize a heading into a valid CANT identifier.
242
+ *
243
+ * Converts "Code Review Agent" to "code-review-agent", strips
244
+ * common suffixes like " Agent", lowercases, and replaces
245
+ * non-alphanumeric chars with hyphens.
246
+ *
247
+ * @param heading - The raw heading text
248
+ * @returns A valid CANT identifier
249
+ */
250
+ function headingToIdentifier(heading) {
251
+ return heading
252
+ .replace(/\bagent\b/gi, '')
253
+ .replace(/^[:]\s*/, '')
254
+ .trim()
255
+ .toLowerCase()
256
+ .replace(/[^a-z0-9]+/g, '-')
257
+ .replace(/^-+|-+$/g, '');
258
+ }
259
+ /**
260
+ * Map a hook heading to a CAAMP event name.
261
+ *
262
+ * Converts headings like "On Session Start" to "SessionStart".
263
+ * Returns null if the heading does not match a known event.
264
+ *
265
+ * @param heading - The raw heading text
266
+ * @returns The CAAMP PascalCase event name, or null
267
+ */
268
+ function headingToEventName(heading) {
269
+ // Direct "On EventName" pattern
270
+ const onMatch = heading.match(/^on\s+(.+)$/i);
271
+ if (onMatch) {
272
+ const eventCandidate = (onMatch[1] ?? '')
273
+ .trim()
274
+ .replace(/\s+/g, '')
275
+ .replace(/^(\w)/, (_, c) => c.toUpperCase());
276
+ if (CAAMP_EVENTS.has(eventCandidate)) {
277
+ return eventCandidate;
278
+ }
279
+ }
280
+ // "When session starts" -> "SessionStart" (common prose variant)
281
+ const whenMatch = heading.match(/^when\s+(?:a\s+)?(\w+)\s+starts?$/i);
282
+ if (whenMatch) {
283
+ const noun = (whenMatch[1] ?? '').trim();
284
+ const candidate = `${noun.charAt(0).toUpperCase()}${noun.slice(1).toLowerCase()}Start`;
285
+ if (CAAMP_EVENTS.has(candidate)) {
286
+ return candidate;
287
+ }
288
+ }
289
+ const whenEndMatch = heading.match(/^when\s+(?:a\s+)?(\w+)\s+ends?$/i);
290
+ if (whenEndMatch) {
291
+ const noun = (whenEndMatch[1] ?? '').trim();
292
+ const candidate = `${noun.charAt(0).toUpperCase()}${noun.slice(1).toLowerCase()}End`;
293
+ if (CAAMP_EVENTS.has(candidate)) {
294
+ return candidate;
295
+ }
296
+ }
297
+ // Generic "Hooks" heading -> null (contains multiple hooks, not a single event)
298
+ return null;
299
+ }
300
+ /**
301
+ * Get the full set of CAAMP event names.
302
+ *
303
+ * @returns A read-only set of the 16 canonical CAAMP events
304
+ */
305
+ function getCaampEvents() {
306
+ return CAAMP_EVENTS;
307
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * CANT AST serializer -- generates .cant file text from structured data.
3
+ *
4
+ * Produces well-formatted .cant files with:
5
+ * - YAML frontmatter (kind, version)
6
+ * - 2-space indentation
7
+ * - Blank lines between top-level blocks
8
+ * - Proper quoting of string values
9
+ */
10
+ import type { ExtractedPermission, ExtractedProperty } from './types';
11
+ /**
12
+ * Intermediate representation of a CANT document for serialization.
13
+ *
14
+ * This is the bridge between the converter's output and the
15
+ * final .cant file text. Each field maps to a CANT construct.
16
+ */
17
+ export interface CantDocumentIR {
18
+ /** Document kind (agent, skill, hook, workflow). */
19
+ kind: string;
20
+ /** Document version (default: 1). */
21
+ version: number;
22
+ /** The primary block (agent definition, hook, workflow, etc.). */
23
+ block: CantBlockIR;
24
+ }
25
+ /** A CANT block (agent, skill, hook, or workflow). */
26
+ export interface CantBlockIR {
27
+ /** Block type keyword (agent, skill, on, workflow, pipeline). */
28
+ type: string;
29
+ /** Block name/identifier (agent name, event name, workflow name). */
30
+ name: string;
31
+ /** Key-value properties. */
32
+ properties: CantPropertyIR[];
33
+ /** Permission entries (only for agent blocks). */
34
+ permissions: ExtractedPermission[];
35
+ /** Nested sub-blocks (hooks inside agents, steps in pipelines, etc.). */
36
+ children: CantBlockIR[];
37
+ /** Raw body lines for hook/workflow bodies that are directive sequences. */
38
+ bodyLines?: string[];
39
+ }
40
+ /** A single property key-value pair. */
41
+ export interface CantPropertyIR {
42
+ /** Property key. */
43
+ key: string;
44
+ /** Property value (string, array, number, boolean). */
45
+ value: string | string[] | number | boolean;
46
+ }
47
+ /**
48
+ * Serialize a CANT document IR into .cant file text.
49
+ *
50
+ * Produces a complete .cant file including frontmatter and body.
51
+ * All string values are quoted, arrays use bracket notation,
52
+ * and indentation uses 2 spaces per level.
53
+ *
54
+ * @param doc - The document IR to serialize
55
+ * @returns The complete .cant file content as a string
56
+ */
57
+ export declare function serializeCantDocument(doc: CantDocumentIR): string;
58
+ /**
59
+ * Format a property value for .cant output.
60
+ *
61
+ * - Strings are double-quoted
62
+ * - Arrays use bracket notation with quoted elements
63
+ * - Numbers and booleans are bare
64
+ *
65
+ * @param value - The value to format
66
+ * @returns Formatted string
67
+ */
68
+ export declare function formatValue(value: string | string[] | number | boolean): string;
69
+ /**
70
+ * Convert extracted properties to CANT property IR format.
71
+ *
72
+ * Maps known markdown property keys to their CANT equivalents:
73
+ * - "model" -> model
74
+ * - "persistence" / "persist" -> persist
75
+ * - "prompt" -> prompt
76
+ * - "skills" -> skills (as array)
77
+ *
78
+ * @param properties - Extracted markdown properties
79
+ * @returns CANT property IR array
80
+ */
81
+ export declare function propertiesToIR(properties: ExtractedProperty[]): CantPropertyIR[];
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ /**
3
+ * CANT AST serializer -- generates .cant file text from structured data.
4
+ *
5
+ * Produces well-formatted .cant files with:
6
+ * - YAML frontmatter (kind, version)
7
+ * - 2-space indentation
8
+ * - Blank lines between top-level blocks
9
+ * - Proper quoting of string values
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.serializeCantDocument = serializeCantDocument;
13
+ exports.formatValue = formatValue;
14
+ exports.propertiesToIR = propertiesToIR;
15
+ /**
16
+ * Serialize a CANT document IR into .cant file text.
17
+ *
18
+ * Produces a complete .cant file including frontmatter and body.
19
+ * All string values are quoted, arrays use bracket notation,
20
+ * and indentation uses 2 spaces per level.
21
+ *
22
+ * @param doc - The document IR to serialize
23
+ * @returns The complete .cant file content as a string
24
+ */
25
+ function serializeCantDocument(doc) {
26
+ const lines = [];
27
+ // Frontmatter
28
+ lines.push('---');
29
+ lines.push(`kind: ${doc.kind}`);
30
+ lines.push(`version: ${doc.version}`);
31
+ lines.push('---');
32
+ lines.push('');
33
+ // Main block
34
+ serializeBlock(doc.block, 0, lines);
35
+ // Ensure trailing newline
36
+ return lines.join('\n') + '\n';
37
+ }
38
+ /**
39
+ * Serialize a single block at the given indentation level.
40
+ */
41
+ function serializeBlock(block, indent, lines) {
42
+ const prefix = ' '.repeat(indent);
43
+ // Block header
44
+ lines.push(`${prefix}${block.type} ${block.name}:`);
45
+ const childIndent = indent + 1;
46
+ const childPrefix = ' '.repeat(childIndent);
47
+ // Properties
48
+ for (const prop of block.properties) {
49
+ lines.push(`${childPrefix}${prop.key}: ${formatValue(prop.value)}`);
50
+ }
51
+ // Permissions
52
+ if (block.permissions.length > 0) {
53
+ lines.push(`${childPrefix}permissions:`);
54
+ const permPrefix = ' '.repeat(childIndent + 1);
55
+ for (const perm of block.permissions) {
56
+ lines.push(`${permPrefix}${perm.domain}: ${perm.values.join(', ')}`);
57
+ }
58
+ }
59
+ // Body lines (directives in hooks, etc.)
60
+ if (block.bodyLines && block.bodyLines.length > 0) {
61
+ for (const bodyLine of block.bodyLines) {
62
+ if (bodyLine.trim() === '') {
63
+ lines.push('');
64
+ }
65
+ else {
66
+ lines.push(`${childPrefix}${bodyLine}`);
67
+ }
68
+ }
69
+ }
70
+ // Child blocks (nested hooks, pipeline steps, etc.)
71
+ if (block.children.length > 0) {
72
+ lines.push('');
73
+ for (const child of block.children) {
74
+ serializeBlock(child, childIndent, lines);
75
+ lines.push('');
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Format a property value for .cant output.
81
+ *
82
+ * - Strings are double-quoted
83
+ * - Arrays use bracket notation with quoted elements
84
+ * - Numbers and booleans are bare
85
+ *
86
+ * @param value - The value to format
87
+ * @returns Formatted string
88
+ */
89
+ function formatValue(value) {
90
+ if (typeof value === 'string') {
91
+ // Don't double-quote if already quoted or is a bare keyword
92
+ if (/^\d+$/.test(value) || value === 'true' || value === 'false') {
93
+ return value;
94
+ }
95
+ return `"${value.replace(/"/g, '\\"')}"`;
96
+ }
97
+ if (Array.isArray(value)) {
98
+ const elements = value.map((v) => `"${v.replace(/"/g, '\\"')}"`);
99
+ return `[${elements.join(', ')}]`;
100
+ }
101
+ // number or boolean
102
+ return String(value);
103
+ }
104
+ /**
105
+ * Convert extracted properties to CANT property IR format.
106
+ *
107
+ * Maps known markdown property keys to their CANT equivalents:
108
+ * - "model" -> model
109
+ * - "persistence" / "persist" -> persist
110
+ * - "prompt" -> prompt
111
+ * - "skills" -> skills (as array)
112
+ *
113
+ * @param properties - Extracted markdown properties
114
+ * @returns CANT property IR array
115
+ */
116
+ function propertiesToIR(properties) {
117
+ const result = [];
118
+ for (const prop of properties) {
119
+ const key = normalizePropertyKey(prop.key);
120
+ const value = normalizePropertyValue(key, prop.value);
121
+ result.push({ key, value });
122
+ }
123
+ return result;
124
+ }
125
+ /**
126
+ * Normalize a markdown property key to CANT form.
127
+ */
128
+ function normalizePropertyKey(key) {
129
+ const keyMap = {
130
+ model: 'model',
131
+ persistence: 'persist',
132
+ persist: 'persist',
133
+ prompt: 'prompt',
134
+ skills: 'skills',
135
+ skill: 'skills',
136
+ description: 'description',
137
+ tier: 'tier',
138
+ };
139
+ return keyMap[key] ?? key;
140
+ }
141
+ /**
142
+ * Normalize a property value based on the key type.
143
+ *
144
+ * Skills are converted to arrays, others remain strings.
145
+ */
146
+ function normalizePropertyValue(key, value) {
147
+ if (key === 'skills') {
148
+ // "ct-cleo, ct-orchestrator" -> ["ct-cleo", "ct-orchestrator"]
149
+ return value
150
+ .split(/[,\s]+/)
151
+ .map((v) => v.trim())
152
+ .filter(Boolean);
153
+ }
154
+ return value;
155
+ }