triad-plus 1.8.0 → 1.9.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.
- package/CHANGELOG.md +22 -0
- package/README.md +14 -12
- package/adapters/antigravity/.agents/agents/triad-developer/agent.md +7 -3
- package/adapters/antigravity/.agents/agents/triad-orchestrator/agent.md +5 -0
- package/adapters/antigravity/.agents/agents/triad-reviewer/agent.md +7 -2
- package/adapters/antigravity/.agents/skills/triad/SKILL.md +22 -0
- package/adapters/claude-code/.claude/agents/triad-developer.md +9 -4
- package/adapters/claude-code/.claude/agents/triad-reviewer.md +10 -5
- package/adapters/claude-code/.claude/commands/triad.md +22 -0
- package/adapters/claude-code/README.md +11 -3
- package/adapters/codex/prompts/triad.md +28 -0
- package/adapters/copilot/.github/agents/triad-developer.agent.md +12 -6
- package/adapters/copilot/.github/agents/triad-evaluator.agent.md +2 -1
- package/adapters/copilot/.github/agents/triad-orchestrator.agent.md +15 -1
- package/adapters/copilot/.github/agents/triad-reviewer.agent.md +11 -5
- package/adapters/copilot/.github/skills/triad/SKILL.md +23 -0
- package/adapters/hermes/skills/triad/SKILL.md +18 -0
- package/adapters/opencode/.opencode/agents/triad-developer.md +9 -4
- package/adapters/opencode/.opencode/agents/triad-orchestrator.md +15 -0
- package/adapters/opencode/.opencode/agents/triad-reviewer.md +10 -3
- package/adapters/opencode/.opencode/commands/triad.md +21 -0
- package/adapters/opencode/README.md +19 -4
- package/adapters/registry.mjs +2 -0
- package/bin/triad-plus.js +8 -2
- package/docs/architecture.md +8 -0
- package/docs/assignment-packets.md +35 -0
- package/docs/bmad-integration.md +187 -82
- package/docs/configuration.md +9 -0
- package/docs/npx-installation.md +4 -0
- package/docs/operating-guide.it.md +8 -0
- package/docs/operating-guide.md +7 -0
- package/docs/verification.md +20 -0
- package/integrations/bmad/README.md +17 -9
- package/integrations/bmad/epics-parser.mjs +697 -0
- package/integrations/bmad/story-importer.mjs +143 -5
- package/package.json +2 -2
- package/runtime/lib/assignment-packet.mjs +516 -0
- package/runtime/triad-assignment-packet.mjs +62 -0
- package/runtime/triad-bmad-intake.mjs +150 -0
- package/runtime/triad-verify.mjs +16 -4
- package/schemas/verification-evidence.schema.json +8 -0
- package/skills/triad-loop-bootstrap/SKILL.md +39 -4
- package/skills/triad-loop-bootstrap/assets/loop-template/runtime/assignments/assignment.template.json +14 -1
- package/skills/triad-loop-developer/SKILL.md +19 -8
- package/skills/triad-loop-orchestrator/SKILL.md +83 -11
- package/skills/triad-loop-reviewer/SKILL.md +15 -5
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
BmadStoryImportError,
|
|
7
|
+
buildTriadCardFromCanonicalStory,
|
|
8
|
+
parseBmadStoryContent
|
|
9
|
+
} from './story-importer.mjs';
|
|
10
|
+
|
|
11
|
+
const SOURCE_KIND = 'bmad-epics';
|
|
12
|
+
const READY_STATUS = 'ready-for-dev';
|
|
13
|
+
const STORY_HEADING = /^Story\s+([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*[:—-]\s*(.+))?$/i;
|
|
14
|
+
const EPIC_HEADING = /^Epic\s+([A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*[:—-]\s*(.+))?$/i;
|
|
15
|
+
|
|
16
|
+
function sha256(value) {
|
|
17
|
+
return createHash('sha256').update(value).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clean(value) {
|
|
21
|
+
return String(value ?? '').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asObject(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function error(code, message, details = {}) {
|
|
29
|
+
return new BmadStoryImportError(code, message, details);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeId(value, label) {
|
|
33
|
+
const normalized = clean(value);
|
|
34
|
+
if (!normalized || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
|
|
35
|
+
throw error('bmad_epics_invalid', `${label} must be a non-empty stable identifier.`, { value });
|
|
36
|
+
}
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseHeading(line) {
|
|
41
|
+
const match = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
42
|
+
return match ? { level: match[1].length, text: match[2].trim() } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function looksLikeStoryHeading(text) {
|
|
46
|
+
return /^Story(?:\s|$)/i.test(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function looksLikeEpicHeading(text) {
|
|
50
|
+
return /^Epic(?:\s|$)/i.test(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ignorableEpicSection(text) {
|
|
54
|
+
return /^(?:Epic\s+List|Epic\s+Breakdown)$/i.test(clean(text));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ignorableStorySection(text) {
|
|
58
|
+
return /^Story\s+List$/i.test(clean(text));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function flushStory(stories, current, endLine) {
|
|
62
|
+
if (!current) return;
|
|
63
|
+
const body = current.lines.join('\n').trim();
|
|
64
|
+
if (!body) throw error('bmad_epics_invalid', `BMAD Story ${current.id} has no body.`, { story_id: current.id });
|
|
65
|
+
const story = parseBmadStoryContent(body, {
|
|
66
|
+
sourcePath: current.sourcePath,
|
|
67
|
+
id: current.id,
|
|
68
|
+
title: current.title,
|
|
69
|
+
epicId: current.epic.id,
|
|
70
|
+
epicTitle: current.epic.title,
|
|
71
|
+
sourceHeading: current.heading,
|
|
72
|
+
sourceHeadingMarkdown: `${'#'.repeat(current.level)} ${current.heading}`,
|
|
73
|
+
sourceRange: { start_line: current.startLine, end_line: endLine }
|
|
74
|
+
});
|
|
75
|
+
story.ingestionReady = true;
|
|
76
|
+
story.executionReady = false;
|
|
77
|
+
stories.push(story);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a native BMAD planning artifact. Only Epic/Story headings delimit
|
|
82
|
+
* records; all Story fields come from the bounded Story body and are parsed
|
|
83
|
+
* deterministically without an LLM.
|
|
84
|
+
*/
|
|
85
|
+
export function parseBmadEpics(source, { sourcePath = null } = {}) {
|
|
86
|
+
if (typeof source !== 'string' || !source.trim()) {
|
|
87
|
+
throw error('bmad_epics_invalid', 'BMAD epics.md source is empty.');
|
|
88
|
+
}
|
|
89
|
+
const lines = source.split(/\r?\n/);
|
|
90
|
+
const epics = [];
|
|
91
|
+
const stories = [];
|
|
92
|
+
const epicIds = new Set();
|
|
93
|
+
const storyIds = new Set();
|
|
94
|
+
let currentEpic = null;
|
|
95
|
+
let currentStory = null;
|
|
96
|
+
|
|
97
|
+
const finishStory = (endLine) => {
|
|
98
|
+
flushStory(stories, currentStory, endLine);
|
|
99
|
+
currentStory = null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
103
|
+
const line = lines[index];
|
|
104
|
+
const heading = parseHeading(line);
|
|
105
|
+
if (heading) {
|
|
106
|
+
if (ignorableEpicSection(heading.text)) continue;
|
|
107
|
+
if (ignorableStorySection(heading.text)) continue;
|
|
108
|
+
const epicMatch = heading.text.match(EPIC_HEADING);
|
|
109
|
+
const storyMatch = heading.text.match(STORY_HEADING);
|
|
110
|
+
if (epicMatch || (looksLikeEpicHeading(heading.text) && !ignorableEpicSection(heading.text))) {
|
|
111
|
+
if (!epicMatch || !epicMatch[2]?.trim()) {
|
|
112
|
+
throw error('bmad_epics_invalid', `Malformed Epic heading at line ${index + 1}.`, { line: index + 1, heading: heading.text });
|
|
113
|
+
}
|
|
114
|
+
if (currentStory) finishStory(index);
|
|
115
|
+
const id = safeId(epicMatch[1], 'Epic id');
|
|
116
|
+
if (epicIds.has(id)) throw error('bmad_epics_ambiguous', `Duplicate Epic id: ${id}.`, { epic_id: id });
|
|
117
|
+
currentEpic = {
|
|
118
|
+
id,
|
|
119
|
+
title: clean(epicMatch[2]),
|
|
120
|
+
heading: heading.text,
|
|
121
|
+
sourceHeadingMarkdown: `${'#'.repeat(heading.level)} ${heading.text}`,
|
|
122
|
+
level: heading.level,
|
|
123
|
+
sourceRange: { start_line: index + 1, end_line: null }
|
|
124
|
+
};
|
|
125
|
+
epics.push(currentEpic);
|
|
126
|
+
epicIds.add(id);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (storyMatch || looksLikeStoryHeading(heading.text)) {
|
|
130
|
+
if (!storyMatch || !storyMatch[2]?.trim()) {
|
|
131
|
+
throw error('bmad_epics_invalid', `Malformed Story heading at line ${index + 1}.`, { line: index + 1, heading: heading.text });
|
|
132
|
+
}
|
|
133
|
+
if (!currentEpic) {
|
|
134
|
+
throw error('bmad_epics_unmappable', `Story ${storyMatch[1]} is not nested under an Epic.`, { story_id: storyMatch[1], line: index + 1 });
|
|
135
|
+
}
|
|
136
|
+
if (currentStory) finishStory(index);
|
|
137
|
+
const id = safeId(storyMatch[1], 'Story id');
|
|
138
|
+
if (storyIds.has(id)) throw error('bmad_epics_ambiguous', `Duplicate Story id: ${id}.`, { story_id: id });
|
|
139
|
+
currentStory = {
|
|
140
|
+
id,
|
|
141
|
+
title: clean(storyMatch[2]),
|
|
142
|
+
heading: heading.text,
|
|
143
|
+
level: heading.level,
|
|
144
|
+
epic: currentEpic,
|
|
145
|
+
sourcePath,
|
|
146
|
+
startLine: index + 1,
|
|
147
|
+
lines: []
|
|
148
|
+
};
|
|
149
|
+
storyIds.add(id);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (currentStory) currentStory.lines.push(line);
|
|
154
|
+
}
|
|
155
|
+
if (currentStory) finishStory(lines.length);
|
|
156
|
+
if (!epics.length) throw error('bmad_epics_not_found', 'No Epic heading found in BMAD epics.md.');
|
|
157
|
+
if (!stories.length) throw error('bmad_epics_unmappable', 'No Story heading found under the BMAD Epics.');
|
|
158
|
+
for (const epic of epics) {
|
|
159
|
+
epic.sourceRange.end_line = epic.sourceRange.end_line ?? lines.length;
|
|
160
|
+
}
|
|
161
|
+
return { sourceKind: SOURCE_KIND, epics, stories };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readStableSource(sourcePath) {
|
|
165
|
+
const resolved = resolve(sourcePath);
|
|
166
|
+
let first;
|
|
167
|
+
try {
|
|
168
|
+
const sourceStat = await stat(resolved);
|
|
169
|
+
if (!sourceStat.isFile()) throw new Error('not a file');
|
|
170
|
+
first = await readFile(resolved);
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
throw error('bmad_epics_not_found', `Cannot read BMAD epics source: ${resolved}.`, { cause: cause.code ?? 'not_a_file' });
|
|
173
|
+
}
|
|
174
|
+
let second;
|
|
175
|
+
try { second = await readFile(resolved); }
|
|
176
|
+
catch (cause) { throw error('bmad_epics_source_mutated', `BMAD epics source changed during import: ${resolved}.`, { cause: cause.code ?? 'read_failed' }); }
|
|
177
|
+
if (!first.equals(second)) throw error('bmad_epics_source_mutated', `BMAD epics source changed during import: ${resolved}.`);
|
|
178
|
+
return { resolved, source: first.toString('utf8'), sourceSha256: sha256(first) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Resolve an epics.md file or a BMAD output directory without changing it. */
|
|
182
|
+
export async function detectBmadPlanningArtifact(inputPath) {
|
|
183
|
+
if (!inputPath || typeof inputPath !== 'string') return null;
|
|
184
|
+
const input = resolve(inputPath);
|
|
185
|
+
let inputStat;
|
|
186
|
+
try { inputStat = await stat(input); } catch { return null; }
|
|
187
|
+
if (inputStat.isFile()) {
|
|
188
|
+
const source = await readFile(input, 'utf8');
|
|
189
|
+
if (input.toLowerCase().endsWith('/epics.md') || /^#{1,6}\s+.*Epic Breakdown/i.test(source) || /^(#{1,6})\s+Epic\s+/mi.test(source)) {
|
|
190
|
+
return { kind: SOURCE_KIND, sourcePath: input };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const candidates = [
|
|
195
|
+
join(input, '_bmad-output', 'planning-artifacts', 'epics.md'),
|
|
196
|
+
join(input, 'planning-artifacts', 'epics.md'),
|
|
197
|
+
join(input, 'epics.md')
|
|
198
|
+
];
|
|
199
|
+
for (const candidate of candidates) {
|
|
200
|
+
try {
|
|
201
|
+
if ((await stat(candidate)).isFile()) return { kind: SOURCE_KIND, sourcePath: candidate };
|
|
202
|
+
} catch {}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function scalar(value) {
|
|
208
|
+
const trimmed = clean(value);
|
|
209
|
+
if (trimmed === 'true') return true;
|
|
210
|
+
if (trimmed === 'false') return false;
|
|
211
|
+
if (trimmed === 'null' || trimmed === '~') return null;
|
|
212
|
+
return trimmed.replace(/^['"]|['"]$/g, '');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Parse only the stable repository-list subset of Triad's project.yaml. */
|
|
216
|
+
export function parseProjectRepositories(source) {
|
|
217
|
+
if (typeof source !== 'string' || !source.trim()) throw error('bmad_execution_not_ready', 'Triad project.yaml is empty.');
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(source);
|
|
220
|
+
return normalizeProjectConfig(parsed);
|
|
221
|
+
} catch {}
|
|
222
|
+
|
|
223
|
+
const repositories = [];
|
|
224
|
+
const projectFields = { prdPath: null, prdBaselineSnapshot: null, qualityBaseline: null, qualityFingerprint: null };
|
|
225
|
+
let projectIndent = null;
|
|
226
|
+
let nestedProjectSection = null;
|
|
227
|
+
let inRepositories = false;
|
|
228
|
+
let repositoryIndent = null;
|
|
229
|
+
let current = null;
|
|
230
|
+
let defaultRepository = null;
|
|
231
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
232
|
+
const line = rawLine.replace(/\s+#.*$/, '');
|
|
233
|
+
const trimmed = line.trim();
|
|
234
|
+
if (!trimmed) continue;
|
|
235
|
+
const indentation = line.search(/\S/);
|
|
236
|
+
const projectMatch = line.match(/^(\s*)project:\s*$/i);
|
|
237
|
+
if (projectMatch) {
|
|
238
|
+
projectIndent = projectMatch[1].length;
|
|
239
|
+
nestedProjectSection = null;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (projectIndent !== null && indentation > projectIndent) {
|
|
243
|
+
const nestedSection = line.match(/^(\s*)(prd_baseline|quality_contract):\s*$/i);
|
|
244
|
+
if (nestedSection && nestedSection[1].length === projectIndent + 2) {
|
|
245
|
+
nestedProjectSection = nestedSection[2].toLowerCase();
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const projectProperty = line.match(/^(\s*)(prd|snapshot|baseline|fingerprint):\s*(.*?)\s*$/i);
|
|
249
|
+
if (projectProperty) {
|
|
250
|
+
const propertyIndent = projectProperty[1].length;
|
|
251
|
+
const key = projectProperty[2].toLowerCase();
|
|
252
|
+
const value = scalar(projectProperty[3]);
|
|
253
|
+
if (propertyIndent === projectIndent + 2 && key === 'prd') projectFields.prdPath = value;
|
|
254
|
+
if (propertyIndent === projectIndent + 4 && nestedProjectSection === 'prd_baseline' && key === 'snapshot') projectFields.prdBaselineSnapshot = value;
|
|
255
|
+
if (propertyIndent === projectIndent + 4 && nestedProjectSection === 'quality_contract' && key === 'baseline') projectFields.qualityBaseline = value;
|
|
256
|
+
if (propertyIndent === projectIndent + 4 && nestedProjectSection === 'quality_contract' && key === 'fingerprint') projectFields.qualityFingerprint = value;
|
|
257
|
+
}
|
|
258
|
+
if (indentation <= projectIndent + 2 && !line.trimStart().startsWith('- ')) nestedProjectSection = null;
|
|
259
|
+
} else if (projectIndent !== null && indentation <= projectIndent) {
|
|
260
|
+
nestedProjectSection = null;
|
|
261
|
+
}
|
|
262
|
+
const defaultMatch = line.match(/^\s*(?:project\.)?default[_-]?repository:\s*(.*?)\s*$/i);
|
|
263
|
+
if (defaultMatch) defaultRepository = scalar(defaultMatch[1]);
|
|
264
|
+
const repositoriesMatch = line.match(/^(\s*)repositories:\s*$/i);
|
|
265
|
+
if (repositoriesMatch) {
|
|
266
|
+
inRepositories = true;
|
|
267
|
+
repositoryIndent = repositoriesMatch[1].length;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (!inRepositories) continue;
|
|
271
|
+
const itemMatch = line.match(/^(\s*)-\s+id:\s*(.*?)\s*$/i);
|
|
272
|
+
if (itemMatch && itemMatch[1].length > repositoryIndent) {
|
|
273
|
+
if (current) repositories.push(current);
|
|
274
|
+
current = { id: scalar(itemMatch[2]) };
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!current) continue;
|
|
278
|
+
const propertyMatch = line.match(/^(\s+)([A-Za-z][A-Za-z0-9_-]*):\s*(.*?)\s*$/);
|
|
279
|
+
if (propertyMatch && propertyMatch[1].length > repositoryIndent) {
|
|
280
|
+
current[propertyMatch[2]] = scalar(propertyMatch[3]);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (line.search(/\S/) <= repositoryIndent && !line.trimStart().startsWith('- ')) {
|
|
284
|
+
inRepositories = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (current) repositories.push(current);
|
|
288
|
+
return normalizeProjectConfig({
|
|
289
|
+
repositories,
|
|
290
|
+
defaultRepository,
|
|
291
|
+
prdPath: projectFields.prdPath,
|
|
292
|
+
prdBaseline: projectFields.prdBaselineSnapshot ? { snapshot: projectFields.prdBaselineSnapshot } : null,
|
|
293
|
+
qualityContract: projectFields.qualityBaseline
|
|
294
|
+
? { baseline: projectFields.qualityBaseline, fingerprint: projectFields.qualityFingerprint }
|
|
295
|
+
: null
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function normalizeProjectConfig(value) {
|
|
300
|
+
const source = asObject(value) ? value : {};
|
|
301
|
+
const project = asObject(source.project) ? source.project : {};
|
|
302
|
+
const repositories = Array.isArray(source.repositories)
|
|
303
|
+
? source.repositories
|
|
304
|
+
: Array.isArray(project.repositories) ? project.repositories : [];
|
|
305
|
+
const normalized = repositories.map((repository) => {
|
|
306
|
+
if (!asObject(repository) || typeof repository.id !== 'string' || !repository.id.trim()) {
|
|
307
|
+
throw error('bmad_execution_not_ready', 'Every Triad repository mapping requires a unique id.');
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
...repository,
|
|
311
|
+
id: repository.id.trim(),
|
|
312
|
+
path: typeof repository.path === 'string' ? repository.path.trim() : null,
|
|
313
|
+
worktree: typeof repository.worktree === 'string' ? repository.worktree.trim() : null
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
const ids = new Set();
|
|
317
|
+
for (const repository of normalized) {
|
|
318
|
+
if (ids.has(repository.id)) throw error('bmad_repository_ambiguous', `Duplicate Triad repository id: ${repository.id}.`);
|
|
319
|
+
ids.add(repository.id);
|
|
320
|
+
if (!repository.path && !repository.worktree) throw error('bmad_execution_not_ready', `Repository ${repository.id} has no path/worktree mapping.`);
|
|
321
|
+
}
|
|
322
|
+
const defaultRepository = clean(source.defaultRepository ?? source.default_repository ?? project.defaultRepository ?? project.default_repository) || null;
|
|
323
|
+
const prdPath = source.prdPath ?? source.prd_path ?? project.prdPath ?? project.prd_path ?? project.prd ?? null;
|
|
324
|
+
const prdBaseline = asObject(source.prdBaseline)
|
|
325
|
+
? source.prdBaseline
|
|
326
|
+
: asObject(source.prd_baseline) ? source.prd_baseline
|
|
327
|
+
: asObject(project.prdBaseline) ? project.prdBaseline
|
|
328
|
+
: asObject(project.prd_baseline) ? project.prd_baseline : null;
|
|
329
|
+
const qualityContract = asObject(source.qualityContract)
|
|
330
|
+
? source.qualityContract
|
|
331
|
+
: asObject(source.quality_contract) ? source.quality_contract
|
|
332
|
+
: asObject(project.qualityContract) ? project.qualityContract
|
|
333
|
+
: asObject(project.quality_contract) ? project.quality_contract : null;
|
|
334
|
+
return {
|
|
335
|
+
repositories: normalized,
|
|
336
|
+
defaultRepository,
|
|
337
|
+
prdPath: typeof prdPath === 'string' ? prdPath.trim() : null,
|
|
338
|
+
prdBaseline,
|
|
339
|
+
qualityContract,
|
|
340
|
+
gatesPath: source.gatesPath ?? source.gates_path ?? project.gatesPath ?? project.gates_path ?? null,
|
|
341
|
+
worktreeStrategy: source.worktreeStrategy ?? source.worktree_strategy ?? project.worktreeStrategy ?? project.worktree_strategy ?? null
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Execution readiness only needs to know that the trusted catalog is present
|
|
346
|
+
// and non-placeholder. Gate parsing/execution remains authoritative in the
|
|
347
|
+
// existing runtime/lib/gates.mjs verifier; this small check keeps the BMAD
|
|
348
|
+
// integration consumable when its assets are materialized under .triad-runtime.
|
|
349
|
+
function inspectGateCatalog(source) {
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(source);
|
|
352
|
+
const gates = Array.isArray(parsed?.gates) ? parsed.gates : [];
|
|
353
|
+
return {
|
|
354
|
+
count: gates.length,
|
|
355
|
+
ids: gates.map((gate) => clean(gate?.id)).filter(Boolean),
|
|
356
|
+
invalid: gates.some((gate) => !gate?.id || !gate.command || /^REPLACE_ME/.test(String(gate.command)))
|
|
357
|
+
};
|
|
358
|
+
} catch {}
|
|
359
|
+
const entries = [];
|
|
360
|
+
let current = null;
|
|
361
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
362
|
+
const line = rawLine.replace(/\s+#.*$/, '');
|
|
363
|
+
const item = line.match(/^\s*-\s+id:\s*(.*?)\s*$/i);
|
|
364
|
+
if (item) {
|
|
365
|
+
if (current) entries.push(current);
|
|
366
|
+
current = { id: clean(item[1]), command: null };
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (current) {
|
|
370
|
+
const command = line.match(/^\s+command:\s*(.*?)\s*$/i);
|
|
371
|
+
if (command) current.command = clean(command[1]).replace(/^['"]|['"]$/g, '');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (current) entries.push(current);
|
|
375
|
+
return {
|
|
376
|
+
count: entries.length,
|
|
377
|
+
ids: entries.map((gate) => gate.id).filter(Boolean),
|
|
378
|
+
invalid: entries.some((gate) => !gate.id || !gate.command || /^REPLACE_ME/.test(gate.command))
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function loadQualityBaselineValidator() {
|
|
383
|
+
const candidates = [
|
|
384
|
+
new URL('../../runtime/lib/quality-baseline.mjs', import.meta.url),
|
|
385
|
+
new URL('../../lib/quality-baseline.mjs', import.meta.url)
|
|
386
|
+
];
|
|
387
|
+
for (const candidate of candidates) {
|
|
388
|
+
try {
|
|
389
|
+
await access(fileURLToPath(candidate));
|
|
390
|
+
return await import(candidate.href);
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function loadExecutionContext({ projectRoot = null, projectConfigPath = null, projectConfig = null, gatesPath = null } = {}) {
|
|
397
|
+
const root = projectRoot ? resolve(projectRoot) : null;
|
|
398
|
+
let normalized;
|
|
399
|
+
if (projectConfig) normalized = normalizeProjectConfig(projectConfig);
|
|
400
|
+
else {
|
|
401
|
+
if (!root) throw error('bmad_execution_not_ready', 'A Triad project root or project config is required for execution readiness.');
|
|
402
|
+
const configPath = resolve(root, projectConfigPath || 'project.yaml');
|
|
403
|
+
let source;
|
|
404
|
+
try { source = await readFile(configPath, 'utf8'); }
|
|
405
|
+
catch (cause) { throw error('bmad_execution_not_ready', `Triad project configuration is missing: ${configPath}.`, { cause: cause.code ?? 'read_failed' }); }
|
|
406
|
+
normalized = parseProjectRepositories(source);
|
|
407
|
+
}
|
|
408
|
+
if (!root) throw error('bmad_execution_not_ready', 'A project root is required to validate repositories and the trusted gate catalog.');
|
|
409
|
+
if (!normalized.repositories.length) throw error('bmad_execution_not_ready', 'Triad project configuration declares no repositories.');
|
|
410
|
+
for (const repository of normalized.repositories) {
|
|
411
|
+
const declaredWorktree = repository.worktree || repository.path;
|
|
412
|
+
const candidate = resolve(root, declaredWorktree);
|
|
413
|
+
try {
|
|
414
|
+
if (!(await stat(candidate)).isDirectory()) throw new Error('not a directory');
|
|
415
|
+
} catch (cause) {
|
|
416
|
+
throw error('bmad_execution_not_ready', `Repository ${repository.id} worktree is not available: ${declaredWorktree}.`, { cause: cause.code ?? 'not_a_directory' });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const configuredQualityPath = normalized.qualityContract?.baseline
|
|
420
|
+
|| normalized.prdBaseline?.snapshot
|
|
421
|
+
|| normalized.prdPath
|
|
422
|
+
|| 'artifacts/prd.md';
|
|
423
|
+
if (typeof configuredQualityPath !== 'string' || !configuredQualityPath.trim()) {
|
|
424
|
+
throw error('bmad_execution_not_ready', 'Triad quality baseline path must be a non-empty project-relative path.');
|
|
425
|
+
}
|
|
426
|
+
if (configuredQualityPath.split(/[\\/]/).includes('..')) {
|
|
427
|
+
throw error('bmad_execution_not_ready', 'Triad quality baseline path must remain inside the control workspace.');
|
|
428
|
+
}
|
|
429
|
+
if (normalized.qualityContract) {
|
|
430
|
+
const expectedQualityFingerprint = normalized.qualityContract.fingerprint;
|
|
431
|
+
if (typeof expectedQualityFingerprint !== 'string' || !expectedQualityFingerprint.trim()) {
|
|
432
|
+
throw error('bmad_execution_not_ready', 'Configured Triad quality contract requires a baseline fingerprint.');
|
|
433
|
+
}
|
|
434
|
+
const validator = await loadQualityBaselineValidator();
|
|
435
|
+
if (!validator?.loadQualityBaseline) {
|
|
436
|
+
throw error('bmad_execution_not_ready', 'Configured Triad quality contract cannot be validated by the installed runtime.');
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
await validator.loadQualityBaseline(configuredQualityPath, {
|
|
440
|
+
projectRoot: root,
|
|
441
|
+
expectedFingerprint: expectedQualityFingerprint
|
|
442
|
+
});
|
|
443
|
+
} catch (cause) {
|
|
444
|
+
throw error('bmad_execution_not_ready', `Triad quality contract is not valid: ${cause.message}.`, {
|
|
445
|
+
cause: cause.code ?? 'quality_baseline_invalid',
|
|
446
|
+
quality_baseline_path: configuredQualityPath
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const qualityPath = resolve(root, configuredQualityPath);
|
|
451
|
+
if (!qualityPath.startsWith(`${root}${sep}`)) {
|
|
452
|
+
throw error('bmad_execution_not_ready', 'Triad quality baseline path escapes the control workspace.');
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const qualityStat = await stat(qualityPath);
|
|
456
|
+
if (!qualityStat.isFile()) throw new Error('not a file');
|
|
457
|
+
} catch (cause) {
|
|
458
|
+
throw error('bmad_execution_not_ready', `Triad quality baseline source is not available: ${configuredQualityPath}.`, {
|
|
459
|
+
cause: cause.code ?? 'not_a_file',
|
|
460
|
+
quality_baseline_path: configuredQualityPath
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const resolvedGatesPath = gatesPath || normalized.gatesPath || (root ? '.loop/quality-gates.yaml' : null);
|
|
464
|
+
if (!resolvedGatesPath) throw error('bmad_execution_not_ready', 'Triad quality-gates catalog path is not configured.');
|
|
465
|
+
if (!root) throw error('bmad_execution_not_ready', 'A project root is required to validate the trusted gate catalog.');
|
|
466
|
+
const absoluteGatesPath = resolve(root, resolvedGatesPath);
|
|
467
|
+
let gateSource;
|
|
468
|
+
try { gateSource = await readFile(absoluteGatesPath, 'utf8'); }
|
|
469
|
+
catch (cause) { throw error('bmad_execution_not_ready', `Triad quality-gates catalog is missing: ${resolvedGatesPath}.`, { cause: cause.code ?? 'read_failed' }); }
|
|
470
|
+
const gateCatalog = inspectGateCatalog(gateSource);
|
|
471
|
+
if (!gateCatalog.count || gateCatalog.invalid) {
|
|
472
|
+
throw error('bmad_execution_not_ready', 'Triad quality-gates catalog is empty or contains placeholder definitions.');
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
...normalized,
|
|
476
|
+
projectRoot: root,
|
|
477
|
+
qualityBaselinePath: configuredQualityPath,
|
|
478
|
+
gatesPath: resolvedGatesPath,
|
|
479
|
+
gateCatalog,
|
|
480
|
+
gateSourceSha256: sha256(gateSource)
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function mapValue(map, storyId, label) {
|
|
485
|
+
if (map === undefined || map === null) return [];
|
|
486
|
+
if (!asObject(map)) throw error('bmad_epics_unmappable', `${label} must be an object keyed by Story id.`);
|
|
487
|
+
if (!Object.hasOwn(map, storyId)) return [];
|
|
488
|
+
const value = map[storyId];
|
|
489
|
+
if (!Array.isArray(value)) throw error('bmad_epics_unmappable', `${label}.${storyId} must be an array.`);
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function validateStoryKeyedOptions(parsedStories, value, label) {
|
|
494
|
+
if (value === undefined || value === null) return;
|
|
495
|
+
if (!asObject(value)) throw error('bmad_epics_unmappable', `${label} must be an object keyed by Story id.`);
|
|
496
|
+
const known = new Set(parsedStories.map((story) => story.id));
|
|
497
|
+
const unknown = Object.keys(value).filter((storyId) => !known.has(storyId));
|
|
498
|
+
if (unknown.length) throw error('bmad_epics_unmappable', `${label} contains unknown Story ids: ${unknown.join(', ')}.`, { story_ids: unknown });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function validateSelectedGates(gates, storyId, gateCatalog) {
|
|
502
|
+
for (const gateId of gates) {
|
|
503
|
+
if (!gateCatalog.ids.includes(gateId)) {
|
|
504
|
+
throw error('bmad_gate_unknown', `Story ${storyId} selects an unavailable trusted gate: ${gateId}.`, {
|
|
505
|
+
story_id: storyId,
|
|
506
|
+
gate_id: gateId,
|
|
507
|
+
available_gate_ids: gateCatalog.ids
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function resolveRepository(story, execution, { targetRepository = null, repositoryOverrides = {} } = {}) {
|
|
514
|
+
const storyOverride = Object.hasOwn(repositoryOverrides ?? {}, story.id)
|
|
515
|
+
? repositoryOverrides[story.id]
|
|
516
|
+
: null;
|
|
517
|
+
if (storyOverride !== null && (typeof storyOverride !== 'string' || !storyOverride.trim())) {
|
|
518
|
+
throw error('bmad_repository_ambiguous', `Repository override for Story ${story.id} must be a non-empty repository id.`);
|
|
519
|
+
}
|
|
520
|
+
if (targetRepository !== null && targetRepository !== undefined
|
|
521
|
+
&& (typeof targetRepository !== 'string' || !targetRepository.trim())) {
|
|
522
|
+
throw error('bmad_repository_ambiguous', 'targetRepository must be a non-empty repository id when supplied.');
|
|
523
|
+
}
|
|
524
|
+
const declared = clean(story.targetRepository);
|
|
525
|
+
const explicitOverride = clean(storyOverride);
|
|
526
|
+
const explicitGlobal = clean(targetRepository);
|
|
527
|
+
const explicit = explicitOverride || declared || explicitGlobal;
|
|
528
|
+
// A per-Story caller override is intentionally the strongest binding. If
|
|
529
|
+
// no such override exists, conflicting Story/caller declarations fail closed
|
|
530
|
+
// instead of silently selecting one.
|
|
531
|
+
const conflicting = explicitOverride ? [] : [declared, explicitGlobal].filter(Boolean);
|
|
532
|
+
if (new Set(conflicting).size > 1) {
|
|
533
|
+
throw error('bmad_repository_ambiguous', `Story ${story.id} has conflicting repository mappings.`, {
|
|
534
|
+
story_id: story.id,
|
|
535
|
+
mappings: { story_override: explicitOverride || null, story: declared || null, caller: explicitGlobal || null }
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const requested = clean(explicit || execution.defaultRepository);
|
|
539
|
+
let repository;
|
|
540
|
+
if (requested) repository = execution.repositories.find((item) => item.id === requested);
|
|
541
|
+
else if (execution.repositories.length === 1) repository = execution.repositories[0];
|
|
542
|
+
else throw error('bmad_repository_ambiguous', `Cannot resolve a repository for Story ${story.id}; multiple repositories are configured.`, {
|
|
543
|
+
story_id: story.id,
|
|
544
|
+
repositories: execution.repositories.map((item) => item.id)
|
|
545
|
+
});
|
|
546
|
+
if (!repository) throw error('bmad_repository_unknown', `Story ${story.id} names an unknown repository: ${requested}.`, {
|
|
547
|
+
story_id: story.id,
|
|
548
|
+
repository: requested
|
|
549
|
+
});
|
|
550
|
+
return repository;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function sourceProvenance({ loaded, story, card = null, targetRepository = null, requiredGates = [], dependsOn = [], executionReady = false }) {
|
|
554
|
+
return {
|
|
555
|
+
schema_version: 1,
|
|
556
|
+
source_kind: SOURCE_KIND,
|
|
557
|
+
source_path: loaded.resolved,
|
|
558
|
+
source_file: loaded.resolved,
|
|
559
|
+
source_sha256: loaded.sourceSha256,
|
|
560
|
+
epic_id: story.epicId,
|
|
561
|
+
epic_title: story.epicTitle,
|
|
562
|
+
story_id: story.id,
|
|
563
|
+
bmad_story_id: story.id,
|
|
564
|
+
source_heading: story.sourceHeading,
|
|
565
|
+
source_heading_markdown: story.sourceHeadingMarkdown ?? null,
|
|
566
|
+
source_range: story.sourceRange,
|
|
567
|
+
ingestion_ready: story.ingestionReady === true,
|
|
568
|
+
execution_ready: executionReady,
|
|
569
|
+
target_repository: targetRepository,
|
|
570
|
+
required_gate_ids: requiredGates,
|
|
571
|
+
depends_on: dependsOn,
|
|
572
|
+
card_sha256: card ? sha256(card) : null
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Read native BMAD epics.md. By default this is ingestion only: no project
|
|
578
|
+
* assumptions are made and no Card is emitted until execution readiness is
|
|
579
|
+
* explicitly requested with a Triad project context.
|
|
580
|
+
*/
|
|
581
|
+
export async function ingestBmadEpics({
|
|
582
|
+
sourcePath,
|
|
583
|
+
projectRoot = null,
|
|
584
|
+
projectConfigPath = null,
|
|
585
|
+
projectConfig = null,
|
|
586
|
+
gatesPath = null,
|
|
587
|
+
execution = false,
|
|
588
|
+
targetRepository = null,
|
|
589
|
+
repositoryOverrides = {},
|
|
590
|
+
requiredGatesByStory = {},
|
|
591
|
+
dependsOnByStory = {}
|
|
592
|
+
} = {}) {
|
|
593
|
+
if (!sourcePath || typeof sourcePath !== 'string') throw error('bmad_epics_not_found', 'Provide a BMAD epics.md source path.');
|
|
594
|
+
const detected = await detectBmadPlanningArtifact(sourcePath);
|
|
595
|
+
if (!detected) throw error('bmad_epics_not_found', `No native BMAD epics.md found at ${resolve(sourcePath)}.`);
|
|
596
|
+
const loaded = await readStableSource(detected.sourcePath);
|
|
597
|
+
const parsed = parseBmadEpics(loaded.source, { sourcePath: loaded.resolved });
|
|
598
|
+
if (!execution) {
|
|
599
|
+
return {
|
|
600
|
+
source_kind: SOURCE_KIND,
|
|
601
|
+
source_path: loaded.resolved,
|
|
602
|
+
source_sha256: loaded.sourceSha256,
|
|
603
|
+
epics: parsed.epics,
|
|
604
|
+
stories: parsed.stories,
|
|
605
|
+
cards: [],
|
|
606
|
+
execution_ready: false
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const context = await loadExecutionContext({ projectRoot, projectConfigPath, projectConfig, gatesPath });
|
|
611
|
+
validateStoryKeyedOptions(parsed.stories, repositoryOverrides, 'repositoryOverrides');
|
|
612
|
+
validateStoryKeyedOptions(parsed.stories, requiredGatesByStory, 'requiredGatesByStory');
|
|
613
|
+
validateStoryKeyedOptions(parsed.stories, dependsOnByStory, 'dependsOnByStory');
|
|
614
|
+
const cards = [];
|
|
615
|
+
const stories = [];
|
|
616
|
+
for (const originalStory of parsed.stories) {
|
|
617
|
+
if (originalStory.status && originalStory.status !== READY_STATUS) {
|
|
618
|
+
throw error('bmad_story_not_ready', `BMAD Story ${originalStory.id} has status ${originalStory.status}; it is ingestible but not execution-ready.`, { status: originalStory.status });
|
|
619
|
+
}
|
|
620
|
+
const repository = resolveRepository(originalStory, context, { targetRepository, repositoryOverrides });
|
|
621
|
+
const gates = mapValue(requiredGatesByStory, originalStory.id, 'requiredGatesByStory');
|
|
622
|
+
const dependencies = mapValue(dependsOnByStory, originalStory.id, 'dependsOnByStory');
|
|
623
|
+
validateSelectedGates(gates, originalStory.id, context.gateCatalog);
|
|
624
|
+
const story = { ...originalStory, targetRepository: repository.id, executionReady: true };
|
|
625
|
+
const card = buildTriadCardFromCanonicalStory(story, {
|
|
626
|
+
executionReady: true,
|
|
627
|
+
requiredGates: gates,
|
|
628
|
+
dependsOn: dependencies,
|
|
629
|
+
sourceKind: SOURCE_KIND
|
|
630
|
+
});
|
|
631
|
+
const provenance = sourceProvenance({ loaded, story, card, targetRepository: repository.id, requiredGates: [...gates], dependsOn: [...dependencies], executionReady: true });
|
|
632
|
+
stories.push({ ...story, executionReady: true });
|
|
633
|
+
cards.push({ story, card, provenance, repository, execution: { gates_path: context.gatesPath, gates_sha256: context.gateSourceSha256 } });
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
source_kind: SOURCE_KIND,
|
|
637
|
+
source_path: loaded.resolved,
|
|
638
|
+
source_sha256: loaded.sourceSha256,
|
|
639
|
+
epics: parsed.epics,
|
|
640
|
+
stories,
|
|
641
|
+
cards,
|
|
642
|
+
execution_ready: true,
|
|
643
|
+
execution: { project_root: context.projectRoot, gates_path: context.gatesPath, gates_sha256: context.gateSourceSha256 }
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function pathExists(target) {
|
|
648
|
+
try { await access(target); return true; } catch { return false; }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Write normal Triad Cards and integration-side provenance, never Story files. */
|
|
652
|
+
export async function writeImportedEpics({ sourcePath, outputDirectory, ...options } = {}) {
|
|
653
|
+
if (!outputDirectory || typeof outputDirectory !== 'string') throw error('bmad_epics_unmappable', 'Provide an output directory for generated Triad Cards.');
|
|
654
|
+
const result = await ingestBmadEpics({ sourcePath, ...options, execution: true });
|
|
655
|
+
const directory = resolve(outputDirectory);
|
|
656
|
+
await mkdir(directory, { recursive: true });
|
|
657
|
+
const outputs = [];
|
|
658
|
+
// Check every destination before writing any Card so a stale/duplicate
|
|
659
|
+
// output cannot leave a partially materialized Epic behind.
|
|
660
|
+
for (const item of result.cards) {
|
|
661
|
+
const cardPath = join(directory, `${item.story.id}.md`);
|
|
662
|
+
const provenancePath = `${cardPath}.bmad-provenance.json`;
|
|
663
|
+
if (await pathExists(cardPath) || await pathExists(provenancePath)) {
|
|
664
|
+
throw error('bmad_epics_unmappable', `Refusing to overwrite an existing Card or provenance record for ${item.story.id}.`, { cardPath, provenancePath });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
for (const item of result.cards) {
|
|
668
|
+
const cardPath = join(directory, `${item.story.id}.md`);
|
|
669
|
+
const provenancePath = `${cardPath}.bmad-provenance.json`;
|
|
670
|
+
const cardTemp = `${cardPath}.tmp-${process.pid}`;
|
|
671
|
+
const provenanceTemp = `${provenancePath}.tmp-${process.pid}`;
|
|
672
|
+
let cardWritten = false;
|
|
673
|
+
try {
|
|
674
|
+
await writeFile(cardTemp, item.card, { encoding: 'utf8', flag: 'wx' });
|
|
675
|
+
await writeFile(provenanceTemp, `${JSON.stringify({ ...item.provenance, card_path: cardPath }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
676
|
+
await rename(cardTemp, cardPath);
|
|
677
|
+
cardWritten = true;
|
|
678
|
+
await rename(provenanceTemp, provenancePath);
|
|
679
|
+
outputs.push({ ...item, cardPath, provenancePath });
|
|
680
|
+
} catch (cause) {
|
|
681
|
+
await rm(cardTemp, { force: true });
|
|
682
|
+
await rm(provenanceTemp, { force: true });
|
|
683
|
+
if (cardWritten) await rm(cardPath, { force: true });
|
|
684
|
+
throw error('bmad_epics_unmappable', `Could not write imported Card ${item.story.id}: ${cause.message}.`, { cause: cause.code ?? 'write_failed' });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { ...result, outputs };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export const bmadEpicsIntegration = {
|
|
691
|
+
sourceKind: SOURCE_KIND,
|
|
692
|
+
detect: detectBmadPlanningArtifact,
|
|
693
|
+
parse: parseBmadEpics,
|
|
694
|
+
ingest: ingestBmadEpics,
|
|
695
|
+
write: writeImportedEpics,
|
|
696
|
+
parseProjectRepositories
|
|
697
|
+
};
|