fraim-hub 2.0.277 → 2.0.278

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,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getPricingConfig = getPricingConfig;
4
+ const pricing_1 = require("../../config/pricing");
5
+ const feature_flags_1 = require("../../config/feature-flags");
6
+ /**
7
+ * GET /api/pricing/config
8
+ * Returns pricing configuration for frontend
9
+ */
10
+ async function getPricingConfig(req, res) {
11
+ try {
12
+ res.json({
13
+ pricing: pricing_1.PRICING,
14
+ fixedFees: pricing_1.FIXED_FEES,
15
+ managedPricing: pricing_1.MANAGED_PRICING,
16
+ founderDiscountRate: pricing_1.FOUNDER_DISCOUNT_RATE,
17
+ consumerEmailDomains: pricing_1.CONSUMER_EMAIL_DOMAINS,
18
+ featureFlags: (0, feature_flags_1.getPublicFeatureFlags)(),
19
+ });
20
+ }
21
+ catch (error) {
22
+ console.error('Error getting pricing config:', error);
23
+ res.status(500).json({ error: 'Failed to get pricing configuration' });
24
+ }
25
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildJobVisualization = buildJobVisualization;
4
+ const local_registry_resolver_1 = require("./utils/local-registry-resolver");
5
+ const job_parser_1 = require("./utils/job-parser");
6
+ function friendlyLabel(id) {
7
+ const PREFIXES = ['implement-', 'address-', 'spec-', 'design-', 'context-'];
8
+ let body = id;
9
+ for (const p of PREFIXES) {
10
+ if (body.startsWith(p) && body.length > p.length) {
11
+ body = body.slice(p.length);
12
+ break;
13
+ }
14
+ }
15
+ const spaced = body.replace(/-/g, ' ').trim();
16
+ if (!spaced)
17
+ return id;
18
+ return spaced[0].toUpperCase() + spaced.slice(1).toLowerCase();
19
+ }
20
+ function readSection(content, heading) {
21
+ const re = new RegExp(`(?:^|\\r?\\n)#{1,6} ${heading}[ \\t]*\\r?\\n([\\s\\S]*?)(?=\\r?\\n#{1,6} |\\r?\\n---|$)`);
22
+ const m = content.match(re);
23
+ if (!m)
24
+ return null;
25
+ const text = m[1]
26
+ .split(/\r?\n/)
27
+ .map((l) => l.trim())
28
+ .filter((l) => l && !l.startsWith('{{'))
29
+ .map((l) => l.replace(/^[-*]\s*/, ''))
30
+ .join(' ')
31
+ .trim();
32
+ return text || null;
33
+ }
34
+ function parseSkillIdFromInclude(line) {
35
+ const m = line.match(/\{\{include:skills\/([^}]+?)(?:\.md)?\}\}/);
36
+ if (!m)
37
+ return null;
38
+ return m[1].replace(/\.md$/, '');
39
+ }
40
+ function skillLabelFromId(skillId) {
41
+ const base = skillId.includes('/') ? skillId.split('/').pop() : skillId;
42
+ return base.replace(/-/g, ' ');
43
+ }
44
+ function isStub(content) {
45
+ return (content.includes('<!-- FRAIM_DISCOVERY_STUB -->') ||
46
+ content.includes('STUB:') ||
47
+ content.includes('<!-- STUB -->'));
48
+ }
49
+ function buildResolver(projectPath) {
50
+ // remoteContentResolver is required by LocalRegistryResolver but never reached
51
+ // for local repos: findRegistryPath now searches registry/ as a fallback layer,
52
+ // and readWorkspaceRegistryFile handles the direct path lookup.
53
+ return new local_registry_resolver_1.LocalRegistryResolver({
54
+ workspaceRoot: projectPath,
55
+ shouldFilter: isStub,
56
+ remoteContentResolver: async (registryPath) => {
57
+ throw new Error(`registry file not found locally: ${registryPath}`);
58
+ },
59
+ });
60
+ }
61
+ function toTitleCase(id) {
62
+ return id
63
+ .split('-')
64
+ .map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase())
65
+ .join(' ');
66
+ }
67
+ function parseJobTitle(content, jobId) {
68
+ const m = content.match(/^#\s+(?:FRAIM Job:\s*)?(.+)/m);
69
+ if (m) {
70
+ const raw = m[1].trim();
71
+ if (/^[a-z0-9-]+$/.test(raw))
72
+ return toTitleCase(raw);
73
+ return raw;
74
+ }
75
+ return toTitleCase(jobId);
76
+ }
77
+ function parseJobLevelIntent(content) {
78
+ const m = content.match(/(?:^|\r?\n)## Intent[ \t]*\r?\n([\s\S]*?)(?=\r?\n#{1,6} |\r?\n---|$)/);
79
+ if (!m)
80
+ return null;
81
+ const lines = m[1].split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('{{'));
82
+ const text = lines.map((l) => l.replace(/^[-*]\s*/, '')).join(' ').trim();
83
+ return text || null;
84
+ }
85
+ const EMPTY_RESPONSE = (jobId) => ({
86
+ jobId,
87
+ title: toTitleCase(jobId),
88
+ intent: null,
89
+ personalized: false,
90
+ phases: [],
91
+ });
92
+ async function buildJobVisualization(jobId, projectPath) {
93
+ const resolver = buildResolver(projectPath);
94
+ const registryPath = await resolver.findRegistryPath('jobs', jobId);
95
+ let jobFile;
96
+ try {
97
+ jobFile = await resolver.resolveFile(registryPath, {
98
+ includeMetadata: false,
99
+ stripMcpHeader: true,
100
+ });
101
+ }
102
+ catch {
103
+ return EMPTY_RESPONSE(jobId);
104
+ }
105
+ const content = jobFile.content;
106
+ if (!content?.trim())
107
+ return EMPTY_RESPONSE(jobId);
108
+ const title = parseJobTitle(content, jobId);
109
+ const intent = parseJobLevelIntent(content);
110
+ // Phase splitting reuses JobParser — the same parser the MCP proxy uses.
111
+ const jobDef = job_parser_1.JobParser.parseContent(content, jobId);
112
+ if (!jobDef || jobDef.phases.size === 0) {
113
+ return { jobId, title, intent, personalized: jobFile.personalized, scope: jobFile.scope, phases: [] };
114
+ }
115
+ const phases = await Promise.all([...jobDef.phases.entries()].map(async ([phaseId, phaseContent]) => {
116
+ const phaseIntent = readSection(phaseContent, 'Intent');
117
+ const phaseOutcome = readSection(phaseContent, 'Outcome');
118
+ const skillIds = [];
119
+ for (const line of phaseContent.split(/\r?\n/)) {
120
+ const id = parseSkillIdFromInclude(line);
121
+ if (id)
122
+ skillIds.push(id);
123
+ }
124
+ const skills = await Promise.all(skillIds.map(async (skillId) => {
125
+ try {
126
+ const skillFile = await resolver.resolveFile(`skills/${skillId}.md`, {
127
+ includeMetadata: false,
128
+ stripMcpHeader: true,
129
+ });
130
+ return {
131
+ id: skillId,
132
+ label: skillLabelFromId(skillId),
133
+ input: readSection(skillFile.content, 'Skill Input'),
134
+ output: readSection(skillFile.content, 'Skill Output'),
135
+ personalized: skillFile.personalized,
136
+ scope: skillFile.scope,
137
+ };
138
+ }
139
+ catch {
140
+ return { id: skillId, label: skillLabelFromId(skillId), input: null, output: null, personalized: false };
141
+ }
142
+ }));
143
+ return {
144
+ id: phaseId,
145
+ label: friendlyLabel(phaseId),
146
+ intent: phaseIntent,
147
+ outcome: phaseOutcome,
148
+ personalized: jobFile.personalized,
149
+ scope: jobFile.scope,
150
+ skills,
151
+ };
152
+ }));
153
+ return {
154
+ jobId,
155
+ title,
156
+ intent,
157
+ personalized: jobFile.personalized,
158
+ scope: jobFile.scope,
159
+ phases,
160
+ };
161
+ }
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ /**
3
+ * InheritanceParser
4
+ *
5
+ * Parses and resolves {{ import: path }} directives in registry files,
6
+ * enabling local overrides to inherit from global registry files.
7
+ *
8
+ * Security features:
9
+ * - Path traversal protection (rejects .. and absolute paths)
10
+ * - Circular import detection
11
+ * - Max depth limit (5 levels)
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.InheritanceParser = exports.InheritanceError = void 0;
15
+ class InheritanceError extends Error {
16
+ constructor(message, path) {
17
+ super(message);
18
+ this.path = path;
19
+ this.name = 'InheritanceError';
20
+ }
21
+ }
22
+ exports.InheritanceError = InheritanceError;
23
+ /**
24
+ * Regular expression to match {{ import: path }} directives
25
+ */
26
+ const IMPORT_REGEX = /\{\{\s*import:\s*([^\}]+)\s*\}\}/g;
27
+ class InheritanceParser {
28
+ constructor(maxDepth = 5) {
29
+ this.maxDepth = maxDepth;
30
+ }
31
+ /**
32
+ * Extract import directives from content without resolving them
33
+ */
34
+ extractImports(content) {
35
+ const imports = [];
36
+ let match;
37
+ // Reset regex state
38
+ IMPORT_REGEX.lastIndex = 0;
39
+ while ((match = IMPORT_REGEX.exec(content)) !== null) {
40
+ imports.push(match[1].trim());
41
+ }
42
+ return imports;
43
+ }
44
+ /**
45
+ * Sanitize and validate import path
46
+ *
47
+ * @throws {InheritanceError} If path is invalid
48
+ */
49
+ sanitizePath(path) {
50
+ const trimmed = path.trim();
51
+ // Reject empty paths
52
+ if (!trimmed) {
53
+ throw new InheritanceError('Import path cannot be empty');
54
+ }
55
+ // Reject absolute paths (Unix and Windows)
56
+ if (trimmed.startsWith('/') || trimmed.match(/^[A-Za-z]:\\/)) {
57
+ throw new InheritanceError(`Absolute paths not allowed: ${trimmed}`, trimmed);
58
+ }
59
+ // Reject path traversal attempts
60
+ if (trimmed.includes('..')) {
61
+ throw new InheritanceError(`Path traversal not allowed: ${trimmed}`, trimmed);
62
+ }
63
+ return trimmed;
64
+ }
65
+ /**
66
+ * Detect circular imports
67
+ *
68
+ * Special case: If the import path is the same as the current path,
69
+ * it's not a circular import - it's importing the parent/remote version.
70
+ * This allows local overrides to inherit from their remote counterparts.
71
+ *
72
+ * @throws {InheritanceError} If circular import detected
73
+ */
74
+ detectCircularImport(path, visited, isParentImport = false) {
75
+ // If this is a parent import (same path as current), allow it
76
+ if (isParentImport) {
77
+ return;
78
+ }
79
+ if (visited.has(path)) {
80
+ throw new InheritanceError(`Circular import detected: ${path}`, path);
81
+ }
82
+ }
83
+ /**
84
+ * Resolve all import and extends directives in content recursively
85
+ *
86
+ * @param content - Content with {{ import }} or extends frontmatter
87
+ * @param currentPath - Path of current file (for circular detection)
88
+ * @param options - Resolution options
89
+ * @returns Resolved content with all inheritance applied
90
+ *
91
+ * @throws {InheritanceError} If circular inheritance, path traversal, or max depth exceeded
92
+ */
93
+ async resolve(content, currentPath, options) {
94
+ const depth = options.currentDepth || 0;
95
+ const visited = options.visited || new Set();
96
+ const maxDepth = options.maxDepth || this.maxDepth;
97
+ // Check depth limit
98
+ if (depth > maxDepth) {
99
+ throw new InheritanceError(`Max inheritance depth exceeded (${maxDepth})`, currentPath);
100
+ }
101
+ // Check circular inheritance (but allow importing/extending the same path as parent)
102
+ this.detectCircularImport(currentPath, visited, false);
103
+ visited.add(currentPath);
104
+ let resolvedContent = content;
105
+ // 1. Handle JSON frontmatter 'extends'
106
+ const metadataMatch = resolvedContent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
107
+ if (metadataMatch) {
108
+ try {
109
+ const metadata = JSON.parse(metadataMatch[1]);
110
+ const extendsPath = metadata.extends;
111
+ if (extendsPath && typeof extendsPath === 'string') {
112
+ // Sanitize path
113
+ const sanitizedExtends = this.sanitizePath(extendsPath);
114
+ const isParentExtends = sanitizedExtends === currentPath;
115
+ // Fetch parent content
116
+ let parentContent;
117
+ try {
118
+ parentContent = await options.fetchParent(sanitizedExtends);
119
+ }
120
+ catch (error) {
121
+ throw new InheritanceError(`Failed to fetch extended parent content: ${sanitizedExtends}. ${error.message}`, sanitizedExtends);
122
+ }
123
+ // Recursively resolve parent
124
+ const parentVisited = isParentExtends ? new Set() : new Set(visited);
125
+ const resolvedParent = await this.resolve(parentContent, sanitizedExtends, {
126
+ ...options,
127
+ currentDepth: depth + 1,
128
+ visited: parentVisited
129
+ });
130
+ // Merge current content with resolved parent
131
+ resolvedContent = this.mergeContent(resolvedContent, resolvedParent);
132
+ }
133
+ }
134
+ catch (error) {
135
+ if (error instanceof SyntaxError) {
136
+ // Not JSON or invalid JSON, ignore extends logic but log it
137
+ console.warn(`[InheritanceParser] Failed to parse frontmatter for ${currentPath}: ${error.message}`);
138
+ }
139
+ else {
140
+ throw error;
141
+ }
142
+ }
143
+ }
144
+ // 2. Handle {{ import: path }}
145
+ const imports = this.extractImports(resolvedContent);
146
+ for (const importPath of imports) {
147
+ // Sanitize path
148
+ const sanitized = this.sanitizePath(importPath);
149
+ // Check if this is a parent import (same path as current)
150
+ const isParentImport = sanitized === currentPath;
151
+ // Fetch parent content
152
+ let parentContent;
153
+ try {
154
+ parentContent = await options.fetchParent(sanitized);
155
+ }
156
+ catch (error) {
157
+ throw new InheritanceError(`Failed to fetch parent content: ${sanitized}. ${error.message}`, sanitized);
158
+ }
159
+ // Recursively resolve parent imports
160
+ const parentVisited = isParentImport ? new Set() : new Set(visited);
161
+ const resolvedParent = await this.resolve(parentContent, sanitized, {
162
+ ...options,
163
+ currentDepth: depth + 1,
164
+ visited: parentVisited
165
+ });
166
+ // Replace import directive with resolved parent content
167
+ const importDirective = `{{ import: ${importPath} }}`;
168
+ resolvedContent = resolvedContent.replace(importDirective, resolvedParent);
169
+ }
170
+ return resolvedContent;
171
+ }
172
+ /**
173
+ * Merge two registry files (child override + parent base)
174
+ *
175
+ * Merging rules:
176
+ * 1. Metadata: JSON merge (child overrides parent)
177
+ * 2. Overview: Parent overview + child overview (if multi-para)
178
+ * 3. Phases: Phase override (child phase with same ID replaces parent phase)
179
+ */
180
+ mergeContent(child, parent) {
181
+ const childMatch = child.match(/^---\r?\n([\s\S]+?)\r?\n---/);
182
+ const parentMatch = parent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
183
+ if (!childMatch || !parentMatch)
184
+ return child;
185
+ // 1. Merge Metadata
186
+ const childMeta = JSON.parse(childMatch[1]);
187
+ const parentMeta = JSON.parse(parentMatch[1]);
188
+ const mergedMeta = { ...parentMeta, ...childMeta };
189
+ delete mergedMeta.extends; // Remove extends from final merged content
190
+ // Deep-merge phase routing so child only overrides the entries it specifies;
191
+ // parent routing for untouched phases flows through unchanged.
192
+ if (parentMeta.phases && childMeta.phases) {
193
+ mergedMeta.phases = { ...parentMeta.phases, ...childMeta.phases };
194
+ }
195
+ // 2. Extract Body (everything after frontmatter)
196
+ const childBody = this.stripRedundantParentImports(child.substring(childMatch[0].length).trim(), typeof childMeta.extends === 'string' ? childMeta.extends : undefined);
197
+ const parentBody = parent.substring(parentMatch[0].length).trim();
198
+ // 3. Parse Phases and Overview
199
+ const parsePhases = (body) => {
200
+ const phases = new Map();
201
+ const sections = body.split(/^##\s+Phase:\s+/m);
202
+ const overview = sections[0]?.trim() || '';
203
+ for (let i = 1; i < sections.length; i++) {
204
+ const section = sections[i];
205
+ if (!section.trim())
206
+ continue;
207
+ // Extract ID from first line: e.g. "implement-scoping (Primary)" -> "implement-scoping"
208
+ const firstLine = section.split(/\r?\n/)[0].trim();
209
+ const id = firstLine.split(/[ (]/)[0].trim().toLowerCase();
210
+ phases.set(id, `## Phase: ${section.trim()}`);
211
+ }
212
+ return { overview, phases };
213
+ };
214
+ const childParts = parsePhases(childBody);
215
+ const parentParts = parsePhases(parentBody);
216
+ // 4. Merge Overview: retain the parent framing, then append local overview additions.
217
+ const mergedOverview = childParts.overview
218
+ ? `${parentParts.overview}\n\n${childParts.overview}`.trim()
219
+ : parentParts.overview;
220
+ // 5. Merge Phases
221
+ const mergedPhases = new Map(parentParts.phases);
222
+ for (const [id, content] of childParts.phases.entries()) {
223
+ mergedPhases.set(id, content);
224
+ }
225
+ // 6. Reassemble
226
+ let finalContent = `---\n${JSON.stringify(mergedMeta, null, 2)}\n---\n\n`;
227
+ if (mergedOverview) {
228
+ finalContent += `${mergedOverview}\n\n`;
229
+ }
230
+ const addedPhases = new Set();
231
+ // First, add parent phases in order, using child overrides when present.
232
+ for (const id of parentParts.phases.keys()) {
233
+ if (mergedPhases.has(id)) {
234
+ finalContent += `${mergedPhases.get(id)}\n\n`;
235
+ addedPhases.add(id);
236
+ }
237
+ }
238
+ // Then append child-only phases in the order the child declared them.
239
+ for (const [id, content] of childParts.phases.entries()) {
240
+ if (!addedPhases.has(id)) {
241
+ finalContent += `${content}\n\n`;
242
+ addedPhases.add(id);
243
+ }
244
+ }
245
+ return finalContent.trim();
246
+ }
247
+ /**
248
+ * Parse content and return detailed information about imports
249
+ */
250
+ parse(content) {
251
+ const imports = this.extractImports(content);
252
+ const hasExtends = /^---\r?\n[\s\S]*?"extends":\s*"[^"]+"[\s\S]*?\r?\n---/m.test(content);
253
+ return {
254
+ content,
255
+ imports,
256
+ hasImports: imports.length > 0 || hasExtends
257
+ };
258
+ }
259
+ normalizeImportRef(path) {
260
+ let normalized = path.trim().replace(/\\/g, '/').replace(/^\/+/, '');
261
+ if (normalized.endsWith('.md')) {
262
+ normalized = normalized.slice(0, -3);
263
+ }
264
+ return normalized;
265
+ }
266
+ stripTypePrefix(path) {
267
+ return path.replace(/^(jobs|workflows|skills|rules|templates)\//, '');
268
+ }
269
+ isEquivalentImportRef(left, right) {
270
+ const normalizedLeft = this.normalizeImportRef(left);
271
+ const normalizedRight = this.normalizeImportRef(right);
272
+ const strippedLeft = this.stripTypePrefix(normalizedLeft);
273
+ const strippedRight = this.stripTypePrefix(normalizedRight);
274
+ return normalizedLeft === normalizedRight ||
275
+ strippedLeft === strippedRight ||
276
+ normalizedLeft.endsWith(`/${strippedRight}`) ||
277
+ normalizedRight.endsWith(`/${strippedLeft}`) ||
278
+ strippedLeft.endsWith(`/${strippedRight}`) ||
279
+ strippedRight.endsWith(`/${strippedLeft}`);
280
+ }
281
+ stripRedundantParentImports(body, extendsPath) {
282
+ if (!extendsPath) {
283
+ return body;
284
+ }
285
+ return body
286
+ .replace(/\{\{\s*import:\s*([^\}]+)\s*\}\}\s*\r?\n?/g, (match, importPath) => {
287
+ return this.isEquivalentImportRef(importPath, extendsPath) ? '' : match;
288
+ })
289
+ .replace(/\n{3,}/g, '\n\n')
290
+ .trim();
291
+ }
292
+ }
293
+ exports.InheritanceParser = InheritanceParser;
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JobParser = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ class JobParser {
7
+ static extractMetadataBlock(content) {
8
+ // Allow leading comments and whitespace before frontmatter
9
+ const frontmatterMatch = content.match(/^[\s\S]*?---\r?\n([\s\S]+?)\r?\n---/);
10
+ if (frontmatterMatch) {
11
+ try {
12
+ const startIndex = frontmatterMatch.index || 0;
13
+ return {
14
+ state: 'valid',
15
+ metadata: JSON.parse(frontmatterMatch[1]),
16
+ bodyStartIndex: startIndex + frontmatterMatch[0].length
17
+ };
18
+ }
19
+ catch {
20
+ return { state: 'invalid' };
21
+ }
22
+ }
23
+ const trimmedStart = content.search(/\S/);
24
+ if (trimmedStart === -1 || content[trimmedStart] !== '{') {
25
+ return { state: 'none' };
26
+ }
27
+ let depth = 0;
28
+ let inString = false;
29
+ let escaping = false;
30
+ for (let i = trimmedStart; i < content.length; i++) {
31
+ const ch = content[i];
32
+ if (inString) {
33
+ if (escaping) {
34
+ escaping = false;
35
+ }
36
+ else if (ch === '\\') {
37
+ escaping = true;
38
+ }
39
+ else if (ch === '"') {
40
+ inString = false;
41
+ }
42
+ continue;
43
+ }
44
+ if (ch === '"') {
45
+ inString = true;
46
+ continue;
47
+ }
48
+ if (ch === '{') {
49
+ depth++;
50
+ continue;
51
+ }
52
+ if (ch === '}') {
53
+ depth--;
54
+ if (depth === 0) {
55
+ const bodyStartIndex = i + 1;
56
+ const remainder = content.slice(bodyStartIndex).trimStart();
57
+ // `{...}\n---` is usually malformed frontmatter, not bare JSON metadata.
58
+ if (remainder.startsWith('---')) {
59
+ return { state: 'none' };
60
+ }
61
+ try {
62
+ return {
63
+ state: 'valid',
64
+ metadata: JSON.parse(content.slice(trimmedStart, bodyStartIndex)),
65
+ bodyStartIndex
66
+ };
67
+ }
68
+ catch {
69
+ return { state: 'invalid' };
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return { state: 'none' };
75
+ }
76
+ /**
77
+ * Parse a job markdown file into a structured definition
78
+ * Supports three formats:
79
+ * 1. Phase-based jobs with JSON frontmatter
80
+ * 2. Phase-based jobs with bare leading JSON metadata
81
+ * 3. Simple jobs without metadata
82
+ */
83
+ static parse(filePath) {
84
+ if (!(0, fs_1.existsSync)(filePath))
85
+ return null;
86
+ let content = (0, fs_1.readFileSync)(filePath, 'utf-8');
87
+ if (content.charCodeAt(0) === 0xfeff) {
88
+ content = content.slice(1);
89
+ }
90
+ const metadataBlock = this.extractMetadataBlock(content);
91
+ if (metadataBlock.state === 'invalid') {
92
+ return null;
93
+ }
94
+ if (metadataBlock.state === 'valid') {
95
+ return this.parsePhaseBasedJob(filePath, content, metadataBlock.metadata, metadataBlock.bodyStartIndex);
96
+ }
97
+ return this.parseSimpleJob(filePath, content);
98
+ }
99
+ static parsePhaseBasedJob(filePath, content, metadata, bodyStartIndex) {
100
+ const contentAfterMetadata = content.substring(bodyStartIndex).trim();
101
+ const firstPhaseIndex = contentAfterMetadata.search(/^##\s+Phase:/m);
102
+ let overview = '';
103
+ let restOfContent = '';
104
+ if (firstPhaseIndex !== -1) {
105
+ overview = contentAfterMetadata.substring(0, firstPhaseIndex).trim();
106
+ restOfContent = contentAfterMetadata.substring(firstPhaseIndex);
107
+ }
108
+ else {
109
+ overview = contentAfterMetadata;
110
+ }
111
+ const phases = new Map();
112
+ const phaseSections = restOfContent.split(/^##\s+Phase:\s*/im);
113
+ if (!metadata.phases) {
114
+ metadata.phases = {};
115
+ }
116
+ for (let i = 1; i < phaseSections.length; i++) {
117
+ const section = phaseSections[i];
118
+ const sectionLines = section.split('\n');
119
+ const firstLine = sectionLines[0].trim();
120
+ const id = firstLine.split(/[ (]/)[0].trim().toLowerCase();
121
+ phases.set(id, `## Phase: ${section.trim()}`);
122
+ }
123
+ return {
124
+ metadata,
125
+ overview,
126
+ phases,
127
+ isSimple: false,
128
+ path: filePath
129
+ };
130
+ }
131
+ static parseSimpleJob(filePath, content) {
132
+ const jobName = (0, path_1.basename)(filePath, '.md');
133
+ const metadata = { name: jobName };
134
+ // If the content contains ## Phase: sections, parse them the same way as
135
+ // phase-based jobs so callers always get a populated phases Map.
136
+ if (/^##\s+Phase:/im.test(content)) {
137
+ return this.parsePhaseBasedJob(filePath, content, metadata, 0);
138
+ }
139
+ return {
140
+ metadata,
141
+ overview: content.trim(),
142
+ phases: new Map(),
143
+ isSimple: true,
144
+ path: filePath
145
+ };
146
+ }
147
+ static parseContent(content, name, path) {
148
+ if (content.charCodeAt(0) === 0xfeff) {
149
+ content = content.slice(1);
150
+ }
151
+ const metadataBlock = this.extractMetadataBlock(content);
152
+ if (metadataBlock.state === 'invalid') {
153
+ return null;
154
+ }
155
+ if (metadataBlock.state === 'valid') {
156
+ return this.parsePhaseBasedJob(path || `content:${name}`, content, metadataBlock.metadata, metadataBlock.bodyStartIndex);
157
+ }
158
+ return this.parseSimpleJob(path || `content:${name}`, content);
159
+ }
160
+ static getOverviewFromContent(content, name) {
161
+ const job = this.parseContent(content, name);
162
+ return job ? job.overview : null;
163
+ }
164
+ static getOverview(filePath) {
165
+ const job = this.parse(filePath);
166
+ return job ? job.overview : null;
167
+ }
168
+ static extractDescription(filePath) {
169
+ const job = this.parse(filePath);
170
+ if (!job)
171
+ return '';
172
+ const intentMatch = job.overview.match(/## Intent\s+([\s\S]+?)(?:\r?\n##|$)/);
173
+ if (intentMatch)
174
+ return intentMatch[1].trim().split(/\r?\n/)[0];
175
+ const firstPara = job.overview.split(/\r?\n/).find(l => l.trim() !== '' && !l.startsWith('#'));
176
+ return firstPara ? firstPara.trim() : '';
177
+ }
178
+ }
179
+ exports.JobParser = JobParser;